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
7 changes: 5 additions & 2 deletions tests/cs/cs-snomed.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -760,8 +760,11 @@ describe('SNOMED CT Subset Validation', () => {
// Create expressions using concepts from our subset
const testExpressions = [
{
expression: '64572001:116676008=128045006',
description: 'Disease with associated morphology cellulitis',
// The value of 116676008 |Associated morphology| has to be a
// morphologic abnormality (MRCM range << 49755003), so cellulitis -
// a disorder - is not a legal value here.
expression: '64572001:116676008=20946005',
description: 'Disease with associated morphology closed fracture',
expectedValid: true
},
{
Expand Down
198 changes: 198 additions & 0 deletions tests/tx/cache-control.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,134 @@ describe('$cache-control routing (scaffolding)', () => {
return p ? p.valueId : undefined;
}

function paramValue(body, name) {
const p = (body.parameter || []).find(x => x.name === name);
if (!p) return undefined;
return p.valueBoolean !== undefined ? p.valueBoolean
: p.valueUnsignedInt !== undefined ? p.valueUnsignedInt
: p.valueId !== undefined ? p.valueId
: p.resource;
}

// ---- mode=check ----
//
// check is both a probe and a keepalive: a client whose own local cache has been
// absorbing its terminology work can go quiet for longer than the server's idle
// timeout while still depending on its cache, so asking "is it still there?" is
// itself use, and resets the idle clock.
describe('mode=check', () => {
async function startCheckCache() {
const started = await request(app)
.post(BASE)
.query({ mode: 'start' })
.set('Content-Type', 'application/json')
.send({ resourceType: 'Parameters', parameter: [] });
return cacheIdFrom(started.body);
}

function check(cacheId) {
return request(app)
.get(BASE)
.query({ mode: 'check' })
.set('Accept', 'application/json')
.set('x-cache-id', cacheId);
}

test('reports a live cache as valid, with its stats', async () => {
const cacheId = await startCheckCache();

const res = await request(app)
.post(BASE)
.query({ mode: 'check' })
.set('Content-Type', 'application/json')
.set('x-cache-id', cacheId)
.send({ resourceType: 'Parameters', parameter: [] });

expect(res.status).toBe(200);
expect(res.body.resourceType).toBe('Parameters');
expect(paramValue(res.body, 'cache-id')).toBe(cacheId);
expect(paramValue(res.body, 'valid')).toBe(true);
expect(paramValue(res.body, 'sealed')).toBe(false); // server default, transitional
expect(paramValue(res.body, 'resource-count')).toBe(0);
expect(typeof paramValue(res.body, 'idle')).toBe('number');
// the server advertises its timeout so a client can size its own polling to
// this server instead of guessing
expect(paramValue(res.body, 'timeout')).toBeGreaterThan(0);
});

test('GET works too, so a cache can be checked from a browser', async () => {
const cacheId = await startCheckCache();
const res = await check(cacheId);

expect(res.status).toBe(200);
expect(paramValue(res.body, 'valid')).toBe(true);
});

/**
* End to end proof that a check both reports the idle time it found AND resets
* it: let a cache go idle, check it (which must report the idle time, so status
* is read before the touch), then check again immediately (which must report
* zero, so the first check did touch it). If check didn't keep the cache alive,
* the second call would report the same idle time as the first.
*/
test('a check reports the idle time it found, and resets it', async () => {
const cacheId = await startCheckCache();
await new Promise(resolve => setTimeout(resolve, 1200));

const first = await check(cacheId);
expect(paramValue(first.body, 'idle')).toBeGreaterThanOrEqual(1);

const second = await check(cacheId);
expect(paramValue(second.body, 'idle')).toBe(0);
});

/**
* The reason a check is not a 404: a heartbeat has to be able to tell "the
* server is up and says my cache is gone" from "I could not reach the server",
* and those call for opposite responses.
*/
test('an unknown cache-id is 200 + valid=false, not 404', async () => {
const res = await check('never-issued-this-id');

expect(res.status).toBe(200);
expect(paramValue(res.body, 'valid')).toBe(false);
expect(paramValue(res.body, 'cache-id')).toBe('never-issued-this-id');
});

test('the outcome parameter carries the coded reason', async () => {
const cacheId = await startCheckCache();
await request(app)
.post(BASE)
.query({ mode: 'end' })
.set('Content-Type', 'application/json')
.set('x-cache-id', cacheId)
.send({ resourceType: 'Parameters', parameter: [] });

const res = await check(cacheId);

expect(res.status).toBe(200);
expect(paramValue(res.body, 'valid')).toBe(false);

const outcome = paramValue(res.body, 'outcome');
expect(outcome.resourceType).toBe('OperationOutcome');
const coding = ((outcome.issue[0] || {}).details || {}).coding || [];
expect(coding.some(c => c.code === 'cache-id-unknown')).toBe(true);
// and it says which of the three fates it met, not a list of maybes
expect(outcome.issue[0].details.text).toMatch(/closed/i);
});

test('a missing cache-id header is a client error', async () => {
const res = await request(app)
.post(BASE)
.query({ mode: 'check' })
.set('Content-Type', 'application/json')
.send({ resourceType: 'Parameters', parameter: [] });

expect(res.status).toBe(400);
expect(res.body.resourceType).toBe('OperationOutcome');
});
});

test('start returns a server-issued cache-id', async () => {
const res = await request(app)
.post(BASE)
Expand Down Expand Up @@ -307,6 +435,76 @@ describe('$cache-control routing (scaffolding)', () => {
expect(res.body.resourceType).toBe('OperationOutcome');
const coding = (((res.body.issue || [])[0] || {}).details || {}).coding || [];
expect(coding.some(c => c.code === 'cache-id-unknown')).toBe(true);
// An id this server never issued must say exactly that, and must not offer
// expiry as a possibility - that sends people hunting a timeout that never ran.
const text = (((res.body.issue || [])[0] || {}).details || {}).text || '';
expect(text).toMatch(/never issued by this server/i);
expect(text).not.toMatch(/expired/i);
});

// The three ways a cache-id can be missing are indistinguishable to a client
// unless the server says which one happened. A cache the client closed itself
// is the one that matters most: it reads like an expiry, but the fix is in the
// client's lifecycle, not the server's timeout.
test('a cache the client closed reports the close, not an expiry', async () => {
const cacheId = await startCache();
const ended = await request(app)
.post(BASE)
.query({ mode: 'end' })
.set('Content-Type', 'application/json')
.set('x-cache-id', cacheId)
.send({ resourceType: 'Parameters', parameter: [] });
expect(ended.status).toBe(200);

const res = 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: colorsVS.url }] });

expect(res.status).toBe(404);
// Same coding as any other missing cache - clients switch on this, and the
// action is the same. Only the diagnostics differ.
const coding = (((res.body.issue || [])[0] || {}).details || {}).coding || [];
expect(coding.some(c => c.code === 'cache-id-unknown')).toBe(true);

const text = (((res.body.issue || [])[0] || {}).details || {}).text || '';
expect(text).toMatch(/closed/i);
expect(text).toMatch(/mode=end/);
expect(text).not.toMatch(/never issued/i);
expect(text).not.toMatch(/expired/i);
});

test('a batch against a closed cache reports the close too', async () => {
const cacheId = await startCache();
await request(app)
.post(BASE)
.query({ mode: 'end' })
.set('Content-Type', 'application/json')
.set('x-cache-id', cacheId)
.send({ resourceType: 'Parameters', parameter: [] });

const res = await request(app)
.post('/tx/r5/ValueSet/$batch-validate-code')
.set('Content-Type', 'application/json')
.set('x-cache-id', cacheId)
.send({
resourceType: 'Parameters',
parameter: [{
name: 'validation',
resource: {
resourceType: 'Parameters',
parameter: [
{ name: 'url', valueString: colorsVS.url },
{ name: 'coding', valueCoding: { system: colorsCS.url, code: 'red' } }
]
}
}]
});

expect(res.status).toBe(404);
const text = (((res.body.issue || [])[0] || {}).details || {}).text || '';
expect(text).toMatch(/closed/i);
});
});

Expand Down
154 changes: 153 additions & 1 deletion tests/tx/resource-cache.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* isolated between different cache-ids.
*/

const { ResourceCache } = require('../../tx/operation-context');
const { ResourceCache, formatDuration } = require('../../tx/operation-context');

const CS = (url, version) => ({ resourceType: 'CodeSystem', url, version });
const VS = (url, version) => ({ resourceType: 'ValueSet', url, version });
Expand Down Expand Up @@ -147,6 +147,158 @@ describe('ResourceCache', () => {
});
});

// A cache-id that isn't here has three possible fates, and telling them apart is
// the whole point: "you closed it yourself" and "it sat idle for 31 minutes" send
// an investigator to completely different places (see issue #279).
describe('tombstones (why a cache-id is gone)', () => {
test('an id that was never issued has no tombstone and reports CACHE_ID_UNKNOWN', () => {
const d = cache.describeMissing('never-seen');
expect(cache.tombstone('never-seen')).toBeNull();
expect(d.messageId).toBe('CACHE_ID_UNKNOWN');
expect(d.params).toEqual(['never-seen']);
});

test('clear() records the client close, and describeMissing says so', () => {
cache.set('c1', [CS('http://a', '1')]);
cache.clear('c1');

expect(cache.tombstone('c1').reason).toBe('closed');
const d = cache.describeMissing('c1');
expect(d.messageId).toBe('CACHE_ID_CLOSED');
expect(d.params[0]).toBe('c1');
expect(d.params).toHaveLength(3); // id, how long ago, how long it was open
});

test('prune() records the expiry with the measured idle time, not the timeout', () => {
cache.add('c1', [CS('http://a', '1')]);
cache.cache.get('c1').lastUsed = Date.now() - 10 * 60 * 1000; // idle 10 min
cache.prune(5 * 60 * 1000); // timeout 5 min - the sweep is late, as it is in production

const t = cache.tombstone('c1');
expect(t.reason).toBe('expired');
expect(t.maxAgeMs).toBe(5 * 60 * 1000);
expect(t.idleMs).toBeGreaterThanOrEqual(10 * 60 * 1000);

const d = cache.describeMissing('c1');
expect(d.messageId).toBe('CACHE_ID_EXPIRED');
expect(d.params[1]).toBe('10 minutes'); // idle
expect(d.params[2]).toBe('5 minutes'); // configured timeout
});

test('clearAll() records a server-wide clear', () => {
cache.add('c1', [CS('http://a', '1')]);
cache.clearAll();
expect(cache.tombstone('c1').reason).toBe('cleared');
expect(cache.describeMissing('c1').messageId).toBe('CACHE_ID_CLEARED');
});

test('an entry still in the cache has no tombstone', () => {
cache.add('c1', [CS('http://a', '1')]);
expect(cache.tombstone('c1')).toBeNull();
});

test('re-issuing an id clears its tombstone - the id is alive again', () => {
cache.set('c1', [CS('http://a', '1')]);
cache.clear('c1');
expect(cache.tombstone('c1')).not.toBeNull();

cache.set('c1', [CS('http://b', '1')]);
expect(cache.tombstone('c1')).toBeNull();
expect(cache.has('c1')).toBe(true);
});

test('the latest fate wins when an id dies twice', () => {
cache.set('c1', [CS('http://a', '1')]);
cache.cache.get('c1').lastUsed = Date.now() - 10000;
cache.prune(5000);
expect(cache.tombstone('c1').reason).toBe('expired');

cache.set('c1', [CS('http://a', '1')]);
cache.clear('c1');
expect(cache.tombstone('c1').reason).toBe('closed');
});

test('tombstones are bounded: the oldest are evicted, never unbounded growth', () => {
const small = new ResourceCache(null, 3);
for (const id of ['a', 'b', 'c', 'd', 'e']) {
small.set(id, [CS('http://' + id, '1')]);
small.clear(id);
}
expect(small.tombstoneCount()).toBe(3);
expect(small.tombstone('a')).toBeNull(); // evicted
expect(small.tombstone('b')).toBeNull(); // evicted
expect(small.tombstone('e')).not.toBeNull(); // newest kept
// An evicted tombstone degrades to "never issued" rather than lying.
expect(small.describeMissing('a').messageId).toBe('CACHE_ID_UNKNOWN');
});

test('closing an id the server never had leaves no tombstone', () => {
cache.clear('not-mine');
expect(cache.tombstone('not-mine')).toBeNull();
expect(cache.describeMissing('not-mine').messageId).toBe('CACHE_ID_UNKNOWN');
});
});

