From 115304cebd754d08feeb4b38ef50fe79c1910111 Mon Sep 17 00:00:00 2001 From: Aaron Lisman Date: Mon, 19 Oct 2026 07:51:23 +0000 Subject: [PATCH] Routing table: one build, shared by every caller that arrives while it runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cold /api/studies took 28–40 s with GENIE over MCP (4–5 s direct). It looks up every study's access at once (Promise.all over 551 studies), each lookup needs the routing table, and while the first build waited on the network every other lookup found the table empty and started its own: 551 builds for one answer — 551 MCP calls to the simulated peer, or 1,102 small queries direct. studyRouting() now keeps the build in progress, so callers that arrive during it wait for the same one. It runs detached from any one reader's cancellation, since it answers them all; a failed or partial build is still not kept. test/routing.test.ts: 50 concurrent lookups on a cold process ask the peer for its studies once (it sees 50 without the change). Measured on a fresh production build (three cold starts each, then a warm request): over MCP: cold 28.5–39.9 s, 552 MCP calls → 1.3–1.5 s, 2 calls direct: cold 4.3–5.1 s → 0.7–0.9 s warm: ~0.03 s either way, before and after. scripts/gate.sh green (1263 unit, 253 e2e). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLBVrkqBG7J7pqX6Qygx3z --- lib/federation.ts | 11 ++++++++++- test/routing.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 test/routing.test.ts diff --git a/lib/federation.ts b/lib/federation.ts index aca388fa..54ebbe3a 100644 --- a/lib/federation.ts +++ b/lib/federation.ts @@ -19,7 +19,7 @@ // for the transport — a real deployment swaps in a second connection // (data-level federation) or an MCP client to a remote cbio-lab // (document-level federation) behind the same interface. -import { chQuery, DB, PRIMARY, connections, loopbackOnlyConn, isAbortError, LoopbackDirectError, type ChConn } from "./ch.ts"; +import { chQuery, DB, PRIMARY, connections, loopbackOnlyConn, isAbortError, LoopbackDirectError, withChContext, type ChConn } from "./ch.ts"; import { sq } from "./compiler.ts"; import { canonicalEventName, nameFoldSql, foldedEqualsSql } from "./event-names.ts"; import { linesCtes, lineGapOf, inLineClassesSql, TREATMENT_EVENT } from "./treatment-lines.ts"; @@ -183,6 +183,15 @@ export const routingCollisions: string[] = []; export const restrictedStudies = new Set(); export async function studyRouting(): Promise> { if (_routing) return _routing; + // One build at a time, shared by every caller that arrives while it runs: a cold request asks + // for hundreds of studies' routes at once, and each starting its own build sent hundreds of + // queries (and, with a simulated peer, hundreds of MCP calls) for one answer. The build answers + // them all, so no one reader's cancellation stops it. + return _routingBuild ??= withChContext({}, buildRouting).finally(() => { _routingBuild = null; }); +} +let _routingBuild: Promise> | null = null; + +async function buildRouting(): Promise> { const map = new Map(); let complete = true; for (const conn of connections()) { diff --git a/test/routing.test.ts b/test/routing.test.ts new file mode 100644 index 00000000..f2fdd77b --- /dev/null +++ b/test/routing.test.ts @@ -0,0 +1,31 @@ +// The routing table (lib/federation.ts studyRouting) is built once however many callers ask for +// it at once: a cold /api/studies looks up every study's route together, and each lookup starting +// its own build sent hundreds of queries — over a simulated peer, hundreds of MCP calls. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { studyRouting } from "../lib/federation.ts"; + +test("concurrent lookups on a cold process share one routing build", async () => { + const env = { LOOPBACK_PEER: "b", LOOPBACK_PEER_URL: "http://loop.invalid/api/mcp", CLICKHOUSE2_URL: "http://ch.invalid", CLICKHOUSE2_USER: "u", + CLICKHOUSE2_PASSWORD: "p", CLICKHOUSE2_DB: "peer_db", CLICKHOUSE2_LABEL: "PEER" }; + const saved = Object.fromEntries(Object.keys(env).map((k) => [k, process.env[k]])); + Object.assign(process.env, env); + const realFetch = globalThis.fetch; + let studyLists = 0; + globalThis.fetch = (async (_url: string, init: RequestInit) => { + const body = JSON.parse(String(init.body)); + if (body.method === "tools/call" && body.params.name === "federated_studies") studyLists++; + await new Promise((r) => setTimeout(r, 20)); // an answer takes a moment, so the callers overlap + const result = body.method === "tools/call" ? { structuredContent: { studies: [{ id: "s1", name: "S1" }] } } : {}; + return new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id, result }), { headers: { "content-type": "application/json" } }); + }) as typeof fetch; + try { + const maps = await Promise.all(Array.from({ length: 50 }, () => studyRouting())); + assert.equal(studyLists, 1, "one build asked the peer for its studies once, not once per caller"); + assert.ok(maps.every((m) => m === maps[0]), "every caller got the same answer"); + assert.equal(maps[0].get("s1")?.id, "b"); + } finally { + globalThis.fetch = realFetch; + for (const [k, v] of Object.entries(saved)) { if (v == null) delete process.env[k]; else process.env[k] = v; } + } +});