Skip to content

Commit 3e567f6

Browse files
fix(cleanup/db): harden raw_json offload, reclaim orphan raw dirs, keep all unfinished batches, correct char-vs-byte labels
Closes the 4 defects from the adversarial review of the DB slim-down / disk-hardening work: FIX 1 (MED) — cleanup.mjs raw_json backfill now validates a JSON round-trip before NULLing the column. Action 1 previously only stat-checked size>0; a non-empty file can still be truncated/corrupt. It now reads the written file back and JSON.parse()s it, only adding the id to the to-be-NULLed set when the parse SUCCEEDS. Any read/parse failure leaves the column intact, logs a warning naming run_id/result_id, and counts toward the skipped total reported in fix output. FIX 2 (MED/LOW) — orphan raw dirs are now reclaimable. Added a namespace-correct orphan-raw-dir sweep to cleanup.mjs (new report section 7 + a --fix reclaim gated on the existing backup+checkpoint guard): run-<id> dirs whose <id> has no row in the runs table are orphans; reported with reclaimable bytes and rm -rf'd under --fix. Handles RAW_DIR absent; stray non-run-<int> entries are reported but never deleted. deleteRun() in server.js is deliberately UNCHANGED (only a clarifying comment added) — it keys on the file-index run NUMBER, a different namespace from the runs-table id that keys raw dirs, and no mapping holds for every path (dbRunId is null for legacy runs and absent for the interrupted/brand-new run case, where the only id available is the LIVE run's). A wrong-id rm would destroy live raw data; correctness over completeness. FIX 3 (LOW) — pruneBatchResults() now keeps ALL unfinished batches, not just getActiveBatch's single newest one. Keep-set step (a) selects every id from batches WHERE finished_at IS NULL, so a second unfinished batch's batch_results can no longer be stripped (which would orphan its parent row, since the parent DELETE guards finished_at IS NOT NULL). Added a :memory: test asserting both unfinished batches survive while old finished ones are pruned, with no orphaned children and a clean foreign_key_check. FIX 4 (LOW) — corrected "16KB"/"16 KB"/byte wording to "16384 chars (UTF-8 byte size varies)" at every mislabeled site in core/db.js (insertErrorLog cap site + v11 migration block) and scripts/cleanup.mjs (LOG_SNIPPET_CAP + report/fix messages). Numeric value and behavior are unchanged. The :memory: raw_json INLINE behavior, TEST RUN paths, and ETA code are untouched. npm test + npm run test:integration both fully green.
1 parent 4b9ab40 commit 3e567f6

4 files changed

Lines changed: 198 additions & 31 deletions

File tree

