Uh oh!
There was an error while loading. Please reload this page.
fix(rest): pay ONE kernel-waiter window per request — optional KernelResolver.resolveEnvironment - #11085
Conversation
…solveEnvironment A REST request on a multi-tenant host paid the host's kernel-waiter window twice. `RestApiPlugin`'s wrapper asked `resolveKernel` — a kernel-acquisition api — for an environment id and discarded the kernel it bought; `resolveProtocol` then acquired it again. Free on a warm environment, a second serial wait on a cold or wedged one: measured 42s to a 503 on REST-owned routes against 21s on dispatcher-owned ones (`waiterTimeoutMs: 20s`). Adds the optional `KernelResolver.resolveEnvironment?(context, defaultKernel)` to the ADR-0006 contract — resolve the environment, acquire no kernel — and has the REST wrapper prefer it, leaving `resolveProtocol` as the single acquisition point. `?.`-optional, so every existing resolver keeps working unchanged. Fail-closed preserved and pinned: the surviving `getOrCreate` still rejects and the caller still gets the declared 503. `waiterTimeoutMs` untouched. Fixes#10988 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r
`check:type-check-debt --re-measure` caught the new test file adding 3 raw errors to the `@objectstack/rest` TEST_DEBT entry (155 -> 158): one TS2835 (extensionless relative import under nodenext) and two TS2550 (`Array.at` is not in this program's lib target). Fixed at the source — the ledger is a shrink-only ratchet and is untouched. Re-measured: 155, zero from this file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r
📓 Docs Drift CheckThis PR changes 2 package(s): 12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin efc6bec79c932d7c0363d183e0b6b08ec248f8ab && git checkout efc6bec79c932d7c0363d183e0b6b08ec248f8ab
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ab47f6974a275586355f06abd83a0975b8637d12 c567b8bd9d11b558f2e2ef593954c4a4528dc5eb && git checkout -B drift-repro ab47f6974a275586355f06abd83a0975b8637d12 && git merge --no-ff c567b8bd9d11b558f2e2ef593954c4a4528dc5eb
node scripts/docs-audit/affected-docs.mjs --json ab47f6974a275586355f06abd83a0975b8637d12
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32589380738 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10988
Adopts the maintainer's Option A ruling (2026-08-22, 「同意所有」item 6 = A; recorded verbatim as 「10988 A」): add the optional environment-only capability to the ADR-0006
KernelResolvercontract, haverest-api-plugin's wrapper prefer it, and leaveresolveProtocolas the single kernel-acquisition point — fail-closed preserved,waiterTimeoutMsuntouched.The defect, restated from the mechanism
RestApiPluginwraps the host'skernel-resolversoRestServercan ask "which environment is this request in?". It askedresolveKernel— a kernel-acquisition api — and kept onlycontext.environmentId. A real host resolver writes the id and then awaits that environment's kernel, so the wrapper paid a full waiter window and discarded what it bought;resolveProtocolthen acquired the kernel again. Free on a warm environment (a cache hit — which is why it stayed invisible for so long), a second serial wait on a cold or wedged one.What changed
packages/runtime/src/http-dispatcher.tsKernelResolvergains an optionalresolveEnvironment?(context, defaultKernel), returning void or a promise of void — resolve the environment ontocontext, acquire no kernel. Optional, so no flag day.packages/rest/src/rest-api-plugin.tsresolveEnvironmentwhen the host implements it, else callsresolveKernelexactly as before.packages/rest/src/rest-server.tsRestRequestEnvResolverstates the cost contract;resolveProtocolis named as THE single acquisition point and its fail-closed obligation is written down. No behaviour change.packages/rest/src/rest-resolve-environment-single-window.test.tspackages/runtime/src/http-dispatcher.kernel-resolver.test.tsresolveKerneleven when the host implements both..changeset/kernel-resolver-resolve-environment.md@objectstack/runtimeminor (new optional member on an exported contract),@objectstack/restpatch.No
resolveKernelfallback whenresolveEnvironmentleaves the id unset. An unset id is the seam's FINAL answer for an unscoped / control-plane request (RestRequestEnvResolver's existing contract), and "retry with the expensive method" would re-buy exactly the window this prefers away, on the requests that need no environment at all. Pinned.What the tests pin — and why "it answered" would have been vacuous
The card already measured the naive fix (catch the resolver's throw, keep the id it wrote) at 2.38x/2.02x with two acquisitions: the window is spent inside the resolver call before any id is returned. So "the request succeeded" / "an env id came back" passes on today's code, on the naive fix, and on this one. Every assertion counts the mechanism —
getOrCreatecalls per request:/api/v1/discoveryand/api/v1/data/:objecteach make exactly onegetOrCreatewhen the resolver implementsresolveEnvironment(expect(km.acquisitions).toEqual([ENV])).resolveRequestEnvironmentIdswallows; the legacy hostname chain then supplies the same id andresolveProtocolopens window ✨ Set up Copilot instructions #2. The harness wires that legacy chain deliberately — without it the back-compat path degrades to the control-plane protocol instead, a different and worse shape.getOrCreatestill rejects, the caller still gets503/SERVICE_UNAVAILABLE(the pair carried by cloud'skernel_warming), and the host protocol answers neither route. Asserted, not argued.Plus: the warm path drops the discarded acquisition too (
/discoveryresolves the environment twice — protocol, then the mcp probe — so 4 acquisitions become 2), the resolved environment is unchanged (the document is served from the environment kernel, never the control plane), and the dispatcher is pinned NOT to take the cheap door, since it serves the request from the kernel it resolves.Reverse verification
Each pin was broken deliberately, with the direction predicted before the run. All four legs ran from a committed state and were restored with
git checkout HEAD -- PATH(nevergit stash);git status --porcelainwas empty after each restore, and the suites are green again onc567b8bd9d.resolveKernel(the fix removed)expected [ 'env_probe', 'env_probe' ] to deeply equal [ 'env_probe' ]. The fail-closed pin stayed GREEN, correctly: two windows still 503.resolveProtocolswallows the acquisition rejection (fail OPEN)expected undefined to be 503— the route was served instead of refused.expected [ 'env_probe' ] to deeply equal [ 'env_probe', 'env_probe' ]— note the direction: fewer acquisitions, which reads as an improvement while actually meaning the host resolver was never consulted at all (aTypeErrorswallowed into the legacy chain).resolveEnvironmentexpected "vi.fn()" to be called 1 times, but got 0 times, i.e.context.kernelleft ondefaultKernel.No ablation was involved: both test files import their subject through in-package relative specifiers, so vitest reads
src, notdist.The four sibling call sites, re-checked in the same pass
resolveEndpointMatchAuthority(~934),resolveMetadataService(~971),resolveI18nService(~1141),probeMcpServeable(~3107) — no change needed, and the reason is structural. Each already routes through the sharedresolveRequestEnvironmentIdentry point and then acquires one kernel for the service slot it actually needs (metadata,i18n,mcp). None of them buys a kernel it discards; they inherited the waste from the entry point's cost, so they are fixed by this change without an edit. That is measured rather than asserted: the warm/discoverypin shows 4 acquisitions collapsing to 2, and the two that remain areresolveProtocol's andprobeMcpServeable's own — i.e. the probe shed its discarded one.The framework's two other
resolveKernelconsumers were re-checked for the same shape and genuinely need the kernel, so both keep callingresolveKernel:HttpDispatcher.resolveProjectKernelObjectQL(resolvesobjectqloff the returned kernel) andplugin-hono-server'sresolveRequestContext(serves identity from the per-environment kernel's services). Leg D pins the dispatcher against the "prefer the cheap method everywhere" follow-up edit.Clause-② path limb — measured, and the naive spelling misleads
The requested command reports a spec path, and it is a false positive:
Two-dot
git diff origin/maincomparesorigin/main's current tip tree with mine, so every sibling PR that landed after this branch was cut is attributed to it —origin/mainmoved1c3a46f876→03bdd148eeduring this task, and that file is another PR's. Measured against the merge base, the intersection is empty:scripts/pm/dispatch-gates.mjsderives the identical 6 paths ("three-dot semantics, never 'origin/main..HEAD'"). Clause-② path limb: NO — this PR touches nothing underpackages/spec/src/**.Governed surfaces — not touched, and none needed
docs/adr/**is untouched, and no ADR edit is owed: ADR-0006's prose never names the resolver interface at all (grep -in "resolver" docs/adr/0006*.md→ 0 matches across all three files;grep -c kernel→ 0/1/0). The contract lives in the code, and the new member is documented there. Nocontent/docs/releases/**,skills/**,.claude/**,AGENTS.mdorCLAUDE.mdedit. Nothing outside the declared file surface was repaired.Verification — all on
c567b8bd9d, the head of this PRHeavy runs went through
scripts/pm/os-verify-lock.sh; every exit code was captured before any pipe.pnpm --filter @objectstack/rest test→EXIT=0,Test Files 135 passed (135),Tests 2187 passed (2187)pnpm --filter @objectstack/runtime test→EXIT=0,Test Files 181 passed (181),Tests 2694 passed (2694)pnpm --filter @objectstack/rest --filter @objectstack/runtime typecheck→EXIT=0(both echoedtsc --noEmit, so this is not a zero-match silent pass)node scripts/pm/dispatch-gates.mjsnamed (no path arguments — it derives the change set itself), pluscheck:nul-bytesand the 5 convention-triggered families for a diff that adds test files. 22 gates, allEXIT=0, re-run in full on this head. Verdict lines, quoted:check-nul-bytes: OK (scanned 6402 text file(s) ... no raw ASCII control bytes).check-engine-double-contract: OK — 377 pinned, 133 in the DEBT ledger, 2 exempt.✓ where-matcher conformance holds: 277 matcher(s) discovered ... 0 silently-wrong and 0 unjudgedcheck-test-source-alias OK — 72 packages with tests scanned✓ check:authz-resolver: single shared authorization resolver intact; both entry points delegate.✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).✓ This diff introduces no 'major' bump.check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured in 254.2s, 1907 raw tsc error(s) total, none above its recorded number.One ratchet fired and was fixed at the source, never in the ledger. The first run of
check:type-check-debt --re-measurereported@objectstack/rest: TEST_DEBT records 155 raw tsc error(s), tsc --noEmit now reports 158 (+3)— the new test file's 3 errors (oneTS2835extensionless relative import undernodenext, twoTS2550forArray.prototype.at, which is outside this program's lib target). Both were fixed in the test file; re-measured at exactly 155, with 0 from this file. No threshold moved, no baseline raised, no test skipped or quarantined.Left to the accepting seat
The cloud-side implementation card — implement
resolveEnvironmentin cloud's kernel-resolver plus the.objectstack-shabump, reopeningobjectstack-ai/cloud#1548— is filed by the accepting seat on landing, per the ruling.objectstack-ai/cloudis outside this session's repo scope and no attempt was made to file there. Until cloud implements it, existing resolvers keep working and keep paying twice on cold builds, by design.CI is not awaited — the standing ruling is that report delivery happens at draft-PR time. Every conclusion above is a local measurement on
c567b8bd9d; the CI gate farm (all 136 families, including the ~120 that no path derivation can narrow) is still in flight at the time of writing.Generated by Claude Code