Skip to content

Commit 43fbb7b

Browse files
perf(db): offload results.raw_json to per-run files; readers rehydrate transparently
The full JSON.stringify(result) blob is no longer stored inline in the results.raw_json column (the single largest contributor to audit.db growth, re-written every node every iteration). Each blob now goes to a per-run file results/raw/run-<run_id>/<result_id>.json; the column is bound NULL going forward. Readers (getNodeHistory, getNodeDetail, getNodeErrors) transparently rehydrate raw_json from the file when the column is NULL, so the REST contract (er.raw_json as a JSON string) is byte-identical and the admin failure-drawer needs zero changes. Old rows keep their inline blob (readers prefer a non-null column), so this is forward-safe before the cleanup.mjs migration runs. - core/constants.js: add RAW_DIR (results/raw) - core/db.js: writeRawJson/readRawJson/rehydrateRawJson; NULL the column on insert; rehydrate in getNodeHistory, getNodeDetail history, getNodeErrors (all 3 return paths). Helper prefers result_id over id (getNodeErrors joins error_logs so row.id is the error_log id, not the result id). - bin/commands/universal-test.js: verify round-trip via getNodeHistory (file rehydrate) instead of a direct raw_json column read.
1 parent 448345b commit 43fbb7b

3 files changed

Lines changed: 100 additions & 12 deletions

File tree

bin/commands/universal-test.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -556,9 +556,11 @@ export async function run({ flags: f = {} } = {}) {
556556
throw new Error(`getBatchResults short (results=${batchRows?.results?.length})`);
557557
}
558558

559-
// Verify raw_json round-trip
560-
const rawRow = mem.prepare('SELECT raw_json FROM results WHERE node_addr = @addr').get({ addr: fakeResults[0].address });
561-
const parsed = JSON.parse(rawRow.raw_json);
559+
// Verify raw_json round-trip. raw_json is offloaded to a per-run file
560+
// (results/raw/run-<id>/<rid>.json) and the column is NULL; getNodeHistory
561+
// rehydrates it from disk, so this exercises the full offload+rehydrate path.
562+
const rawHist = getNodeHistory(fakeResults[0].address, { limit: 1 });
563+
const parsed = JSON.parse(rawHist[0].raw_json);
562564
if (parsed.address !== fakeResults[0].address) throw new Error('raw_json round-trip failed');
563565

564566
closeDb();

core/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export const SESSION_MAP_TTL = 120_000; // 2 min
9494

9595
// ─── Paths ───────────────────────────────────────────────────────────────────
9696
export const RESULTS_DIR = path.join(PROJECT_ROOT, 'results');
97+
export const RAW_DIR = path.join(RESULTS_DIR, 'raw');
9798
export const RESULTS_FILE = path.join(RESULTS_DIR, 'results.json');
9899
export const CREDS_FILE = path.join(RESULTS_DIR, 'session-credentials.json');
99100
export const FAILURE_LOG = path.join(RESULTS_DIR, 'failures.jsonl');

core/db.js

Lines changed: 94 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,81 @@
99

1010
import Database from 'better-sqlite3';
1111
import path from 'path';
12-
import { mkdirSync, existsSync } from 'fs';
12+
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';
16+
17+
// ─── Raw-JSON file offload ───────────────────────────────────────────────────
18+
// The full JSON.stringify(result) blob is no longer stored inline in the
19+
// results.raw_json column. Instead each result's blob is written to a per-run
20+
// file: results/raw/run-<run_id>/<result_id>.json. The column is bound NULL
21+
// going forward; readers transparently rehydrate from the file so the REST
22+
// contract (er.raw_json as a JSON string) stays byte-identical.
23+
24+
/** Deterministic path to a result's raw-json file. */
25+
function rawJsonPath(run_id, result_id) {
26+
return path.join(RAW_DIR, `run-${run_id}`, `${result_id}.json`);
27+
}
28+
29+
/**
30+
* Write the raw-json blob for a single result to its per-run file.
31+
* Logs but never throws — the file is a backup, not load-bearing for the insert
32+
* (mirrors pipeline.js raw-write philosophy). Works for :memory: DBs too since
33+
* the path is independent of the DB backend.
34+
*
35+
* @param {number} run_id
36+
* @param {number|bigint} result_id
37+
* @param {string} json - the JSON.stringify(result) string
38+
*/
39+
function writeRawJson(run_id, result_id, json) {
40+
try {
41+
const dir = path.join(RAW_DIR, `run-${run_id}`);
42+
mkdirSync(dir, { recursive: true });
43+
writeFileSync(path.join(dir, `${result_id}.json`), json);
44+
} catch (e) {
45+
console.error('[db] raw_json file write failed:', e.message);
46+
}
47+
}
48+
49+
/**
50+
* Read the raw-json blob for a result from its per-run file.
51+
*
52+
* @param {number} run_id
53+
* @param {number|bigint} result_id
54+
* @returns {string|null} the JSON string, or null if missing/unreadable
55+
*/
56+
export function readRawJson(run_id, result_id) {
57+
if (run_id == null || result_id == null) return null;
58+
try {
59+
return readFileSync(rawJsonPath(run_id, result_id), 'utf8');
60+
} catch {
61+
// ENOENT or any read error → no blob available.
62+
return null;
63+
}
64+
}
65+
66+
/**
67+
* Mutate result rows in place: when raw_json is NULL/empty but run_id and id
68+
* are present, rehydrate raw_json from the per-run file. Keeps er.raw_json a
69+
* JSON string exactly as before the offload.
70+
*
71+
* @param {object[]} rows
72+
* @returns {object[]} the same array (mutated)
73+
*/
74+
function rehydrateRawJson(rows) {
75+
for (const row of rows) {
76+
if (!row || (row.raw_json != null && row.raw_json !== '')) continue;
77+
// Prefer result_id: getNodeErrors joins error_logs (el.*), so row.id is the
78+
// error_log id while result_id is the FK to results.id (the file key).
79+
// Plain results-table rows have only `id`, so fall back to it.
80+
const id = row.result_id ?? row.id;
81+
if (row.run_id != null && id != null) {
82+
row.raw_json = readRawJson(row.run_id, id);
83+
}
84+
}
85+
return rows;
86+
}
1587

