Skip to content

finding(objectql): registerReapGuard 后注册者静默顶掉前者,且注册表私有 —— 第二个注册方察觉不到自己解除了别人的 guard #5535

Description

@os-zhuang

在核对 #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。

两点合起来才构成问题:

  1. 同一 object 上的第二个注册方静默顶掉第一个;
  2. 第二个注册方无法察觉自己顶掉了什么——注册表私有、无探针、无日志。

对比同一文件里 #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_filesys_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 并提供一个探针,让注册方至少能看见冲突、能选择不注册。

参考

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions