Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions tests/tx/cache-control.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
22 changes: 19 additions & 3 deletions tx/operation-context.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -174,7 +189,8 @@ class ResourceCache {
this.cache.set(cacheId, {
resources: [...resources],
lastUsed: Date.now(),
concepts
concepts,
sealed: !!sealed
});
this._trackMax();
}
Expand Down
33 changes: 31 additions & 2 deletions tx/workers/cache-control.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 16 additions & 7 deletions tx/workers/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading