Skip to content

Commit 2c9678b

Browse files
feat(cleanup): one-time DB slim-down migration (raw_json offload backfill + log_snippet cap + batch_results prune)
1 parent b2f0748 commit 2c9678b

1 file changed

Lines changed: 121 additions & 0 deletions

File tree

scripts/cleanup.mjs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@
1919
* · register orphan snapshot dirs (no fabricated spend)
2020
* · prune runaway 'continuous-loop iteration%' rows
2121
* · backfill file snapshots missing from SQLite
22+
* · DB slim-down (one-time backfill for legacy data):
23+
* – offload inline results.raw_json blobs to
24+
* results/raw/run-<run_id>/<id>.json, then NULL
25+
* the column (write+verify BEFORE null = no loss)
26+
* – cap legacy error_logs.log_snippet at 16 KB tail
27+
* (idempotent backstop to migration v11)
28+
* – prune batch_results to DEFAULT_BATCH_RETENTION
2229
* --purge-orphan-logs (with --fix) quarantine orphan audit logs into
2330
* results/.cleanup-trash/<ts>/ (move, not delete;
2431
* never the active or a recently-written log).
@@ -40,6 +47,7 @@ import Database from 'better-sqlite3';
4047
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, mkdirSync, copyFileSync } from 'fs';
4148
import path from 'path';
4249
import { fileURLToPath } from 'url';
50+
import { RAW_DIR, DEFAULT_BATCH_RETENTION } from '../core/constants.js';
4351

4452
const __dirname = path.dirname(fileURLToPath(import.meta.url));
4553
const ROOT = path.join(__dirname, '..');
@@ -56,6 +64,17 @@ const MATCH_TOLERANCE_MS = 15_000;
5664
const BACKFILL_TOLERANCE_MS = 60_000; // wider dedup window for backfill (save-time skew)
5765
const RECENT_LOG_MS = 24 * 60 * 60 * 1000; // never purge a log written in the last day
5866
const RUNAWAY_NOTES = 'continuous-loop iteration%';
67+
const LOG_SNIPPET_CAP = 16384; // bytes — mirrors core/db.js insertErrorLog + migration v11
68+
69+
// Human-readable byte size for the slim-down report.
70+
const humanBytes = n => {
71+
if (n == null || !Number.isFinite(Number(n))) return '0 B';
72+
let b = Number(n);
73+
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
74+
let i = 0;
75+
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
76+
return `${b.toFixed(i === 0 ? 0 : 1)} ${u[i]}`;
77+
};
5978

6079
// ─── reporter ─────────────────────────────────────────────────────────────────
6180
// hard = cannot be auto-fixed (integrity / schema / unparseable / write failures)
@@ -207,6 +226,43 @@ if (existsSync(RESULTS_DIR)) {
207226
else { info(`${orphanLogs.length} orphan log file(s): ${orphanLogs.slice(0, 5).join(', ')}${orphanLogs.length > 5 ? ' …' : ''}`); if (PURGE_LOGS && !FIX) console.log(C.dim(' (--purge-orphan-logs needs --fix to take effect — ignored)')); else if (!(FIX && PURGE_LOGS)) console.log(C.dim(' (pass --fix --purge-orphan-logs to quarantine these)')); }
208227
}
209228

