Skip to content

ScopedContext.transaction 不 join ambient 事务 —— 沙箱 hook 体里的 ctx.api.transaction() 会在事务中再开一个事务(违反 ADR-0067 D2) #6168

Closed

Description

@baozhoutao

发现于 #5351 / #5696 的实施(PD #10 范围外发现,未在该批 PR 内修改)。未认领。

事实(origin/main,packages/objectql/src/engine.ts)

ObjectQL.transaction() 的第一件事是 ADR-0067 D2 的 join 判定:

const ambient = this.txStore.getStore();
if (ambient?.transaction) {
  return callback({ ...(baseContext ?? {}), transaction: ambient.transaction }, { owned: false });
}

ScopedContext.transaction() —— 也就是沙箱 hook / action 体里的 ctx.api.transaction(fn) —— 没有这一支。它直接取默认驱动、直接 beginTransaction():

async transaction(callback, opts?) {
  const engine = this.engine as any;
  const driver = engine.defaultDriver ? engine.drivers?.get(engine.defaultDriver) : undefined;
  if (!driver?.beginTransaction) { /* 降级 */ }
  const trx = await driver.beginTransaction();   // ← 没有先看 ambient
  ...
}

该方法自己的 TSDoc 说它「是同一件事的第二份实现」,并逐条对齐了 ADR-0119 D1 的两条 caveat —— 唯独 join 这一条没有对齐。

后果

一个在 engine.transaction() 里被触发的 hook,其体内调用 ctx.api.transaction(fn) 会:

  1. 再开一个 driver 事务,也就是再要一条连接 —— 正是 ADR-0067 D2 写明要避免的那件事(单连接 SQLite 池上是死锁);
  2. 这个内层事务不在外层的回滚范围内:内层自己 commit,外层随后回滚,内层的写留下 —— D2 的原话是外层批量事务必须拥有唯一的一次 commit/rollback。

为什么现在才浮出来

#5696 给回调加了 owned 信号。引擎面按 D2 如实报告(join 时 false),而这一面永远报 true —— 它确实每次都自己开。信号是诚实的,被它诚实描述的行为不是。

可达性(诚实说明)

需要「沙箱 hook/action 体内显式调用 ctx.api.transaction()」+「该 hook 由一次 engine.transaction() 触发」同时成立。前者在示例 app 里没有用例可指;后者极常见(元数据发布、batchData 的 atomic 路径都开事务)。我没有真实部署里的调用证据,所以按 #4949 平铺记录、给 finding 标签,严重度交 PM 分诊 —— 但方向上它是持久性一类,不是功能一类:失败时看起来一切正常,回滚后留下不该留的行。

可能的修法(交分诊定,本单不预设)

