feat(plugins): OD_SNAPSHOT_RETENTION_DAYS referenced-row TTL (PB2)

Plan M1 / spec PB2 / §16 Phase 5.

Closes the 'expire even referenced rows' knob spec PB2 reserved as
operator-opt-in. When OD_SNAPSHOT_RETENTION_DAYS is set,
pruneExpiredSnapshots additionally retires snapshot rows whose
project_id no longer exists in the projects table AND whose
applied_at is older than the configured window. Live-project rows
stay pinned forever (reproducibility wins).

The 'project deleted' check is the v1 stand-in for the spec's
'referencing run/conversation/project is itself terminal' clause:
runs are in-memory in v1 (no SQLite signal we can read), and
conversations have no archived_at column. Deleting a project is
the loudest 'this is over' signal in v1, and it's the failure mode
that actually accumulates dangling snapshot rows in practice.

apps/daemon/src/plugins/gc.ts threads
readPluginEnvKnobs().snapshotRetentionDays into both the periodic
tick and the synchronous sweep() handle so the operator escape
hatch (od plugin snapshots prune --before <ts>) and the worker
share one rule.

Daemon tests: 1516 → 1519 (+3 cases on plugins-snapshot-gc:
deleted-project + old-enough-row pruned, deleted-project + recent
row survives, live-project + ancient row never pruned).

Co-authored-by: Tom Huang <1043269994@qq.com>
This commit is contained in:
Cursor Agent 2026-05-09 14:07:02 +00:00
parent 35b4030834
commit 1fdda937f7
No known key found for this signature in database
3 changed files with 128 additions and 12 deletions

View file

@ -52,7 +52,17 @@ export function startSnapshotGc(opts: SnapshotGcOptions): SnapshotGcHandle {
const tick = () => {
try {
const result = pruneExpiredSnapshots(opts.db);
// Plan §3.M1 / spec PB2 — feed OD_SNAPSHOT_RETENTION_DAYS into
// pruneExpiredSnapshots so referenced rows whose project has
// been deleted are eligible for deletion after the configured
// window. The unreferenced-TTL sweep stays the v1 default
// path; retention only kicks in when the operator opted in.
const knobs = readPluginEnvKnobs();
const result = pruneExpiredSnapshots(opts.db, {
...(typeof knobs.snapshotRetentionDays === 'number' && knobs.snapshotRetentionDays > 0
? { retentionDays: knobs.snapshotRetentionDays }
: {}),
});
if (result.removed > 0) {
log(`[plugins] snapshot GC removed ${result.removed} expired snapshot(s)`, {
ids: result.ids,
@ -71,7 +81,15 @@ export function startSnapshotGc(opts: SnapshotGcOptions): SnapshotGcHandle {
stop: () => {
clearInterval(timer);
},
sweep: (now?: number) => pruneExpiredSnapshots(opts.db, now ? { now } : {}),
sweep: (now?: number) => {
const knobs = readPluginEnvKnobs();
return pruneExpiredSnapshots(opts.db, {
...(now ? { now } : {}),
...(typeof knobs.snapshotRetentionDays === 'number' && knobs.snapshotRetentionDays > 0
? { retentionDays: knobs.snapshotRetentionDays }
: {}),
});
},
};
}

View file

@ -188,15 +188,37 @@ export interface PruneExpiredResult {
ids: string[];
}
export interface PruneExpiredOptions {
// Override for tests so the clock is deterministic.
now?: number;
// Operator escape hatch: force-delete unreferenced rows older than
// this unix-ms timestamp even when their TTL has not yet expired.
// Does NOT touch referenced rows (run_id IS NOT NULL); the
// `retentionDays` knob below is the only way to reach those.
before?: number;
// Plan §3.M1 / spec PB2 / §16 Phase 5 — operator-opt-in
// referenced-row TTL. When set, snapshots are eligible for deletion
// even after they have been linked to a run / conversation / project,
// provided two conditions:
//
// (1) `applied_at < now - retentionDays * 86_400_000`
// (2) the referenced run / conversation / project is "terminal"
//
// v1 implements (2) as: the snapshot's `project_id` no longer
// appears in `projects` (i.e. the project was deleted). Runs are
// in-memory in v1 so we cannot distinguish "active" vs "completed"
// from SQLite alone; `conversations.archived_at` does not exist.
// The conservative rule keeps reproducibility wins for live
// projects while letting operators clean up after `od project
// delete <id>` so dangling snapshot rows don't accumulate.
retentionDays?: number;
}
export function pruneExpiredSnapshots(
db: SqliteDb,
options: { now?: number; before?: number } = {},
options: PruneExpiredOptions = {},
): PruneExpiredResult {
const now = options.now ?? Date.now();
// The `before` cutoff is an operator escape hatch (`od plugin snapshots prune --before <ts>`)
// that lets a hosted operator force-delete unreferenced rows older than
// an arbitrary time even when their TTL has not yet expired. It does
// NOT touch referenced rows (run_id IS NOT NULL) — reproducibility wins.
const cutoff = typeof options.before === 'number' ? options.before : now;
const expiredIds = db
.prepare(
@ -212,11 +234,35 @@ export function pruneExpiredSnapshots(
)
.all(options.before) as Array<{ id: string }>)
: [];
const ids = [...expiredIds, ...beforeIds].map((r) => r.id);
if (ids.length === 0) return { removed: 0, ids: [] };
const placeholders = ids.map(() => '?').join(', ');
db.prepare(`DELETE FROM applied_plugin_snapshots WHERE id IN (${placeholders})`).run(...ids);
return { removed: ids.length, ids };
// Plan §3.M1 — referenced-row TTL.
//
// Pull every snapshot whose `applied_at` is older than
// `now - retentionDays * 86_400_000` and whose `project_id` no
// longer exists in `projects`. The LEFT JOIN approach lets us run
// a single query per sweep instead of N project lookups.
const retentionIds: Array<{ id: string }> = [];
if (typeof options.retentionDays === 'number' && options.retentionDays > 0) {
const retentionCutoff = now - options.retentionDays * 24 * 60 * 60 * 1000;
const rows = db
.prepare(
`SELECT s.id AS id
FROM applied_plugin_snapshots s
LEFT JOIN projects p ON p.id = s.project_id
WHERE s.applied_at <= ?
AND p.id IS NULL`,
)
.all(retentionCutoff) as Array<{ id: string }>;
retentionIds.push(...rows);
}
const ids = [...expiredIds, ...beforeIds, ...retentionIds].map((r) => r.id);
// Dedupe — a row might match both expires_at and retentionDays.
const unique = Array.from(new Set(ids));
if (unique.length === 0) return { removed: 0, ids: [] };
const placeholders = unique.map(() => '?').join(', ');
db.prepare(`DELETE FROM applied_plugin_snapshots WHERE id IN (${placeholders})`).run(...unique);
return { removed: unique.length, ids: unique };
}
export function countSnapshotsForProject(db: SqliteDb, projectId: string): number {

View file

@ -123,4 +123,56 @@ describe('snapshot GC', () => {
expect(result.removed).toBe(0);
expect(getSnapshot(db, referenced.snapshotId)).not.toBeNull();
});
// Plan §3.M1 / spec PB2 — referenced-row TTL.
//
// Operators who opt into OD_SNAPSHOT_RETENTION_DAYS expect referenced
// snapshots whose project has been deleted to be reaped after the
// configured window. Live projects keep their snapshots pinned
// forever (reproducibility wins).
describe('OD_SNAPSHOT_RETENTION_DAYS referenced-row TTL', () => {
it('prunes referenced snapshots whose project no longer exists and applied_at is older than the window', () => {
const referenced = createSnapshot(db, baseInput());
linkSnapshotToRun(db, referenced.snapshotId, 'run-r');
// Backdate to 90 days ago, then drop the project so the LEFT JOIN
// matches the no-longer-exists condition. Disable foreign-key
// enforcement around the DELETE so the FK cascade doesn't beat
// the GC sweep to it (the daemon's real openDb path has
// foreign_keys=ON; the in-memory test DB defaults to OFF, but
// we set it explicitly to keep the test readable).
const oldEnough = Date.now() - 90 * 24 * 60 * 60 * 1000;
db.prepare('UPDATE applied_plugin_snapshots SET applied_at = ? WHERE id = ?')
.run(oldEnough, referenced.snapshotId);
db.pragma('foreign_keys = OFF');
db.prepare('DELETE FROM projects WHERE id = ?').run('project-1');
db.pragma('foreign_keys = ON');
const result = pruneExpiredSnapshots(db, { retentionDays: 30 });
expect(result.ids).toContain(referenced.snapshotId);
expect(getSnapshot(db, referenced.snapshotId)).toBeNull();
});
it('keeps referenced snapshots whose project is still alive', () => {
const referenced = createSnapshot(db, baseInput());
linkSnapshotToRun(db, referenced.snapshotId, 'run-r');
const oldEnough = Date.now() - 90 * 24 * 60 * 60 * 1000;
db.prepare('UPDATE applied_plugin_snapshots SET applied_at = ? WHERE id = ?')
.run(oldEnough, referenced.snapshotId);
const result = pruneExpiredSnapshots(db, { retentionDays: 30 });
expect(result.removed).toBe(0);
expect(getSnapshot(db, referenced.snapshotId)).not.toBeNull();
});
it('respects the retentionDays window — recent rows survive even on a deleted project', () => {
const referenced = createSnapshot(db, baseInput());
linkSnapshotToRun(db, referenced.snapshotId, 'run-r');
db.pragma('foreign_keys = OFF');
db.prepare('DELETE FROM projects WHERE id = ?').run('project-1');
db.pragma('foreign_keys = ON');
// applied_at is now (well within retentionDays); the project
// is gone but the row is still recent enough to keep.
const result = pruneExpiredSnapshots(db, { retentionDays: 30 });
expect(result.removed).toBe(0);
expect(getSnapshot(db, referenced.snapshotId)).not.toBeNull();
});
});
});