Skip to content

Commit 10385bd

Browse files
committed
feat(lifecycle): sys_file orphan reclaim — reap guards + attachment tombstone hooks (#2755)
Part 1 of #2755: deleting a sys_attachment join row orphaned the backing sys_file row and its storage bytes forever. - objectql: LifecycleService reap guards (registerReapGuard) — a guarded object is never blind-deleted; candidates are fetched in batches, the guard confirms (after external cleanup) or vetoes each row, an erroring guard fails safe, and engines without find skip guarded objects. - service-storage: sys_file gains the 'attachments' scope option, a deleted_at tombstone field, and an ADR-0057 lifecycle declaration (ttl 30d on deleted_at for orphans; retention 7d onlyWhen pending for abandoned uploads). Tombstone hooks on sys_attachment mark a file deleted when its last join row goes away (attachments scope only) and un-tombstone on re-attach. The sys_file reap guard re-verifies zero references at sweep time and deletes the storage bytes before confirming the row reap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187NT3Qer9oep5dCRb9b8Lt
1 parent 750901c commit 10385bd

10 files changed

Lines changed: 807 additions & 35 deletions

File tree

packages/objectql/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export type {
6363
LifecycleObjectLike,
6464
LifecycleSettingsLike,
6565
LifecycleGovernanceAlert,
66+
LifecycleReapGuard,
6667
} from './lifecycle/lifecycle-service.js';
6768
export { parseLifecycleDuration } from './lifecycle/duration.js';
6869
export { lifecycleSettingsManifest } from './lifecycle/lifecycle-settings.js';

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

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@ function captureEngine(
1717
deleteImpl?: (object: string, options: any) => any;
1818
driver?: Record<string, unknown>;
1919
datasources?: Record<string, unknown>;
20+
/** When set, the engine exposes `find` (guarded-reap candidate reads). */
21+
findImpl?: (object: string, options: any) => any;
2022
} = {},
2123
) {
2224
const deletes: Array<{ object: string; where: any; multi: any; context: any }> = [];
23-
const engine = {
25+
const finds: Array<{ object: string; where: any; limit: any; context: any }> = [];
26+
const engine: any = {
2427
registry: { getAllObjects: () => objects },
2528
async delete(object: string, options: any) {
2629
deletes.push({ object, where: options?.where, multi: options?.multi, context: options?.context });
@@ -33,7 +36,13 @@ function captureEngine(
3336
return ds;
3437
},
3538
};
36-
return { engine, deletes };
39+
if (opts.findImpl) {
40+
engine.find = async (object: string, options: any) => {
41+
finds.push({ object, where: options?.where, limit: options?.limit, context: options?.context });
42+
return opts.findImpl!(object, options);
43+
};
44+
}
45+
return { engine, deletes, finds };
3746
}
3847

3948
function service(engine: any, extra: Partial<ConstructorParameters<typeof LifecycleService>[0]> = {}) {
@@ -307,6 +316,107 @@ describe('LifecycleService.sweep — Reaper', () => {
307316
});
308317
});
309318

319+
describe('LifecycleService.sweep — reap guard', () => {
320+
const guarded: LifecycleObjectLike[] = [
321+
{ name: 'sys_file', lifecycle: { class: 'transient', ttl: { field: 'deleted_at', expireAfter: '30d' } } },
322+
];
323+
324+
it('deletes only guard-confirmed ids, by $in, after fetching candidates with the cutoff filter', async () => {
325+
const rows = [
326+
{ id: 'f1', deleted_at: '2020-01-01T00:00:00Z' },
327+
{ id: 'f2', deleted_at: '2020-01-02T00:00:00Z' },
328+
{ id: 'f3', deleted_at: '2020-01-03T00:00:00Z' },
329+
];
330+
const { engine, deletes, finds } = captureEngine(guarded, { findImpl: () => rows });
331+
const svc = service(engine);
332+
const guard = vi.fn(async () => ['f1', 'f3']); // vetoes f2
333+
svc.registerReapGuard('sys_file', guard);
334+
335+
const report = await svc.sweep();
336+
337+
expect(finds).toHaveLength(1);
338+
expect(finds[0].where).toEqual({ deleted_at: { $lt: isoCutoff('30d') } });
339+
expect(finds[0].context).toEqual({ isSystem: true, positions: [], permissions: [] });
340+
expect(guard).toHaveBeenCalledWith('sys_file', rows);
341+
expect(deletes).toHaveLength(1);
342+
expect(deletes[0].where).toEqual({ id: { $in: ['f1', 'f3'] } });
343+
expect(deletes[0].multi).toBe(true);
344+
expect(report.swept).toEqual([
345+
{ object: 'sys_file', class: 'transient', policy: 'ttl', cutoff: isoCutoff('30d'), deleted: 2 },
346+
]);
347+
expect(report.errors).toEqual([]);
348+
});
349+
350+
it('an erroring guard fails safe: no rows deleted, error reported', async () => {
351+
const { engine, deletes } = captureEngine(guarded, { findImpl: () => [{ id: 'f1' }] });
352+
const svc = service(engine);
353+
svc.registerReapGuard('sys_file', async () => {
354+
throw new Error('storage unreachable');
355+
});
356+
357+
const report = await svc.sweep();
358+
359+
expect(deletes).toHaveLength(0);
360+
expect(report.swept).toEqual([]);
361+
expect(report.errors).toEqual([{ object: 'sys_file', error: 'storage unreachable' }]);
362+
});
363+
364+
it('a guard on one object never changes the blind reap of others (regression pin)', async () => {
365+
const { engine, deletes } = captureEngine(
366+
[
367+
...guarded,
368+
{ name: 'sys_job_run', lifecycle: { class: 'telemetry', retention: { maxAge: '30d' } } },
369+
],
370+
{ findImpl: () => [] },
371+
);
372+
const svc = service(engine);
373+
svc.registerReapGuard('sys_file', async () => []);
374+
375+
const report = await svc.sweep();
376+
377+
// sys_file: no candidates → no delete. sys_job_run: classic blind reap.
378+
expect(deletes).toHaveLength(1);
379+
expect(deletes[0].object).toBe('sys_job_run');
380+
expect(deletes[0].where).toEqual({ created_at: { $lt: isoCutoff('30d') } });
381+
expect(report.errors).toEqual([]);
382+
});
383+
384+
it('a guarded object on an engine without find is skipped, never blind-deleted', async () => {
385+
const { engine, deletes } = captureEngine(guarded); // no findImpl → no engine.find
386+
const svc = service(engine);
387+
svc.registerReapGuard('sys_file', async () => ['f1']);
388+
389+
const report = await svc.sweep();
390+
391+
expect(deletes).toHaveLength(0);
392+
expect(report.swept).toEqual([]);
393+
expect(report.skipped).toEqual([{ object: 'sys_file', reason: 'reap-guard-unsupported' }]);
394+
});
395+
396+
it('drains full batches but stops the pass when a batch is not fully confirmed', async () => {
397+
// Two "pages" of 500, then a short page. All of page 1 confirmed → loop
398+
// continues; page 2 only partially confirmed → pass ends (vetoed rows
399+
// would be re-fetched forever within one sweep).
400+
const page = (n: number, prefix: string) =>
401+
Array.from({ length: n }, (_, i) => ({ id: `${prefix}${i}`, deleted_at: '2020-01-01T00:00:00Z' }));
402+
const pages = [page(500, 'a'), page(500, 'b')];
403+
let call = 0;
404+
const { engine, deletes, finds } = captureEngine(guarded, { findImpl: () => pages[call++] ?? [] });
405+
const svc = service(engine);
406+
svc.registerReapGuard('sys_file', async (_object, rows) =>
407+
rows[0].id === 'a0' ? rows.map((r: any) => r.id) : rows.slice(0, 10).map((r: any) => r.id),
408+
);
409+
410+
const report = await svc.sweep();
411+
412+
expect(finds).toHaveLength(2);
413+
expect(deletes).toHaveLength(2);
414+
expect((deletes[0].where.id.$in as string[]).length).toBe(500);
415+
expect((deletes[1].where.id.$in as string[]).length).toBe(10);
416+
expect(report.swept[0].deleted).toBe(510);
417+
});
418+
});
419+
310420
describe('LifecycleService.sweep — Archiver (P3)', () => {
311421
const AUDIT_OBJ: LifecycleObjectLike = {
312422
name: 'sys_audit_log',

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

Lines changed: 95 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,28 @@ interface ArchiveCapableDriver {
200200
const ARCHIVE_BATCH_SIZE = 500;
201201
const ARCHIVE_MAX_BATCHES_PER_SWEEP = 20;
202202

203+
/** Guarded reap batching — same posture as the Archiver: bound one sweep's
204+
* work, drain the backlog across sweeps. */
205+
const REAP_GUARD_BATCH_SIZE = 500;
206+
const REAP_GUARD_MAX_BATCHES_PER_SWEEP = 20;
207+
208+
/**
209+
* Reap guard (ADR-0057 amendment): a domain callback consulted by the Reaper
210+
* before rows of the guarded object are deleted. The guard receives the
211+
* candidate rows and returns the ids it CONFIRMS for deletion — performing
212+
* any external cleanup (e.g. storage-byte reclaim) for those ids before
213+
* returning. Ids not returned are kept this sweep (vetoed — e.g. the row
214+
* regained references since it was marked).
215+
*
216+
* Guards are registered at runtime (`registerReapGuard`), not declared in the
217+
* spec: detection and scheduling stay inside the single platform sweep
218+
* (ADR-0057 §3.3 — a guard is a domain callback, not a second sweeper).
219+
*/
220+
export type LifecycleReapGuard = (
221+
object: string,
222+
rows: Array<Record<string, unknown>>,
223+
) => Promise<Array<string | number>>;
224+
203225
export class LifecycleService {
204226
private readonly now: () => number;
205227
private timer: ReturnType<typeof setInterval> | undefined;
@@ -209,6 +231,8 @@ export class LifecycleService {
209231
private lastCounts = new Map<string, number>();
210232
/** Governance snapshot for the sweep in flight. */
211233
private governance: GovernanceSnapshot = DEFAULT_GOVERNANCE;
234+
/** Per-object reap guards ({@link LifecycleReapGuard}). */
235+
private readonly reapGuards = new Map<string, LifecycleReapGuard>();
212236

213237
constructor(private readonly opts: LifecycleServiceOptions) {
214238
this.now = opts.now ?? (() => Date.now());
@@ -241,6 +265,17 @@ export class LifecycleService {
241265
this.timer = undefined;
242266
}
243267

268+
/**
269+
* Register a {@link LifecycleReapGuard} for one object. From then on the
270+
* Reaper never blind-deletes that object's rows: candidates are fetched,
271+
* the guard confirms (after external cleanup) or vetoes each row, and only
272+
* confirmed ids are deleted. One guard per object (last registration wins —
273+
* guards are platform wiring, not user surface).
274+
*/
275+
registerReapGuard(object: string, guard: LifecycleReapGuard): void {
276+
this.reapGuards.set(object, guard);
277+
}
278+
244279
/**
245280
* Apply every declared lifecycle policy once. Safe to call directly (the
246281
* dogfood growth gate and `db:clean`-style tooling do); re-entrant calls
@@ -604,56 +639,89 @@ export class LifecycleService {
604639
// rows outside it (live workflow state) are retained regardless of age.
605640
const scope = onlyWhen ?? {};
606641

642+
// A guarded object is NEVER blind-deleted: without row reads the guard
643+
// cannot confirm, so the reap is skipped (fail-safe), not degraded.
644+
const guard = this.reapGuards.get(object);
645+
if (guard && typeof engine.find !== 'function') {
646+
if (!report.skipped.some((s) => s.object === object && s.reason === 'reap-guard-unsupported')) {
647+
report.skipped.push({ object, reason: 'reap-guard-unsupported' });
648+
}
649+
return 0;
650+
}
651+
607652
let total: number | undefined = 0;
608-
const accumulate = (res: unknown) => {
609-
const n = countDeleted(res);
653+
const accumulate = (n: number | undefined) => {
610654
if (n === undefined) total = undefined;
611655
else if (total !== undefined) total += n;
612656
};
657+
const reapWhere = async (where: Record<string, unknown>): Promise<number | undefined> =>
658+
guard
659+
? this.guardedReap(engine, object, guard, where)
660+
: countDeleted(await engine.delete(object, { where, multi: true, context: { ...SYSTEM_CTX } }));
613661

614662
if (tenantWindows.length === 0) {
615-
accumulate(
616-
await engine.delete(object, {
617-
where: { [field]: { $lt: cutoff }, ...scope },
618-
multi: true,
619-
context: { ...SYSTEM_CTX },
620-
}),
621-
);
663+
accumulate(await reapWhere({ [field]: { $lt: cutoff }, ...scope }));
622664
} else {
623665
// Tenant-level windows (P4): each overriding tenant gets its own
624666
// cutoff on its own rows…
625667
for (const t of tenantWindows) {
626668
const tMs = this.effectiveWindowMs(t[overrideKey], windowMs, `${object} (tenant ${t.tenantId})`);
627669
const tCutoff = new Date(this.now() - tMs).toISOString();
628-
accumulate(
629-
await engine.delete(object, {
630-
where: { [field]: { $lt: tCutoff }, organization_id: t.tenantId, ...scope },
631-
multi: true,
632-
context: { ...SYSTEM_CTX },
633-
}),
634-
);
670+
accumulate(await reapWhere({ [field]: { $lt: tCutoff }, organization_id: t.tenantId, ...scope }));
635671
}
636672
// …and the global pass covers everyone else, INCLUDING rows with no
637673
// organization (a bare `$nin` would silently skip NULL-org rows).
638674
accumulate(
639-
await engine.delete(object, {
640-
where: {
641-
[field]: { $lt: cutoff },
642-
$or: [
643-
{ organization_id: { $nin: tenantWindows.map((t) => t.tenantId) } },
644-
{ organization_id: null },
645-
],
646-
...scope,
647-
},
648-
multi: true,
649-
context: { ...SYSTEM_CTX },
675+
await reapWhere({
676+
[field]: { $lt: cutoff },
677+
$or: [
678+
{ organization_id: { $nin: tenantWindows.map((t) => t.tenantId) } },
679+
{ organization_id: null },
680+
],
681+
...scope,
650682
}),
651683
);
652684
}
653685

654686
report.swept.push({ object, class: lc.class, policy, cutoff, deleted: total });
655687
return total;
656688
}
689+
690+
/**
691+
* Guarded reap: fetch candidate rows in batches, let the guard confirm
692+
* (after performing external cleanup) or veto each, delete only confirmed
693+
* ids. A guard error propagates to the per-object handler in `sweep()` —
694+
* an erroring guard must never fail open into deletion. A batch that isn't
695+
* fully confirmed ends the pass: vetoed rows still match the cutoff filter
696+
* and would be re-fetched forever; the next sweep retries them.
697+
*/
698+
private async guardedReap(
699+
engine: LifecycleEngineLike,
700+
object: string,
701+
guard: LifecycleReapGuard,
702+
where: Record<string, unknown>,
703+
): Promise<number> {
704+
let total = 0;
705+
for (let batch = 0; batch < REAP_GUARD_MAX_BATCHES_PER_SWEEP; batch++) {
706+
const rows = await engine.find!(object, {
707+
where,
708+
limit: REAP_GUARD_BATCH_SIZE,
709+
context: { ...SYSTEM_CTX },
710+
});
711+
if (!rows?.length) break;
712+
const confirmed = (await guard(object, rows)).filter((id) => id !== null && id !== undefined);
713+
if (confirmed.length > 0) {
714+
await engine.delete(object, {
715+
where: { id: { $in: confirmed } },
716+
multi: true,
717+
context: { ...SYSTEM_CTX },
718+
});
719+
total += confirmed.length;
720+
}
721+
if (confirmed.length < rows.length || rows.length < REAP_GUARD_BATCH_SIZE) break;
722+
}
723+
return total;
724+
}
657725
}
658726

659727
/** Best-effort row-count extraction from a driver's delete result. */

0 commit comments

Comments
 (0)