Skip to content

Look up supplements in an index kept with the CodeSystem map, instead of scanning every CodeSystem - #340

Merged
grahamegrieve merged 1 commit into
HealthIntersections:mainfrom
jmandel:fix/supplement-index
Nov 4, 2026
Merged

grahamegrieve merged 1 commit into
HealthIntersections:mainfrom
jmandel:fix/supplement-index

Conversation

@jmandel

@jmandel jmandel commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Register row P2.

What was wrong

Provider.loadSupplements walks every CodeSystem the endpoint has loaded (thousands, from the npm packages), asking each one isSupplementFor(url, version). It does this on every code-system lookup, several times per $validate-code. In a CPU profile of replayed IG Publisher traffic, this scan was 18–24% of all CPU time. One process kept up with about 80 requests/s with the scan, and about 160 with an index. That was measured on our development branch with an earlier version of this index, which had the same lookup cost.

There's no single curl for this: it is a cost paid by every request, and it shows under load.

Code path

tx/provider.js on main, Provider.loadSupplements (line 114), lines 119–125:

for (let cs of this.codeSystems.values()) {
  if (cs.isSupplementFor(url, version)) {
    if (cs.isGlobalSupplement() || this.isStatedSupplement(cs, statedSupplements)) {
      supplements.set(cs.vurl, cs);

CodeSystem.isSupplementFor (tx/library/codesystem.js:742) is true only when content is supplement and supplements either equals url or starts with url + '|'.

Change

provider.codeSystems becomes a CodeSystemMap: a Map that also keeps, for each url, the set of its own keys whose CodeSystem has a supplements value with that url. "That url" means the part before any |. loadSupplements asks isSupplementFor only of those, in the map's own order. Everything else is unchanged: the same isSupplementFor, isGlobalSupplement and isStatedSupplement checks, and the same supplements.set(cs.vurl, cs).

Why this is safe

The aim is that, for every question, the index gives the same objects in the same order as the scan.

Nothing is missed. If isSupplementFor(url, …) is true, then supplements equals url or starts with url + '|'. Either way, the part of supplements before its first | is the part of url before its first |, and that is the index key on both sides. So every CodeSystem the scan would accept is among the candidates. A url that itself carries |version (CodeSystem/$validate-code?url=http://x|1) works too: its key is http://x.

Extra candidates do no harm. The index takes in any CodeSystem with a supplements value, whatever its content. isSupplementFor still decides, as before. A version mismatch, or content other than supplement, is turned away exactly as the scan turned it away.

The same order. loadSupplements keys its result by vurl. When two different objects share a vurl (a core-package copy and a THO copy, say), the one seen last wins, so order matters. The index records the position at which each key first entered the map. Map semantics are that replacing a key keeps its place and deleting then re-adding moves it to the end; the index mirrors this. Candidates come back sorted by that position, which is the order values() gives.

Every change to the map updates the index. A Map changes only through set, delete and clear. CodeSystemMap overrides all three and updates the index in the same call. When a key is replaced or deleted, it is removed from the bucket it was put in, recorded at the time. That bucket is not worked out again from the value, so a value that changed since can't leave a stale entry. These are every place on main that changes provider.codeSystems:

Where (main) What
tx/library.js:1175, Library.cloneWithFhirVersion creates the map (now new CodeSystemMap()). One per endpoint
tx/provider.js:660, addCodeSystem delete(cs.url) when a THO code system takes over from a core one
tx/provider.js:668, addCodeSystem set(cs.url, cs)
tx/provider.js:676, addCodeSystem set(cs.vurl, cs)
tx/provider.js:731, 732, deleteCodeSystem delete(cs.vurl), delete(cs.url)
tx/provider.js:746, deleteCodeSystem set(existing.url, existing): re-points the url at the latest survivor

And every caller of those two methods:

Where (main) When
tx/provider.js:212, loadNpm each CodeSystem in an npm package, at startup
tx/library.js:1210, cloneWithFhirVersion each CodeSystem from the library's code system providers, at startup
tx/provider.js:620, 623, 626, updateCodeSystemList added, changed and deleted CodeSystems from a provider's getCodeSystemChanges (only OCL implements it), every 60 s (tx/tx.js:334)

Nothing else changes the map. No code calls Map.prototype.set on it directly, and no other code assigns provider.codeSystems.

Values aren't changed in place. The index reads a CodeSystem's supplements when it is set. Nothing on main assigns jsonObj.supplements after a CodeSystem is built: codeSystemToR5 runs once, in the constructor. OCL does change jsonObj.content in place (tx/ocl/cs-ocl.cjs:440 and others), but the index doesn't look at content.

A supplement re-added with another supplements url. addCodeSystem of the new object sets its keys. If the url changed, the keys move from the old url's bucket to the new one. deleteCodeSystem deletes them. Both are covered by the tests below.

Versions. The index ignores versions on both sides. isSupplementFor still does the version matching (versionMatchesByAlgorithm) on the candidates, exactly as before.

Request-scoped supplements are not in this map. Supplements sent with the request (tx-resource), or held under a cache-id ($cache-control, ResourceCache in tx/operation-context.js:83), go into the worker's additionalResources (tx/workers/worker.js:746, setupAdditionalResources). They never enter provider.codeSystems. TerminologyWorker.#findSupplements (worker.js:343) walks them itself, after asking the provider. That walk is unchanged.

The one difference. A CodeSystem marked content: supplement with no supplements element makes isSupplementFor throw (suppU.startsWith on undefined). With one of those loaded, the scan threw on every lookup of every code system. The index never offers it, so it is skipped.

How this differs from #332's version

The index in #332 differed from the scan in three ways. Each was found by the comparison test below, run against that version:

  1. A url with |version was missed. It was looked up as-is, under a key without |.
  2. Order. A replaced key moved to the end of its bucket.
  3. Which copy wins. Because of (2), when two objects shared a vurl, the other one could win.

It also keyed on content === 'supplement' and worked out the bucket again from the value when removing it. And it cached the request's supplement resources per request, a second invariant to argue for. That part is left out: it was a small saving next to this one.

Test

tests/tx/codesystem-map.test.js:

  • Property tests. The same random sequences run against a Provider whose map is a plain Map (the scan) and one whose map is a CodeSystemMap (the index):

    • 8 seeds × 150 steps of addCodeSystem/deleteCodeSystem, with code systems from THO, core and other packages;
    • 4 seeds × 200 steps of raw set/delete/clear on any key.

    The code systems are supplements or not, global or not, with or without a version, and with supplements with and without |version, pointing at several urls (including http://x/a and http://x/a/sub). After every step, the test asks 60 questions:

    • url: http://x/a, http://x/b, http://x/a/sub, http://x/a|1.0.0 or http://x/c;
    • version: none, 1.0.0, 2.0.0 or 1;
    • stated supplements: none, or two different sets.

    The two must return the same objects in the same order, and the maps must hold the same entries. More than 50 steps per seed find supplements, so the comparison isn't vacuous. Run against Small fixes: requests no longer wait behind batches, one hung reply, regex alternation, and a few speed-ups #332's version, these tests fail. A count over the same 8 add/delete sequences found 1,672 differing answers for a |version url, 306 that differ only in order, and 87 that return a different object.

  • Cases:

    • a key moving between urls keeps its place;
    • delete then re-add moves to the end;
    • a supplement re-added with another supplements url is found under the new one only;
    • a supplement pinned to url|2.0.0 is found for 2.0.0 and not for 1.0.0;
    • the malformed supplement above: the scan throws, the index skips it.
  • Existing suites pass unchanged: provider-codesystem-list, provider-tho-precedence, supplement-filters, params-supplement-cachekey, lookup and ocl-factory-discovery.

Results

The touched suites, run in band: 74 of 74 pass. HL7 tx-ecosystem test cases (validator 7.0.3), with this branch merged with the other three from #332's split: 5,048 of 5,061 pass. The 13 failures are the same 13 main 9059fb9 fails: cs-batch-validate, -bad and -codings (R4 and R5, and R5 cached), and translate-reverse-r5+ and -a (R5, plain and cached).

🤖 Generated with Claude Code

…nning every CodeSystem

loadSupplements() walked every loaded CodeSystem (thousands, from the npm packages) on every
code-system lookup, several times per $validate-code, asking each isSupplementFor(url, version).
Under replayed IG-build traffic that scan was 18-24% of all CPU time.

The provider's CodeSystem map is now a CodeSystemMap: a Map that also keeps, for each url, the
keys whose CodeSystem has a supplements value with that url (the part before any '|'). It is
updated in set, delete and clear, the only ways to change a Map, and Provider.addCodeSystem and
deleteCodeSystem are the only code that changes this map. loadSupplements asks
isSupplementFor of those candidates only, in the map's own order, so it returns the same objects
in the same order as the scan; random sequences of adds, deletes, sets and clears check that.

isSupplementFor is true only when supplements equals the url or starts with url + '|', and in
both cases the parts before any '|' are equal, so no supplement the scan would find is missed,
including for a url that carries |version. The index takes in any CodeSystem with a supplements
value, whatever its content, and removes a key from the bucket it was put in, not one worked out
again from the value. Request resources (tx-resource, cache-id) are not in this map, and are
walked by the worker as before.

The one difference: a CodeSystem marked as a supplement with no supplements element made
isSupplementFor throw, so the scan threw on every lookup; the index skips it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@grahamegrieve grahamegrieve left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok thanks

@grahamegrieve
grahamegrieve merged commit 62f888b into HealthIntersections:main Nov 4, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants