Repository navigation
Conversation
A value set that lists codes from a code system whose content is not-present (THO's time-period-ranges, for one) can't be expanded: there is nothing to check the codes against. checkSource refused it with an Issue that had no status, so the client got 500 business-rule. The same went for a code system with no content element, and for a supplement named as a system. They now answer 422, as a code system the server doesn't have at all already does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 500 Extensions.checkNoModifiers threw an Issue with no status, so $expand of a value set whose filter carries an unknown modifier extension (VSAC's vsacOpModifier, for one) answered 500 business-rule. Refusing is right, as the extension changes what the element means, but the problem is in the resource's content, not the server: the Issue now carries 422. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… '??', never 'undefined' A value set given in the request needn't have a url. The VALUESET_TOO_COSTLY message then named it 'undefined' (from vurl) or '??', depending on where the limit was hit. A small helper, valueSetName, gives the url when there is one, else the value set's id, else '??' (the text tx-ecosystem's snomed-expand-inactive2 expects), and all five places that raise the message use it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s that The importer stores CLASSTYPE 3 as 'Claims attachment' (descClassType), but the provider's classTypes map knew only 'Claims attachments'. For the 1,161 LOINC 2.83 codes of that class type (18719-5, 19005-8, ...) the CLASSTYPE property's value was undefined, so $lookup failed its response check with a 500, every time. The CLASSTYPE = 3 filter searched for the plural and found none of them. The map uses the importer's spelling, and still accepts the plural. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…very word HealthIntersections#335 adds the codes whose supplement designations match a text filter to LOINC's and SNOMED CT's own matches (CodeSystemProvider._supplementSearchCodes). It tested each designation with SearchFilterText.passes, which is true when any one word of the filter matches, while the code system's own search needs every word. So with the Uzbek LOINC supplement loaded (language en), $expand of http://loinc.org/vs with filter "serum glucose" listed codes whose supplement text had either word: of the first 1,000, only 47 had "glucose" in their display. Before HealthIntersections#335 it listed none. SearchFilterText.passesAll matches each word as passes does (by stem), but needs all of them, and the supplement search uses it. Which supplements take part is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hing, rather than failing concept =, is-a, descendent-of, child-of, generalizes and descendent-leaf on a concept the edition doesn't have threw "The SNOMED CT Concept ... is not known", and $expand answered 500. That happens in real traffic: US Core's VSAC-derived value sets ask for descendants of 584021000052104, a concept from a national extension that the US Edition doesn't include. concept in failed the same way on any unknown id in its list. The provider now does what the FHIR CodeSystem, LOINC and other providers do for an unknown code: such a filter selects no concepts (is-not-a selects every concept), and concept in skips the unknown ids. ECL is unchanged: an unknown concept there is still INVALID_ECL. doesFilter is unchanged too, so $validate-code still reports concept in with an unknown id as a filter it doesn't understand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fore the inner try An unknown cache-id (and any other Issue thrown by setupAdditionalResources) fell into the 'handled in the inner method' branch of handleCodeSystem, so no response was ever sent and the socket was held until the client gave up. Answer with the OperationOutcome, as the ValueSet handler does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r code AnswerList.csv has one row with no AnswerStringId for each externally defined answer list (ExtDefinedYN = Y; 65 in 2.83). Each such row was imported as an answer whose code is '', shared by all of them and linked to all 65 lists, so LOINC 2.83 had 257,267 codes rather than 257,266 (the extra one is code '', type Answer, no display). The list itself is still a code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Node closes an idle keep-alive connection after 5 s; nginx keeps idle upstream connections for 60 s. When nginx reuses a connection just as Node closes it, the request is reset, and nginx does not retry a POST, so the client gets a 502 (seen in load tests as bursts of resets on reused connections, up to 2% of requests at some steps). keepAliveTimeout is now 65 s, and headersTimeout 66 s, above it as Node requires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Node fires a timer whose delay is above 2^31-1 ms (about 596 hours) after 1 ms instead, so a refreshIntervalHours above that ran the VSAC refresh back to back (100,000 hours gave thousands of unhandled rejections within seconds). The interval is clamped to the longest a timer can wait, and a value that is not a positive number falls back to the 24 hour default, each with a warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ponse twice The res.json wrapper stringified the resource to log its size and then called res.json(data), which stringified it again: about 3 ms of extra work per MB of response. Send the string (same headers: Content-Type was set above). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e codes HealthIntersections#335 anchors every regex filter as ^(?:pattern)$, and tests alternation for the FHIR CodeSystem provider. This adds the same check for the country provider: US|CA matches US and CA, and not USA, LCA or TCA. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e large includes in log notes
A 1,000-entry ValueSet/$batch-validate-code of RxNorm codes against VSAC
Medication Brand Name (33,341 codes) ran out of heap. Two things combined:
- Each entry's log notes ("Prepare include", "check code in") wrote out
every listed code: two ~267 KB strings per entry, kept in the context's
logEntries and its TimeTracker steps.
- Each entry's context shared the batch's provider list, so every entry's
providers (each with its own sqlite connection and a reference to the
entry's context) stayed reachable until the whole batch ended.
displayValueSetInclude now writes the first 20 codes and a count. Batch
entries get their own provider list (OperationContext.copy({ownProviders}))
which is closed as soon as the entry is answered, including on error.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ans the archive RxNormServices.locate() looks a code RXNCONSO doesn't have up in RXNATOMARCHIVE, by RXCUI and SAB. The table had no index on RXCUI, so every unknown code scanned all ~372k rows: 43 ms per miss, against 0.1 ms with the index. The importer now makes the index, and RXNORM_LOAD_INDEXES adds it to a database imported before it did, the way the other late indexes are added (a one-off 0.2 s on the current RxNorm database). A database that can't be written is now used as it is, with a warning, instead of failing to load: the load-time indexes only make lookups faster. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ExpansionCache.computeKey puts a content hash of every additional resource into the expansion cache key. With a cache-id those resources are the client's whole $cache-control cache, so every $expand serialised and sha256-hashed everything the IG had uploaded. Under replayed IG Publisher traffic at C=16 that was about a quarter of the main thread. CodeSystem, ValueSet and ConceptMap (CanonicalResource) now work the hash out on first use and remember it, in a non-enumerable field tied to the jsonObj it was computed from (a replaced jsonObj, or a replacement resource, is hashed again). setExpansionKeyHash lets a store that already knows the hash record it. The hash is exactly the one computeKey made, and the key is built the same way, so keys are byte-identical and expansions already cached stay valid. Remembering the hash needs a cached resource's json not to change once it is in use. One thing did change it: $compare sorted, merged and removed the compose includes of the value sets it compared, in place - cache-id resources and the server's own. It now works on copies. Tests: computeKey against the previous implementation for a mix of resources and parameters; one serialisation and hash per resource across many keys; a new hash for a replacement resource; and expand, validate-code, lookup and compare through a sealed cache-id with the cached json frozen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
overAbortThreshold, which every deadCheck calls, read a fresh process.memoryUsage.rss() each time: about 11 reads a request, each a read of /proc/self/stat by libuv, about 3% of the main thread at saturation. It now reuses a sample up to ABORT_SAMPLE_MS (10 ms) old. The request gate still samples every 250 ms, a GC while refusing still re-measures at once, and the thresholds are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Nov 4, 2026
Contributor
|
Thanks. A few notes:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Twelve independent fixes, one commit each, each with its own test. Any subset can be taken, and each commit can be reverted alone. They were found while load-testing FHIRsmith with real IG Publisher traffic and comparing its answers with tx.fhir.org's. The last one adds a test and no code.
Each section gives the request that shows the problem (a curl against tx-dev.fhir.org where it reproduces there), the code path on main, the change, and the test. Every test was run against main without its fix, and fails there.
Three other changes from #332 have their own PRs: the event-loop yield changes (#338), the supplement index (#340) and locating a code once per include (#339). The edition-only SNOMED CT pin change from #332 is withdrawn.
One open question for you, in section 6 (K3):
$validate-codeagainst a value set withconcept in <unknown concept>still answers 400 "filter not understood", as on main. Should it answerresultfalse instead?1. $expand from a code system with no content answers 422, not 500
Register row K2.
What was wrong
A value set that lists codes from a code system whose content is
not-presentcan't be expanded, since there is nothing to check the codes against. Refusing is right, but the refusal had no status, so the client got 500, as if the server had failed. A code system the server doesn't have at all already answers 422.Before (tx-dev, 3 Nov): 500 business-rule "The code system definition for http://terminology.hl7.org/CodeSystem/time-period-ranges has no content, so this expansion cannot be performed". After: 422, same message.
Code path
tx/workers/expand.js,ValueSetExpander.checkSource(line 667). Three Issues are thrown there with no status (lines 698, 701, 703): a code system with nocontent, one whose content isnot-present, and a supplement named as the system.Change
All three carry 422. The messages are unchanged.
Test
tests/tx/expand.test.js, "A code system with nothing to expand from": an include ofcolor-rgb(not-present) and one ofexample-supplementeach give 422 with their message. The test fails on main (500) and passes with the change.2. A modifier extension the server doesn't know is refused with 422, not 500
Register row K10.
What was wrong
Extensions.checkNoModifiersrefuses an element that carries a modifier extension the server doesn't know. Refusing is right, since the extension changes what the element means. But the Issue had no status, so the client got 500, as if the server had failed. A real case: VSAC's value set 2.16.840.1.113883.11.20.9.46 (Ability) has one (vsacOpModifier) on an exclude filter.Before (tx-dev, 3 Nov): 500 business-rule "Cannot process resource http://example.org/fhir/ValueSet/unknown-filter-modifier at "filter" due to the presence of the modifier extension http://example.org/fhir/StructureDefinition/unknown-op-modifier". After: 422, same message.
Code path
tx/library/extensions.js,Extensions.checkNoModifiers(line 32). It throws at lines 47 and 49 (one or several extensions) with no status.Change
Both Issues carry 422.
Test
tests/tx/expand.test.js, "a filter with an unknown modifier extension: 422": a filter with an unknown modifier extension gives 422 business-rule, and the message names the extension. The test fails on main (500) and passes with the change.3. The too-costly message names a value set with no url by its id, or as '??', never 'undefined'
Register row K11.
What was wrong
A value set given in the request needn't have a url. When it is too costly to expand, the message named it
'undefined'or'??', depending on where the limit was hit.Before (tx-dev, 3 Nov): 422 "The value set 'undefined' expansion has too many codes to produce (>3000)". After: "The value set '??' expansion …". A value set with an id but no url is named by its id.
Code path
tx/workers/expand.js, five places raiseVALUESET_TOO_COSTLY:includeCode(line 355) andexcludeCode(539), which usesrcURLor'??';checkSource(735),includeCodes(849) andexpandSeen(1650), which usesource.vurlorvsSrc.vurl.vurlisundefinedwhen there is no url, andincludeCodesis the path this request takes.Change
A small helper,
ValueSetExpander.valueSetName(url), gives the url, else the value set's id, else'??'. All five places use it. Plainer words, such as "the value set in the request", would read better, but tx-ecosystem'ssnomed-expand-inactive2expects the exact text'??', so that test would have to change first.Test
tests/tx/expand.test.js, "The too-costly message": a value set with no url, one with only an id, and one with a url, each with and withoutactiveOnly, expanded withlimit2. The message names them'??', by the id, and by the url. The test fails on main and passes with the change.4. LOINC: class type 3 is stored as 'Claims attachment', so look it up as that
Register row L6.
What was wrong
$lookupof 1,161 LOINC codes (class type 3) always answered 500. Among them are 18719-5, 19005-8, 18834-2, 18725-2, 26436-6, 18783-1, 18782-3 and 18726-0, all seen in IG Publisher traffic.Before (tx-dev, 3 Nov): 500 "Code system provider http://loinc.org produced an invalid $lookup response at parameter.property.value: valueString must be a string, not a undefined". After (local server,
Loinc_2.83b.db): 200, withCLASSTYPE= 3, "Claims attachment". The same for the other seven. An expansion with the filterCLASSTYPE = 3went from 0 codes to 1,161.Code path
The importer stores class type 3 as "Claims attachment", singular (
tx/importers/import-loinc.module.js,descClassType, line 1673). The provider's mapclassTypes(tx/cs/cs-loinc.js:56) knew only "Claims attachments". So for those codes,classTypes[row.Value](line 543) wasundefined, and the$lookupresponse check rejected the property. TheCLASSTYPE = 3filter (line 1018) searched for the plural, and found nothing.Change
The map uses the importer's spelling, and still accepts the plural.
Test
tests/cs/cs-loinc.test.js, "CLASSTYPE 3 (Claims attachment)": on a test database with such a code,$lookupreports the property, and theCLASSTYPE = 3filter finds the code. The test fails on main and passes with the change.5. A supplement's designations match a text filter only when they have every word
Register row X7.
What was wrong
#335 adds the codes whose supplement designations match a text filter to the code system's own matches. It tests each designation with
SearchFilterText.passes, which is true when any one word of the filter matches. LOINC's and SNOMED CT's own text search needs every word.The Uzbek LOINC supplement is loaded on the server, and its
languageisen. So a two-word English LOINC filter now lists codes whose supplement text has either word:Before (tx-dev, 3 Nov): a full page of 1,000 codes, only 47 of them with "glucose" in the display (such as "8(9)-Cholestenol [Mass/volume] in Serum or Plasma"). Before #335, no codes. After (local server): 38 codes. 34 have "glucose" in the display. The other 4 have a word that starts with it ("Glucosamine", "glucosidase"), which is how words match in text filters.
Code path
tx/cs/cs-api.js,CodeSystemProvider._supplementSearchCodes(line 351) callsfilter.passes(...)on each supplement display and designation.passesis intx/library/designations.js,SearchFilterText.passes(line 49).Change
A new
SearchFilterText.passesAllmatches each word aspassesdoes (the word's stem starts with the filter word's stem), but needs all of them._supplementSearchCodesuses it.Whether a supplement nobody asked for should take part in the search at all is a separate question (register X5), and is unchanged here.
Test
tests/tx/designations.test.js:passesAllneeds every word,passesonly one.tests/cs/cs-loinc.test.js: with a supplement loaded, "serum glucose" matches a designation only when it has both words.Both fail on main and pass with the change.
6. SNOMED CT: a filter on a concept the edition doesn't have selects nothing, rather than failing
Register row K3.
What was wrong
These
conceptfilters threw "The SNOMED CT Concept … is not known" on a concept the edition doesn't have, and$expandanswered 500:=,is-a,descendent-of,child-of,generalizesanddescendent-leaf;in, for any unknown id in its list.This happens in real traffic. US Core's VSAC-derived value sets ask for descendants of 584021000052104, a concept from a national extension that the US Edition doesn't have. tx.fhir.org gave the same 500 during a US Core IG build on 30 Oct. The FHIR CodeSystem and LOINC providers answer an unknown code in such a filter with an empty set.
Before (tx-dev, 3 Nov): 500 exception "The SNOMED CT Concept 41000147108 is not known". After (local server): 200, an empty expansion.
The same happens with
is-a 584021000052104andsystem-version=http://snomed.info/sct|http://snomed.info/sct/731000124108(US Edition). After the change,$expandgives 200 with no codes, and$validate-codeof 22298006 against that value set givesresultfalse instead of 500.Code path
tx/cs/cs-snomed.js:SnomedProvider.filter(line 3550) hands eachconceptop to aSnomedServiceshelper, and each helper throws on an unknown concept (lines 825–901);SnomedServices.filterIn(line 931) throws on an unknown id (line 949).Change
SnomedProvider.filter, a concept the edition doesn't have selects no concepts, or every concept foris-not-a.filterInskips unknown ids.ECL is unchanged: an unknown concept there is still
INVALID_ECL.Open point.
$validate-codeagainst a value set withconcept in <unknown>still answers 400 "filter … not understood", as main does. This is becauseSnomedProvider.doesFilter(line 3362) still says no for an unknown concept in aninlist. It could answerresultfalse instead, asis-anow does. Which one do you want?Test
tests/cs/cs-snomed.test.js, "a concept the edition does not have selects no concepts, rather than failing":=,is-a,descendent-of,child-of,generalizes,descendent-leafandinon 41000147108 selects nothing, and 16763008 is not in it;in 41000147108,16763008gives 16763008;is-not-agives every concept.The test fails on main and passes with the change.
7. CodeSystem/$validate-code answers an Issue raised before the inner try, instead of never answering
Register row G1 (its
CodeSystem/$validate-codecase).What was wrong
CodeSystem/$validate-codewith an unknown cache-id never answers. The client waits until its own timeout, and the connection is held meanwhile.ValueSet/$validate-codeand the other operations answer 404cache-id-unknown.Before (tx-dev, 3 Nov): no response in 20 s. After: 404, an OperationOutcome with
cache-id-unknown.Code path
tx/workers/validate.js:ValidateWorker.handleCodeSystemInner(line 2108) callssetupAdditionalResourcesandTxParameters.readParamsbefore itstry(lines 2113–2116).setupAdditionalResources(tx/workers/worker.js:746) throws a 404cache-id-unknownIssue, or a 400cache-id-duplicateIssue.readParamsthrows a 400 Issue forexclude-system(tx/params.js:472).try, the innercatchturns every Issue not markedhandleAsOOinto aresult(handlePrepareError), and rethrows the rest.catchinhandleCodeSystem(line 2080) unmarked are those thrown before the innertry. There, an unmarked Issue fell into an empty branch, commented "this is actually handled in the inner method" (line 2099). No response was sent.Change
The outer
catchanswers every Issue that reaches it with its OperationOutcome and status, as the ValueSet handler does. This only affects requests that got no response at all on main. Issues raised inside the validation are still turned into a result by the inner method, as before.The same change also answers
CodeSystem/$validate-code?…&exclude-system=…(400, "not supported"), which likewise got no response on main.Test
tests/tx/cache-id.test.js:CodeSystem/$validate-codewith an unknown cache-id answers 404 with thecache-id-unknowncoding. On main the test times out; with the change it passes.8. LOINC importer: an externally defined answer list adds no blank Answer code
Register row L7.
What was wrong
AnswerList.csvhas one row with noAnswerStringIdfor each externally defined answer list (ExtDefinedYN = Y: ICD-10, RxNorm and so on; 65 lists in LOINC 2.83). The importer turned each such row into an answer with the code''. All 65 lists share that one blank Answer, with anAnswer/AnswerListlink to each. SoLoinc_2.83b.dbhas 257,267 codes, one more than LOINC 2.83 has.Before (tx-dev, 3 Nov):
total257,267. The extra code is'', type Answer, with no display. After, with a database built by the fixed importer: 257,266.Code path
tx/importers/import-loinc.module.js,processListItem(line 1103) readsanswerCodefrom the row (line 1120), and adds it as a code even when it is empty.Change
A row with no
AnswerStringIdadds the list, and nothing else.This changes the database, not the server, so nothing changes until the LOINC database is rebuilt. Then tx-ecosystem's
loinc-expand-all-limitedandloinc-expand-all-limited-total, which expect 257,267 today, need to expect 257,266.Test
tests/tx/loinc-import-answer-lists.test.jsimports a smallAnswerList.csv:AnswerStringId) becomes a code, with no blank answer;The first fails on main and passes with the change.
9. Keep idle connections open longer than nginx does
Register row F4.
What was wrong
Node closes an idle keep-alive connection after 5 s, but nginx keeps idle upstream connections for 60 s. When nginx reuses a connection just as Node closes it, the request is reset. nginx does not retry a POST, so the client gets a 502. In our load tests behind nginx, up to 2% of requests failed this way at some load levels, mostly just after the event loop had stalled.
There's no single request that shows it: it is a race between nginx reusing a connection and Node closing it. In a test, a connection left idle for 6 s and then reused fails on main, and works with this change.
Code path
server.js:839:app.listen(PORT, …)keeps Node's defaults (keepAliveTimeout5 s,headersTimeout60 s).Change
A small helper,
library/keep-alive.js, setskeepAliveTimeoutto 65 s, longer than nginx's 60 s. It setsheadersTimeoutto 66 s, since Node needs it longer thankeepAliveTimeout, or it applies it to the idle connection first.server.jscalls the helper on the serverapp.listenreturns. The values are fixed; a config setting is easy to add if a deployment needs another value.Test
tests/library/keep-alive.test.js:headersTimeoutis the longer;Without the helper the test fails.
10. VSAC: clamp a refresh interval longer than a timer can wait
Register row F5.
What was wrong
Node runs a timer whose delay is above 2^31−1 ms (about 596 hours) after 1 ms instead. So a VSAC
refreshIntervalHoursabove 596 makes the refresh run back to back. Setting 100000, meaning "effectively never", gave thousands of unhandled rejections within seconds.This is configuration, so there's no request to show it. Set
refreshIntervalHours: 100000in the VSAC library entry and start the server.Code path
tx/vs/vs-vsac.js,VSACValueSetProvider._startRefreshTimer(line 113) passesrefreshIntervalHours * 3,600,000straight tosetInterval.Change
_refreshIntervalMs()clamps the interval to the longest delay a timer accepts, and logs a warning. A value that is not a positive number falls back to the 24-hour default, also with a warning.Test
tests/vs/vs-vsac-refresh-interval.test.js:setIntervalis called with the clamped delay.The tests fail on main and pass with the change.
11. Send the JSON string already built, instead of serialising the response twice
Register row P5.
What was wrong
The
res.jsonwrapper intx.jsstringifies the resource to check it and log its size, then calls Express's ownres.json(data), which stringifies it again. That is about 3 ms of extra CPU per MB of response, and large expansions are several MB.This costs CPU on every JSON response, but changes no response, so there's no curl that shows it.
Code path
tx/tx.js,TXModule.setupEndpoint(line 278), theres.jsonwrapper (lines 424–482). In the JSON branch,JSON.stringify(data)is at line 464, thenoriginalJson(data)at line 468.Change
Send the string already built:
res.send(jsonStr). The bytes and headers are the same.Content-Typeis set just above, sores.sendkeeps it and addscharset=utf-8asres.jsondoes. The app sets nojson spaces,json replacerorjson escape, so Express'sres.jsonwould build the same string.The XML-fallback branch (line 461) still calls
originalJson; it is rare.Test
tests/tx/serialise-once.test.js: a$lookupanswers 200 withapplication/fhir+json; charset=utf-8and a compact JSON body, and Express'sres.jsonis not called. On main it is called once, so the test fails; with the change it passes.12. Country codes: test that a regex filter with alternation matches whole codes
Register row M1 (the fix itself is in #335).
What was wrong
Nothing, now. Before #335, the FHIR CodeSystem and country providers anchored a regex filter as
'^' + value + '$'. SoUS|CAbecame^US|CA$, which means "starts with US, or ends with CA", and matched USA, LCA and TCA. #335 anchors every regex filter as^(?:pattern)$, and tests alternation for the FHIR CodeSystem provider only.Code path
tx/cs/cs-country.js,CountryCodeServices.filter(line 203). The regex is built at line 215, already anchored as a whole.Change
None to the code. This adds one test for the country provider.
Test
tests/cs/cs-country.test.js, "a pattern with alternation matches whole codes only":US|CAgives exactly CA and US. With the pre-#335 anchoring put back, it fails; on main it passes.It is folded in here because it is test-only: it guards a fix that is already in, for one more provider. Drop the commit if you'd rather not have it.
Results
tests/cs/cs-loinc.test.jsrunning past its 120 s timeout; it does the same on main on this machine.cs-batch-validate,-badand-codings(R4 and R5, and R5 cached), andtranslate-reverse-r5+and-a(R5, plain and cached).Added since opening
$batch-validate-codeagainst a large enumerated value set runs out of memory (register row K13)What was wrong. A 1,000-entry
$batch-validate-codeof RxNorm codes against VSAC Medication Brand Name (2.16.840.1.113883.3.88.12.80.16, 33,341 codes) ran out of heap on main after 33 s, with no answer.Code path.
ValueSetCheckerintx/workers/validate.jsadds two notes per entry withrenderer.displayValueSetInclude, which lists every code. One is "Prepare include", the other "check code". Each note is a 267 KB string, kept in bothlogEntriesand theTimeTracker.tx/workers/batch-validate.js,processBatchgives every entry a copy of oneOperationContext. They share one_openProviderslist, so every entry's provider stays reachable until the batch ends, and through it the entry's context and notes, plus one open RxNorm connection per entry.Change. Either part alone keeps the heap flat, and both are kept.
displayValueSetIncludewrites the first 20 codes, then "+N more (M codes)". Includes of 20 codes or fewer, and filters, read as before. These notes only feed thediagnosticsoutput.OperationContext.copy({ownProviders: true})gives a copy its own provider list.processBatchuses it for each entry and closes that entry's providers in afinallyonce the entry is answered. Plaincopy()is unchanged.Numbers (local, same data):
Test.
tests/tx/batch-validate-memory.test.js(7 tests, with a fake validate worker); 4 of them fail on main.An unknown RxNorm code scans the whole archive table (register row R6)
What was wrong. Looking up a code RxNorm doesn't have takes about 43 ms.
locatefinds nothing inrxnconsoand falls back toRXNATOMARCHIVE(tx/cs/cs-rxnorm.js), which has no index on the columns it filters on, so each miss reads all ~372k rows (SCAN).Change. An index on
RXNATOMARCHIVE(RXCUI, SAB):load()now logs a warning and carries on. On main, a read-only database missing any of these indexes failed to load.Numbers (on a copy of
rxnorm_03022026.db):SEARCH USING INDEX);CodeSystem/$validate-codewith an unknown code: 89–103 ms → 3–12 ms.Test.
tests/cs/cs-rxnorm-archive-index.test.jschecks that the importer makes the index, that load adds it, and that a read-only database still loads. It uses a small built database, since the main RxNorm suite is skipped offline, and all 3 tests fail on main.A related change, a time-limit check inside the loop that looks for another form of a code, is in #339, since that PR rewrites the same loop.
Every
$expandwith a cache-id re-serialises and re-hashes the whole cache (register row P6)What was wrong. We replayed IG Publisher traffic at 16 concurrent builds, with the main thread saturated.
ExpansionCache.computeKeywas on the stack in 24% of main's busy CPU profile. Of that, 16% was spent serialising (themapcallback that callsJSON.stringify) and about 7% in sha256.Code path.
TerminologyWorker.setupAdditionalResources(tx/workers/worker.js) setsadditionalResourcestoresourceCache.get(cacheId), which is everything the client has put in its$cache-controlcache.ValueSetExpander(tx/workers/expand.js) callscomputeKey(valueSet, params, additionalResources)on every$expand.computeKeydidsha256(JSON.stringify(r.jsonObj)).substring(0, 16)for each of those resources, every time.Change.
CanonicalResource, the base of CodeSystem, ValueSet and ConceptMap, now hasexpansionKeyHash(). It computes the same 16-character hash on first use, and keeps it in a non-enumerable field tied to thejsonObjit hashed. A replacement resource, or a replacedjsonObj, gets hashed again.setExpansionKeyHash(hash)lets a store that already knows the hash record it.computeKeyuses the remembered hash. Raw JSON is hashed exactly as before. The sort, join and final sha256 are unchanged, so keys are byte-identical and expansions already cached stay valid.$expand.What made it safe, and one fix it needed. Nothing may change a cached resource's JSON while it is in use. The r4→r5 conversion happens in the constructors, and expand and validate work on copies. Filter analysis is remembered on filter elements under non-enumerable symbols, which JSON never sees. One thing did write:
$compare'stidyIncludesmerged, removed and sorted the compose includes of the value sets it compared, in place. Those were cache-id resources and also the server's own value sets. It now works on astructuredCloneof each compose.Test.
tests/tx/expansion-key-hash.test.js, 8 tests:computeKeyagainst a copy of the previous implementation, for a mix of wrappers, raw JSON and parameters;JSON.stringifyand onecreateHashper resource across 100 keys;$expand,$validate-code,$lookupand$comparethrough a sealed cache-id, with the cached resources' JSON frozen. Before the$comparefix this test fails with a 500, "Cannot assign to read only property".Numbers. Same replay: one server, 16 concurrent builds, private data copies, 8 GB cap.
computeKeyshare of main's CPU profileThe main thread stays about 89% busy in all three, so the saving shows up as throughput. Answers: the 11,810 calls of the C=4 warm-up pass are identical with and without the change, comparing bodies with ports normalised. At C=16, the 3–4
$batch-validate-codebodies that differ also differ between two runs of main.The abort check reads RSS from /proc on every deadCheck (register row: none, found while profiling)
What was wrong.
memoryGuard.overAbortThreshold, which everydeadCheckcalls (about 11 times a request), read a freshprocess.memoryUsage.rss()each time. Each read costs about 7 µs, because libuv reads/proc/self/stat. Once P6 was fixed, that was 4.2% of the main thread at saturation.Change. The abort check reuses an RSS sample up to
ABORT_SAMPLE_MS(10 ms) old. The request gate's 250 ms sampling, the re-measure after a forced GC and the thresholds are unchanged.Test.
tests/library/memory-guard.test.jshas 3 new tests: 100 checks inside 10 ms make one RSS read; a rise is seen as soon as the sample is more than 10 ms old; the gate keeps its own interval. The existing memory-guard tests pass.Numbers (same replay, on top of P6):
overAbortThresholdshare of the main thread: 4.2% → 0.36%;🤖 Generated with Claude Code