229+
// ─── 6. DB slim-down (one-time backfill for legacy data) ──────────────────────
230+
// These mirror the forward-going behaviour that core/db.js now applies on every
231+
// insert: raw_json blobs offloaded to per-run files, log_snippet capped at 16 KB,
232+
// batch_results bounded. Existing rows from before those commits still carry the
233+
// inline blob / oversized snippet / unbounded batch history; this section reports
234+
// (and, under --fix, performs) the one-time migration of that legacy data.
235+
section('6. DB slim-down (legacy backfill)');
236+
let rawJsonRows = 0; // results rows still holding an inline raw_json blob
237+
let logSnippetRows = 0; // error_logs rows whose snippet exceeds the 16 KB cap
238+
let batchCount = 0; // total batches
239+
let batchResultRows = 0; // total batch_results rows
240+
if (rdb) {
241+
try {
242+
const r = rdb.prepare('SELECT COUNT(*) AS c, COALESCE(SUM(length(raw_json)), 0) AS bytes FROM results WHERE raw_json IS NOT NULL').get();
243+
rawJsonRows = r.c;
244+
rawJsonRows > 0
245+
? repairable(`${rawJsonRows.toLocaleString()} results row(s) still hold an inline raw_json blob (~${humanBytes(r.bytes)} reclaimable → offload to results/raw/run-*/<id>.json + NULL column)`)
246+
: ok('no inline raw_json blobs (already offloaded)');
247+
} catch (e) { hard(`raw_json scan failed: ${e.message}`); }
248+
249+
try {
250+
logSnippetRows = rdb.prepare(`SELECT COUNT(*) AS c FROM error_logs WHERE log_snippet IS NOT NULL AND length(log_snippet) > ${LOG_SNIPPET_CAP}`).get().c;
251+
logSnippetRows > 0
252+
? repairable(`${logSnippetRows.toLocaleString()} error_logs row(s) exceed the 16 KB log_snippet cap (would truncate to tail)`)
253+
: ok('no oversized log_snippet rows (migration v11 already applied)');
254+
} catch (e) { hard(`log_snippet scan failed: ${e.message}`); }
255+
256+
try {
257+
batchCount = rdb.prepare('SELECT COUNT(*) AS c FROM batches').get().c;
258+
batchResultRows = rdb.prepare('SELECT COUNT(*) AS c FROM batch_results').get().c;
259+
const overRetention = Math.max(0, batchCount - DEFAULT_BATCH_RETENTION);
260+
overRetention > 0
261+
? repairable(`${batchCount.toLocaleString()} batch(es) / ${batchResultRows.toLocaleString()} batch_results row(s); ~${overRetention.toLocaleString()} batch(es) over the ${DEFAULT_BATCH_RETENTION} retention cap (would prune)`)
262+
: ok(`${batchCount.toLocaleString()} batch(es) / ${batchResultRows.toLocaleString()} batch_results row(s) — within the ${DEFAULT_BATCH_RETENTION} retention cap`);
263+
} catch (e) { hard(`batch_results scan failed: ${e.message}`); }
264+
}
265+
210266
// Close our OWN read-only handle before mutating: if it stays open, the WAL
211267
// checkpoint below can report `busy` and falsely abort the whole --fix on a
212268
// perfectly healthy, server-stopped box.
@@ -296,6 +352,71 @@ if (FIX) {
296352
} catch (e) { hard(`SQLite backfill failed: ${e.message}`); }
297353
} else console.log(` ${C.dim('↳ backfill skipped (DB writes disabled)')}`);
298354