core/db.js

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -477,13 +477,14 @@ function runMigrations(db) {
477477

478478
if (current < 11) {
479479
db.transaction(() => {
480-
// ── Migration v11: cap error_logs.log_snippet at 16 KB ──────────────────
481-
// The inline log_snippet column historically stored up to 512 KB per
482-
// failure, bloating audit.db. The full log already lives on disk in the
483-
// per-run audit-*.log file, so the DB only needs a readable snippet for
484-
// the copy-failure UX. Going forward insertErrorLog() caps at 16384 chars
485-
// from the tail; this migration truncates any legacy oversized rows to
486-
// match. substr(s, length(s) - 16384 + 1) keeps the LAST 16384 chars,
480+
// ── Migration v11: cap error_logs.log_snippet at 16384 chars ────────────
481+
// (16384 chars; UTF-8 byte size varies). The inline log_snippet column
482+
// historically stored up to ~512K chars per failure, bloating audit.db. The
483+
// full log already lives on disk in the per-run audit-*.log file, so the DB
484+
// only needs a readable snippet for the copy-failure UX. Going forward
485+
// insertErrorLog() caps at 16384 chars from the tail; this migration
486+
// truncates any legacy oversized rows to match.
487+
// substr(s, length(s) - 16384 + 1) keeps the LAST 16384 chars,
487488
// mirroring the `.slice(-16384)` tail semantics. Bounded fast UPDATE —
488489
// safe on the boot path (no file I/O here).
489490
db.prepare(`
@@ -976,7 +977,7 @@ export function getRunStats(runId, which) {
976977
* @param {string} opts.stage - 'handshake'|'session'|'speedtest'|'wallet'|'rpc'|'other'
977978
* @param {string} [opts.error_code]
978979
* @param {string} [opts.error_message]
979-
* @param {string} [opts.log_snippet] - a readable snippet of log context (capped at 16 KB)
980+
* @param {string} [opts.log_snippet] - a readable snippet of log context (capped at 16384 chars; UTF-8 byte size varies)
980981
* @returns {number} inserted id
981982
*/
982983
export function insertErrorLog({
@@ -987,8 +988,8 @@ export function insertErrorLog({
987988
log_snippet = null,
988989
}, which) {
989990
const db = getDb(which);
990-
// Store only a readable snippet of the per-node tester log (capped at 16 KB
991-
// = 16384 chars from the tail). The FULL log is persisted to disk in the
991+
// Store only a readable snippet of the per-node tester log (capped at 16384
992+
// chars from the tail; UTF-8 byte size varies). The FULL log is persisted to disk in the
992993
// per-run audit-*.log file; the DB column exists purely to power the
993994
// copy-failure UX, so a bounded tail is all we need. Production callers
994995
// already pre-truncate to ~4 KB via pipeline.js (_sanitizeSnippet /
@@ -1694,10 +1695,14 @@ export function getLastBatch(which) {
16941695
* re-tests every node and writes a full set of rows.
16951696
*
16961697
* KEEP-SET RATIONALE — three readers must never lose their data:
1697-
* (a) the ACTIVE batch (finished_at IS NULL): the continuous-loop resume path
1698-
* re-seeds already-tested addresses from its batch_results (getActiveBatch
1699-
* + raw SELECTs in audit/continuous.js). Pruning it mid-flight would lose
1700-
* the resume seed.
1698+
* (a) ALL active/unfinished batches (finished_at IS NULL): the continuous-loop
1699+
* resume path re-seeds already-tested addresses from its batch_results
1700+
* (getActiveBatch + raw SELECTs in audit/continuous.js). Pruning one
1701+
* mid-flight would lose the resume seed. We keep EVERY unfinished batch,
1702+
* not just getActiveBatch's single newest one — if two+ batches have
1703+
* finished_at IS NULL, dropping the older one's batch_results would orphan
1704+
* its parent row (the parent DELETE guards finished_at IS NOT NULL, so the
1705+
* childless row would survive).
17011706
* (b) the single most-recent FINISHED batch (getLastBatch): drives the /live
17021707
* last-completed snapshot. Pruning it would blank /live between runs.
17031708
* (c) the last K batches overall (by recency): preserves recent history for
@@ -1734,9 +1739,14 @@ export function pruneBatchResults({ keepBatches = DEFAULT_BATCH_RETENTION } = {}
17341739
keepIds.add(row.id);
17351740
}
17361741

1737-
// (a) the active batch (finished_at IS NULL).
1738-
const active = getActiveBatch(which);
1739-
if (active?.batch?.id != null) keepIds.add(active.batch.id);
1742+
// (a) ALL active/unfinished batches (finished_at IS NULL). Keeping only
1743+
// getActiveBatch's single newest unfinished batch would let the child DELETE
1744+
// strip a second unfinished batch's batch_results, orphaning its parent row.
1745+
for (const row of db.prepare(
1746+
'SELECT id FROM batches WHERE finished_at IS NULL',
1747+
).all()) {
1748+
keepIds.add(row.id);
1749+
}
17401750

17411751
// (b) the most-recent finished batch.
17421752
const last = getLastBatch(which);

scripts/cleanup.mjs

Lines changed: 106 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@
2323
* – offload inline results.raw_json blobs to
2424
* results/raw/run-<run_id>/<id>.json, then NULL
2525
* 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)
26+
* – cap legacy error_logs.log_snippet at the
27+
* 16384-char tail (UTF-8 byte size varies;
28+
* idempotent backstop to migration v11)
2829
* – prune batch_results to DEFAULT_BATCH_RETENTION
2930
* --purge-orphan-logs (with --fix) quarantine orphan audit logs into
3031
* results/.cleanup-trash/<ts>/ (move, not delete;
@@ -44,7 +45,7 @@
4445
* boot-reconcile, continuous-loop and retest paths and could fabricate values.
4546
*/
4647
import Database from 'better-sqlite3';
47-
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, mkdirSync, copyFileSync } from 'fs';
48+
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, mkdirSync, copyFileSync, rmSync } from 'fs';
4849
import path from 'path';
4950
import { fileURLToPath } from 'url';
5051
import { RAW_DIR, DEFAULT_BATCH_RETENTION } from '../core/constants.js';
@@ -64,7 +65,7 @@ const MATCH_TOLERANCE_MS = 15_000;
6465
const BACKFILL_TOLERANCE_MS = 60_000; // wider dedup window for backfill (save-time skew)
6566
const RECENT_LOG_MS = 24 * 60 * 60 * 1000; // never purge a log written in the last day
6667
const RUNAWAY_NOTES = 'continuous-loop iteration%';
67-
const LOG_SNIPPET_CAP = 16384; // bytes — mirrors core/db.js insertErrorLog + migration v11
68+
const LOG_SNIPPET_CAP = 16384; // 16384 chars (UTF-8 byte size varies) — mirrors core/db.js insertErrorLog + migration v11
6869

6970
// Human-readable byte size for the slim-down report.
7071
const humanBytes = n => {
@@ -228,13 +229,14 @@ if (existsSync(RESULTS_DIR)) {
228229

229230
// ─── 6. DB slim-down (one-time backfill for legacy data) ──────────────────────
230231
// 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
232+
// insert: raw_json blobs offloaded to per-run files, log_snippet capped at 16384
233+
// chars (UTF-8 byte size varies), batch_results bounded. Existing rows from
234+
// before those commits still carry the
233235
// inline blob / oversized snippet / unbounded batch history; this section reports
234236
// (and, under --fix, performs) the one-time migration of that legacy data.
235237
section('6. DB slim-down (legacy backfill)');
236238
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
239+
let logSnippetRows = 0; // error_logs rows whose snippet exceeds the 16384-char cap (UTF-8 byte size varies)
238240
let batchCount = 0; // total batches
239241
let batchResultRows = 0; // total batch_results rows
240242
if (rdb) {
@@ -249,7 +251,7 @@ if (rdb) {
249251
try {
250252
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;
251253
logSnippetRows > 0
252-
? repairable(`${logSnippetRows.toLocaleString()} error_logs row(s) exceed the 16 KB log_snippet cap (would truncate to tail)`)
254+
? repairable(`${logSnippetRows.toLocaleString()} error_logs row(s) exceed the 16384-char log_snippet cap (UTF-8 byte size varies; would truncate to tail)`)
253255
: ok('no oversized log_snippet rows (migration v11 already applied)');
254256
} catch (e) { hard(`log_snippet scan failed: ${e.message}`); }
255257

@@ -263,6 +265,59 @@ if (rdb) {
263265
} catch (e) { hard(`batch_results scan failed: ${e.message}`); }
264266
}
265267

268+
// ─── 7. Orphan raw_json dirs (results/raw/run-<id>) ───────────────────────────
269+
// Each result's raw_json blob is offloaded to results/raw/run-<id>/<id>.json,
270+
// keyed by the SQLite runs-table id. When a run row is deleted (runaway prune,
271+
// backfill churn, manual cleanup) its raw dir is left behind — server.js
272+
// deleteRun() operates on the file-index run NUMBER, a DIFFERENT namespace from
273+
// the runs-table id that keys these dirs, so it can't safely reclaim them (a
274+
// wrong-id rm would destroy a LIVE run's raw data). This sweep is the
275+
// namespace-correct reclaimer: a run-<id> dir whose <id> has no row in `runs`
276+
// is an orphan. Stray entries that aren't run-<int> dirs are reported but never
277+
// deleted.
278+
section('7. Orphan raw dirs (results/raw)');
279+
const orphanRawDirs = []; // { name, dir, bytes }
280+
let orphanRawBytes = 0;
281+
if (!existsSync(RAW_DIR)) {
282+
ok(`no raw dir at ${path.relative(ROOT, RAW_DIR)} (ok — nothing offloaded yet)`);
283+
} else if (!rdb) {
284+
info('raw dir present but audit.db unreadable — cannot determine orphans (skipped)');
285+
} else {
286+
// Recursively sum a directory's byte size for the reclaimable-bytes report.
287+
const dirBytes = (d) => {
288+
let total = 0;
289+
let entries;
290+
try { entries = readdirSync(d, { withFileTypes: true }); }
291+
catch (e) { console.error('[cleanup] raw dir size scan failed:', e.message); return 0; }
292+
for (const ent of entries) {
293+
const p = path.join(d, ent.name);
294+
if (ent.isDirectory()) total += dirBytes(p);
295+
else { try { total += statSync(p).size; } catch (e) { console.error('[cleanup] raw file stat failed:', e.message); } }
296+
}
297+
return total;
298+
};
299+
const runExists = rdb.prepare('SELECT 1 FROM runs WHERE id = ? LIMIT 1');
300+
let names;
301+
try { names = readdirSync(RAW_DIR, { withFileTypes: true }); }
302+
catch (e) { hard(`raw dir listing failed: ${e.message}`); names = []; }
303+
for (const ent of names) {
304+
const m = /^run-(\d+)$/.exec(ent.name);
305+
if (!ent.isDirectory() || !m) { info(`unexpected entry in raw dir (left untouched): ${ent.name}`); continue; }
306+
const id = parseInt(m[1], 10);
307+
let present;
308+
try { present = !!runExists.get(id); }
309+
catch (e) { hard(`runs lookup for raw dir ${ent.name} failed: ${e.message}`); continue; }
310+
if (present) continue; // live/known run — keep
311+
const dir = path.join(RAW_DIR, ent.name);
312+
const bytes = dirBytes(dir);
313+
orphanRawBytes += bytes;
314+
orphanRawDirs.push({ name: ent.name, dir, bytes });
315+
repairable(`orphan raw dir ${ent.name} (no runs row for id=${id}, ${humanBytes(bytes)} reclaimable)`);
316+
}
317+
if (!orphanRawDirs.length) ok('no orphan raw dirs (every run-<id> maps to a runs row)');
318+
else { repairable(`${orphanRawDirs.length} orphan raw dir(s), ${humanBytes(orphanRawBytes)} total reclaimable`); if (!FIX) console.log(C.dim(' (pass --fix to rm -rf these orphan dirs)')); }
319+
}
320+
266321
// Close our OWN read-only handle before mutating: if it stays open, the WAL
267322
// checkpoint below can report `busy` and falsely abort the whole --fix on a
268323
// perfectly healthy, server-stopped box.
@@ -379,11 +434,14 @@ if (FIX) {
379434
try {
380435
mkdirSync(dir, { recursive: true });
381436
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}`); }
437+
// VERIFY before we agree to null: read the file back and JSON.parse
438+
// it. A non-empty file can still be truncated/corrupt — size>0 is not
439+
// enough. Only NULL the column when the round-trip parse SUCCEEDS;
440+
// any read/parse failure leaves the column intact (no data loss).
441+
const back = readFileSync(file, 'utf8');
442+
JSON.parse(back); // throws on truncated/corrupt JSON
443+
ready.push(row.id);
444+
} catch (e) { skippedWrite++; hard(`raw_json offload: round-trip verify failed for run-${row.run_id}/result ${row.id} (${file}) — left column intact: ${e.message}`); }
387445
}
388446
if (ready.length) {
389447
sdb.transaction(() => { for (const id of ready) { nullStmt.run(id); offloaded++; } })();
@@ -402,7 +460,7 @@ if (FIX) {
402460
`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}`,
403461
).run();
404462
capRes.changes > 0
405-
? fixd(`capped ${capRes.changes.toLocaleString()} oversized log_snippet row(s) to 16 KB tail`)
463+
? fixd(`capped ${capRes.changes.toLocaleString()} oversized log_snippet row(s) to the 16384-char tail (UTF-8 byte size varies)`)
406464
: console.log(` ${C.dim('↳ log_snippet cap: nothing to truncate (v11 already applied)')}`);
407465

408466
// Action 3: prune batch_results to retention (reuse core/db.js keep-set).
@@ -417,6 +475,40 @@ if (FIX) {
417475
} catch (e) { hard(`DB slim-down failed (restore audit.db from the backup): ${e.message}`); }
418476
} else console.log(` ${C.dim('↳ slim-down skipped (DB writes disabled)')}`);
419477

478+
// ── Orphan raw-dir reclaim ────────────────────────────────────────────────
479+
// Gated on the SAME backup+checkpoint guard as the DB actions (dbWriteOk).
480+
// Re-scan against the CURRENT runs table (the runaway prune above may have
481+
// just orphaned more dirs) so one --fix pass reclaims them all. Namespace
482+
// note: these dirs are keyed by the runs-table id, NOT the file-index run
483+
// number — see the section-7 comment for why deleteRun() can't reclaim them.
484+
if (dbWriteOk && existsSync(RAW_DIR)) {
485+
try {
486+
const { getDb } = await import('../core/db.js');
487+
const odb = getDb();
488+
const runExists = odb.prepare('SELECT 1 FROM runs WHERE id = ? LIMIT 1');
489+
let removed = 0, reclaimed = 0;
490+
let names;
491+
try { names = readdirSync(RAW_DIR, { withFileTypes: true }); }
492+
catch (e) { hard(`raw dir listing failed: ${e.message}`); names = []; }
493+
for (const ent of names) {
494+
const m = /^run-(\d+)$/.exec(ent.name);
495+
if (!ent.isDirectory() || !m) continue; // never delete non-run-<int> entries
496+
const id = parseInt(m[1], 10);
497+
let present;
498+
try { present = !!runExists.get(id); }
499+
catch (e) { hard(`runs lookup for raw dir ${ent.name} failed: ${e.message}`); continue; }
500+
if (present) continue;
501+
const dir = path.join(RAW_DIR, ent.name);
502+
const known = orphanRawDirs.find(o => o.name === ent.name);
503+
const bytes = known ? known.bytes : 0;
504+
try { rmSync(dir, { recursive: true, force: true }); removed++; reclaimed += bytes; fixd(`removed orphan raw dir ${ent.name} (${humanBytes(bytes)})`); }
505+
catch (e) { hard(`could not remove orphan raw dir ${ent.name}: ${e.message}`); }
506+
}
507+
if (!removed) console.log(` ${C.dim('↳ orphan raw dirs: none to reclaim')}`);
508+
else fixd(`reclaimed ${removed} orphan raw dir(s), ${humanBytes(reclaimed)} freed`);
509+
} catch (e) { hard(`orphan raw-dir reclaim failed: ${e.message}`); }
510+
} else if (!dbWriteOk) console.log(` ${C.dim('↳ orphan raw-dir reclaim skipped (DB writes disabled)')}`);
511+
420512
if (PURGE_LOGS && orphanLogs.length && !abortAll) {
421513
const trash = path.join(RESULTS_DIR, '.cleanup-trash', String(ts));
422514
try {

server.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,6 +1075,17 @@ function deleteRun(num) {
10751075
if (i !== -1) index.runs.splice(i, 1);
10761076
if (index.activeRun === num) index.activeRun = null;
10771077
saveRunsIndex(index);
1078+
// NOTE: we deliberately do NOT delete this run's raw_json dir
1079+
// (results/raw/run-<id>) here. That dir is keyed by the SQLite runs-table id,
1080+
// whereas deleteRun operates on the file-index run NUMBER — a DIFFERENT
1081+
// namespace. The entry MAY carry a `dbRunId`, but it is null for legacy runs
1082+
// (pre-dbRunId field) and entirely absent for the interrupted/brand-new run
1083+
// case (i === -1, entry === null), where the only id available is
1084+
// state.activeDbRunId — i.e. the LIVE run's raw data. There is no mapping that
1085+
// holds for every code path, and a wrong-id `rm -rf` would destroy a live
1086+
// run's raw blobs. Correctness over completeness: the raw dir is reclaimed
1087+
// (namespace-correctly) by scripts/cleanup.mjs's orphan-raw-dir sweep, which
1088+
// matches run-<id> dirs against the runs table directly.
10781089
// Remove the snapshot dir (results.json, summary.txt, failures.jsonl, audit.log).
10791090
const runDir = path.join(RUNS_DIR, `test-${String(num).padStart(3, '0')}`);
10801091
if (_ex(runDir)) {

0 commit comments

Comments
 (0)