// status()/touch() back $cache-control?mode=check: a client that hasn't needed the
// server for a while asks whether its cache is still there, and the asking keeps it
// there. They are separate calls on purpose - see the comments on status().
describe('status / touch (mode=check)', () => {
test('status reports a live cache without touching it', () => {
cache.set('c1', [CS('http://a', '1'), VS('http://b', '1')], true);
cache.cache.get('c1').lastUsed = Date.now() - 90 * 1000;

const s = cache.status('c1');
expect(s.exists).toBe(true);
expect(s.sealed).toBe(true);
expect(s.resources).toBe(2);
expect(s.idleMs).toBeGreaterThanOrEqual(90 * 1000);

// reading must not have reset the idle clock - otherwise the answer is always 0
expect(cache.status('c1').idleMs).toBeGreaterThanOrEqual(90 * 1000);
});

test('status of an unknown cache-id just says so', () => {
expect(cache.status('nope')).toEqual({ exists: false });
});

test('touch resets the idle clock and saves the cache from the next prune', () => {
cache.add('c1', [CS('http://a', '1')]);
cache.cache.get('c1').lastUsed = Date.now() - 10000;

expect(cache.touch('c1')).toBe(true);
expect(cache.status('c1').idleMs).toBeLessThan(1000);

cache.prune(5000); // would have evicted it before the touch
expect(cache.has('c1')).toBe(true);
});

test('touching an unknown cache-id reports false and creates nothing', () => {
expect(cache.touch('nope')).toBe(false);
expect(cache.has('nope')).toBe(false);
});

test('the idle timeout is reported when the server has advertised one', () => {
cache.set('c1', [CS('http://a', '1')]);
expect(cache.status('c1').timeoutMs).toBeNull();

cache.setIdleTimeout(30 * 60 * 1000);
expect(cache.status('c1').timeoutMs).toBe(30 * 60 * 1000);
});
});

describe('formatDuration', () => {
test('reads naturally across the ranges an idle timeout spans', () => {
expect(formatDuration(0)).toBe('0 seconds');
expect(formatDuration(1000)).toBe('1 second');
expect(formatDuration(45000)).toBe('45 seconds');
expect(formatDuration(31 * 60 * 1000)).toBe('31 minutes');
expect(formatDuration(60 * 1000)).toBe('60 seconds');
expect(formatDuration(125 * 60 * 1000)).toBe('2 hours 5 minutes');
expect(formatDuration(120 * 60 * 1000)).toBe('2 hours');
expect(formatDuration(null)).toBe('an unknown time');
});
});

describe('running concept count', () => {
test('starts at zero', () => {
expect(cache.conceptCount()).toBe(0);
Expand Down
Loading
Loading