Skip to content

Commit ce53354

Browse files
Merge pull request #264 from HealthIntersections/2026-07-gg-cache-rework
rework caching
2 parents c93d839 + ad4aceb commit ce53354

4 files changed

Lines changed: 179 additions & 12 deletions

File tree

tests/tx/cache-control.test.js

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,4 +309,117 @@ describe('$cache-control routing (scaffolding)', () => {
309309
expect(coding.some(c => c.code === 'cache-id-unknown')).toBe(true);
310310
});
311311
});
312+
313+
// ---- sealed vs unsealed caches ----
314+
//
315+
// `sealed` (start parameter) governs whether the cache may grow after creation.
316+
// Sealed: only the front-loaded resources are ever in the cache; resources sent
317+
// on a later request are used for that request but not retained. Unsealed: the
318+
// cache accumulates resources it sees, so a resource sent once resolves by
319+
// reference thereafter.
320+
//
321+
// NOTE: the server default is currently unsealed (transitional), which differs
322+
// from the protocol default of sealed=true; that flips once all clients send an
323+
// explicit `sealed`.
324+
describe('sealed vs unsealed caches', () => {
325+
const csA = {
326+
resourceType: 'CodeSystem',
327+
url: 'http://example.org/seal-test/csA',
328+
version: '1.0.0', status: 'active', content: 'complete',
329+
concept: [{ code: 'a1', display: 'A One' }]
330+
};
331+
const vsA = {
332+
resourceType: 'ValueSet',
333+
url: 'http://example.org/seal-test/vsA',
334+
version: '1.0.0', status: 'active',
335+
compose: { include: [{ system: csA.url }] }
336+
};
337+
// A second VS not front-loaded; used to probe whether the cache grew.
338+
const csB = {
339+
resourceType: 'CodeSystem',
340+
url: 'http://example.org/seal-test/csB',
341+
version: '1.0.0', status: 'active', content: 'complete',
342+
concept: [{ code: 'b1', display: 'B One' }]
343+
};
344+
const vsB = {
345+
resourceType: 'ValueSet',
346+
url: 'http://example.org/seal-test/vsB',
347+
version: '1.0.0', status: 'active',
348+
compose: { include: [{ system: csB.url }] }
349+
};
350+
351+
async function start(sealed) {
352+
const params = [
353+
{ name: 'tx-resource', resource: csA },
354+
{ name: 'valueSet', resource: vsA }
355+
];
356+
if (sealed !== undefined) params.push({ name: 'sealed', valueBoolean: sealed });
357+
const started = await request(app)
358+
.post(BASE).query({ mode: 'start' })
359+
.set('Content-Type', 'application/json')
360+
.send({ resourceType: 'Parameters', parameter: params });
361+
return started.body;
362+
}
363+
364+
test('start echoes the sealed flag it applied', async () => {
365+
const body = await start(true);
366+
const p = (body.parameter || []).find(x => x.name === 'sealed');
367+
expect(p && p.valueBoolean).toBe(true);
368+
});
369+
370+
test('default (no sealed param) is unsealed on this server (transitional)', async () => {
371+
const body = await start(undefined);
372+
const p = (body.parameter || []).find(x => x.name === 'sealed');
373+
expect(p && p.valueBoolean).toBe(false);
374+
});
375+
376+
test('a sealed cache does not retain a resource sent on a later request', async () => {
377+
const cacheId = cacheIdFrom(await start(true));
378+
379+
// Send vsB/csB inline on a validate call (works for this call)...
380+
const first = await request(app)
381+
.post('/tx/r5/ValueSet/$validate-code')
382+
.set('Content-Type', 'application/json')
383+
.set('x-cache-id', cacheId)
384+
.send({ resourceType: 'Parameters', parameter: [
385+
{ name: 'valueSet', resource: vsB },
386+
{ name: 'tx-resource', resource: csB },
387+
{ name: 'coding', valueCoding: { system: csB.url, code: 'b1' } }
388+
] });
389+
expect(first.status).toBe(200);
390+
391+
// ...but the sealed cache must not have kept vsB: a by-reference call now 404s.
392+
const second = await request(app)
393+
.post('/tx/r5/ValueSet/$expand')
394+
.set('Content-Type', 'application/json')
395+
.set('x-cache-id', cacheId)
396+
.send({ resourceType: 'Parameters', parameter: [{ name: 'url', valueUri: vsB.url }] });
397+
expect(second.status).not.toBe(200);
398+
});
399+
400+
test('an unsealed cache retains a resource sent on a later request', async () => {
401+
const cacheId = cacheIdFrom(await start(false));
402+
403+
const first = await request(app)
404+
.post('/tx/r5/ValueSet/$validate-code')
405+
.set('Content-Type', 'application/json')
406+
.set('x-cache-id', cacheId)
407+
.send({ resourceType: 'Parameters', parameter: [
408+
{ name: 'valueSet', resource: vsB },
409+
{ name: 'tx-resource', resource: csB },
410+
{ name: 'coding', valueCoding: { system: csB.url, code: 'b1' } }
411+
] });
412+
expect(first.status).toBe(200);
413+
414+
// The unsealed cache kept vsB: it now resolves by reference.
415+
const second = await request(app)
416+
.post('/tx/r5/ValueSet/$expand')
417+
.set('Content-Type', 'application/json')
418+
.set('x-cache-id', cacheId)
419+
.send({ resourceType: 'Parameters', parameter: [{ name: 'url', valueUri: vsB.url }] });
420+
expect(second.status).toBe(200);
421+
const codes = ((second.body.expansion || {}).contains || []).map(c => c.code);
422+
expect(codes).toContain('b1');
423+
});
424+
});
312425
});

