mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
35b4030834
commit
1fdda937f7
3 changed files with 128 additions and 12 deletions
|
|
@ -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 }
|
||||
: {}),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue