Fix retention query performance and cleanup scheduling

This commit is contained in:
2026-10-06 10:39:32 -04:00
parent 9fd448339d
commit 88a950692a
8 changed files with 104 additions and 8 deletions
+47
View File
@@ -22,6 +22,53 @@ it('previews eligible campaigns, respects latest-N and explicit holds, and treat
a.saveFleetSettings({ ...a.fleetSettings(),deployKeepDays: 0,historyKeepCount: 0 });
expect(a.pruneHistory('campaign')).toBe(0);
});
it('seeks expired campaigns and uses indexed dependency checks even with failure retention enabled', async () => {
const a = await api();
const now = new Date().toISOString();
const insert = a.db.prepare("INSERT INTO deployments(id,action,started_at,finished_at) VALUES(?,'drift',?,?)");
a.db.transaction(() => {
for (let n=0;n<8000;n++) insert.run(`recent-${n}`,now,now);
})();
a.saveFleetSettings({...a.fleetSettings(),deployKeepDays:7,failureKeepDays:7});
const prepare = vi.spyOn(a.db,'prepare');
let sql: string;
try {
expect(a.pruneHistory('campaign')).toBe(0);
sql = prepare.mock.calls.find(([sql]) => sql.startsWith('SELECT o.rowid AS rid'))![0];
} finally { prepare.mockRestore(); }
const plan = a.db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all({category:'campaign',cutoff:old,failureCutoff:old}) as {detail:string}[];
const details = plan.map(row=>row.detail).join('\n');
expect(details).toMatch(/SEARCH o USING (?:COVERING )?INDEX idx_deployments_started \(started_at<\?\)/);
for (const index of ['idx_deployments_rolled_back_by','idx_watchdog_actions_campaign','idx_provisioning_sources_source']) {
expect(details).toContain(index);
}
expect(details).not.toMatch(/SCAN [odas]\b/);
});
it('selects resolved failures separately while preserving age, protection and batch limits', async () => {
const a = await api();
const now = new Date().toISOString();
for (const id of ['normal','unresolved','resolved','held','new-resolution','changed']) {
a.db.prepare("INSERT INTO deployments(id,action,started_at,finished_at) VALUES(?,'drift',?,?)").run(id,old,old);
if (id !== 'normal') a.db.prepare("INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES(?,'s',1,'failed')").run(id);
}
for (const id of ['resolved','held','new-resolution','changed']) {
a.setRetentionControl({category:'campaign',id,resolve:true,protected:id==='held'});
if (id !== 'new-resolution') a.db.prepare("UPDATE retention_controls SET resolved_at=? WHERE record_id=?").run(old,id);
}
a.db.prepare("UPDATE deployments SET started_at=? WHERE id='changed'").run(now);
// Failure retention works independently of normal campaign retention.
a.saveFleetSettings({...a.fleetSettings(),deployKeepDays:0,failureKeepDays:7});
expect(a.retentionPreview().find(p=>p.category==='campaign')?.records).toEqual([{id:'resolved'}]);
expect(a.pruneHistory('campaign')).toBe(1);
a.saveFleetSettings({...a.fleetSettings(),deployKeepDays:7});
const insert = a.db.prepare("INSERT INTO deployments(id,action,started_at) VALUES(?,'drift',?)");
a.db.transaction(() => { for (let n=0;n<600;n++) insert.run(`old-${n}`,old); })();
expect(a.pruneHistory('campaign')).toBe(500);
expect(a.pruneHistory('campaign')).toBe(101);
expect(a.db.prepare('SELECT id FROM deployments ORDER BY id').all()).toEqual([
{id:'changed'},{id:'held'},{id:'new-resolution'},{id:'unresolved'}
]);
});
it('expires file bodies without silently turning rollback into file deletion', async () => {
const a = await api();
a.db.prepare("INSERT INTO deployments(id,action,started_at,finished_at) VALUES('p','promote',?,?)").run(old,old);
+10
View File
@@ -16,6 +16,16 @@ afterEach(async () => {
});
describe('sweep', () => {
it('leaves history cleanup to the retention requester', async () => {
const retention = await import('../../src/lib/server/retention.js');
const prune = vi.spyOn(retention,'pruneHistory');
try {
const { sweep } = await import('../../src/lib/server/pipeline/sweep.js');
await sweep();
expect(prune).not.toHaveBeenCalled();
} finally { prune.mockRestore(); }
});
it('emits server state only when state, players, or error changes', async () => {
const { sweep } = await import('../../src/lib/server/pipeline/sweep.js');
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
+25
View File
@@ -6,6 +6,7 @@ vi.mock('../../src/lib/server/pipeline/beta-runner.js', () => ({ betaTick: vi.fn
vi.mock('../../src/lib/server/maintenance/index.js', () => ({ runMaintenance: vi.fn() }));
vi.mock('../../src/lib/server/game-files/index.js', () => ({ cleanupControllerLibraries: vi.fn(async () => {}) }));
vi.mock('../../src/lib/server/provisioning/worker.js', () => ({ processProvisioning: vi.fn(async () => {}) }));
vi.mock('../../src/lib/server/retention.js', () => ({ pruneHistory: vi.fn(), pruneSampleHistory: vi.fn() }));
function deferred() {
let resolve!: () => void;
@@ -31,6 +32,30 @@ async function setup() {
}
describe('independent provisioning scheduling', () => {
it('runs retention at most every five minutes across ticks and explicit kicks', async () => {
const s = await setup();
const { pruneHistory, pruneSampleHistory } = await import('../../src/lib/server/retention.js');
vi.useFakeTimers();
try {
s.startWorker();
expect(pruneHistory).toHaveBeenCalledTimes(1);
expect(pruneSampleHistory).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(285_000);
await s.kick();
expect(pruneHistory).toHaveBeenCalledTimes(1);
expect(pruneSampleHistory).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(15_000);
expect(pruneHistory).toHaveBeenCalledTimes(2);
expect(pruneSampleHistory).toHaveBeenCalledTimes(2);
expect(s.process.mock.calls.length).toBeGreaterThan(2);
} finally {
s.stopWorker(); s.background.resolve();
await vi.advanceTimersByTimeAsync(0);
vi.useRealTimers();
await vi.waitFor(() => expect(s.workerActivity()).toEqual([]));
}
});
it('runs controller library cleanup on worker ticks without overlapping a slow cleanup', async () => {
const s = await setup();
const { cleanupControllerLibraries } = await import('../../src/lib/server/game-files/index.js');