1688
const __dirname = path.dirname(fileURLToPath(import.meta.url));
1789
const DATA_DIR = path.join(__dirname, '..', 'data');
@@ -502,7 +574,11 @@ function mapResultToRow(run_id, r) {
502574
error_code: r.errorCode || null,
503575
error_message: r.error || null,
504576
tested_at,
505-
raw_json: JSON.stringify(r),
577+
// raw_json is offloaded to a per-run file after insert; the column is
578+
// bound NULL going forward. _rawJson carries the blob to the writer but is
579+
// NOT a column param (SQLite ignores extra named props it doesn't bind).
580+
raw_json: null,
581+
_rawJson: JSON.stringify(r),
506582
pass,
507583
stage,
508584
sdk: r.sdk || null,
@@ -533,7 +609,9 @@ const _insertResultSql = `
533609
export function insertResult(run_id, result, which) {
534610
const db = getDb(which);
535611
const row = mapResultToRow(run_id, result);
536-
const info = db.prepare(_insertResultSql).run(row);
612+
const { _rawJson, ...bind } = row;
613+
const info = db.prepare(_insertResultSql).run(bind);
614+
writeRawJson(run_id, info.lastInsertRowid, _rawJson);
537615
return info.lastInsertRowid;
538616
}
539617

@@ -548,7 +626,11 @@ export function insertResultsBatch(run_id, results, which) {
548626
const db = getDb(which);
549627
const stmt = db.prepare(_insertResultSql);
550628
const insert = db.transaction((rows) => {
551-
for (const r of rows) stmt.run(r);
629+
for (const row of rows) {
630+
const { _rawJson, ...bind } = row;
631+
const info = stmt.run(bind);
632+
writeRawJson(run_id, info.lastInsertRowid, _rawJson);
633+
}
552634
});
553635
insert(results.map(r => mapResultToRow(run_id, r)));
554636
}
@@ -703,9 +785,10 @@ export function getLatestResultPerNode({ q = null, country = null, limit = 200,
703785
* @returns {object[]}
704786
*/
705787
export function getNodeHistory(nodeAddr, { limit = 50 } = {}, which) {
706-
return getDb(which).prepare(
788+
const rows = getDb(which).prepare(
707789
'SELECT * FROM results WHERE node_addr = @node_addr ORDER BY tested_at DESC LIMIT @limit',
708790
).all({ node_addr: nodeAddr, limit });
791+
return rehydrateRawJson(rows);
709792
}
710793

711794
// ─── Aggregate Stats ──────────────────────────────────────────────────────────
@@ -1059,12 +1142,14 @@ export function getNodeDetail(addr, { historyLimit = 100 } = {}, which) {
10591142
LIMIT 1
10601143
`).get({ addr });
10611144

1062-
const history = db.prepare(`
1145+
if (node) rehydrateRawJson([node]);
1146+
1147+
const history = rehydrateRawJson(db.prepare(`
10631148
SELECT * FROM results
10641149
WHERE node_addr = @addr
10651150
ORDER BY tested_at DESC
10661151
LIMIT @limit
1067-
`).all({ addr, limit: historyLimit });
1152+
`).all({ addr, limit: historyLimit }));
10681153

10691154
const errors = db.prepare(`
10701155
SELECT el.*, r.tested_at, r.actual_mbps, r.node_addr, r.moniker
@@ -1115,7 +1200,7 @@ export function getNodeErrors(addr, { limit = 50, stage = null } = {}, which) {
11151200
ORDER BY el.captured_at DESC
11161201
LIMIT @limit
11171202
`).all(params);
1118-
if (rows.length > 0) return rows;
1203+
if (rows.length > 0) return rehydrateRawJson(rows);
11191204

11201205
// Fallback: no error_logs row exists for this node yet (race between
11211206
// upsertResult writing the result and insertErrorLog completing, or an old
@@ -1154,7 +1239,7 @@ export function getNodeErrors(addr, { limit = 50, stage = null } = {}, which) {
11541239
ORDER BY r.tested_at DESC
11551240
LIMIT @limit
11561241
`).all(params);
1157-
if (fallback.length > 0) return fallback;
1242+
if (fallback.length > 0) return rehydrateRawJson(fallback);
11581243

11591244
// Second fallback: continuous-loop (public) runs persist per node ONLY to
11601245
// batch_results — never to results/error_logs — so a node that failed only

0 commit comments

Comments
 (0)