Repository navigation
perf(mcp): reuse the request's plan lookup in tools/call instead of repeating it untraced - #725
Conversation
…epeating it untraced getUserPlanName now memoises per (request span, user) when given a span, and tools/call passes the request span, so it reuses the user_plans lookup that mcp/index.ts already made for the same request instead of a second serial, untraced round trip. Where index.ts skips the lookup (BYOD) the call now runs traced. Failed (null) lookups are not memoised; the unspanned REST path is unchanged. Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
✅ No issues foundNo new commits since the last review of 8578d8e, so this run re-checked the gates only. The description now covers the 9700c48 fix (REST router gets null on a DB error, eight Deno cases), which clears the earlier description warning. Checked: 0 of 3 dependent files traced Progress: open review threads 1 → 0 → 0 across the last 3 reviews What this change reaches — 3 changed exports · 3 dependent files · 0 checked · 3 not checkedflowchart LR
subgraph pr["Changed in this PR"]
s1["getUserPlanName<br/>signature changed"]:::changed
s2["handleMcp<br/>body changed"]:::changed
s3["recordUsageEvent<br/>body changed"]:::changed
end
s1c1["supabase/functions/_shared/api/router.ts<br/>? not checked"]:::unknown
s1c2["supabase/functions/_shared/telemetry/usage.plan-memo.test.ts<br/>? not checked"]:::unknown
s2c1["supabase/functions/mcp/index.ts<br/>? not checked"]:::unknown
s3c1["supabase/functions/_shared/api/router.ts<br/>? not checked"]:::unknown
s1 -.-> s1c1
s1 -.-> s1c2
s2 -.-> s2c1
s3 -.-> s3c1
classDef changed fill:#eef2ff,stroke:#6366f1,color:#1e1b4b
classDef ok fill:#e7f6ec,stroke:#16a34a,color:#14532d
classDef partial fill:#fef9c3,stroke:#ca8a04,color:#713f12
classDef unknown fill:#f3f4f6,stroke:#9ca3af,stroke-dasharray:4 3,color:#374151
classDef bad fill:#fde8e8,stroke:#dc2626,color:#7f1d1d
classDef warn fill:#fff4e5,stroke:#d97706,color:#78350f
classDef imports fill:#f8fafc,stroke:#64748b,stroke-dasharray:4 3,color:#1e293b
Review details
Found Quality — produced 0 → posted inline 0 · cleared 0 · carried forward 0 · deferred 0 · below-bar 0 Run incremental · 0 lines in delta · depth checkout Nothing to report — standards (skipped), optimality (skipped), measurability (skipped), severity, 0 files skipped.
|
…memo retries Addresses @dash0-dev[bot]'s comment: #725 (comment) Refs: #725 Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
Summary
Every hosted MCP
tools/callwaits for a second, serial, untraceduser_plansquery before the tool's first DB query. The transport preamble inmcp/index.tshas already looked up the same row for the same user. This affects every authenticated agent callingmemory.*tools. With this changegetUserPlanNamememoises per request span andtools/callpasses the request span, so the preamble's lookup is reused. Where the preamble skips the lookup (BYOD), the call now runs traced instead of invisibly.Evidence (production telemetry)
lorekit.memory.*MCP handler span's start and its first DBCLIENTchild (service.name=api,deployment.environment.name=production). Current window is 2026-10-19T06:01:30Z..2026-10-26T06:01:30Z, baseline is 2026-09-28T06:01:30Z..2026-10-19T06:01:30Z:memory.write: 177 ms (n=1,382) vs 106 ms (n=2,052)memory.list: 280 ms (n=1,303) vs 133 ms (n=1,931)memory.read: 271 ms (n=633) vs 165 ms (n=981)memory.search: 334 ms (n=466) vs 159 ms (n=714)lorekit.memories.get/*) show a 3-4 ms gap because they don't repeat the lookup.SELECT user_plans, issued byindex.ts) averaged 144 ms (n=16,615) this week, with 615 calls over 500 ms. That matchesmemory.write's gap, since write makes no other call before its first query. list/read/search also make the untracedlorekit_member_org_idsRPC first (avg 225 ms where traced), which this PR does not change.tools/callroot SERVER span duration p50 0.59 s vs 0.50 s, p95 3.43 s vs 1.53 s (n=3,927 vs 5,809).01a15d42968b7d8c8d55b8b120765cd5(memory.write, 23 s beforeCALL memory_write). The multi-second outliers line up with a database contention episode on 2026-10-20..10-22. This change removes the steady ~144 ms per call. It does not fix those outliers.Root cause
mcp/index.tsL193-L213 resolves the plan for every authenticated hosted request, traced and concurrent with the rate-limit check. L244 then callshandleMcp(req, auth, span, adapter)without passing the plan on.mcp/mcp-handler.tsL330-L363 opens thelorekit.<tool>span and then runsawait getUserPlanName(db, analyticsUserId)again for the same user (in hosted modeanalyticsUserId === auth.userId). It passes no span, so the round trip shows up as unexplained time at the start of the handler span. The value is used only for thelorekit.planattribute and the usage event, never for enforcement._shared/telemetry/usage.tsL165-L185 has no memo. A memo keyed on the db client would not help either:getDbbuilds a new client on every call. The request's root span is the one object both call sites share.Change
_shared/telemetry/usage.ts: whengetUserPlanNameis given aparentSpan, it memoises the in-flight promise in a module-levelWeakMap<Span, Map<userId, Promise<string | null>>>. This follows the per-request WeakMap memomemberOrgIdsuses inmcp/tools.ts. Concurrent callers share one query. Anull(failed, fail-open) result is evicted, so a later caller retries as before. Without a span (the REST router) nothing is memoised. Values: the plan name,'free'for a missing row, andnullon any error — including a PostgREST error returned in the response, which previously fell through to'free'(review fix9700c48). That last case changes one thing outside MCP: on a DB error the REST router's usage event and span now carry no plan instead of'free', matching the function's documented fail-open contract; nothing enforces on plan name.mcp/mcp-handler.ts:tools/callpasses the requestspanas the third argument. In hosted mode this hits the memoindex.tspopulated, so there is no second query. In BYOD modeindex.tsskips the lookup anddbis the storage project, so it misses, queries the same db as before, and is now traced (as a child of the request span).mcp/index.ts,mcp/auth.ts,_shared/api/auth.ts(they are in another open PR), andotel.ts._shared/telemetry/usage.tsis not a mirror pair (packages/cli/src/shared/mirror-pairs.mjs), so nothing in mcp-core needed changing.Test
packages/mcp-core/src/mcp-guards/mcp-handler-plan-reuse.spec.ts(vitest, run by CI): a source-scan guard in the style ofmcp-preamble-usage.spec.ts. On comment-stripped source it asserts thatmcp-handler.tscallsgetUserPlanName(db, analyticsUserId, span)and has no two-argument call, and thatusage.tsmemoises in aWeakMap<Span, …>. It also has an anti-vacuity check that both still exist.npx vitest run --root packages/mcp-core src/mcp-guards/mcp-handler-plan-reuse.spec.tsgaveTests 2 failed | 1 passed (3). The two property assertions failed and the anti-vacuity check passed.Tests 3 passed (3). The existingmcp-preamble-usage.spec.tsstays green (5/5).supabase/functions/_shared/telemetry/usage.plan-memo.test.ts(Deno, behavioural, not run by CI: the edge tree has no Deno test job). It uses a fake db that countsfrom('user_plans')calls and checks eight cases: same span + same user across two db clients gives 1 query; concurrent calls give 1; a different span gives 2; a different user gives 2; no span gives 2; a thrown failure is not memoised (null, then a retry returns'pro'); a PostgREST error returned in the response isnulland not memoised (the next call returns'pro'); a missing row returns'free'and is memoised.NODE_USE_ENV_PROXY=1 deno test --no-check --node-modules-dir=none supabase/functions/_shared/telemetry/usage.plan-memo.test.tsgaveFAILED | 4 passed | 3 failed, withActual 2 / Expected 1on the three memo cases.ok | 8 passed | 0 failed(the returned-error case was added with the review fix and failed first with["free","free"]).node scripts/ci/deno-check-functions.mjs: every function at 0 errors, no baseline exceeded.nxtargetsmcp-core:typecheck,mcp-core:lint,supabase:lint,supabase:test: all pass, 0 lint errors.nx affectedrun (which also includes web, web-e2e and evals through the project graph) ran out of memory in the sandbox, so only CI can confirm those projects.How we will know it worked
memory.writeavg gap should drop from 177 ms toward ~30 ms (the handler's own code).memory.list,memory.readandmemory.searchshould each drop by ~140 ms.SELECT user_plansspan count pertools/callwill not change, because a memo hit emits no span. That is why the gap query, not the span count, is the metric.Opened by the weekly Agent0 telemetry-driven improvement automation. It is merged automatically when the review loop converges with an approving verdict and CI is green (and, for a web UI change, when the ui-verify test passes on this PR's Vercel preview), unless it carries the needs-human-merge label.