pediatric-ai-scribe-v3/test/mcp-client-concurrency.test.js
Daniel 5d28ebad9c
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s
feat: the assistant degrades politely under load — bounded retrieval queue, per-user limit, short search cache
Three things the load path lacked. The retrieval slots had an unbounded
line behind them, so a burst meant silent waiting; past a bounded line, or
after eight seconds in it, a caller now gets 'the library is busy' and a
503 with a retry hint. The paid routes had no per-account ceiling; they
now get one, counted in Redis so every replica sees the same count and
nothing is refused when Redis is absent. And the same library search
asked twice within a minute (a retry, a refresh) went to the library
twice; it is now answered from Redis, with 0 turning that off.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-14 07:56:26 +02:00

71 lines
3.7 KiB
JavaScript

// Library searches run a few at a time rather than one behind another, and
// the session is renewed before it expires.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const src = fs.readFileSync(path.join(__dirname, '..', 'src/utils/clinicalMcpClient.js'), 'utf8');
// The slot and queue logic on its own, with the bounds given explicitly.
function slots(concurrency, queueMax, waitMs) {
const slice = src.slice(src.indexOf('function busyError'), src.indexOf('async function callMcpTool'));
return new Function('MCP_CONCURRENCY', 'MCP_QUEUE_MAX', 'MCP_QUEUE_WAIT_MS',
'var _inFlight = 0, _waiting = [];' + slice +
'; return { acquireSlot, releaseSlot, count: () => _inFlight, waiting: () => _waiting.length };')(concurrency, queueMax, waitMs);
}
test('a bounded number of calls run at once; the rest wait their turn', async () => {
// The slot logic on its own, with the environment's default bound.
const fn = slots(3, 12, 8000);
await fn.acquireSlot(); await fn.acquireSlot(); await fn.acquireSlot();
assert.equal(fn.count(), 3);
let fourthStarted = false;
const fourth = fn.acquireSlot().then(() => { fourthStarted = true; });
await new Promise(r => setTimeout(r, 5));
assert.equal(fourthStarted, false, 'the fourth waits');
assert.equal(fn.waiting(), 1);
fn.releaseSlot();
await fourth;
assert.equal(fourthStarted, true, 'and runs when a slot frees');
assert.equal(fn.count(), 3, 'the slot passed hands rather than being freed');
fn.releaseSlot(); fn.releaseSlot(); fn.releaseSlot();
assert.equal(fn.count(), 0);
});
test('past the queue bound a caller is told the library is busy, not held', async () => {
const fn = slots(1, 2, 8000);
await fn.acquireSlot();
const second = fn.acquireSlot(); const third = fn.acquireSlot();
assert.equal(fn.waiting(), 2);
await assert.rejects(fn.acquireSlot(), e => e.statusCode === 503 && e.code === 'RETRIEVAL_BUSY' && e.retryAfterSeconds === 5);
fn.releaseSlot(); await second; fn.releaseSlot(); await third; fn.releaseSlot();
assert.equal(fn.count(), 0);
});
test('a caller that waits too long is released from the line, and the line forgets it', async () => {
const fn = slots(1, 4, 20);
// The wait timer is unref'd (a live search keeps the process up in real
// use); hold the loop open here so it can fire.
const hold = setTimeout(() => {}, 200);
await fn.acquireSlot();
const late = fn.acquireSlot();
await assert.rejects(late, e => e.code === 'RETRIEVAL_BUSY');
assert.equal(fn.waiting(), 0, 'the timed-out waiter is gone');
fn.releaseSlot();
assert.equal(fn.count(), 0, 'releasing does not hand the slot to a caller that already gave up');
clearTimeout(hold);
});
test('the queue bounds and the busy answer are wired where the route can see them', () => {
assert.match(src, /MCP_QUEUE_MAX = positiveInt\(process\.env\.CLINICAL_ASSISTANT_MCP_QUEUE_MAX, MCP_CONCURRENCY \* 4\)/);
assert.match(src, /MCP_QUEUE_WAIT_MS = positiveInt\(process\.env\.CLINICAL_ASSISTANT_MCP_QUEUE_WAIT_MS, 8000\)/);
assert.match(src, /queueDepth: queueDepth/);
});
test('the serial promise chain is gone, the session is kept warm, and the split is logged', () => {
assert.doesNotMatch(src, /_mcpCallQueue = queued\.catch/, 'no more one-at-a-time chain');
assert.match(src, /var MCP_CONCURRENCY = positiveInt\(process\.env\.CLINICAL_ASSISTANT_MCP_CONCURRENCY, 3\)/);
assert.match(src, /setInterval\(function\(\) \{[\s\S]*?getMcpSession\(\)\.catch/, 'renewed on a timer');
assert.match(src, /_warmTimer\.unref/, 'the timer never keeps the process alive');
assert.match(src, /mcp ' \+ name \+ ': session=' \+ sessionMs \+ 'ms total='/);
});