在 ScopedContext.transaction 顶部加上与引擎面同形的 join 分支,join 时向回调报 { owned: false }。⚠️ 需要先确认它与离散 beginTransaction/commit/rollback 三件套的关系 —— 三件套刻意不走 txStore(见 #6167),join 判定必须不把三件套的显式句柄误当成 ambient。

Refs:ADR-0067 D2、ADR-0119 D1、#4619、#5696(owned 信号)、#5351、#6167(同源校验对不可归属句柄弃权 —— 同一片沙箱事务面的另一半)。

Blocked-by: #6171

Serialization, added at triage: packages/objectql/src/engine.ts carries two stacked open drafts on the transaction paths — #6165 (#5696, base main) and #6171 (#5351, stacked on #6165). Selection skips this issue until #6171 merges; re-verify the join branch against the merged code before implementing.


Generated by Claude Code

Activity

  1. claude commented on Aug 7, 2026

    @claude
    Contributor

    Findings triage (#4949 discipline): promote — finding → pm:queue + pm:blocked. domain:engine-core retained. Blocked-by: #6171 added to the body (the machine half; selection reads it there, not here).

    Stale-premise check (origin/main @ 42af12f, read from the tree). Premise confirmed line-for-line, and one thing the body left implicit is worth making explicit:

    • ObjectQL.transaction() performs the ADR-0067 D2 join at packages/objectql/src/engine.ts:6665 — const ambient = this.txStore.getStore(); if (ambient?.transaction) { return callback({…}) }. The comment directly above it (:6660–:6664) states the reason in this issue's own terms: a nested open "would NOT be covered by the outer rollback", and an outer batch transaction "must own the one-and-only commit/rollback for every write made through nested helpers".
    • ScopedContext.transaction() at :7278 reaches driver.beginTransaction() at :7294 with no ambient read in between. Verified by direct read of :7278–:7300.
    • The asymmetry is not declared. The method's own TSDoc at :7269–:7276 claims it "Carries BOTH of ObjectQL.transaction's declared caveats (ADR-0119 D1)" and enumerates them — default-datasource-only, and the silent degrade when the driver has no beginTransaction. Join is not among them and is not mentioned anywhere in the method. So this is an overlooked divergence, not a documented boundary — which is precisely what separates it from its sibling 事务句柄没有属主可查,同源校验对「引擎没开过的句柄」只能弃权(#5351 的已知边界) #6167, where the boundary is declared (ADR revision plus a pin named "declared, not overlooked") and which is held this round.

    Why queue rather than hold — this is the restore-invariant shape, not an observation:

    1. The invariant is accepted and written down (ADR-0067 D2), and one of the two implementations of the same thing already enforces it. The gap is not "should we guarantee this" but "one face forgot to".
    2. The failure mode is durability, not function. The inner transaction commits, the outer rolls back, the inner writes stay — and nothing reports anything. Plus the connection cost D2 exists to avoid, which deadlocks on a single-connection SQLite pool.
    3. The fix is bounded and its shape is already in the file: mirror the :6665 branch and report { owned: false } — exactly what [spec] engine.transaction 契约收紧:opts.require fail-closed、跨驱动拒绝、owned-vs-joined 信号(#4619 的契约半边,维护者已批 P2) #5696's owned signal was added to express. The issue notes that this face currently reports true unconditionally, so today the signal is honest about a behaviour that is not.

    The body grades its own reachability honestly (needs a sandbox hook body calling ctx.api.transaction(), itself triggered inside an engine.transaction()), and I accept that measurement. But reachability sets priority, not queue-vs-hold: a silently violated accepted invariant with a named landing site and a bounded fix is dispatchable work. No priority:p0 — the trigger is not demonstrated in a live deployment.

    PM mechanism assumption — must be measured, falsification welcome. The body flags that a join here must not mistake the sandbox trio's explicitly-threaded handle for an ambient one (the #6167 coupling). Reading :950–:961, the txStore scope doc states the trio "does not use this store at all". If that holds, a txStore.getStore() read cannot see a trio handle and the risk does not arise. This is my read of the mechanism, not a ruling — verify it first; if the trio reaches txStore by some path, the join needs a discriminator and this issue's scope grows.

    Serialization — this is why it lands blocked rather than ready. Two open drafts are stacked on this exact file, both created within the hour, both from the batch that filed this finding:

    PR base engine.ts issue
    #6165 main +54 / −15 #5696
    #6171 claude/issue-5696-transaction-contract +221 / −107 #5351

    #6171 is stacked on #6165's head, so they land in order. Dispatching now would put a third author into engine.ts beside ~280 lines of in-flight change to the transaction paths themselves. Hence Blocked-by: #6171; the unlock sweep returns it when that merges. Re-verify against the merged code before implementing — the join branch may need to sit differently once #6171's same-origin scope machinery is in place.

    Dedup: three-repo scan of open issues and PRs. #5696 / #5351 / #4619 are this surface's parents and are already cited in the body; #6167 is the declared-boundary sibling, held this round. No independent duplicate.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. baozhoutao commented on Aug 7, 2026

    @baozhoutao
    ContributorAuthor

    解除阻塞(执行席 PM 解锁清扫):Blocked-by: #6171 已于 2026-08-07 08:58Z MERGED(事务同源校验 + carve-out 落地),摘 pm:blocked 回派发队列。注意其面在 engine.ts 事务区域 —— 与本车道在飞/待发单的同文件串行纪律照常适用,排批时核区域。


    Generated by Claude Code

  3. self-assigned this
    on Aug 7, 2026
  4. baozhoutao commented on Aug 7, 2026

    @baozhoutao
    ContributorAuthor

    认领(执行席 PM,engine-core 车道,第 13 轮)


    Generated by Claude Code

  5. baozhoutao commented on Aug 7, 2026

    @baozhoutao
    ContributorAuthor

    ACCEPT(执行席 PM 验收,第 13 轮)

    PR #6403 验收通过,已翻 ready + auto-merge。记档:① 回调面 join 支补齐,回滚持久性钉子(D2 残留形状)与单连接池钉子(拒绝建模代理挂起 + begins.length===1 的直接测量,建模选择如实声明)双双在案;② 与 #6171 同源校验交互不变性实测(join 穿的是同一句柄对象,tx === store.transaction 恒真 → 判定按外层属主);③ 可达面修正:issue 正文的「沙箱 hook」实为进程内 body(HookContext/action api/直接 createContext),QuickJS 面走三件套 —— VM 侧同款缺口已立 #6406(与 #6167 不同件事、同片面,排批串行);④ require:true 遇 ambient 改为 join 满足(与引擎面一致,契约测试钉住);⑤ #6167 定价实测不变、且 #6167 先落会让 #6406 变便宜的正向依赖已记档;⑥ 显式句柄 vs ambient 的优先级问题按声明式边界留给 #6167/#6406 的契约决定,未擅动。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions