Repository navigation
LOINC: look up a filter's part name or regex once per factory; index Codes.Description - #384
Conversation
A relationship filter that names its part (SCALE_TYP = Doc) found the part with Codes.Description = ? COLLATE NOCASE inside every membership query; Description has no index, so each $validate-code read all 257,267 codes. A regex filter (CLASS regex) re-scanned the property's values on every request. Both answers depend only on the database, so the factory now keeps them (most recently used 256, at most 1,000,000 keys in all), and the filter's SQL uses the keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Nice speedup, and the cache looks safe: bounded, held per factory, and nothing is stored when a lookup fails or times out. One thing before merging: the provider now assumes Minor: the name key is case-sensitive while the match isn't, so Also, this touches the same |
|
Following up on the name lookup: the real fix is an index. I checked the query plans on the 2.83 database, and Can you add Keep the factory cache: regex filters can't use an index, because every candidate value still has to be tested, so caching is the only thing that helps them. For names, the cache still helps databases built before the index, and with the index a miss costs almost nothing anyway. |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The name lookup of a relationship filter (SCALE_TYP = Doc) was the only filter query that read a whole table. The importer now creates idx_codes_description ON Codes(Description COLLATE NOCASE), which the query uses (SEARCH ... USING COVERING INDEX): on LOINC 2.83 the lookup takes 0.04 ms instead of 18 ms, and the index is 14 MB. The CHANGELOG gives the sqlite3 command that adds it to an existing database. The factory's cache keys a name as NOCASE compares it (ASCII letters lower-cased), so Doc, DOC and doc are looked up once. A provider built without the factory's shared data (sqlite-callback-throws) makes its own cache instead of failing with a TypeError. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
…x/loinc-in-filter HealthIntersections#386 builds on HealthIntersections#384: a part name in a relationship in is looked up with HealthIntersections#384's #partKeysByName. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An in filter is now the union of = each value for every filter that takes in: answers-for in (each value an answer list or a term, as answers-for = reads it), and a relationship in takes part names as well as part codes (SCALE_TYP in Doc,Nar), as = does - a legacy form servers must accept. Each name is looked up with HealthIntersections#384's #partKeysByName (one indexed query, kept by the factory), and the filter uses the union of the keys. answers-for in is no longer refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
Thanks. All three are done (71aa079, on top of a merge of main e4abfec):
The factory cache stays as it was. #386 now builds on this branch: a part name in a relationship |
|
Thanks Josh, looks good. I'll tidy up the change log. And it would be good if the server noticed an existing database without idx_codes_description and said so once at startup (or in --check), since operators won’t read the CHANGELOG for a sqlite command; the cache makes a missing index cheap, but the hint costs nothing. Separately, not this PR: cs-loinc.test.js re-imports data/loinc-testing.db while cs-loinc-classtype, cs-worker-readonly and cluster-worker-readonly copy it, so they can copy a half-written file when run in parallel. cs-loinc-classtype failed that way for me in a combined run on this branch. I'll merge this, but you might want to follow up on those things. |
#384) On a copy of LOINC 2.83 imported before #384: one warning, with the command; after running that command (1.1 s), none. LoincServicesFactory.load looks for the index in sqlite_master and, when it's missing, logs once per file: LOINC: <file> has no index on Codes.Description, so a filter that names a part (SCALE_TYP = Doc) reads every code the first time. Add it once, while the server isn't using the file: sqlite3 <file> "CREATE INDEX IF NOT EXISTS idx_codes_description ON Codes(Description COLLATE NOCASE)" A tx-worker (readOnly) doesn't check: the master loads the same file. 4 tests: warns once without the index (path quoted for the shell), silent with it, silent in a worker, and the logged command silences it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…amed into place (#384) 4 suites in 4 jest workers, 10 runs: 0/10 passed before (cs-worker-readonly and cluster-worker-readonly copied a half-written file: "no such table: Codes"), 10/10 after. - tests/loinc-testing-db.js: importLoincTestingDb imports to a temporary file next to data/loinc-testing.db, sets journal_mode DELETE, renames it into place; loincTestingDbCopy copies it, importing first if it's missing - cs-loinc.test.js imports through it (no longer deleting the file first) and opens it read-only; its provider tests use a copy - cs-loinc-classtype, cs-worker-readonly, cluster-worker-readonly, ancestors-of and closure-providers use copies: a provider loading the shared file set WAL and made indexes in it while others copied it - the import goes to the repository's data/ (where every reader looked), not FHIRSMITH_DATA_DIR Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
After #379, a LOINC filter checked by
$validate-coderuns one indexed query per code (lsql). Two filter forms still redo a whole-table read on every request:SCALE_TYP = Doc). The membership query was... TargetKey IN (SELECT CodeKey FROM Codes WHERE Description = 'Doc' COLLATE NOCASE) AND SourceKey = ?.Codes.Descriptionhad no index, so each call scanned all 257,267 codes on LOINC 2.83: 17.9 ms CPU for that query alone (plan:LIST SUBQUERY 1 | SCAN Codes). With the two keys written in, the same query takes 0.014 ms.CLASS regex CYTO|HL7\.CYTOGEN|...).#findRegexMatchesre-reads the property's values and re-runs the regex on every request.Change
tx/cs/cs-loinc.js:LoincFilterLookupsthat remembers:Description = ? COLLATE NOCASEquery, run once per name). The key is the name lower-cased as NOCASE compares it (ASCII letters only), soDoc,DOCanddocshare one entry;TargetKey IN (70401,71649)), so the per-codelsqlis a plain indexed lookup.tests/cs/sqlite-callback-throws.test.jsdoes) makes its own:this.filterLookups = sharedData.filterLookups || new LoincFilterLookups().tx/importers/import-loinc.module.js: a new index next to the otherCodesindexes,The name lookup was the only filter query that read a whole table. On LOINC 2.83 (a copy, index added with the sqlite3 command line, 1.6 s):
SELECT CodeKey FROM Codes WHERE Description = ? COLLATE NOCASESCAN CodesSEARCH Codes USING COVERING INDEX idx_codes_description (Description=?)Doc,DOC,Nar,Qn, a name no code has)The cache stays: a regex can't use an index (every candidate value is tested), and for names it covers databases imported before the index.
CHANGELOG.md(Unreleased): both changes, and the command that adds the index to an existing database without re-importing:The branch has upstream main (e4abfec) merged in, so the changelog goes in a new Unreleased section above 1.0.2.
Measured
The numbers below are from the first commit (cache only, on a database without the new index). Built-in LOINC 2.83, one server, requests sent one at a time, warm (second pass measured). The numbers are server CPU per call: the machine was shared with other jobs.
Two requests, each sent 100 times (
POST /r4/ValueSet/$validate-code):{"resourceType":"Parameters","parameter":[ {"name":"coding","valueCoding":{"system":"http://loinc.org","code":"60591-5"}}, {"name":"valueSet","resource":{"resourceType":"ValueSet","url":"http://example.org/ValueSet/scale-doc","status":"active", "compose":{"include":[{"system":"http://loinc.org","filter":[{"property":"SCALE_TYP","op":"=","value":"Doc"}]}]}}}]}{"resourceType":"Parameters","parameter":[ {"name":"coding","valueCoding":{"system":"http://loinc.org","code":"2345-7"}}, {"name":"valueSet","resource":{"resourceType":"ValueSet","url":"http://example.org/ValueSet/lab-not-path","status":"active", "compose":{"include":[{"system":"http://loinc.org","filter":[{"property":"STATUS","op":"=","value":"ACTIVE"},{"property":"CLASSTYPE","op":"=","value":"1"}]}], "exclude":[{"system":"http://loinc.org","filter":[{"property":"CLASS","op":"regex","value":"CYTO|HL7\\.CYTOGEN|HL7\\.GENETICS|^PATH(\\..*)?|^MOLPATH(\\..*)?|NR STATS|H&P\\.HX\\.LAB|CHALSKIN|LABORDERS"}]}]}}}]}SCALE_TYP = DocCLASS regexexcludeWorkload replayed from IG Publisher traffic: 1,866
$validate-codecalls against LOINC rule-based value sets, two runs of each, alternating:SCALE_TYP =(183 calls), CPU per callCLASS regex+ ... (66 calls), CPU per callOn the whole replay (44,498
$validate-codecalls), CPU went from 120.7 s to 115.1 s, and the LOINC rule-based calls in it from 12.7 s to 6.0 s. All 44,498 answers are identical to main's.Tests
tests/cs/cs-loinc.test.js, 2 new tests:SCALE_TYP = Docand= DOClist and check the codes related to the parts named Doc. A name no part has gives no codes. The "each filter: checked = listed" list now also includes these filters andCLASS regex.Doc,DOCanddoc. The test counts the statements that readCodes.Descriptionand the CLASS part scan. On main they run[3, 3]times, so the test fails there; with a case-sensitive cache key the names run 3 times. Here they run[1, 1]times.tests/cs/sqlite-callback-throws.test.js: 5 of 5 pass (without the constructor fallback, the COMPONENT regex test fails withCannot read properties of undefined (reading 'get')).cs-loinc,cs-loinc-classtype,sqlite-callback-throws,sqlite-provider-close,cs-worker-readonly,ancestors-of,tx/loinc-import-answer-lists): 116 passed, 2 skipped (7 suites)tests/csandtests/tx: 171 suites pass (5 skipped): 3,260 tests pass, 87 skipped (npx jest tests/cs tests/tx, test-cases run separately below)tests/tx/test-cases.test.js: 5,251 of 5,251 pass (validator jar viaFHIRSMITH_VALIDATOR_JAR)🤖 Generated with Claude Code