From ad4aceb26cc42a181820c226cb03e1629c6fd265 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Wed, 1 Jul 2026 10:47:13 +1000 Subject: [PATCH] rework caching --- tests/tx/cache-control.test.js | 113 +++++++++++++++++++++++++++++++++ tx/operation-context.js | 22 ++++++- tx/workers/cache-control.js | 33 +++++++++- tx/workers/worker.js | 23 +++++-- 4 files changed, 179 insertions(+), 12 deletions(-) diff --git a/tests/tx/cache-control.test.js b/tests/tx/cache-control.test.js index 99f50f40..af07933d 100644 --- a/tests/tx/cache-control.test.js +++ b/tests/tx/cache-control.test.js @@ -309,4 +309,117 @@ describe('$cache-control routing (scaffolding)', () => { expect(coding.some(c => c.code === 'cache-id-unknown')).toBe(true); }); }); + + // ---- sealed vs unsealed caches ---- + // + // `sealed` (start parameter) governs whether the cache may grow after creation. + // Sealed: only the front-loaded resources are ever in the cache; resources sent + // on a later request are used for that request but not retained. Unsealed: the + // cache accumulates resources it sees, so a resource sent once resolves by + // reference thereafter. + // + // NOTE: the server default is currently unsealed (transitional), which differs + // from the protocol default of sealed=true; that flips once all clients send an + // explicit `sealed`. + describe('sealed vs unsealed caches', () => { + const csA = { + resourceType: 'CodeSystem', + url: 'http://example.org/seal-test/csA', + version: '1.0.0', status: 'active', content: 'complete', + concept: [{ code: 'a1', display: 'A One' }] + }; + const vsA = { + resourceType: 'ValueSet', + url: 'http://example.org/seal-test/vsA', + version: '1.0.0', status: 'active', + compose: { include: [{ system: csA.url }] } + }; + // A second VS not front-loaded; used to probe whether the cache grew. + const csB = { + resourceType: 'CodeSystem', + url: 'http://example.org/seal-test/csB', + version: '1.0.0', status: 'active', content: 'complete', + concept: [{ code: 'b1', display: 'B One' }] + }; + const vsB = { + resourceType: 'ValueSet', + url: 'http://example.org/seal-test/vsB', + version: '1.0.0', status: 'active', + compose: { include: [{ system: csB.url }] } + }; + + async function start(sealed) { + const params = [ + { name: 'tx-resource', resource: csA }, + { name: 'valueSet', resource: vsA } + ]; + if (sealed !== undefined) params.push({ name: 'sealed', valueBoolean: sealed }); + const started = await request(app) + .post(BASE).query({ mode: 'start' }) + .set('Content-Type', 'application/json') + .send({ resourceType: 'Parameters', parameter: params }); + return started.body; + } + + test('start echoes the sealed flag it applied', async () => { + const body = await start(true); + const p = (body.parameter || []).find(x => x.name === 'sealed'); + expect(p && p.valueBoolean).toBe(true); + }); + + test('default (no sealed param) is unsealed on this server (transitional)', async () => { + const body = await start(undefined); + const p = (body.parameter || []).find(x => x.name === 'sealed'); + expect(p && p.valueBoolean).toBe(false); + }); + + test('a sealed cache does not retain a resource sent on a later request', async () => { + const cacheId = cacheIdFrom(await start(true)); + + // Send vsB/csB inline on a validate call (works for this call)... + const first = await request(app) + .post('/tx/r5/ValueSet/$validate-code') + .set('Content-Type', 'application/json') + .set('x-cache-id', cacheId) + .send({ resourceType: 'Parameters', parameter: [ + { name: 'valueSet', resource: vsB }, + { name: 'tx-resource', resource: csB }, + { name: 'coding', valueCoding: { system: csB.url, code: 'b1' } } + ] }); + expect(first.status).toBe(200); + + // ...but the sealed cache must not have kept vsB: a by-reference call now 404s. + const second = await request(app) + .post('/tx/r5/ValueSet/$expand') + .set('Content-Type', 'application/json') + .set('x-cache-id', cacheId) + .send({ resourceType: 'Parameters', parameter: [{ name: 'url', valueUri: vsB.url }] }); + expect(second.status).not.toBe(200); + }); + + test('an unsealed cache retains a resource sent on a later request', async () => { + const cacheId = cacheIdFrom(await start(false)); + + const first = await request(app) + .post('/tx/r5/ValueSet/$validate-code') + .set('Content-Type', 'application/json') + .set('x-cache-id', cacheId) + .send({ resourceType: 'Parameters', parameter: [ + { name: 'valueSet', resource: vsB }, + { name: 'tx-resource', resource: csB }, + { name: 'coding', valueCoding: { system: csB.url, code: 'b1' } } + ] }); + expect(first.status).toBe(200); + + // The unsealed cache kept vsB: it now resolves by reference. + const second = await request(app) + .post('/tx/r5/ValueSet/$expand') + .set('Content-Type', 'application/json') + .set('x-cache-id', cacheId) + .send({ resourceType: 'Parameters', parameter: [{ name: 'url', valueUri: vsB.url }] }); + expect(second.status).toBe(200); + const codes = ((second.body.expansion || {}).contains || []).map(c => c.code); + expect(codes).toContain('b1'); + }); + }); }); diff --git a/tx/operation-context.js b/tx/operation-context.js index 9be55b07..4739417b 100644 --- a/tx/operation-context.js +++ b/tx/operation-context.js @@ -118,6 +118,19 @@ class ResourceCache { return this.cache.has(cacheId); } + /** + * Whether a cache is sealed. A sealed cache holds only the resources it was + * created with (at $cache-control?mode=start) and does not grow as further + * resources are seen on subsequent operations. An unsealed cache accumulates + * every resource it sees. Unknown/absent cache-ids report false. + * @param {string} cacheId - The cache identifier + * @returns {boolean} + */ + isSealed(cacheId) { + const entry = this.cache.get(cacheId); + return entry ? !!entry.sealed : false; + } + /** * Add resources to a cache-id (merges with existing) * @param {string} cacheId - The cache identifier @@ -158,9 +171,11 @@ class ResourceCache { * Set resources for a cache-id (replaces existing) * @param {string} cacheId - The cache identifier * @param {Array} resources - Resources to set + * @param {boolean} [sealed=false] - If true, the cache is fixed at these + * resources and will not grow when further resources are seen later. */ - set(cacheId, resources) { - this.log.info(`cache-id '${cacheId}': set (replace all) with ${resources.length} resource(s): ${resources.map(r => this._resourceKey(r)).join(', ')}`); + set(cacheId, resources, sealed = false) { + this.log.info(`cache-id '${cacheId}': set (replace all, sealed=${!!sealed}) with ${resources.length} resource(s): ${resources.map(r => this._resourceKey(r)).join(', ')}`); // Drop the old entry's contribution, then count the replacement. const existing = this.cache.get(cacheId); if (existing) { @@ -174,7 +189,8 @@ class ResourceCache { this.cache.set(cacheId, { resources: [...resources], lastUsed: Date.now(), - concepts + concepts, + sealed: !!sealed }); this._trackMax(); } diff --git a/tx/workers/cache-control.js b/tx/workers/cache-control.js index 2171fe3f..47e3b5ce 100644 --- a/tx/workers/cache-control.js +++ b/tx/workers/cache-control.js @@ -124,17 +124,46 @@ class CacheControlWorker extends TerminologyWorker { const { txResources, primaryResources } = this.collectSuppliedResources(params.jsonObj); const resources = txResources.concat(primaryResources); + // `sealed` controls whether the cache may grow after creation. When sealed, + // the cache holds only the resources front-loaded here; when unsealed, later + // operations accumulate every resource they see into it (see + // setupAdditionalResources). + // + // NOTE: the protocol default is `true`, but the server default here is + // deliberately `false` during the transition: existing clients that rely on + // incremental population and do not yet send `sealed` must keep working. + // Flip this to default-true once all clients send an explicit value. + const sealed = this.readSealed(params.jsonObj); + const cacheId = crypto.randomUUID(); - cache.set(cacheId, resources); + cache.set(cacheId, resources, sealed); return res.status(200).json({ resourceType: 'Parameters', parameter: [ - { name: 'cache-id', valueId: cacheId } + { name: 'cache-id', valueId: cacheId }, + { name: 'sealed', valueBoolean: sealed } ] }); } + /** + * Read the `sealed` boolean from the start request's Parameters. + * + * Server-side default is FALSE (transitional — see start()): a cache is only + * sealed if the client explicitly asks for it. Accepts a real JSON boolean or + * the string "true"/"false" for robustness across clients. + * + * @param {Object} params - Parameters resource (jsonObj) + * @returns {boolean} + */ + readSealed(params) { + const p = this.findParameter(params, 'sealed'); + if (!p) return false; + const v = this.getParameterValue(p); + return v === true || v === 'true'; + } + /** * mode=end: release the cache named by the `${CACHE_ID_HEADER}` header so the * server can reclaim it now rather than waiting for the idle timeout. diff --git a/tx/workers/worker.js b/tx/workers/worker.js index 0213d390..89c83bfa 100644 --- a/tx/workers/worker.js +++ b/tx/workers/worker.js @@ -666,14 +666,23 @@ class TerminologyWorker { 'cache-id-unknown', 404); } - // The cache exists: merge any resources supplied on this request into it - // (incremental population is allowed), then expose the full cache contents. - const toCache = txResources.concat(primaryResources); - if (toCache.length > 0) { - this.opContext.resourceCache.add(cacheId, toCache); + // The cache exists. If it is unsealed, merge any resources supplied on this + // request into it (incremental population). If it is sealed, the cache is + // fixed at what it was created with: resources supplied now are still used + // for this request (via additionalResources below) but are NOT added to the + // shared cache, so a sealed cache never grows. + if (!this.opContext.resourceCache.isSealed(cacheId)) { + const toCache = txResources.concat(primaryResources); + if (toCache.length > 0) { + this.opContext.resourceCache.add(cacheId, toCache); + } + this.additionalResources = this.opContext.resourceCache.get(cacheId); + } else { + // Sealed: expose the cache contents plus this request's own inline + // resources (used for this call only), without mutating the cache. + this.additionalResources = this.opContext.resourceCache.get(cacheId) + .concat(txResources, primaryResources); } - - this.additionalResources = this.opContext.resourceCache.get(cacheId); this.additionalResourcesCacheId = cacheId; } else { // No cache-id, just use the tx-resources directly