fix(runtime): 每个请求从自己解析出的 kernel 取服务 —— 多租户 host 上两个请求不再互相串改 (#5155) - #5385
Merged
Merged
Conversation
…patcher (#5155) One HttpDispatcher serves a whole host, but the kernel a request resolves to is per request. That answer was stored on the instance field `this.kernel`, written once per request by `resolveRequestScope()` and read by every service lookup afterwards — each behind at least one `await`. Two interleaved requests on a multi-tenant host therefore swapped data sources under each other: A resolved env-1, yielded, B resolved env-2, and A resumed reading env-2. `HttpProtocolContext` now carries `kernel`, written by `resolveRequestScope()` next to the `environmentId` / `dataDriver` / `executionContext` it already writes there. `this.kernel` is gone. Every kernel-reading member of `DomainHandlerDeps` / `ActionExecutionDeps` takes the request as its first parameter, so the dependency is visible at the call site and the compiler asks for it — chosen over AsyncLocalStorage, which would have reintroduced implicit mutable ambient context, the same defect in a new costume. Three host-level readers (`/ready`, its driver-health probe, the memoized `default-project` lookup) now name `defaultKernel` explicitly instead of reading whichever tenant resolved most recently. Covered by a deterministic interleaving regression test: request A parks inside its own identity resolution, request B runs to completion, A resumes. On the old code A is served env-2's i18n bundle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VkPSGsX9o17MsGv3Lbxu2w
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 21 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
baozhoutao
marked this pull request as ready for review
August 5, 2026 03:20
baozhoutao
enabled auto-merge
August 5, 2026 03:20
Contributor
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 30972041566 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
This was referenced Aug 5, 2026
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Aug 6, 2026
) (objectstack-ai#5392) `POST /packages/:id/publish-drafts` 的 ADR-0045 可见性翻转是一个循环:每个 app 一次独立的 `saveMetaItem`,每次成功各自落盘。但 `unhidden` 声明在 try 之内、 `result.unhiddenApps` 只在整个循环跑完后才赋值 —— N 个 app 里第 k 个抛异常时, 前 k-1 个确实已翻转并持久化,却随栈一起被丢弃:响应里 `unhiddenApps` 不存在, 紧随其后读这个字段的 `metadata:reloaded` 广播也漏播这些 app,boot-cached 的 消费者(automation engine)要等下一次重启才同步。 改为增量累积:`unhidden` 与它的赋值一并提到 try 之外,名字只在对应 `saveMetaItem` 兑现之后才 push,因此该列表在任意时刻恰好等于"已经落盘的那些"; 赋值移到 try/catch 之后,成功与中途失败两条路径都执行,且仍在 announce 段之前。 部分失败时 `unhiddenApps` 与 `unhideError` 并存 —— 前者说什么翻成功了,后者说 还有没翻完的。`unhidden` 是每请求局部量,不引入共享可变状态(objectstack-ai#5385 姿态)。 同时修正那条 error 日志的措辞:它原先断言"其 app 全部仍以 hidden: true 存着", 一旦有翻转已落盘这句话就是假的;现在按两半如实点名。 响应契约不变:仍然 200,字段仍是原来那两个,只是部分失败时可以同时出现; 重跑依旧幂等。 Claude-Session: https://claude.ai/code/session_01VkPSGsX9o17MsGv3Lbxu2w Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Aug 6, 2026
…able until the E7 flip" comment family + cover the multi-tenant decline branch (objectstack-ai#5399) (objectstack-ai#5404) The objectstack-ai#5040 E7 publish flip landed (packages/spec/src/api/endpoint-publish-gate.ts opens with "This module is that flip"), and E8 moved endpoints back into the OpenAPI document. Thirteen comments across runtime/metadata/rest still asserted the pre-flip world -- "Structurally unreachable today", "Nothing calls this yet", "Today it emits nothing" -- which is exactly the objectstack-ai#5078 defect: a comment that contradicts the code in front of it. Each site is rewritten to describe current reality and cite the authority (the publish gate, or the real-boot probe in packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts, whose /openapi.json case disproves "emits nothing" directly). Surrounding still-true prose is preserved. Pure comment change: the only non-comment line touched is a descriptive `note:` string in the route ledger. Also adds dispatcher-plugin.multi-tenant-endpoint.integration.test.ts, covering the E5b branch "multi-tenant resolution finds no environment -> decline + warn", which had zero test references repo-wide because the kernel-resolver PROVIDER ships in the cloud distribution. A stub resolver drives a real boot over a real socket: a placed request executes on its own tenant kernel; an unplaced one gets the transport's bare 404, the warn, and no probe of any declaration. The host kernel deliberately declares the same path too. Without that, the decline cases pass vacuously -- verified by deleting the branch and re-running: with tenant-only declarations only the warn assertion moved, because the step declined a second time for an unrelated reason. With the host copy in place, deleting the branch turns both decline cases red with "expected 200 to be 404", the cross-tenant answer the branch exists to prevent. Out of scope, filed as objectstack-ai#5400: the sibling `else` branch still logs `debug` when an adapter exposes no setFallbackHandler. Its own comment scheduled a move to `warn` "when that flip lands" -- it has landed, so declared endpoints are now silently unservable there -- but changing the level is a behavior change, so this commit only makes the comment truthful and names the tracking issue. Refs objectstack-ai#5231, objectstack-ai#5040, objectstack-ai#5078, objectstack-ai#5230, objectstack-ai#5385 Claude-Session: https://claude.ai/code/session_01VkPSGsX9o17MsGv3Lbxu2w Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5155
先证可达性,再动修法
按 PM 裁定的顺序:先写交错回归测试,在未改动的
origin/main上跑,证实「请求 A 跨 await 读到请求 B 的 kernel」确实可达。新增
packages/runtime/src/http-dispatcher.multi-tenant-concurrency.test.ts。交错是确定性的,不靠调度时序:请求 A 在自己的身份解析里被一个测试持有的闸门 park 住(它那个 kernel 的auth查找 await 住),请求 B 全程跑完,然后才放行 A。在改动前的代码上:
请求 A(env-1)拿到的是 env-2 的 i18n 包。issue 的前提成立,而且是走完整
dispatch()管线复现的,不是构造出来的。事实核对
issue 引用的行号已漂移(#5237、E 系列之后),但代码事实逐条成立:
this.kernel是实例字段,resolveRequestScope()每请求写一次,resolveService()/getService()/getObjectQL()/getRequestKernelService()/announceKernelEvent()/getRegisteredAiRoutes()全部从它读,每一个都在至少一个 await 之后。Node 单线程不保护这个 —— 它保护的是「不跨 await 持有可变共享状态」的代码。修法:方案 A(显式参数)
HttpProtocolContext新增kernel字段,由resolveRequestScope()写入 —— 和它本来就在那儿写的environmentId/dataDriver/executionContext并排。this.kernel整个删掉。DomainHandlerDeps/ActionExecutionDeps上每一个读 kernel 的成员,第一个参数改成请求本身。选它而不是 B(AsyncLocalStorage):后者把隐式可变上下文又请回来一次,正是这次事故换个壳。显式参数让依赖在调用点可见,而且编译器会替你要 —— 26 个文件、65 个调用点全是 tsc 点名出来的,没有一处靠人肉扫。为什么传的是 context 而不是裸 kernel:
resolveProjectKernelObjectQL(context)这个 seam 要把换好的 kernel 写回去(domains/actions.ts:125换完之后还继续做deps.*查找,必须看到换后的值)。传裸值的话这个写回无处可去,得靠调用方手工接返回值再逐层传 —— 那是新的一类容易写错的地方。context 本来就是每请求对象,kernel 明明白白挂在context.kernel上,调用点读得到。顺带修掉的同源问题:
/ready、它的 driver 健康探针、以及记忆化的default-project查找,这三个是副本级而不是请求级的读,原来同样读this.kernel,也就是「读到最近一个租户的 kernel」。现在显式读defaultKernel。getDiscoveryInfo(prefix)加了个可选第二参数;适配器和 dispatcher plugin 直接从 host 服务/discovery的路径不用改,现在确定性地描述 host kernel,而不是最后一个请求的租户。反向验证
方向是跑之前先定的:把共享可变字段放回去(在
requestKernel()前面插一个__revertProbeLastKernel),预测测试 1、2 变红,测试 3 保持绿 —— 因为缺陷在读不在写,context.kernel仍然写得对。实测完全一致:探针已移除。
夹具处置
5 个既有测试文件的 fake deps 是「用了旧签名」这一类,按改写处置(不是整体替换):fake 现在照新 arity 接 context。这不是走过场 —— 一个漏传 context 的调用点会让
name收到 envId,查找落空,测试就红,所以 arity 本身是有牙的。验证
按消费半径扫过跨包夹具(#5046 的教训),改动确认只落在
packages/runtime—— 仓库内与两个兄弟仓都没有DomainHandlerDeps/ActionExecutionDeps的实现方或调用方。跨包回归:门禁:
check-nul-bytesOK、check:startup-registry-verdictOK、check:durability-log-levelOK、eslint 干净。边界
matchEndpoint取数逻辑 —— 那是 声明式端点的两个机器可读面会说谎:runtime-authoredapi行在 /meta/api 与 /openapi.json 里在场,匹配器却永远看不见(真实 boot 实测) #5224 的裁决面(Blocked-byapi不在 metadata 类型注册表里 —— Studio 直写路径完全不校验端点,publishPackageDrafts 也没有 E7 门 #5206)。本 PR 只动每请求 kernel 状态。this.kernel === defaultKernel恒成立,赋值本来就幂等),这也正是本地和 CI 一直看不见它的原因。Generated by Claude Code