fix(objectql): bound every lifecycle reap, not just the guarded ones (#5194) - #5753
Merged
Merged
Conversation
…5194) `LifecycleService.reap()` issued a single unlimited `delete(..., { multi: true })` per sweep on any object without a registered reap guard — no limit, no paging. Batching lived only on the two side paths, `guardedReap` and the Archiver (both 500 x 20 per sweep), whose comment states the reason plainly: "bound one sweep's work, drain the backlog across sweeps". That reason never depended on a guard being registered. Steady state was never the problem — an hourly sweep deletes a small increment. The cost landed exactly once per table, on the first sweep after `retention` is declared on a table that already holds history: one DELETE scanning every historical row. SQLite holds the whole database's write lock for its duration; Postgres takes it as autovacuum debt. Unguarded reaps now run the same batched machinery the guarded ones do, rather than a second copy of it. An object with no guard is the EMPTY guard intersection, which confirms every candidate row, so `guardedReap` already computed the right answer for it — the guard loop just does not execute. It is renamed `batchedReap` because bounding the work, not consulting guards, is what it is for. One reap path, one place where a reap decides what to delete. Three things the unification had to settle, each pinned by a test: - An id-less candidate row is dropped before the guards see it. With guards, the intersection dropped such rows as a side effect of matching ids; with zero guards nothing narrows, and `where: { id: undefined }` with `multi: true` is not a by-id delete at all — the engine's dispatch finds no scalar id and routes to deleteMany over the batch's whole cutoff predicate. - The paging loop checks the #4747 abort bit per page. `sweep()` checks it between objects, which was the whole story when an unguarded reap was one `await`; it is now up to 20 pages of reads and deletes. - An engine with no `find` cannot page, so it keeps the previous single bulk DELETE. That is the no-`find` path, not the unguarded path: losing retention enforcement entirely would be the worse trade, and no guard is waiting to confirm anything. Every real engine has `find`. Cutoff and `retention.onlyWhen` predicates are unchanged and now select the candidate read; the report's `deleted` count reflects rows actually deleted. Cost of the bound, stated in the file: one afterDelete hook per reaped row instead of one per object per sweep. No platform object changes its audit output — all of them are in the audit writer's SKIP_OBJECTS, and `sys_file`, the one that is audited, already reaped per id via its guards. 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:
|
This was referenced Aug 6, 2026
Closed
Contributor
Author
|
范围外发现,已按 Prime Directive #10 单独立 issue(均未指派,
Generated by Claude Code |
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 #5194
前提核验(基于合并后的 origin/main
488b66c,含 #5708)前提成立。
lifecycle-service.ts中无 guard 路径仍是单条无上限删除(#5708 之后行号漂到 1089):分批仍只存在于
guardedReap与 Archiver 两条旁路,注释写明理由「bound one sweep's work, drain the backlog across sweeps」—— 该理由与是否注册 guard 无关。取舍:统一路径成立,已采用
PM 要求优先评估「无 guard 对象直接走 guardedReap(传空 guard 列表)」。逐条核对后成立:
for (const guard of guards)循环体根本不执行,不会有任何多余回调;confirmed.length === rows.length,收敛条件退化为rows.length < REAP_BATCH_SIZE(短页即排空),500 × 20 上限与余量跨 sweep 排空的语义完全相同;countDeleted(driver 返回值),driver 不报数时为undefined;分批后每次删除都是单行,逐行累加与逐行countDeleted求和等价,且恒为已知数 —— 符合 PM「如实反映本轮实际删除数」的要求;fields: ['id']投影:那会让两条路径的读形状分叉,恰是统一要消除的东西,而收益我无法在此测量 —— 无实测支撑的优化不该现在建。所以
guardedReap更名为batchedReap(它存在的意义是「限住一轮的工作量」,不是「咨询 guard」),无 guard 对象即「零 guard 的交集 = 全部确认」。一条删除路径,而不是两份并行分批实现。⛔ 未改 driver 契约(没有加带 limit 的 delete),未碰
engine.ts、未碰 driver 包。统一必须settle 的三件事(各有测试钉住)
where: { id: undefined }配multi: true根本不是 by-id 删除 —— 引擎 dispatch 取不到标量 id,会路由到deleteMany,跑的是这一批的整个 cutoff 谓词。放在 guard 之前丢弃,也顺带避免 guard 为一个根本删不掉的行回收字节(与 finding(objectql): registerReapGuard 后注册者静默顶掉前者,且注册表私有 —— 第二个注册方察觉不到自己解除了别人的 guard #5535「先收窄再询问」同一理由)。os migrate子命令关停时,悬空引用巡检都会把sys_metadata/sys_view_definition报成unreadableObjects(连接已关闭) #4747 的 abort 位。sweep()在对象之间检查;从前无 guard reap 是两次检查之间的单个await,那就是全部,现在则是最多 20 页的读与删。find的引擎无法分页,保留原单条 bulk DELETE。这是 no-find路径,不是无 guard 路径:此时没有 guard 在等待确认,直接跳过会让 retention 彻底失效,是更坏的取舍。真实引擎恒有find(ObjectQL.find,plugin.ts接线),所以生产环境一律分批。代价,写在文件里而不是留给下一个人踩
reap 现在是每删一行一次 afterDelete hook,不再是每对象每轮一次 —— 文件头原来那句「at most ONE afterDelete hook fires per object per sweep」自 #5535 起对 guard 对象就已不成立,这次一并改正。
对今天的对象群这是零代价,已逐个核对:11 个声明 lifecycle 的平台对象中,10 个在 audit writer 的
SKIP_OBJECTS里(sys_job_run/sys_job_queue/sys_automation_run/sys_upload_session/sys_device_code/sys_notification及三个 delivery/receipt/inbox、sys_http_delivery),本来就不产生 audit 行;唯一被审计的sys_file因带 reap guard,本来就是逐 id 删。一处如实记录:每轮上限是按 (对象, where 作用域) 计的,租户覆写会让一个对象有 N+1 个 pass、各自一份 500 × 20 预算 —— 这是 guard 路径原有性质,本 PR 未改变。
测试
新增
LifecycleService.sweep — unguarded reap batching (#5194)五例 + teardown 一例:大表首轮上限(10007 行 → 首轮恰好 10000、finds恰好 20 页、余 7 行次轮排空)、稳态小增量(单页单读)、onlyWhen谓词随候选读生效、无find回退、无 id 行不落入谓词删除、stop() 中途结束分页。既有 fixture 分诊(逐个判,不批量改写):
a guard on one object never changes the blind reap of others:它钉的正是被删掉的那条肢(断言无上限 DELETE),留着会因「什么都没产生」而假绿。改为给sys_job_run真实候选行,断言它被逐 id 删且sys_file的 guard 从未被调用(expect(guard).not.toHaveBeenCalled())—— 统一之后「别的对象的 guard 会不会串进来」反而是个更尖锐的问题,不是失效问题。标题里的 "blind reap" 一并更名。engine.find供租户枚举,所以现在会走分批;被测性质(每个 pass 自己的 cutoff + org 作用域、全局 pass 用$or覆盖 NULL-org、floor 兜底)完全没变,只是谓词从deletes[i].where移到了候选读finds[i].where。同时补断言逐 id 删除确实发生,比原来更强而非更弱。消费半径扫描(#5046 的教训 —— 改的是 objectql,坏的 fixture 可能在别的包):
grep出所有驱动sweep()的测试,packages/services/service-queue(真实 LifecycleService 跑真实SysJobQueue)与packages/qa/dogfood(真实引擎)都在半径内,已全部跑过。反向验证(方向先判后跑,判的是「红」,结果就是红):把 dispatch 改回
guards.length > 0(恢复无 guard 单条无上限 DELETE),9 个用例转红 —— 新增 6 例全红,3 个改写的既有租户用例全红。唯一不转红的是「无find引擎保留 bulk DELETE」那例,这正确:该例钉的是回退路径,对 dispatch 的改动本就不敏感。Generated by Claude Code