Skip to content

fix(objectql): 同一 object 的多个 reap guard 按交集组合,后注册者不再静默顶掉前者 (#5535) - #5708

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5535-reap-guard-composition
Aug 6, 2026
Merged

fix(objectql): 同一 object 的多个 reap guard 按交集组合,后注册者不再静默顶掉前者 (#5535)#5708
os-zhuang merged 2 commits into
mainfrom
claude/issue-5535-reap-guard-composition

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#5535

前提复核(先证后改)

issue 的两条前提在 origin/main(28ce594)上仍然成立,逐条核对过:

另外查证一点 issue 未展开的:ADR-0057 §3.3 及其 amendment 从未写过「一个 object 一个 guard / last wins」——全文搜不到这条规则。所以交集组合不是推翻已记录的决定(Prime Directive #13),是把 ADR 已经邀请的形态(「a guard is a domain callback」)在实现层补齐,ADR 正文无需修订。

为什么是交集,而不是 last-wins + warn + 探针

guard 的确认不是投票,是回执——「外部副作用已经做完,这行现在可以删了」。sys_file 的 guard 先 storage.delete(row.key) 再确认,而行是字节的唯一指针。单槽 last-wins 意味着第二个注册方把这段字节回收整体解除:行照删、字节泄漏、零日志,且新来者没有任何可读的东西能察觉。退一步的 last-wins + warn + 探针要求注册方遵守一条无法验证的约定,不取。

新契约:注册追加而非替换;只有全部 guard 确认的 id 才进删除集,任一 veto 即保留、下一轮 sweep 重试。与 registerRetentionFloor(#5195,每个注册方保留发言权、最严者胜)同构。

交集实现为"收窄流水线"而不是"各自表决再取交集",这是本 PR 唯一需要判断的语义细节:后一个 guard 只会被问到「前面的 guard 已确认」的行。理由还是回执语义——把 guard 1 已经 veto 的行递给 guard 2,会让 guard 2 为一行即将被保留的记录做不可逆清理(去索引化一行还活着的记录 → 它从搜索里静默消失,且没有任何东西会回来补索引)。删除集本身与注册顺序无关,这条有测试钉住。

guard 抛异常的处置:不变,对齐现状。异常上抛到 sweep() 的 per-object handler,report.errors 记一条,该 object 本轮一行不删。同批里更早的 guard 已做掉的清理由下一轮重试,而不会在无人完成确认的批次上兑现成删除。批量上限(500 行/批、20 批/sweep)不变。

顺带:重复注册同一个函数引用是 no-op(重跑 wiring,不是第二份意见)。与自己求交集不改变结果,却会让它的外部清理每批跑两遍。

未加只读探针:交集组合之后「察觉不到自己顶掉了谁」这个问题本身消失了(没有东西被顶掉),测试也不需要探针即可观察全部语义,故不为探针而探针。

反向验证(方向是事前预测的:红)

registerReapGuard 临时改回单槽 set()(guardedReap 的组合逻辑保留),预测组合类测试转红、单 guard 回归保持绿。实测完全吻合——7 failed | 57 passed:

  • 五条单 guard 回归全部保持绿(语义逐条未变)。
  • 七条组合测试转红,其中最要命的一条正是 issue 描述的形态:keeps byte reclaim running when a second consumer registers 断言残留行 expected [] to deeply equal [ 'f2' ] —— last-wins 下三行全被删掉,包括字节回收失败(应当 veto)的 f2。行没了、字节还在,即 issue 说的「行照删、字节泄漏」。
  • 两条组合测试在 last-wins 下依然绿,如实记录:registering the identical guard twice(单槽天然只留一个)与 multiple guards on an engine without find are skipped(一个 guard 就足以触发 fail-safe 跳过)。它们钉的是别的东西,不是组合。

验证

  • pnpm --filter @objectstack/objectql testTest Files 121 passed (121) / Tests 1979 passed (1979)
  • 目标文件单跑 → Tests 64 passed (64)(9 条新增组合测试 + 5 条原有单 guard 回归原样通过)
  • pnpm --filter @objectstack/objectql typecheck → 干净
  • pnpm check:query-options-erasureratchet holds: 84 unswept non-test site(s), none new
  • node scripts/check-nul-bytes.mjs → OK;并对两个改动文件做了越过该 gate 盲区的控制字符自扫(grep -naP,无命中)
  • pnpm --filter @objectstack/service-storage testTests 283 passed (283),该包零改动——现役注册方行为不变的证据

文件面

严格落在约束内:packages/objectql/src/lifecycle/lifecycle-service.ts、同目录 .test.ts、一个 changeset。未碰 engine.ts(#5504 在飞)、未碰 packages/services/**、未碰 packages/specsys_file 形态的字节回收回归 pin 用同形 stub 写在 objectql 测试内,不 import services 包。

(构建过程中 packages/spec/authorable-surface.base.jsongen:schema 重锚到本 HEAD 并带进 #5682scoping 键——与本单无关,已 checkout 还原,未进提交。)

本单合并即解除 #4672(知识插件走 reap-guard 去索引化,方案 C)的 Blocked-by:它可以直接在 sys_file 上追加注册,不必担心顶掉字节回收。

🤖 Generated with Claude Code

https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx


Generated by Claude Code

…ns (#5535)
`registerReapGuard` was a single-slot `set()` documented as "one guard per
object (last registration wins)", over a `private` registry with no probe and
no warn on overwrite. A second registrant on one object therefore silently
unhooked the first, and could not have noticed.
That is unsafe on this seam specifically, because a guard's confirmation is
not a vote but a receipt: "external cleanup is done, this row may go now".
`sys_file`'s guard reclaims storage bytes before confirming, and the row is
the only pointer to those bytes — so displacing it means rows deleted, bytes
leaked, zero log lines. ADR-0057 §3.3 explicitly invites a second domain
callback (#4672's de-indexing is one), which is what turns this from theory
into the next merge.
Guards now compose by intersection: registration appends, and only ids every
guard confirmed are deleted; one veto keeps the row for the next sweep. Same
shape as `registerRetentionFloor` one policy over, where every registrar keeps
its say and the strictest wins.
The intersection is a narrowing pipeline, not N verdicts unioned at the end: a
guard is asked only about rows the guards before it confirmed, so it never
performs irreversible cleanup for a row another guard is about to keep. The
delete set does not depend on registration order. A throwing guard still
aborts the object's reap with nothing deleted. Re-registering the identical
function is a no-op.
Single-guard behaviour is unchanged — the five existing guard tests pass
untouched, and service-storage's two registrars need no change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx
@vercel

vercelBot commented Aug 6, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 6, 2026 2:07am

Request Review

@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 6, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx(via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx(via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx(via @objectstack/objectql)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/objectql)
  • content/docs/kernel/services.mdx(via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx(via @objectstack/objectql)
  • content/docs/plugins/index.mdx(via @objectstack/objectql)
  • content/docs/plugins/packages.mdx(via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx(via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx(via @objectstack/objectql)

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.

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-zhuang@claude