Skip to content

Commit a7d2779

Browse files
perf(db): bound batch_results growth via pruneBatchResults retention (keep active + last-finished + last K)
1 parent feca5cf commit a7d2779

4 files changed

Lines changed: 168 additions & 2 deletions

File tree

audit/continuous.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,20 @@ async function _runLoop() {
632632
passed,
633633
failed,
634634
}, 'real');
635+
636+
// Bound batch_results growth: every iteration re-tests every node and
637+
// writes a full row set, so the table grows unbounded without pruning.
638+
// This is the safe spot — the just-finished batch is now the
639+
// last-finished (kept), and the next iteration's batch hasn't started,
640+
// so nothing in-flight is at risk. Pruning is housekeeping: a failure
641+
// must NOT abort the audit, so it gets its own try/catch.
642+
try {
643+
const { pruneBatchResults } = await import('../core/db.js');
644+
const { DEFAULT_BATCH_RETENTION } = await import('../core/constants.js');
645+
pruneBatchResults({ keepBatches: DEFAULT_BATCH_RETENTION }, 'real');
646+
} catch (e) {
647+
console.error('[continuous] batch_results prune failed:', e.message);
648+
}
635649
} catch (dbErr) {
636650
console.error(`[continuous] updateBatchOnFinish failed: ${dbErr.message}`);
637651
}

