/** * Backup cadence: what a manifest declares, and when the sweep acts on it. * * The due-ness tests are the interesting half. celilo#685 was a *daily* module * attempted 24 times a day for a day, each attempt assembling ~1.9 GB before * being OOM-killed, because due-ness was measured only from the last SUCCESS * and a module that cannot succeed never advances that timestamp. */ import { describe, expect, test } from 'bun:test'; import type { ModuleManifest } from '../manifest/schema'; import { DEFAULT_BACKUP_SCHEDULE, MAX_RAPID_RETRIES, effectiveBackupSchedule, isBackupDueFromHistory, } from './backup-schedule'; import { type Cadence, parseCadence } from './cadence'; const HOUR = 60 * 60 * 1000; const DAY = 24 * HOUR; const NOW = Date.UTC(2026, 7, 13, 12, 0, 0); function ago(ms: number): Date { return new Date(NOW - ms); } /** Parse a cadence in a test, failing loudly rather than passing null on (Rule 7.2). */ function cadence(value: string): Cadence { const parsed = parseCadence(value); if (parsed === null) throw new Error(`test fixture is not a cadence: ${value}`); return parsed; } function manifestWith(schedule?: string): ModuleManifest { return { backup: schedule ? { schedule } : undefined } as unknown as ModuleManifest; } describe('effectiveBackupSchedule', () => { test('an absent cadence from both sources means daily, not manual', () => { expect(effectiveBackupSchedule(manifestWith(), undefined)).toEqual(cadence('daily')); expect(DEFAULT_BACKUP_SCHEDULE).toEqual(cadence('daily')); }); test("the manifest's suggestion is honoured when nobody has overridden", () => { expect(effectiveBackupSchedule(manifestWith('manual'), undefined)).toBe('manual'); expect(effectiveBackupSchedule(manifestWith('weekly'), undefined)).toEqual(cadence('weekly')); }); test('the operator override wins over the suggestion', () => { expect(effectiveBackupSchedule(manifestWith('daily'), 'hourly')).toEqual(cadence('hourly')); expect(effectiveBackupSchedule(manifestWith('daily'), '6h')).toEqual(cadence('6h')); }); test('`manual` is reachable from either source', () => { expect(effectiveBackupSchedule(manifestWith('daily'), 'manual')).toBe('manual'); expect(effectiveBackupSchedule(manifestWith('manual'), undefined)).toBe('manual'); }); test('an override applies to a module whose manifest suggests nothing', () => { expect(effectiveBackupSchedule(manifestWith(), 'weekly')).toEqual(cadence('weekly')); }); test('an unparseable override falls back to the manifest, never to manual', () => { // Values are validated at SET time, so this is hand-edited state. Backing // up MORE often than asked is the safe direction; silently never backing // up is not. expect(effectiveBackupSchedule(manifestWith('weekly'), 'dailyy')).toEqual(cadence('weekly')); }); }); describe('isBackupDueFromHistory', () => { test('manual never runs on a schedule', () => { expect( isBackupDueFromHistory( 'manual', { lastSuccessAt: null, lastAttemptAt: null, consecutiveFailures: 0 }, NOW, ), ).toBe(false); }); test('a module that has never been backed up is due', () => { expect( isBackupDueFromHistory( cadence('daily'), { lastSuccessAt: null, lastAttemptAt: null, consecutiveFailures: 0 }, NOW, ), ).toBe(true); }); test('a fresh success is not due again until its interval has passed', () => { const history = { lastSuccessAt: ago(2 * HOUR), lastAttemptAt: ago(2 * HOUR), consecutiveFailures: 0, }; expect(isBackupDueFromHistory(cadence('daily'), history, NOW)).toBe(false); expect(isBackupDueFromHistory(cadence('hourly'), history, NOW)).toBe(true); }); test('a single failure retries on the next tick', () => { // Most failures are transient. Waiting a whole cadence period after one bad // minute at the storage endpoint would cost more coverage than it saves. expect( isBackupDueFromHistory( cadence('daily'), { lastSuccessAt: ago(2 * DAY), lastAttemptAt: ago(5 * 60 * 1000), consecutiveFailures: 1, }, NOW, ), ).toBe(true); }); test('a run of failures backs off to the module cadence', () => { // The celilo#685 shape: daily module, no success in a week, failing every // hour. Under the old rule this was due at every tick forever. const forgejo = { lastSuccessAt: ago(7 * DAY), lastAttemptAt: ago(1 * HOUR), consecutiveFailures: 20, }; expect(isBackupDueFromHistory(cadence('daily'), forgejo, NOW)).toBe(false); // ...and still runs once its own interval has elapsed. Backing off is not // giving up. expect( isBackupDueFromHistory(cadence('daily'), { ...forgejo, lastAttemptAt: ago(25 * HOUR) }, NOW), ).toBe(true); }); test('the back-off boundary is MAX_RAPID_RETRIES', () => { const justFailed = { lastSuccessAt: ago(7 * DAY), lastAttemptAt: ago(1 * HOUR) }; expect( isBackupDueFromHistory( cadence('daily'), { ...justFailed, consecutiveFailures: MAX_RAPID_RETRIES - 1 }, NOW, ), ).toBe(true); expect( isBackupDueFromHistory( cadence('daily'), { ...justFailed, consecutiveFailures: MAX_RAPID_RETRIES }, NOW, ), ).toBe(false); }); test('a never-succeeded module also backs off once it is clearly failing', () => { // `signal` on celilo-mgr: no successful backup has ever existed. Without // this branch "never succeeded" reads as "always due" and the doomed // attempt runs every tick. expect( isBackupDueFromHistory( cadence('daily'), { lastSuccessAt: null, lastAttemptAt: ago(1 * HOUR), consecutiveFailures: 8 }, NOW, ), ).toBe(false); }); });