在核对 #4672(知识索引对账)时发现的旁支问题,与该 issue 的实现无关,单独记录。
现象
LifecycleService.registerReapGuard(object, guard) 的实现是一行 set(packages/objectql/src/lifecycle/lifecycle-service.ts:420-421),docstring 也明确写了「One guard per object (last registration wins — guards are platform wiring, not user surface)」。同时 reapGuards 注册表是 private(:335),对外没有 hasReapGuard / listReapGuards 之类的探针,覆盖时也没有 warn。
两点合起来才构成问题:
- 同一 object 上的第二个注册方静默顶掉第一个;
- 第二个注册方无法察觉自己顶掉了什么——注册表私有、无探针、无日志。
对比同一文件里 #5195 落的 registerRetentionFloor:键是 object::policy::declaredBy(:463),注释写得很直白——「a re-registration replaces rather than accumulates, while two independent consumers of one object both keep their say (the strictest wins)」。也就是说 floor 是可组合的,guard 不是。这个不对称目前没有任何地方记录为有意权衡。
为什么今天不出事(observation-class)
全仓当前只有 service-storage 注册 guard,而且注册在两个不同 object 上——sys_file 与 sys_upload_session(packages/services/service-storage/src/storage-service-plugin.ts:320-337),不存在碰撞。所以这是一条「今天没人踩到」的记录,不是现网缺陷。
什么时候会出事
guard 的语义是「外部副作用先做完,再确认这行可以删」,而 sys_file 的 guard 做的正是字节回收:确认前先 storage.delete(row.key),失败则 veto 留到下一轮(行是字节的唯一指针,先删行就永久泄漏)。任何第二个消费者在 sys_file 上注册,这段字节回收就被整体解除:行照删、字节泄漏,而且没有一行日志说明发生了什么。
第二消费者不是假设场景:#4672 讨论的「派生索引在行消失前按 id 去索引化」就是同一形态,ADR-0057 §3.3 的 amendment 也正是把这种 domain callback 认定为合法形态(「a guard is a domain callback, not a second sweeper」)。也就是说,只要 ADR 鼓励的这条扩展点被第二个包用起来,覆盖就从理论变成日常。
处置方向(建议,非裁决)
guard 的契约是「确认才删」,天然可交集组合:同一 object 上的多个 guard 依次执行,只有全部确认的 id 才进删除集(任一 veto 即保留到下一轮由下次 sweep 重试)。这与单 guard 时的语义完全兼容,也与 floor 的「最严者胜」同构。
退一步的最小修法:保留 last-wins,但覆盖时打 warn 并提供一个探针,让注册方至少能看见冲突、能选择不注册。
参考
在核对 #4672(知识索引对账)时发现的旁支问题,与该 issue 的实现无关,单独记录。
现象
LifecycleService.registerReapGuard(object, guard)的实现是一行 set(packages/objectql/src/lifecycle/lifecycle-service.ts:420-421),docstring 也明确写了「One guard per object (last registration wins — guards are platform wiring, not user surface)」。同时reapGuards注册表是private(:335),对外没有hasReapGuard/listReapGuards之类的探针,覆盖时也没有 warn。两点合起来才构成问题:
对比同一文件里 #5195 落的
registerRetentionFloor:键是object::policy::declaredBy(:463),注释写得很直白——「a re-registration replaces rather than accumulates, while two independent consumers of one object both keep their say (the strictest wins)」。也就是说 floor 是可组合的,guard 不是。这个不对称目前没有任何地方记录为有意权衡。为什么今天不出事(observation-class)
全仓当前只有
service-storage注册 guard,而且注册在两个不同 object 上——sys_file与sys_upload_session(packages/services/service-storage/src/storage-service-plugin.ts:320-337),不存在碰撞。所以这是一条「今天没人踩到」的记录,不是现网缺陷。什么时候会出事
guard 的语义是「外部副作用先做完,再确认这行可以删」,而
sys_file的 guard 做的正是字节回收:确认前先storage.delete(row.key),失败则 veto 留到下一轮(行是字节的唯一指针,先删行就永久泄漏)。任何第二个消费者在sys_file上注册,这段字节回收就被整体解除:行照删、字节泄漏,而且没有一行日志说明发生了什么。第二消费者不是假设场景:#4672 讨论的「派生索引在行消失前按 id 去索引化」就是同一形态,ADR-0057 §3.3 的 amendment 也正是把这种 domain callback 认定为合法形态(「a guard is a domain callback, not a second sweeper」)。也就是说,只要 ADR 鼓励的这条扩展点被第二个包用起来,覆盖就从理论变成日常。
处置方向(建议,非裁决)
guard 的契约是「确认才删」,天然可交集组合:同一 object 上的多个 guard 依次执行,只有全部确认的 id 才进删除集(任一 veto 即保留到下一轮由下次 sweep 重试)。这与单 guard 时的语义完全兼容,也与 floor 的「最严者胜」同构。
退一步的最小修法:保留 last-wins,但覆盖时打 warn 并提供一个探针,让注册方至少能看见冲突、能选择不注册。
参考
packages/objectql/src/lifecycle/lifecycle-service.ts:320(LifecycleReapGuard契约)、:414-421(注册)、:1020(读取)、:1083-1111(guardedReap,500 行/批、20 批/sweep)packages/objectql/src/lifecycle/lifecycle-service.ts:463(registerRetentionFloor的可组合键,finding(objectql): lifecycle 的 settings 覆盖窗口没有下限校验 —— 运维把 maxAge 调到某个消费方的假设之下时,后果是静默的(以 sys_job_queue 去重窗为例) #5195)packages/services/service-storage/src/storage-service-plugin.ts:320-337(唯一现役注册方)·packages/services/service-storage/src/attachment-lifecycle.ts:196+(字节回收 guard)