tx/operation-context.js

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,19 @@ class ResourceCache {
118118
return this.cache.has(cacheId);
119119
}
120120

121+
/**
122+
* Whether a cache is sealed. A sealed cache holds only the resources it was
123+
* created with (at $cache-control?mode=start) and does not grow as further
124+
* resources are seen on subsequent operations. An unsealed cache accumulates
125+
* every resource it sees. Unknown/absent cache-ids report false.
126+
* @param {string} cacheId - The cache identifier
127+
* @returns {boolean}
128+
*/
129+
isSealed(cacheId) {
130+
const entry = this.cache.get(cacheId);
131+
return entry ? !!entry.sealed : false;
132+
}
133+
121134
/**
122135
* Add resources to a cache-id (merges with existing)
123136
* @param {string} cacheId - The cache identifier
@@ -158,9 +171,11 @@ class ResourceCache {
158171
* Set resources for a cache-id (replaces existing)
159172
* @param {string} cacheId - The cache identifier
160173
* @param {Array} resources - Resources to set
174+
* @param {boolean} [sealed=false] - If true, the cache is fixed at these
175+
* resources and will not grow when further resources are seen later.
161176
*/
162-
set(cacheId, resources) {
163-
this.log.info(`cache-id '${cacheId}': set (replace all) with ${resources.length} resource(s): ${resources.map(r => this._resourceKey(r)).join(', ')}`);
177+
set(cacheId, resources, sealed = false) {
178+
this.log.info(`cache-id '${cacheId}': set (replace all, sealed=${!!sealed}) with ${resources.length} resource(s): ${resources.map(r => this._resourceKey(r)).join(', ')}`);
164179
// Drop the old entry's contribution, then count the replacement.
165180
const existing = this.cache.get(cacheId);
166181
if (existing) {
@@ -174,7 +189,8 @@ class ResourceCache {
174189
this.cache.set(cacheId, {
175190
resources: [...resources],
176191
lastUsed: Date.now(),
177-
concepts
192+
concepts,
193+
sealed: !!sealed
178194
});
179195
this._trackMax();
180196
}

tx/workers/cache-control.js

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,17 +124,46 @@ class CacheControlWorker extends TerminologyWorker {
124124
const { txResources, primaryResources } = this.collectSuppliedResources(params.jsonObj);
125125
const resources = txResources.concat(primaryResources);
126126

127+
// `sealed` controls whether the cache may grow after creation. When sealed,
128+
// the cache holds only the resources front-loaded here; when unsealed, later
129+
// operations accumulate every resource they see into it (see
130+
// setupAdditionalResources).
131+
//
132+
// NOTE: the protocol default is `true`, but the server default here is
133+
// deliberately `false` during the transition: existing clients that rely on
134+
// incremental population and do not yet send `sealed` must keep working.
135+
// Flip this to default-true once all clients send an explicit value.
136+
const sealed = this.readSealed(params.jsonObj);
137+
127138
const cacheId = crypto.randomUUID();
128-
cache.set(cacheId, resources);
139+
cache.set(cacheId, resources, sealed);
129140

130141
return res.status(200).json({
131142
resourceType: 'Parameters',
132143
parameter: [
133-
{ name: 'cache-id', valueId: cacheId }
144+
{ name: 'cache-id', valueId: cacheId },
145+
{ name: 'sealed', valueBoolean: sealed }
134146
]
135147
});
136148
}
137149

150+
/**
151+
* Read the `sealed` boolean from the start request's Parameters.
152+
*
153+
* Server-side default is FALSE (transitional — see start()): a cache is only
154+
* sealed if the client explicitly asks for it. Accepts a real JSON boolean or
155+
* the string "true"/"false" for robustness across clients.
156+
*
157+
* @param {Object} params - Parameters resource (jsonObj)
158+
* @returns {boolean}
159+
*/
160+
readSealed(params) {
161+
const p = this.findParameter(params, 'sealed');
162+
if (!p) return false;
163+
const v = this.getParameterValue(p);
164+
return v === true || v === 'true';
165+
}
166+
138167
/**
139168
* mode=end: release the cache named by the `${CACHE_ID_HEADER}` header so the
140169
* server can reclaim it now rather than waiting for the idle timeout.

tx/workers/worker.js

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -666,14 +666,23 @@ class TerminologyWorker {
666666
'cache-id-unknown', 404);
667667
}
668668

669-
// The cache exists: merge any resources supplied on this request into it
670-
// (incremental population is allowed), then expose the full cache contents.
671-
const toCache = txResources.concat(primaryResources);
672-
if (toCache.length > 0) {
673-
this.opContext.resourceCache.add(cacheId, toCache);
669+
// The cache exists. If it is unsealed, merge any resources supplied on this
670+
// request into it (incremental population). If it is sealed, the cache is
671+
// fixed at what it was created with: resources supplied now are still used
672+
// for this request (via additionalResources below) but are NOT added to the
673+
// shared cache, so a sealed cache never grows.
674+
if (!this.opContext.resourceCache.isSealed(cacheId)) {
675+
const toCache = txResources.concat(primaryResources);
676+
if (toCache.length > 0) {
677+
this.opContext.resourceCache.add(cacheId, toCache);
678+
}
679+
this.additionalResources = this.opContext.resourceCache.get(cacheId);
680+
} else {
681+
// Sealed: expose the cache contents plus this request's own inline
682+
// resources (used for this call only), without mutating the cache.
683+
this.additionalResources = this.opContext.resourceCache.get(cacheId)
684+
.concat(txResources, primaryResources);
674685
}
675-
676-
this.additionalResources = this.opContext.resourceCache.get(cacheId);
677686
this.additionalResourcesCacheId = cacheId;
678687
} else {
679688
// No cache-id, just use the tx-resources directly

0 commit comments

Comments
 (0)