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
5 changes: 5 additions & 0 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,11 @@ async function buildRootPageContent() {
content += `<td style="background-color:${pctColor(processPCT)}"><strong>Process Memory:</strong> ${rssMB} MB of ${memLimitMB} MB (${processPCT.toFixed(0)}%)</td>`;
content += `<td style="background-color:${pctColor(sysMemPCT)}"><strong>System Memory:</strong> ${usedMemMB} MB of ${totalMemMB} MB (${sysMemPCT.toFixed(0)}%)</td>`;
content += '</tr>';
content += '<tr>';
content += `<td><strong>Expansion Cache:</strong> ${stats.expansionItems()} items</td>`;
content += `<td><strong>Client Cache:</strong> ${stats.clientCaches()} caches / ${stats.clientConcepts()} concepts</td>`;
content += `<td><strong>Max Client Cache:</strong> ${stats.maxClientCaches()} caches / ${stats.maxClientConcepts()} concepts</td>`;
content += '</tr>';
content += getLogStats();
content += '</table>';

Expand Down
44 changes: 26 additions & 18 deletions stats.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,24 +36,26 @@ class ServerStats {
: (now - this.startTime) / 60000;
const requestsPerMin = minutesSinceStart > 0 ? requestsDelta / minutesSinceStart : 0;

const currentCpu = this.readSystemCpu();
const idleDelta = currentCpu.idle - this.lastUsage.idle;
const totalDelta = currentCpu.total - this.lastUsage.total;
const percent = totalDelta > 0 ? 100 * (1 - idleDelta / totalDelta) : 0;

const loopDelay = this.eventLoopMonitor.mean / 1e6;
let cacheCount = 0;
// Two distinct caches: the expansion cache (count entries) and the client
// (resource) cache (count concepts held - a sense of how much it's carrying).
let expansionItems = 0;
let clientConcepts = 0;
for (let m of this.cachingModules) {
cacheCount = cacheCount + m.cacheCount();
if (typeof m.expansionItemCount === 'function') {
expansionItems = expansionItems + m.expansionItemCount();
}
if (typeof m.clientConceptCount === 'function') {
clientConcepts = clientConcepts + m.clientConceptCount();
}
}

this.history.push({time: now, mem: currentMem - this.startMem, rpm: requestsPerMin, tat: requestsTat, cpu: percent, block: loopDelay, cache : cacheCount});
this.history.push({time: now, mem: currentMem - this.startMem, rpm: requestsPerMin, tat: requestsTat, block: loopDelay, expansion: expansionItems, clientConcepts: clientConcepts});

this.eventLoopMonitor.reset();
this.requestCountSnapshot = combinedCount;
this.requestTime = 0;
this.lastTime = now;
this.lastUsage = currentCpu;

// Prune old data (keep 24 hours)
const cutoff = now - (24 * 60 * 60 * 1000); // 24 hours ago
Expand All @@ -65,7 +67,6 @@ class ServerStats {
this.started = true;
this.startMem = process.memoryUsage().heapUsed;
this.startTime = Date.now();
this.lastUsage = this.readSystemCpu();
this.lastTime = this.startTime;
this.eventLoopMonitor = monitorEventLoopDelay({ resolution: 20 });
this.eventLoopMonitor.enable();
Expand Down Expand Up @@ -141,17 +142,24 @@ class ServerStats {
clearInterval(this.timer);
}

readSystemCpu() {
const os = require('os');
const cpus = os.cpus();
let idle = 0, total = 0;
for (const cpu of cpus) {
idle += cpu.times.idle;
total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq;
// Live cache aggregates across all registered caching modules (defensive: a
// module that doesn't expose a given metric contributes 0).
_sumCachingModules(method) {
let total = 0;
for (let m of this.cachingModules) {
if (typeof m[method] === 'function') {
total = total + m[method]();
}
}
return { idle, total };
return total;
}

expansionItems() { return this._sumCachingModules('expansionItemCount'); }
clientCaches() { return this._sumCachingModules('clientCacheCount'); }
clientConcepts() { return this._sumCachingModules('clientConceptCount'); }
maxClientCaches() { return this._sumCachingModules('maxClientCacheCount'); }
maxClientConcepts() { return this._sumCachingModules('maxClientConceptCount'); }

getTaskColor(status) {
switch (status) {
case "started": return "LightGrey";
Expand Down
207 changes: 207 additions & 0 deletions tests/tx/cache-control.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/**
* $cache-control routing smoke tests
*
* At this stage the operation is scaffolding only: start/end parse the request but
* do nothing yet. These tests just confirm the route is wired up and dispatches on
* the `mode` query parameter. Behavioural tests come with the implementation.
*/

const request = require('supertest');
const { getTestApp, shutdownTestApp } = require('./setup');

const BASE = '/tx/r5/$cache-control';

describe('$cache-control routing (scaffolding)', () => {
let app;

beforeAll(async () => {
app = await getTestApp();
}, 60000);

afterAll(async () => {
await shutdownTestApp();
});

// An empty Parameters body stands in for "no front-loaded resources". A real
// client always sets a Content-Type on a POST; the endpoint's media-type gate
// rejects a truly bodyless POST with 415 (see note - bodyless POST is a separate
// decision).
const emptyBody = { resourceType: 'Parameters', parameter: [] };

test('POST mode=start resolves and returns a Parameters', async () => {
const res = await request(app)
.post(BASE)
.query({ mode: 'start' })
.set('Accept', 'application/json')
.set('Content-Type', 'application/json')
.send(emptyBody);
expect(res.status).toBe(200);
expect(res.body.resourceType).toBe('Parameters');
});

test('GET mode=start resolves (browsable, like the other operations)', async () => {
const res = await request(app)
.get(BASE)
.query({ mode: 'start' })
.set('Accept', 'application/json');
expect(res.status).toBe(200);
expect(res.body.resourceType).toBe('Parameters');
});

test('POST mode=end resolves and returns a Parameters', async () => {
const res = await request(app)
.post(BASE)
.query({ mode: 'end' })
.set('Accept', 'application/json')
.set('Content-Type', 'application/json')
.set('x-cache-id', 'some-cache-id')
.send(emptyBody);
expect(res.status).toBe(200);
expect(res.body.resourceType).toBe('Parameters');
});

test('missing mode is a 400 OperationOutcome', async () => {
const res = await request(app)
.post(BASE)
.set('Accept', 'application/json')
.set('Content-Type', 'application/json')
.send(emptyBody);
expect(res.status).toBe(400);
expect(res.body.resourceType).toBe('OperationOutcome');
});

test('unknown mode is a 400 OperationOutcome', async () => {
const res = await request(app)
.post(BASE)
.query({ mode: 'bogus' })
.set('Accept', 'application/json')
.set('Content-Type', 'application/json')
.send(emptyBody);
expect(res.status).toBe(400);
expect(res.body.resourceType).toBe('OperationOutcome');
});

test('mode supplied as a Parameters body parameter is accepted as a fallback', async () => {
const res = await request(app)
.post(BASE)
.set('Accept', 'application/json')
.set('Content-Type', 'application/json')
.send({ resourceType: 'Parameters', parameter: [{ name: 'mode', valueCode: 'start' }] });
expect(res.status).toBe(200);
expect(res.body.resourceType).toBe('Parameters');
});

// ---- behaviour ----

function cacheIdFrom(body) {
const p = (body.parameter || []).find(x => x.name === 'cache-id');
return p ? p.valueId : undefined;
}

test('start returns a server-issued cache-id', async () => {
const res = await request(app)
.post(BASE)
.query({ mode: 'start' })
.set('Accept', 'application/json')
.set('Content-Type', 'application/json')
.send(emptyBody);
expect(res.status).toBe(200);
const id = cacheIdFrom(res.body);
expect(typeof id).toBe('string');
expect(id).toMatch(/^[0-9a-f-]{36}$/i); // UUID shape
});

test('each start mints a distinct cache-id', async () => {
const a = await request(app).post(BASE).query({ mode: 'start' })
.set('Content-Type', 'application/json').send(emptyBody);
const b = await request(app).post(BASE).query({ mode: 'start' })
.set('Content-Type', 'application/json').send(emptyBody);
expect(cacheIdFrom(a.body)).not.toBe(cacheIdFrom(b.body));
});

test('start front-loads supplied resources into the cache, retrievable by url', async () => {
const colorsCS = {
resourceType: 'CodeSystem',
url: 'http://example.org/cc-test/colors',
version: '1.0.0',
status: 'active',
content: 'complete',
concept: [{ code: 'red', display: 'Red' }]
};
const colorsVS = {
resourceType: 'ValueSet',
url: 'http://example.org/cc-test/colors-vs',
version: '1.0.0',
status: 'active',
compose: { include: [{ system: colorsCS.url }] }
};

// Front-load CS + VS via start.
const started = await request(app)
.post(BASE)
.query({ mode: 'start' })
.set('Content-Type', 'application/json')
.send({
resourceType: 'Parameters',
parameter: [
{ name: 'tx-resource', resource: colorsCS },
{ name: 'valueSet', resource: colorsVS }
]
});
const cacheId = cacheIdFrom(started.body);
expect(cacheId).toBeDefined();

// The front-loaded ValueSet should now resolve by url under that cache-id.
// (validate still reads cache-id from the parameter in this step; the header
// read path is a later change.)
const validated = await request(app)
.post('/tx/r5/ValueSet/$validate-code')
.set('Content-Type', 'application/json')
.send({
resourceType: 'Parameters',
parameter: [
{ name: 'cache-id', valueId: cacheId },
{ name: 'url', valueString: colorsVS.url },
{ name: 'coding', valueCoding: { system: colorsCS.url, code: 'red' } }
]
});
expect(validated.status).toBe(200);
const result = (validated.body.parameter || []).find(x => x.name === 'result');
expect(result && result.valueBoolean).toBe(true);
});

test('end with the cache-id header releases the cache', async () => {
const started = await request(app).post(BASE).query({ mode: 'start' })
.set('Content-Type', 'application/json').send(emptyBody);
const cacheId = cacheIdFrom(started.body);

const ended = await request(app)
.post(BASE)
.query({ mode: 'end' })
.set('Content-Type', 'application/json')
.set('x-cache-id', cacheId)
.send(emptyBody);
expect(ended.status).toBe(200);
expect(ended.body.resourceType).toBe('Parameters');
});

test('end without the cache-id header is a 400', async () => {
const res = await request(app)
.post(BASE)
.query({ mode: 'end' })
.set('Content-Type', 'application/json')
.send(emptyBody);
expect(res.status).toBe(400);
expect(res.body.resourceType).toBe('OperationOutcome');
});

test('end of an unknown cache-id is a tolerant no-op (200)', async () => {
const res = await request(app)
.post(BASE)
.query({ mode: 'end' })
.set('Content-Type', 'application/json')
.set('x-cache-id', 'never-issued-this-id')
.send(emptyBody);
expect(res.status).toBe(200);
});
});
Loading
Loading