core/constants.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,14 @@ export const LCD_ENDPOINTS = [
8888
// ─── Batch Payment ───────────────────────────────────────────────────────────
8989
export const BATCH_SIZE = 5;
9090

91+
// ─── batch_results Retention ──────────────────────────────────────────────────
92+
// In continuous-loop mode every iteration re-tests every node and writes a full
93+
// set of batch_results rows, so the table grows unbounded. pruneBatchResults()
94+
// (core/db.js) keeps only the last N finished+active batches. 200 batches of a
95+
// full-network sweep is ample history for the failure-log batch-fallback while
96+
// keeping the table bounded.
97+
export const DEFAULT_BATCH_RETENTION = 200;
98+
9199
// ─── Cache TTLs ──────────────────────────────────────────────────────────────
92100
export const NODE_CACHE_TTL = 5 * 60_000; // 5 min
93101
export const SESSION_MAP_TTL = 120_000; // 2 min

core/db.js

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import path from 'path';
1212
import { mkdirSync, existsSync, writeFileSync, readFileSync } from 'fs';
1313
import { fileURLToPath } from 'url';
1414
import { countryToContinent } from './countries.js';
15-
import { RAW_DIR } from './constants.js';
15+
import { RAW_DIR, DEFAULT_BATCH_RETENTION } from './constants.js';
1616

1717
// ─── Raw-JSON file offload ───────────────────────────────────────────────────
1818
// The full JSON.stringify(result) blob is no longer stored inline in the
@@ -1638,6 +1638,89 @@ export function getLastBatch(which) {
16381638
return { batch, nodes };
16391639
}
16401640

1641+
/**
1642+
* Prune old batch_results (and their now-empty parent `batches` rows) so the
1643+
* table stays bounded under continuous-loop mode, where every iteration
1644+
* re-tests every node and writes a full set of rows.
1645+
*
1646+
* KEEP-SET RATIONALE — three readers must never lose their data:
1647+
* (a) the ACTIVE batch (finished_at IS NULL): the continuous-loop resume path
1648+
* re-seeds already-tested addresses from its batch_results (getActiveBatch
1649+
* + raw SELECTs in audit/continuous.js). Pruning it mid-flight would lose
1650+
* the resume seed.
1651+
* (b) the single most-recent FINISHED batch (getLastBatch): drives the /live
1652+
* last-completed snapshot. Pruning it would blank /live between runs.
1653+
* (c) the last K batches overall (by recency): preserves recent history for
1654+
* the failure-log batch-fallback in getNodeErrors and operator review.
1655+
* The union of these three is the keep-set. Everything else is deleted.
1656+
*
1657+
* Children (batch_results) are deleted explicitly BEFORE parents (batches) to
1658+
* keep `PRAGMA foreign_key_check` clean, mirroring scripts/cleanup.mjs. An
1659+
* active/unfinished batch row is NEVER deleted from `batches` even if it somehow
1660+
* fell outside the recency window (the keep-set already includes it, but the
1661+
* `finished_at IS NOT NULL` guard on the parent delete is a second backstop).
1662+
*
1663+
* Pruning is housekeeping — it is NOT called on the insert hot path (that would
1664+
* risk the active batch). audit/continuous.js calls it once per iteration,
1665+
* immediately after the just-finished batch becomes the last-finished one.
1666+
*
1667+
* @param {{ keepBatches?: number }} [opts] - how many recent batches to retain
1668+
* @param {string} [which] - DB scope (back-compat); pass ':memory:' in tests
1669+
* @returns {{ deletedBatchResults: number, deletedBatches: number, keptBatches: number }}
1670+
*/
1671+
export function pruneBatchResults({ keepBatches = DEFAULT_BATCH_RETENTION } = {}, which) {
1672+
const db = getDb(which);
1673+
const keep = Math.max(1, Number(keepBatches) || DEFAULT_BATCH_RETENTION);
1674+
1675+
return db.transaction(() => {
1676+
// Build the keep-set of batch ids.
1677+
const keepIds = new Set();
1678+
1679+
// (c) last K batches overall, by recency. `batches` is ordered by
1680+
// started_at everywhere else (getActiveBatch / idx_batches_started_at).
1681+
for (const row of db.prepare(
1682+
'SELECT id FROM batches ORDER BY started_at DESC LIMIT @keep',
1683+
).all({ keep })) {
1684+
keepIds.add(row.id);
1685+
}
1686+
1687+
// (a) the active batch (finished_at IS NULL).
1688+
const active = getActiveBatch(which);
1689+
if (active?.batch?.id != null) keepIds.add(active.batch.id);
1690+
1691+
// (b) the most-recent finished batch.
1692+
const last = getLastBatch(which);
1693+
if (last?.batch?.id != null) keepIds.add(last.batch.id);
1694+
1695+
const ids = [...keepIds];
1696+
// Guard: an empty keep-set would delete everything. `keep >= 1` plus the
1697+
// active/last lookups mean this is only empty when there are no batches at
1698+
// all — in which case both deletes below are no-ops anyway, but bail early.
1699+
if (ids.length === 0) {
1700+
return { deletedBatchResults: 0, deletedBatches: 0, keptBatches: 0 };
1701+
}
1702+
1703+
const placeholders = ids.map((_, i) => `@k${i}`).join(',');
1704+
const params = {};
1705+
ids.forEach((id, i) => { params[`k${i}`] = id; });
1706+
1707+
// Child delete first (batch_results), then parent (batches) — never delete
1708+
// an unfinished/active batch row.
1709+
const delResults = db.prepare(
1710+
`DELETE FROM batch_results WHERE batch_id NOT IN (${placeholders})`,
1711+
).run(params);
1712+
const delBatches = db.prepare(
1713+
`DELETE FROM batches WHERE id NOT IN (${placeholders}) AND finished_at IS NOT NULL`,
1714+
).run(params);
1715+
1716+
return {
1717+
deletedBatchResults: delResults.changes,
1718+
deletedBatches: delBatches.changes,
1719+
keptBatches: ids.length,
1720+
};
1721+
})();
1722+
}
1723+
16411724
// ─── Close ────────────────────────────────────────────────────────────────────
16421725

16431726
/**

test/db.smoke.test.js

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88

99
import { getDb, useDb, insertRun, updateRunOnFinish, insertResult, insertResultsBatch,
1010
getRun, findRunByKey, listRuns, getLatestResultPerNode,
11-
getNodeHistory, getNetworkStats, insertErrorLog, getNodeErrors, closeDb } from '../core/db.js';
11+
getNodeHistory, getNetworkStats, insertErrorLog, getNodeErrors, closeDb,
12+
insertBatch, insertBatchResult, updateBatchOnFinish, getBatchResults,
13+
getActiveBatch, getLastBatch, pruneBatchResults } from '../core/db.js';
1214

1315
// ─── Use a fresh in-memory DB for this test run ───────────────────────────────
1416
// `useDb` sets the module singleton so all exported helpers target this handle.
@@ -207,6 +209,65 @@ assert(
207209
`log_snippet capped at <= 16384 chars (got ${capErrors[0].log_snippet?.length})`,
208210
);
209211

212+
// 14. pruneBatchResults — keep active + last-finished + last K batches
213+
console.log('14. pruneBatchResults retention...');
214+
// Build 6 batches with staggered started_at so recency is unambiguous.
215+
// b1..b5 are finished (oldest→newest); b6 is left ACTIVE (finished_at NULL)
216+
// but with the OLDEST started_at so it would fall outside a recency window —
217+
// proving the active-batch keep is independent of the last-K window.
218+
const KEEP = 3;
219+
const batchIds = [];
220+
for (let i = 0; i < 6; i++) {
221+
const bid = Number(insertBatch({
222+
started_at: NOW + i * 1000, // b1 oldest .. b6 newest
223+
snapshot_size: 1,
224+
mode: 'p2p',
225+
}));
226+
batchIds.push(bid);
227+
// One node result per batch, distinct address so we can verify survival.
228+
insertBatchResult(bid, {
229+
address: `sentnode1batch${i}`,
230+
actualMbps: 10 + i,
231+
testedAt: NOW + i * 1000,
232+
});
233+
}
234+
const [b1, b2, b3, b4, b5, b6] = batchIds;
235+
// Finish b1..b5 (oldest→newest by finished_at). Leave b6 ACTIVE.
236+
[b1, b2, b3, b4, b5].forEach((bid, idx) => {
237+
updateBatchOnFinish(bid, { finished_at: NOW + 10_000 + idx * 1000, passed: 1, failed: 0 });
238+
});
239+
// Make b6 ACTIVE but the OLDEST by started_at so it's outside the last-K window.
240+
db.prepare('UPDATE batches SET started_at = @ts, finished_at = NULL WHERE id = @id')
241+
.run({ ts: NOW - 100_000, id: b6 });
242+
243+
// Sanity: active = b6, last-finished = b5 (latest finished_at).
244+
eq(getActiveBatch().batch.id, b6, 'active batch is b6 (finished_at NULL)');
245+
eq(getLastBatch().batch.id, b5, 'last-finished batch is b5');
246+
247+
const summary = pruneBatchResults({ keepBatches: KEEP });
248+
// keep-set: last-K by started_at (b5,b4,b3) ∪ active(b6) ∪ last-finished(b5) = {b3,b4,b5,b6}
249+
eq(summary.keptBatches, 4, 'keep-set size = last-K(3) ∪ active ∪ last-finished = 4');
250+
251+
// (a) active batch rows survive
252+
eq(getBatchResults(b6).results.length, 1, 'active batch (b6) rows survive');
253+
// (b) most-recent finished batch rows survive
254+
eq(getBatchResults(b5).results.length, 1, 'last-finished batch (b5) rows survive');
255+
// (c) batches inside the last-K window survive
256+
eq(getBatchResults(b4).results.length, 1, 'recent batch (b4) rows survive');
257+
eq(getBatchResults(b3).results.length, 1, 'recent batch (b3) rows survive');
258+
// (d) old batches outside the keep-set are gone (rows AND parent batch row)
259+
eq(getBatchResults(b2).results.length, 0, 'old batch (b2) rows pruned');
260+
eq(getBatchResults(b1).results.length, 0, 'old batch (b1) rows pruned');
261+
assert(getBatchResults(b1).batch == null, 'old batch (b1) parent row deleted');
262+
assert(getBatchResults(b2).batch == null, 'old batch (b2) parent row deleted');
263+
// Two batches deleted (b1, b2), each with one batch_results row.
264+
eq(summary.deletedBatches, 2, 'deletedBatches = 2 (b1, b2)');
265+
eq(summary.deletedBatchResults, 2, 'deletedBatchResults = 2');
266+
// FK integrity intact after prune.
267+
assert(db.prepare('PRAGMA foreign_key_check').all().length === 0, 'foreign_key_check clean after prune');
268+
// Active batch never deleted as a parent even if outside window — already
269+
// asserted via b6 survival above.
270+
210271
// ─── Results ──────────────────────────────────────────────────────────────────
211272

212273
console.log(`\n${'='.repeat(50)}`);

0 commit comments

Comments
 (0)