import { describe, expect, test } from 'bun:test'; import { ABANDONED_BACKUP_MESSAGE, type InProgressBackup, type ReapStagingDeps, type ResolveAbandonedDeps, STAGING_PREFIX, STAGING_TTL_MS, type StagingOwner, reapOrphanedStaging, resolveAbandonedBackups, stagingDirFor, } from './backup-staging'; const NOW = new Date('2026-08-06T12:00:00Z').getTime(); function deps( owners: Record, options: { dirs?: string[]; runnablePids?: number[]; unremovable?: string[]; } = {}, ): ReapStagingDeps & { removed: string[] } { const removed: string[] = []; return { removed, listStagingDirs: () => options.dirs ?? Object.keys(owners).map((id) => stagingDirFor(id)), lookupOwner: (id) => owners[id] ?? null, isPidRunnable: (pid) => (options.runnablePids ?? []).includes(pid), remove: (path) => { if (options.unremovable?.includes(path)) throw new Error('EACCES'); removed.push(path); }, now: () => NOW, }; } function owner(over: Partial = {}): StagingOwner { return { status: 'in_progress', pid: 111, startedAt: new Date(NOW - 60_000), ...over }; } describe('reapOrphanedStaging', () => { test('reclaims staging whose backup record no longer exists', () => { const d = deps({ 'gone-id': null }); const report = reapOrphanedStaging(d); expect(report.reclaimed).toHaveLength(1); expect(report.reclaimed[0]?.reason).toBe('record-absent'); expect(d.removed).toEqual([stagingDirFor('gone-id')]); }); test.each([['completed'], ['failed']])('reclaims staging for a %s record', (status) => { const d = deps({ 'done-id': owner({ status }) }); const report = reapOrphanedStaging(d); expect(report.reclaimed[0]?.reason).toBe('record-terminal'); expect(d.removed).toEqual([stagingDirFor('done-id')]); }); test('reclaims staging whose owning process is dead', () => { // in_progress, inside the TTL — only the liveness probe can tell. const d = deps({ 'dead-id': owner({ pid: 999 }) }, { runnablePids: [] }); const report = reapOrphanedStaging(d); expect(report.reclaimed[0]?.reason).toBe('process-dead'); expect(d.removed).toEqual([stagingDirFor('dead-id')]); }); // The one that must never regress: a running backup writing gigabytes into // its staging dir must survive a concurrent sweep. test('KEEPS staging owned by a live backup', () => { const d = deps({ 'live-id': owner({ pid: 111 }) }, { runnablePids: [111] }); const report = reapOrphanedStaging(d); expect(report.reclaimed).toHaveLength(0); expect(report.kept).toEqual([stagingDirFor('live-id')]); expect(d.removed).toEqual([]); }); test('reclaims a record past the TTL even when its pid looks alive', () => { // Guards pid reuse: after the pid space wraps, a stale record's pid names // an unrelated live process and liveness alone would strand this forever. const d = deps( { 'stale-id': owner({ pid: 111, startedAt: new Date(NOW - STAGING_TTL_MS - 1) }) }, { runnablePids: [111] }, ); const report = reapOrphanedStaging(d); expect(report.reclaimed[0]?.reason).toBe('expired'); }); test('keeps a pid-less record until it expires, then reclaims it', () => { const fresh = deps({ 'old-fmt': owner({ pid: null }) }); expect(reapOrphanedStaging(fresh).kept).toHaveLength(1); const expired = deps({ 'old-fmt': owner({ pid: null, startedAt: new Date(NOW - STAGING_TTL_MS - 1) }), }); expect(reapOrphanedStaging(expired).reclaimed[0]?.reason).toBe('expired'); }); test('ignores directories that are not staging, rather than deleting them', () => { const d = deps( {}, { dirs: [ '/tmp/something-else', '/tmp/celilo-unrelated', `/tmp/${STAGING_PREFIX}`, // prefix with no record id ], }, ); const report = reapOrphanedStaging(d); expect(report.ignored).toHaveLength(3); expect(report.reclaimed).toHaveLength(0); expect(d.removed).toEqual([]); }); test('an undeletable directory is reported as kept, not as reclaimed space', () => { const path = stagingDirFor('locked-id'); const d = deps({ 'locked-id': null }, { unremovable: [path] }); const report = reapOrphanedStaging(d); expect(report.reclaimed).toHaveLength(0); expect(report.kept).toEqual([path]); }); test('one undeletable directory does not stop the rest of the pass', () => { const locked = stagingDirFor('a-locked'); const d = deps({ 'a-locked': null, 'b-orphan': null }, { unremovable: [locked] }); const report = reapOrphanedStaging(d); expect(report.reclaimed.map((r) => r.recordId)).toEqual(['b-orphan']); expect(report.kept).toEqual([locked]); }); }); function recordDeps( records: InProgressBackup[], runnablePids: number[] = [], ): ResolveAbandonedDeps & { failed: Array<{ id: string; message: string }> } { const failed: Array<{ id: string; message: string }> = []; return { failed, listInProgress: () => records, isPidRunnable: (pid) => runnablePids.includes(pid), fail: (id, message) => failed.push({ id, message }), now: () => NOW, }; } function record(over: Partial = {}): InProgressBackup { return { id: 'rec-1', pid: 222, startedAt: new Date(NOW - 60_000), ...over }; } describe('resolveAbandonedBackups', () => { // THE REGRESSION THIS EXISTS FOR (#616). Every earlier test supplied a // staging directory, so the coupled implementation passed all of them while // leaving 107 real records stranded. Nothing here mentions staging at all — // that is the point. test('resolves a dead record even though no staging directory exists', () => { const d = recordDeps([record({ id: 'orphan', pid: 999 })], []); const report = resolveAbandonedBackups(d); expect(report.resolved).toEqual(['orphan']); expect(d.failed).toEqual([{ id: 'orphan', message: ABANDONED_BACKUP_MESSAGE }]); }); // All 107 rows on celilo-mgr predate the pid column. Age is the only signal // they carry, so they must resolve on it rather than linger forever. test('resolves a pid-less record once it is past the TTL', () => { const d = recordDeps([ record({ id: 'pre-migration', pid: null, startedAt: new Date(NOW - STAGING_TTL_MS - 1) }), ]); expect(resolveAbandonedBackups(d).resolved).toEqual(['pre-migration']); }); test('keeps a pid-less record that is still inside the TTL', () => { const d = recordDeps([record({ id: 'recent', pid: null })]); const report = resolveAbandonedBackups(d); expect(report.resolved).toEqual([]); expect(report.kept).toEqual(['recent']); expect(d.failed).toEqual([]); }); // Must never regress: a backup mid-flight is not abandoned. test('KEEPS a record whose process is alive', () => { const d = recordDeps([record({ id: 'live', pid: 222 })], [222]); const report = resolveAbandonedBackups(d); expect(report.resolved).toEqual([]); expect(report.kept).toEqual(['live']); expect(d.failed).toEqual([]); }); test('resolves a record past the TTL even when its pid looks alive', () => { // Same pid-reuse guard the reaper applies. const d = recordDeps( [record({ id: 'stale', pid: 222, startedAt: new Date(NOW - STAGING_TTL_MS - 1) })], [222], ); expect(resolveAbandonedBackups(d).resolved).toEqual(['stale']); }); test('sorts a mixed set without touching the live one', () => { const d = recordDeps( [ record({ id: 'dead', pid: 999 }), record({ id: 'live', pid: 222 }), record({ id: 'old', pid: null, startedAt: new Date(NOW - STAGING_TTL_MS - 1) }), ], [222], ); const report = resolveAbandonedBackups(d); expect(report.resolved.sort()).toEqual(['dead', 'old']); expect(report.kept).toEqual(['live']); }); test('nothing in progress is a no-op', () => { const d = recordDeps([]); const report = resolveAbandonedBackups(d); expect(report).toEqual({ resolved: [], kept: [] }); expect(d.failed).toEqual([]); }); });