Skip to content

fix(runtime): 每个请求从自己解析出的 kernel 取服务 —— 多租户 host 上两个请求不再互相串改 (#5155) - #5385

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-5155-dispatcher-request-kernel
Aug 5, 2026
Merged

fix(runtime): 每个请求从自己解析出的 kernel 取服务 —— 多租户 host 上两个请求不再互相串改 (#5155)#5385
baozhoutao merged 1 commit into
mainfrom
claude/issue-5155-dispatcher-request-kernel

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

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。

在改动前的代码上:

AssertionError: expected [ 'env-2-locale' ] to deeply equal [ 'env-1-locale' ]
- Expected     + Received
-   "env-1-locale",
+   "env-2-locale",

请求 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 仍然写得对。实测完全一致:

× an interleaved request does not move the first request onto the other tenant's kernel
× the endpoint fallback path resolves services on the scope it resolved, not the latest one
AssertionError: expected [ 'env-2-locale' ] to deeply equal [ 'env-1-locale' ]
AssertionError: expected 'env-2' to be 'env-1'
      Tests  2 failed | 1 passed (3)

探针已移除。

夹具处置

5 个既有测试文件的 fake deps 是「用了旧签名」这一类,按改写处置(不是整体替换):fake 现在照新 arity 接 context。这不是走过场 —— 一个漏传 context 的调用点会让 name 收到 envId,查找落空,测试就红,所以 arity 本身是有牙的。

验证

pnpm --filter @objectstack/runtime typecheck   → Done(0 error)
pnpm --filter @objectstack/runtime test        → Test Files 91 passed (91) / Tests 1336 passed (1336)

按消费半径扫过跨包夹具(#5046 的教训),改动确认只落在 packages/runtime —— 仓库内与两个兄弟仓都没有 DomainHandlerDeps / ActionExecutionDeps 的实现方或调用方。跨包回归:

@objectstack/http-conformance  46 passed   (跨适配器一致性套件)
@objectstack/hono              73 passed
@objectstack/client           222 passed
@objectstack/cloud-connection  89 passed
@objectstack/verify            17 passed

门禁:check-nul-bytes OK、check:startup-registry-verdict OK、check:durability-log-level OK、eslint 干净。

边界


Generated by Claude Code

…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
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 5, 2026 3:16am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime.

21 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via packages/runtime)
  • content/docs/api/index.mdx (via @objectstack/runtime)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/runtime)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/runtime)
  • content/docs/concepts/north-star.mdx (via packages/runtime)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/runtime)
  • content/docs/kernel/cluster.mdx (via @objectstack/runtime)
  • content/docs/permissions/authentication.mdx (via @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/runtime)
  • content/docs/plugins/packages.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime)
  • content/docs/releases/implementation-status.mdx (via @objectstack/runtime)
  • content/docs/releases/v17.mdx (via @objectstack/runtime)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 30972041566 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (3/3) — 失败步骤: Run this shard's tests

    �[41m�[1m FAIL �[22m�[49m src/commands/serve-tenancy-posture-gate.test.ts�[2m > �[22mthe gate runs before serve does ANY boot work�[2m > �[22mrefuses before the config file is even read, and writes no
    

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 1 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 在其他 PR 的同类评论里搜同名测试;出现过 ⇒ flaky 实锤,开 issue 修/隔离那条测试。修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit fd8521f Aug 5, 2026
24 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-5155-dispatcher-request-kernel branch August 5, 2026 03:41
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HttpDispatcher 把「本请求解析出的 kernel」存在实例字段上(this.kernel),多租户 host 上并发请求会互相串改

2 participants