fix(objectql): 同一 object 的多个 reap guard 按交集组合,后注册者不再静默顶掉前者 (#5535) - #5708
Merged
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…p-guard-composition
os-zhuang
marked this pull request as ready for review
August 6, 2026 02:37
This was referenced Aug 6, 2026
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 #5535
前提复核(先证后改)
issue 的两条前提在
origin/main(28ce594)上仍然成立,逐条核对过:registerReapGuard仍是一行set(),docstring 明写「One guard per object (last registration wins)」——lifecycle-service.ts:413-422(行号随 finding(objectql): lifecycle 的 settings 覆盖窗口没有下限校验 —— 运维把 maxAge 调到某个消费方的假设之下时,后果是静默的(以 sys_job_queue 去重窗为例) #5195 之后的内容漂移,以内容定位)。reapGuards注册表仍是private单槽表,无hasReapGuard/listReapGuards之类探针,覆盖时不打日志。service-storage,注册在两个不同 object(sys_file/sys_upload_session)上,今天确实不碰撞。另外查证一点 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:keeps byte reclaim running when a second consumer registers断言残留行expected [] to deeply equal [ 'f2' ]—— last-wins 下三行全被删掉,包括字节回收失败(应当 veto)的 f2。行没了、字节还在,即 issue 说的「行照删、字节泄漏」。registering the identical guard twice(单槽天然只留一个)与multiple guards on an engine without find are skipped(一个 guard 就足以触发 fail-safe 跳过)。它们钉的是别的东西,不是组合。验证
pnpm --filter @objectstack/objectql test→Test Files 121 passed (121) / Tests 1979 passed (1979)Tests 64 passed (64)(9 条新增组合测试 + 5 条原有单 guard 回归原样通过)pnpm --filter @objectstack/objectql typecheck→ 干净pnpm check:query-options-erasure→ratchet holds: 84 unswept non-test site(s), none newnode scripts/check-nul-bytes.mjs→ OK;并对两个改动文件做了越过该 gate 盲区的控制字符自扫(grep -naP,无命中)pnpm --filter @objectstack/service-storage test→Tests 283 passed (283),该包零改动——现役注册方行为不变的证据文件面
严格落在约束内:
packages/objectql/src/lifecycle/lifecycle-service.ts、同目录.test.ts、一个 changeset。未碰engine.ts(#5504 在飞)、未碰packages/services/**、未碰packages/spec。sys_file形态的字节回收回归 pin 用同形 stub 写在 objectql 测试内,不 import services 包。(构建过程中
packages/spec/authorable-surface.base.json被gen:schema重锚到本 HEAD 并带进 #5682 的scoping键——与本单无关,已 checkout 还原,未进提交。)本单合并即解除 #4672(知识插件走 reap-guard 去索引化,方案 C)的 Blocked-by:它可以直接在
sys_file上追加注册,不必担心顶掉字节回收。🤖 Generated with Claude Code
https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx
Generated by Claude Code