Repository navigation
Conversation
…nning every CodeSystem loadSupplements() walked every loaded CodeSystem (thousands, from the npm packages) and every request resource on each code-system lookup, several times per $validate-code: 18-24% of the CPU time under IG-build traffic. The provider's CodeSystem map now keeps an index of supplements by supplemented URL, maintained on set/delete (data kept with the map, not a cache), and a worker filters the request's supplement resources once per request. Replaying IG-build traffic against one process: at 100 requests/s the process was busy 51% of the time instead of 94%, and it kept up with about 160 requests/s instead of 80. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Validating an entry never yields, so a 1,000-entry batch from an IG build held the event loop for about 1 s and every request arriving meanwhile waited for it. checkAndYield before each entry; it only yields after YIELD_INTERVAL ms of compute, so small batches are unaffected. Measured on main with a real 1,000-code SNOMED CT batch running and a single-code $validate-code (4-6 ms on its own) sent every 10 ms: those lookups typically waited 0.6-0.9 s (worst 1.35 s) before, about 50 ms (worst 73-238 ms) after; the batch was no slower. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While a long operation runs, other requests wait about two slices: 22 ms typical instead of 51 (worst 42 vs 86) during a 1,000-code batch, with no measurable cost to the batch (842 vs 879 ms). Measured with a single lookup sent every 10 ms during the batch. 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>
…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>
…f the edition registerProvider overwrote the partial-version key (the edition URI without its date, or a semver major.minor) on every load, so with several versions of an edition loaded the pin resolved to whichever was listed last. It now keeps the latest version, whatever the load order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n as a whole '^' + value + '$' turned 'code1|code3' into "starts with code1 or ends with code3". The pattern is now wrapped: ^(?:value)$. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ce per listed code Before trying each listed concept as the same concept in another form (ICD-11's short code and entity id), checkConceptSet called sameConceptDifferentForm for every listed concept, and each call located the code to ask whether it has another form at all. That answer does not depend on the listed concept, so it is now asked once per include and the loop runs only when it is yes. Same answers. A miss against a 33,000-code VSAC RxNorm list used to get no answer within 600 s (a 1,185-code list took 99 s); a 300-code list in the new test located the code 301 times, now a handful. 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>
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>
The scan that runs just after startup reads every value set (about 4,000 with tx.fhir.org's sources) and held the event loop for about a second, so requests arriving then waited for all of it. It now gives the event loop a turn every 10 ms of work. Same counts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
this is a bad idea. there's evidently correct changes in here, but there's also wrong statements that lead to wrong code. For instance:
I don't believe that's true, though it's possible that it's so terse I didn't understand it correctly. then there's statements like this:
how can that be safe?
that's a feature. |
HealthIntersections#335 anchors regex filters as ^(?:pattern)$ in cs-cs.js and cs-country.js, which is what 03944f2 did: main's version is kept, so that commit's code change is now redundant (its country test stays). 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>
…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>
… 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>
…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>
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>
…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>
|
Thanks, Grahame. Fair point: too much in one PR, and some of the wording was too terse to check. I'm closing this in favour of four smaller PRs. Each issue in them has the request, the code path on main, the change and its test. On your three points: "That answer is the same for every listed concept." It was too terse. For each listed concept, "How can that be safe?" The index is part of the Map, not a cache beside it. Edition-only pins. Understood that it's a feature; that change is withdrawn. The rest:
|
Small fixes: requests no longer wait behind batches, one hung reply, and a few speed-ups
Seventeen independent fixes found while load-testing FHIRsmith with real IG Publisher traffic and comparing its answers with tx.fhir.org's. Each is its own commit with its own test, and they can be taken separately. The branch is merged with main after #335.
Decisions for you
Each fix is independent, so you can take any subset. Most have one obvious answer; these involve a choice:
'??'snomed-expand-inactive2expects'??'exactly; plainer words need that test changed first$validate-codeagainstconcept in <unknown>still answers 400 "filter not understood", as main does; it could answerresultfalse insteadcontentelement or a supplement used as the systemkeepAliveTimeout65 s andheadersTimeout66 s, fixedloinc-expand-all-limitedand-totalneed their expected total updated when the test database is rebuiltSpeed
Supplements are looked up in an index, not by scanning every CodeSystem
What was wrong.
Provider.loadSupplements()walked every loaded CodeSystem (thousands, from the npm packages) and every resource in the request, on every code-system lookup, several times per$validate-code. In a CPU profile of IG-build traffic this scan was 18–24% of all CPU time.Effect. Replaying IG-build traffic against one process: at 100 requests/s the process was busy 51% of the time instead of 94%, and it kept up with about 160 requests/s instead of 80. (Measured on our development branch, which carries the same change; the scan is in code it shares with main.)
Change. The provider's CodeSystem map keeps an index of supplements by the URL they supplement, updated on every set and delete. The worker picks out the request's own supplement resources once per request.
$batch-validate-codegives other requests a turn between entriesWhat was wrong. Validating one entry never yields the event loop, and IG builds send batches of up to 1,000 codes. A 1,000-code batch held the event loop for about 1 s, and every request that arrived meanwhile waited for it.
Effect. On main, with a real 1,000-code SNOMED CT batch from an IG build running, a single-code
$validate-codethat takes 4–6 ms on its own was sent every 10 ms. Those lookups typically waited 0.6–0.9 s behind the batch, the worst 1.35 s, and in three of five runs 36–48 of them took over 1 s. With this change they typically took about 50 ms (worst 73–238 ms) and none took over 1 s. The batch was no slower (5 runs each).Change.
checkAndYieldbefore each entry. It only yields afterYIELD_INTERVALms of compute, so small batches are unaffected.Long operations yield every 10 ms instead of every 25 ms
Effect. In the same experiment (with the batch yield above), a lookup that arrives during the batch waits about two slices: typically 22 ms instead of 51 (worst 42 instead of 86). The batch took 842 ms instead of 879, so there is no measurable cost. This was measured only in that experiment, on our development branch with the same yield code, not under full load.
Change.
YIELD_INTERVALinoperation-context.jsis 10 instead of 25.Responses are serialised once
What was wrong. The
res.jsonwrapper intx.jsstringified the resource to log its size, then called the originalres.json(data), which stringified it again. That is about 3 ms of extra work per MB of response.Change. Send the string already built. The headers are the same, since
Content-Typeis set just above.A code that is not in an enumerated include is looked up once, not once per listed code
What was wrong. When the code being validated is not in an include's concept list, the checker tries each listed concept as the same concept written in another form (ICD-11's short code and entity id). For every listed concept,
sameConceptDifferentFormfirst looked up the code being validated, to ask whether it has another form at all. That answer is the same for every listed concept.Effect. On tx-dev.fhir.org, an RxNorm code not in a 1,185-code VSAC value set took 99 s (about 80 ms per listed code), and one not in a 33,341-code value set got no answer in 600 s. In the new test, a miss against a 300-code list looked the code up 301 times; it now looks it up twice.
Change. Ask once per include whether the code has another form, and only try the listed concepts when it does. The answers are the same.
The ConceptUsageTracker startup scan yields
What was wrong. Just after startup the tracker reads every value set (about 4,000 with tx.fhir.org's sources), about 1 s of work without a break. Requests arriving then waited for all of it.
Change. Give the event loop a turn every 10 ms of the scan. The counts are the same.
Correctness
An unknown cache-id on
CodeSystem/$validate-codeis answeredWhat was wrong.
setupAdditionalResourcesreports an unknown cache-id as a 404cache-id-unknownIssue, and a duplicatedx-cache-idheader as a 400cache-id-duplicateIssue. InhandleCodeSystem(type-levelCodeSystem/$validate-code), this is thrown before the innertry, so it reached the outercatch. There, an Issue not markedhandleAsOOfell into an empty branch ("handled in the inner method"), so no response was ever sent. The client waited until its own timeout. This was confirmed on tx.fhir.org. The other operations already answer these Issues.Change. The outer
catchanswers every Issue that reaches it with its OperationOutcome and status code, as the ValueSet handler does. Issues raised inside the validation are still turned into a result by the inner method, as before.An edition-only SNOMED CT version resolves to the latest loaded version of that edition
What was wrong.
registerProvideralso registers each code system under a partial version: for SNOMED CT, the edition URI without its date, and for semver,major.minor. It overwrote that key on every load, so with several versions of an edition loaded, a request that names only the edition got whichever version the library listed last. #331 picks the latest version for implicit value-set URLs, but not for this key.Change. Keep the latest version under the partial-version key, whatever the load order.
The LOINC importer no longer adds a blank Answer code
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 2.83). The importer turned each such row into an answer with the code''. All 65 lists shared that one blank Answer, with anAnswer/AnswerListlink to each list. SoLoinc_2.83b.dbhas 257,267 codes, one more than LOINC 2.83 has (257,266). That is the totalloinc-expand-all-limitedandloinc-expand-all-limited-totalexpect.Change. Such a row adds the list and nothing else. A database built with this change has 257,266 codes, so those two tests' expected total moves to 257,266 once the test database is rebuilt. Until then, the existing
Loinc_2.83b.dbstill has the blank code.$lookupworks for LOINC codes of class type 3 (register L6)What was wrong. The importer stores the
CLASSTYPEof class type 3 as "Claims attachment", but the LOINC provider's map knew only "Claims attachments". For those codes theCLASSTYPEproperty's value wasundefined, so the$lookupresponse check failed and the client got a 500, every time. That is 1,161 codes in LOINC 2.83, among them 18719-5, 19005-8, 18834-2, 18725-2, 26436-6, 18783-1, 18782-3 and 18726-0, all seen in IG Publisher traffic. TheCLASSTYPE = 3filter searched for the plural too, and found none of them.Effect. On a local server with
Loinc_2.83b.db,$lookupof each of those eight codes went from 500 to 200, withCLASSTYPE= 3 and the description "Claims attachment". An expansion with the filterCLASSTYPE = 3went from 0 codes to 1,161.Change. The map uses the importer's spelling, and still accepts the plural.
A supplement's designations match a text filter only when they have every word (register 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. So with the Uzbek LOINC supplement loaded (itslanguageisen),$expand?url=http://loinc.org/vs&filter=serum%20glucoselisted codes whose supplement text has either word. It hit the 1,000-code limit, and only 47 of those 1,000 had "glucose" in their display. Before #335 it listed none.Effect. The same request now lists 38 codes. 34 have "glucose" in their display; the other 4 have a word that starts with it ("Glucosamine", "glucosidase"), which is how words match in text filters. Whether a supplement that wasn't asked for takes part at all is a separate question (register X5), and is unchanged.
Change. A new
SearchFilterText.passesAllmatches each word aspassesdoes, but needs all of them. The supplement search uses it.A SNOMED CT filter on a concept the edition doesn't have selects nothing, rather than failing (register K3)
What was wrong.
concept is-a,=,descendent-of,child-of,generalizesanddescendent-leafon a concept that isn't in the edition threw "The SNOMED CT Concept … is not known", and$expandanswered 500.concept indid the same 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.Effect. On a local server,
$expandwithconcept in 41000147108(International Edition) and withconcept is-a 584021000052104(US Edition) went from 500 to 200 with no codes.$validate-codeof 22298006 against the second value set went from 500 toresultfalse.$validate-codeagainst theinvalue set still answers 400 "filter … not understood", as on main, because the provider'sdoesFilterstill says no to an unknown concept.Change. In the provider's
filter, a concept the edition doesn't have selects no concepts (every concept, foris-not-a), andfilterInskips unknown ids. ECL is unchanged: an unknown concept there is stillINVALID_ECL.The too-costly message never names a value set 'undefined' (register K11)
What was wrong. A value set given in the request needn't have a url. The too-costly message then named it
'undefined'or'??', depending on where the limit was hit.Effect. For the register's request (all of
urn:iso:std:iso:3166:-2, inline, no url), the 422 now says "The value set '??' expansion has too many codes to produce (>3000)" instead of naming it 'undefined'. A value set with an id is named by its id.Change. A small helper gives the url, else the value set's id, else
'??'. All five places that raise the message use it. We would have liked "the value set in the request", but tx-ecosystem'ssnomed-expand-inactive2expects the exact text'??', so changing it means changing that test first.Regex filters with alternation match whole codes: fixed upstream in #335
#335 anchors every regex filter as
^(?:pattern)$, which is what this branch's commit did for the FHIR CodeSystem and country providers. Main's version is kept and this fix is dropped. The branch still adds a country-code test (US|CAmust not matchUSA,LCAorTCA), since #335 tests alternation only for the FHIR CodeSystem provider.Robustness
An unknown modifier extension is refused with 422, not 500 (register K10)
What was wrong.
Extensions.checkNoModifiersrefuses an element that carries a modifier extension the server doesn't know. That 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. VSAC's value set 2.16.840.1.113883.11.20.9.46 (Ability) has one (vsacOpModifier) on an exclude filter.Effect. The register's request (a SNOMED CT
is-afilter with an unknown modifier extension) answers 422 business-rule instead of 500, with the same message.Change. The Issue carries 422.
$expandfrom a code system with no content answers 422, not 500 (register 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. The refusal had no status, so the client got 500. A code system the server doesn't have at all already answers 422.Effect. The register's request (THO's
time-period-ranges, codeP10Y--P15Y) answers 422 instead of 500, with the same message.Change. The refusal carries 422. So do its two neighbours in
checkSource: a code system with nocontent, and a supplement named as the system.Idle connections stay open longer than nginx keeps them
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 load tests, up to 2% of requests failed this way at some load levels.
Change.
keepAliveTimeoutis 65 s, andheadersTimeoutis 66 s (Node requires it to be longer). They are set in a small helper,library/keep-alive.js, called fromserver.js.A VSAC refresh interval that is too long no longer turns into 1 ms
What was wrong. Node runs a timer whose delay is above 2^31−1 ms (about 596 hours) after 1 ms instead. With
refreshIntervalHoursabove that, the VSAC refresh ran back to back. 100,000 hours gave thousands of unhandled rejections within seconds.Change. The interval is clamped to the longest delay a timer accepts. A value that is not a positive number falls back to the 24-hour default. Both log a warning.
Tests
New or extended tests. The enumerated-include, keep-alive and usage-tracker tests were also run against the code without their fix, and fail there.
tests/tx/loinc-import-answer-lists.test.js: an externally defined list adds no blank answer; ordinary lists are unchanged.tests/tx/codesystem-map.test.js: the supplement index across set, replace, delete and clear.tests/tx/batch-validate-yield.test.js: one yield check per batch entry, and the results are unchanged.tests/tx/cache-id.test.js:CodeSystem/$validate-codewith an unknown cache-id answers 404 with the coded error.tests/tx/library-partial-version.test.js: the partial version resolves to the latest loaded version, in either load order.tests/cs/cs-country.test.js: alternation matches whole codes only (the fix itself is big bug hunt #2 - various issues per Josh's report #335's).tests/tx/validate-enumerated-miss.test.js: a miss against a 300-code list looks the code up a handful of times, not 301; a listed code still validates.tests/vs/vs-vsac-refresh-interval.test.js: clamping, the fallback, and the delay passed tosetInterval.tests/library/keep-alive.test.js: the timeouts, and a connection idle for 6 s can still be reused.tests/tx/usage-tracker-yield.test.js: other work runs during the scan, with the same counts.tests/cs/cs-loinc.test.js: a code withCLASSTYPE"Claims attachment" has the property in$lookupand is found byCLASSTYPE = 3; a supplement's designations match "serum glucose" only when they have both words.tests/tx/designations.test.js:passesAllneeds every word,passesonly one.tests/cs/cs-snomed.test.js: each concept filter on a concept the edition doesn't have selects nothing (is-not-aselects everything), andconcept inskips it.tests/tx/expand.test.js: an unknown modifier extension on a filter gives 422; a code system that isnot-present, or a supplement, gives 422; the too-costly message names a value set by url, id, or as'??'.Each of these six tests fails without its fix and passes with it.
Results, on this branch merged with main after #335 (9059fb9):
cs-cs,cs-country,validate,batch-validate,expandand big bug hunt #2 - various issues per Josh's report #335'ssupplement-filters, and the new LOINC importer test.tests/tx(except the test-cases suite),tests/cs,tests/vsandtests/librarygive 3,732 passed, 2 failed, 79 skipped. Both failures are the LOINC import intests/cs/cs-loinc.test.jsrunning past its timeout (the import test, and the table check that waits on it) while the HL7 suite ran alongside; the LOINC provider tests in that file pass. (This run was before the too-costly fallback became'??'; the five expand suites were run again on the tip, f6ba83d, and pass.)cs-batch-validate,-badand-codings(R4 and R5, plus the R5 cached runs) andtranslate-reverse-r5+and-a(R5, plain and cached).Before #335 (on main e4a3b1f):
tests/tx(except the test-cases suite),tests/cs,tests/vs,tests/librarytests/tx/test-cases.test.js)The one jest failure was the same on both: the LOINC import test in
tests/cs/cs-loinc.test.jsran past its 120 s timeout on this machine.🤖 Generated with Claude Code