Skip to content

Commit 441d79f

Browse files
os-zhuangclaude
andauthored
fix(objectql): compose reap guards by intersection instead of last-wins (#5535) (#5708)
`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. Claude-Session: https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx Co-authored-by: Claude <noreply@anthropic.com>
1 parent ef4efa8 commit 441d79f

3 files changed

Lines changed: 358 additions & 20 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
fix(objectql): 同一 object 的多个 reap guard 现在按交集组合,后注册者不再静默顶掉前者 (#5535)
6+
7+
`LifecycleService.registerReapGuard(object, guard)` 此前是一行 `set()`,契约写明
8+
「One guard per object (last registration wins)」,而注册表是 `private`、无探针、
9+
覆盖时也不打日志。两点合起来:同一 object 上的**第二个注册方静默解除第一个**,且
10+
**无从察觉**
11+
12+
这在 reap guard 这个座位上格外危险,因为 guard 的语义不是「投票」而是**回执**——
13+
「外部副作用已经做完,这行现在可以删了」。`sys_file` 的 guard 做的正是字节回收:
14+
`storage.delete(row.key)`,失败则 veto 把行留到下一轮(行是字节的唯一指针,
15+
先删行就永久泄漏)。任何第二个消费者在 `sys_file` 上注册,这段字节回收会被**整体
16+
解除**:行照删、字节泄漏、零日志。而 ADR-0057 §3.3 的 amendment 恰恰把「domain
17+
callback」认定为 guard 的合法形态,第二个注册方是被 ADR 鼓励出来的,不是假想。
18+
19+
**新契约:交集组合。** 一个 object 可以有多个 guard,注册**追加**而非替换;只有
20+
**全部** guard 都确认的 id 才进删除集,任一 veto 即保留、由下一轮 sweep 重试。这与
21+
单 guard 时的语义完全兼容(现有五条单 guard 回归全部原样通过),也与同文件
22+
`registerRetentionFloor` 的「每个注册方都保留发言权,最严者胜」同构。
23+
24+
写 guard 时值得知道的两点:
25+
26+
- **guard 按注册顺序执行,但只会被问到「前面的 guard 已经确认过」的行。** 这是
27+
刻意的:被问到即意味着「到目前为止所有人都同意这行可以删」,于是 guard 不会
28+
为一行别人正要保留的记录做不可逆的清理(否则就会出现「行还在、字节已删」或
29+
「行还在、索引已删」)。删除集本身与注册顺序无关。
30+
- **guard 抛异常的处置不变**:异常上抛到 `sweep()` 的 per-object handler,该
31+
object 本轮一行不删(erroring guard 永不 fail open 进删除)。同一批里更早的
32+
guard 已经做掉的清理由下一轮 sweep 重试,而不会在无人完成确认的批次上兑现成
33+
一次删除。
34+
35+
重复注册**同一个函数引用**是 no-op(重跑 wiring,不是第二份意见):与自己求交集
36+
不改变任何结果,却会让它的外部清理在每批上跑两遍。
37+
38+
无需调用方改动:`service-storage` 的两个注册方(`sys_file` / `sys_upload_session`)
39+
一行未改,行为逐条不变。本单落地即解除 #4672(知识插件走 reap-guard 去索引化)的
40+
Blocked-by——它可以直接在 `sys_file` 上追加注册,而不必担心顶掉字节回收。

packages/objectql/src/lifecycle/lifecycle-service.test.ts

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,216 @@ describe('LifecycleService.sweep — reap guard', () => {
415415
});
416416
});
417417

418+
// [#5535] Two registrars on ONE object. Before this, `registerReapGuard` was a
419+
// single-slot `set()`: the second registrant silently unhooked the first — on
420+
// `sys_file` that means the byte-reclaim guard stops running while the rows
421+
// (the only pointer to those bytes) keep being deleted. Guards now compose by
422+
// intersection, which is what "confirm before delete" already implied.
423+
describe('LifecycleService.sweep — reap guard composition', () => {
424+
const guarded: LifecycleObjectLike[] = [
425+
{ name: 'sys_file', lifecycle: { class: 'transient', ttl: { field: 'deleted_at', expireAfter: '30d' } } },
426+
];
427+
428+
const rowsOf = (...ids: string[]) => ids.map((id) => ({ id, deleted_at: '2020-01-01T00:00:00Z' }));
429+
430+
/** A store the sweep actually drains, so "retried next sweep" is observable. */
431+
function backedEngine(seed: Array<Record<string, unknown>>) {
432+
const store = [...seed];
433+
const captured = captureEngine(guarded, {
434+
findImpl: () => store.slice(),
435+
deleteImpl: (_object, options) => {
436+
const idx = store.findIndex((r) => r.id === options?.where?.id);
437+
if (idx >= 0) store.splice(idx, 1);
438+
return { deletedCount: idx >= 0 ? 1 : 0 };
439+
},
440+
});
441+
return { ...captured, store };
442+
}
443+
444+
const idsOf = (rows: Array<Record<string, unknown>>) => rows.map((r) => r.id as string);
445+
446+
it('deletes only ids EVERY guard confirmed; a veto by either one keeps the row', async () => {
447+
const rows = rowsOf('f1', 'f2', 'f3');
448+
const { engine, deletes } = captureEngine(guarded, { findImpl: () => rows });
449+
const svc = service(engine);
450+
// Each guard vetoes a different id — neither alone would keep both.
451+
svc.registerReapGuard('sys_file', async (_o, r) => idsOf(r).filter((id) => id !== 'f2'));
452+
svc.registerReapGuard('sys_file', async (_o, r) => idsOf(r).filter((id) => id !== 'f3'));
453+
454+
const report = await svc.sweep();
455+
456+
expect(deletes.map((d) => d.where)).toEqual([{ id: 'f1' }]);
457+
expect(report.swept[0].deleted).toBe(1);
458+
expect(report.errors).toEqual([]);
459+
});
460+
461+
it('is order-independent: the same two guards registered the other way round agree', async () => {
462+
const vetoF2 = async (_o: string, r: Array<Record<string, unknown>>) =>
463+
idsOf(r).filter((id) => id !== 'f2');
464+
const vetoF3 = async (_o: string, r: Array<Record<string, unknown>>) =>
465+
idsOf(r).filter((id) => id !== 'f3');
466+
467+
const deleteSets: string[][] = [];
468+
for (const order of [[vetoF2, vetoF3], [vetoF3, vetoF2]]) {
469+
const { engine, deletes } = captureEngine(guarded, { findImpl: () => rowsOf('f1', 'f2', 'f3') });
470+
const svc = service(engine);
471+
for (const g of order) svc.registerReapGuard('sys_file', g);
472+
await svc.sweep();
473+
deleteSets.push(deletes.map((d) => d.where.id as string));
474+
}
475+
476+
expect(deleteSets[0]).toEqual(['f1']);
477+
expect(deleteSets[1]).toEqual(deleteSets[0]);
478+
});
479+
480+
it('asks a guard only about rows the guards before it confirmed', async () => {
481+
// The point of the narrowing: a guard's confirmation is the RECEIPT for
482+
// cleanup it has already performed. Showing guard 2 a row guard 1 vetoed
483+
// would have it reclaim/de-index a row that then survives the sweep.
484+
const rows = rowsOf('f1', 'f2', 'f3');
485+
const { engine } = captureEngine(guarded, { findImpl: () => rows });
486+
const svc = service(engine);
487+
const first = vi.fn(async (_o: string, r: Array<Record<string, unknown>>) =>
488+
idsOf(r).filter((id) => id !== 'f2'),
489+
);
490+
const second = vi.fn(async (_o: string, r: Array<Record<string, unknown>>) => idsOf(r));
491+
svc.registerReapGuard('sys_file', first);
492+
svc.registerReapGuard('sys_file', second);
493+
494+
await svc.sweep();
495+
496+
expect(first).toHaveBeenCalledWith('sys_file', rows); // the full candidate batch
497+
expect(second).toHaveBeenCalledTimes(1);
498+
expect(idsOf(second.mock.calls[0][1])).toEqual(['f1', 'f3']); // f2 already vetoed
499+
});
500+
501+
it('a guard that vetoes the whole batch spares the later guards the call', async () => {
502+
const { engine, deletes } = captureEngine(guarded, { findImpl: () => rowsOf('f1', 'f2') });
503+
const svc = service(engine);
504+
const second = vi.fn(async (_o: string, r: Array<Record<string, unknown>>) => idsOf(r));
505+
svc.registerReapGuard('sys_file', async () => []);
506+
svc.registerReapGuard('sys_file', second);
507+
508+
const report = await svc.sweep();
509+
510+
expect(second).not.toHaveBeenCalled();
511+
expect(deletes).toHaveLength(0);
512+
expect(report.swept[0].deleted).toBe(0);
513+
});
514+
515+
it('a row one guard vetoed is retried by the next sweep and deleted once both confirm', async () => {
516+
const { engine, store } = backedEngine(rowsOf('f1', 'f2'));
517+
const svc = service(engine);
518+
let firstSweep = true;
519+
svc.registerReapGuard('sys_file', async (_o, r) =>
520+
idsOf(r).filter((id) => !(firstSweep && id === 'f2')),
521+
);
522+
svc.registerReapGuard('sys_file', async (_o, r) => idsOf(r));
523+
524+
const one = await svc.sweep();
525+
expect(idsOf(store)).toEqual(['f2']); // vetoed, still there
526+
expect(one.swept[0].deleted).toBe(1);
527+
528+
firstSweep = false;
529+
const two = await svc.sweep();
530+
expect(store).toEqual([]); // retried and reaped
531+
expect(two.swept[0].deleted).toBe(1);
532+
});
533+
534+
it('a throwing guard deletes nothing — not even ids an earlier guard confirmed', async () => {
535+
// Same fail-safe as the single-guard case: the error reaches the per-object
536+
// handler in sweep() and no row is deleted. The earlier guard's external
537+
// cleanup for this batch is simply retried next sweep — never paid out in
538+
// a delete on a batch no one finished confirming.
539+
const { engine, deletes } = captureEngine(guarded, { findImpl: () => rowsOf('f1', 'f2') });
540+
const svc = service(engine);
541+
const first = vi.fn(async (_o: string, r: Array<Record<string, unknown>>) => idsOf(r));
542+
svc.registerReapGuard('sys_file', first);
543+
svc.registerReapGuard('sys_file', async () => {
544+
throw new Error('index unreachable');
545+
});
546+
547+
const report = await svc.sweep();
548+
549+
expect(first).toHaveBeenCalledTimes(1);
550+
expect(deletes).toHaveLength(0);
551+
expect(report.swept).toEqual([]);
552+
expect(report.errors).toEqual([{ object: 'sys_file', error: 'index unreachable' }]);
553+
});
554+
555+
it('registering the identical guard twice runs it once (re-run wiring, not a second opinion)', async () => {
556+
const { engine, deletes } = captureEngine(guarded, { findImpl: () => rowsOf('f1') });
557+
const svc = service(engine);
558+
const guard = vi.fn(async (_o: string, r: Array<Record<string, unknown>>) => idsOf(r));
559+
svc.registerReapGuard('sys_file', guard);
560+
svc.registerReapGuard('sys_file', guard);
561+
562+
await svc.sweep();
563+
564+
// Called twice, its external cleanup would run twice per batch.
565+
expect(guard).toHaveBeenCalledTimes(1);
566+
expect(deletes.map((d) => d.where)).toEqual([{ id: 'f1' }]);
567+
});
568+
569+
it('multiple guards on an engine without find are skipped, never blind-deleted', async () => {
570+
const { engine, deletes } = captureEngine(guarded); // no findImpl → no engine.find
571+
const svc = service(engine);
572+
svc.registerReapGuard('sys_file', async () => ['f1']);
573+
svc.registerReapGuard('sys_file', async () => ['f1']);
574+
575+
const report = await svc.sweep();
576+
577+
expect(deletes).toHaveLength(0);
578+
expect(report.skipped).toEqual([{ object: 'sys_file', reason: 'reap-guard-unsupported' }]);
579+
});
580+
581+
it('keeps byte reclaim running when a second consumer registers (the #5535 shape)', async () => {
582+
// service-storage's `sys_file` guard, in the same shape but stubbed here:
583+
// reclaim the bytes FIRST, confirm only if that succeeded (the row is the
584+
// only pointer to the bytes, so a failed reclaim must veto). The second
585+
// registrar is the ADR-0057 §3.3 domain callback #4672 will add — it
586+
// de-indexes by id. Under the old single-slot registry the byte reclaim
587+
// was unhooked wholesale by that second call: rows deleted, bytes leaked.
588+
const bytes = new Map([
589+
['f1', 'k1'],
590+
['f2', 'k2'],
591+
['f3', 'k3'],
592+
]);
593+
const index = new Set(['f1', 'f2', 'f3']);
594+
const { engine, store } = backedEngine(rowsOf('f1', 'f2', 'f3'));
595+
const svc = service(engine);
596+
597+
svc.registerReapGuard('sys_file', async (_o, rows) => {
598+
const confirmed: string[] = [];
599+
for (const row of rows) {
600+
const id = row.id as string;
601+
if (id === 'f2') continue; // storage.delete threw → veto, keep the pointer
602+
bytes.delete(id);
603+
confirmed.push(id);
604+
}
605+
return confirmed;
606+
});
607+
svc.registerReapGuard('sys_file', async (_o, rows) => {
608+
const confirmed: string[] = [];
609+
for (const row of rows) {
610+
index.delete(row.id as string);
611+
confirmed.push(row.id as string);
612+
}
613+
return confirmed;
614+
});
615+
616+
const report = await svc.sweep();
617+
618+
expect(idsOf(store)).toEqual(['f2']); // vetoed row retained for the next sweep
619+
expect([...bytes.keys()]).toEqual(['f2']); // …with its bytes intact — no leak
620+
// …and the de-indexer was never shown f2, so a surviving row keeps its
621+
// index entry rather than silently disappearing from search.
622+
expect([...index]).toEqual(['f2']);
623+
expect(report.swept[0].deleted).toBe(2);
624+
expect(report.errors).toEqual([]);
625+
});
626+
});
627+
418628
describe('LifecycleService.sweep — Archiver (P3)', () => {
419629
const AUDIT_OBJ: LifecycleObjectLike = {
420630
name: 'sys_audit_log',

0 commit comments

Comments
 (0)