355+
// ── DB slim-down (one-time legacy backfill) ───────────────────────────────
356+
// Gated on dbWriteOk (audit.db backed up + WAL checkpoint succeeded → server
357+
// stopped). Mirrors core/db.js forward behaviour for pre-slim-down rows.
358+
if (dbWriteOk) {
359+
try {
360+
const { getDb, readRawJson, pruneBatchResults } = await import('../core/db.js');
361+
const sdb = getDb();
362+
363+
// Action 1: offload inline raw_json blobs to per-run files, then NULL the
364+
// column. Order is write+verify BEFORE null so a crash mid-pass never
365+
// loses a blob — a re-run just re-offloads any row whose column is still
366+
// set. writeRawJson is not exported from core/db.js, so replicate its exact
367+
// path + write (mkdirSync recursive + writeFileSync) inline.
368+
const blobRows = sdb.prepare('SELECT id, run_id, raw_json FROM results WHERE raw_json IS NOT NULL').all();
369+
if (!blobRows.length) console.log(` ${C.dim('↳ raw_json offload: no inline blobs to migrate')}`);
370+
else {
371+
const nullStmt = sdb.prepare('UPDATE results SET raw_json = NULL WHERE id = ?');
372+
let offloaded = 0, skippedWrite = 0;
373+
// Stage writes first (file I/O outside the txn), collecting the ids whose
374+
// file is confirmed on disk; null only those, inside a single txn.
375+
const ready = [];
376+
for (const row of blobRows) {
377+
const dir = path.join(RAW_DIR, `run-${row.run_id}`);
378+
const file = path.join(dir, `${row.id}.json`);
379+
try {
380+
mkdirSync(dir, { recursive: true });
381+
writeFileSync(file, row.raw_json);
382+
// VERIFY before we agree to null: file exists and is non-empty.
383+
const st = statSync(file);
384+
if (st.size > 0) ready.push(row.id);
385+
else { skippedWrite++; hard(`raw_json offload: ${file} wrote 0 bytes — left column intact for result ${row.id}`); }
386+
} catch (e) { skippedWrite++; hard(`raw_json offload: write failed for result ${row.id} — left column intact: ${e.message}`); }
387+
}
388+
if (ready.length) {
389+
sdb.transaction(() => { for (const id of ready) { nullStmt.run(id); offloaded++; } })();
390+
}
391+
// Sanity sample: rehydrate the first migrated row from disk.
392+
if (ready.length) {
393+
const sample = blobRows.find(r => r.id === ready[0]);
394+
const back = readRawJson(sample.run_id, sample.id);
395+
if (back == null) hard(`raw_json offload: verify-read of run-${sample.run_id}/${sample.id}.json returned null after write`);
396+
}
397+
fixd(`offloaded ${offloaded.toLocaleString()} raw_json blob(s) to results/raw/run-*/<id>.json + NULLed column${skippedWrite ? ` (${skippedWrite} skipped — column left intact, no data loss)` : ''}`);
398+
}
399+
400+
// Action 2: cap legacy oversized log_snippet (idempotent backstop to v11).
401+
const capRes = sdb.prepare(
402+
`UPDATE error_logs SET log_snippet = substr(log_snippet, length(log_snippet) - ${LOG_SNIPPET_CAP} + 1) WHERE log_snippet IS NOT NULL AND length(log_snippet) > ${LOG_SNIPPET_CAP}`,
403+
).run();
404+
capRes.changes > 0
405+
? fixd(`capped ${capRes.changes.toLocaleString()} oversized log_snippet row(s) to 16 KB tail`)
406+
: console.log(` ${C.dim('↳ log_snippet cap: nothing to truncate (v11 already applied)')}`);
407+
408+
// Action 3: prune batch_results to retention (reuse core/db.js keep-set).
409+
const pr = pruneBatchResults({ keepBatches: DEFAULT_BATCH_RETENTION });
410+
(pr.deletedBatchResults || pr.deletedBatches)
411+
? fixd(`pruned ${pr.deletedBatchResults.toLocaleString()} batch_results row(s) + ${pr.deletedBatches.toLocaleString()} batch(es) (kept ${pr.keptBatches.toLocaleString()})`)
412+
: console.log(` ${C.dim(`↳ batch_results prune: within retention (kept ${pr.keptBatches.toLocaleString()})`)}`);
413+
414+
// Reclaim freed pages and flush the WAL.
415+
try { sdb.pragma('wal_checkpoint(TRUNCATE)'); }
416+
catch (e) { hard(`slim-down checkpoint failed: ${e.message}`); }
417+
} catch (e) { hard(`DB slim-down failed (restore audit.db from the backup): ${e.message}`); }
418+
} else console.log(` ${C.dim('↳ slim-down skipped (DB writes disabled)')}`);
419+
299420
if (PURGE_LOGS && orphanLogs.length && !abortAll) {
300421
const trash = path.join(RESULTS_DIR, '.cleanup-trash', String(ts));
301422
try {

0 commit comments

Comments
 (0)