Two streams of work land together: they are interleaved in storage.zig
and db.zig and only build as a unit.
Already in the working tree before this session:
- ReleaseFast as the default zig build (Debug was 10-200x slower)
- group commit: one fsync per write command instead of per document
- plan_id returned a pointer to a stack temporary; ReleaseFast read
garbage and silently broke findOne({_id: ObjectId})
- perf suite: big.js, compare.js, compare-run.sh, e2e6.js
Phase 1 performance work:
Record integrity hash CRC32 -> XxHash3. std.hash.Crc32 is the
table-driven byte-at-a-time Crc32IsoHdlc, measured at 408 MB/s against
XxHash3's 31 GB/s: 38us versus 0.5us on a 16 KiB document, which was
about two thirds of the entire bulk-insert cost. The record header
grows from u32 crc to u64 hash (header_len 20 -> 24), a breaking
format change. Bulk insert 260 -> 700 MB/s, reopen 1.1 -> 0.5s.
Compaction fsynced once per live document, because Log.open leaves
defer_sync false and compact never set it. It now issues one sync for
the whole rewrite, before the rename that publishes it.
Compaction triggers on the share of the log that is garbage
(live_docs/dead_docs, maintained at evict_doc, the single point where
a document dies) rather than on bytes appended. A fixed byte count is
wrong in both directions: a 1 GB bulk load holds no garbage at all yet
would compact ~64 times under the 16 MiB default, rewriting 1 GB each
time, while a small collection rewritten in place accumulates garbage
indefinitely without ever reaching the count. Pure inserts now never
compact, and the file stays near 1.25x the live data. Bulk load at the
default threshold: 41.6 -> 702.7 MB/s.
remove() never called maybe_compact, so a delete-heavy workload grew
the log without bound.
e2e6's compaction check required the file to bloat past 30 MiB before
being reclaimed, which encoded the old policy and failed on strictly
better behaviour (ends at 15.8 MB against ~12 MB live, was ~30 MB). It
now asserts the file ends near the live size and peaked well above it,
which does not depend on when the trigger fires. Sampling interval 50
-> 10ms: the operations now finish inside the old window.
Verified: 70 unit tests under both ReleaseFast and ReleaseSafe, e2e
29/16/17/3/2 checks, the crash-a/kill -9/crash-b pair, and e2e6 72/72
across three consecutive runs.
348 lines
15 KiB
JavaScript
348 lines
15 KiB
JavaScript
// Big-collection harness: how mongo-lite behaves with multi-GB collections.
|
|
//
|
|
// Spawns its own server, bulk-inserts up to ~5 GB of documents, measures
|
|
// insert throughput, log/compaction behavior and server RSS, benchmarks
|
|
// find/count/update/delete against the full dataset, then kills the server
|
|
// and measures reopen (replay) time and crash durability.
|
|
//
|
|
// node tests/e2e/big.js [options]
|
|
// --size <n> target collection size; k/m/g suffixes (default 5g)
|
|
// --doc-size <n> approximate bytes per document (default 32k)
|
|
// --batch <n> docs per insertMany call (default 500)
|
|
// --index <f> create a secondary index on field f *before* inserting
|
|
// (entry insert is O(n), so this makes the load quadratic)
|
|
// --oid use ObjectId _ids (driver-generated): O(1) _id lookups.
|
|
// Without it, int _ids fall back to a full scan (the _id
|
|
// fast path is skipped for serialization-ambiguous
|
|
// numeric classes), so findOne({_id}) costs a scan.
|
|
// --compact-threshold <n>
|
|
// pass through to the server: log bytes between
|
|
// compactions (default 16m). Raise for bulk loads.
|
|
// --port <n> server port (default 27221)
|
|
// --keep keep the db file after the run
|
|
// --quick tiny run (256m, 16k docs)
|
|
//
|
|
// Env: ML_BIN server binary (default ../../zig-out/bin/mongo-lite)
|
|
// BIG_DB db file path (default .zig-cache/big.log)
|
|
const { MongoClient } = require('mongodb');
|
|
const { spawn } = require('child_process');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const BIN = process.env.ML_BIN || path.resolve(__dirname, '../../zig-out/bin/mongo-lite');
|
|
const PORT = Number(process.env.BIG_PORT || 27221);
|
|
const DBFILE = process.env.BIG_DB || path.resolve(__dirname, '../../.zig-cache/big.log');
|
|
const URL = `mongodb://127.0.0.1:${PORT}`;
|
|
|
|
function parseSize(s) {
|
|
const m = /^(\d+(?:\.\d+)?)([kmgt]?)$/i.exec(String(s).trim());
|
|
if (!m) throw new Error(`bad size '${s}'`);
|
|
const mult = { '': 1, k: 1 << 10, m: 1 << 20, g: 1 << 30, t: 1 << 40 }[m[2].toLowerCase()];
|
|
return Math.round(parseFloat(m[1]) * mult);
|
|
}
|
|
|
|
let opt = { size: '5g', docSize: '32k', batch: 500, index: null, port: PORT, keep: false, oid: false, compactThreshold: null };
|
|
for (let i = 2; i < process.argv.length; i++) {
|
|
const a = process.argv[i];
|
|
if (a === '--quick') { opt.size = '256m'; opt.docSize = '16k'; }
|
|
else if (a === '--keep') opt.keep = true;
|
|
else if (a === '--oid') opt.oid = true;
|
|
else if (a === '--index') opt.index = process.argv[++i];
|
|
else if (a.startsWith('--size=')) opt.size = a.slice(7);
|
|
else if (a.startsWith('--doc-size=')) opt.docSize = a.slice(11);
|
|
else if (a.startsWith('--batch=')) opt.batch = Number(a.slice(8));
|
|
else if (a.startsWith('--port=')) opt.port = Number(a.slice(7));
|
|
else if (a.startsWith('--compact-threshold=')) opt.compactThreshold = a.slice(20);
|
|
else if (a === '--size') opt.size = process.argv[++i];
|
|
else if (a === '--doc-size') opt.docSize = process.argv[++i];
|
|
else if (a === '--batch') opt.batch = Number(process.argv[++i]);
|
|
else if (a === '--compact-threshold') opt.compactThreshold = process.argv[++i];
|
|
else { console.error(`unknown option ${a}`); process.exit(2); }
|
|
}
|
|
|
|
const SIZE = parseSize(opt.size);
|
|
const DOC_SIZE = parseSize(opt.docSize);
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
let server = null;
|
|
let serverLog = '';
|
|
let serverDead = false;
|
|
function cleanup() {
|
|
if (server && !serverDead) {
|
|
try { server.kill('SIGKILL'); } catch {}
|
|
}
|
|
}
|
|
process.on('exit', cleanup);
|
|
process.on('SIGINT', () => { cleanup(); process.exit(130); });
|
|
process.on('SIGTERM', () => { cleanup(); process.exit(143); });
|
|
function startServer() {
|
|
return new Promise((resolve, reject) => {
|
|
const t0 = Date.now();
|
|
serverDead = false;
|
|
const args = ['--port', String(opt.port), '--db', DBFILE];
|
|
if (opt.compactThreshold) args.push('--compact-threshold', opt.compactThreshold);
|
|
server = spawn(BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
server.stdout.on('data', (d) => (serverLog += d));
|
|
server.stderr.on('data', (d) => (serverLog += d));
|
|
server.on('error', (e) => reject(new Error(`cannot start ${BIN}: ${e.message}`)));
|
|
server.on('exit', (code, sig) => {
|
|
serverDead = true;
|
|
if (code !== null && sig === null) serverLog += `\n[child exited rc=${code}]`;
|
|
});
|
|
const deadline = Date.now() + 120000;
|
|
(async () => {
|
|
while (Date.now() < deadline) {
|
|
if (serverDead) {
|
|
reject(new Error(`server child exited during start (port ${opt.port} busy?)\n${serverLog}`));
|
|
return;
|
|
}
|
|
const c = new MongoClient(URL, { serverSelectionTimeoutMS: 1000 });
|
|
try {
|
|
await c.connect();
|
|
await c.db('admin').command({ ping: 1 });
|
|
await c.close();
|
|
return resolve(Date.now() - t0);
|
|
} catch {
|
|
try { await c.close(); } catch {}
|
|
await sleep(100);
|
|
}
|
|
}
|
|
reject(new Error(`server did not come up on :${opt.port}\n${serverLog}`));
|
|
})();
|
|
});
|
|
}
|
|
async function stopServer(sig = 'SIGKILL') {
|
|
if (!server) return;
|
|
const exited = new Promise((r) => server.once('exit', r));
|
|
server.kill(sig);
|
|
await Promise.race([exited, sleep(5000)]);
|
|
serverDead = true;
|
|
server = null;
|
|
}
|
|
async function rssMB() {
|
|
if (!server) return 0;
|
|
try {
|
|
const out = (await new Promise((r) => require('child_process').exec(`ps -o rss= -p ${server.pid}`, (e, so) => r(so || '')))).trim();
|
|
return Math.round(Number(out) / 1024);
|
|
} catch { return 0; }
|
|
}
|
|
|
|
const report = [];
|
|
function row(label, value) { report.push([label, value]); console.log(` ${String(label).padEnd(46)} ${value}`); }
|
|
|
|
async function main() {
|
|
if (!fs.existsSync(BIN)) {
|
|
console.error(`server binary not found at ${BIN} — run \`zig build\` first`);
|
|
process.exit(1);
|
|
}
|
|
fs.rmSync(DBFILE, { force: true });
|
|
const fmt = (n) => (n >= 1e9 ? (n / 1e9).toFixed(2) + ' GB' : n >= 1e6 ? (n / 1e6).toFixed(1) + ' MB' : n >= 1e3 ? (n / 1e3).toFixed(1) + ' KB' : n + ' B');
|
|
console.log(`mongo-lite big-collection harness`);
|
|
console.log(` size ${fmt(SIZE)} · doc ~${fmt(DOC_SIZE)} · batch ${opt.batch} · index ${opt.index || 'none'} · ids ${opt.oid ? 'ObjectId' : 'int'} · compact-threshold ${opt.compactThreshold || '16m'} · db ${DBFILE}`);
|
|
|
|
console.log('\n== server start (fresh log) ==');
|
|
const openMs = await startServer();
|
|
row('open + first ping (fresh log)', `${openMs} ms`);
|
|
|
|
const client = new MongoClient(URL, { serverSelectionTimeoutMS: 10000 });
|
|
await client.connect();
|
|
const db = client.db('big');
|
|
const coll = db.collection('items');
|
|
await coll.drop().catch(() => {});
|
|
|
|
if (opt.index) {
|
|
const t0 = Date.now();
|
|
await coll.createIndex({ [opt.index]: 1 });
|
|
row(`createIndex({${opt.index}: 1}) on empty coll`, `${Date.now() - t0} ms`);
|
|
}
|
|
|
|
// ---- insert ------------------------------------------------------------
|
|
console.log('\n== insert ==');
|
|
const nDocs = Math.max(1, Math.ceil(SIZE / DOC_SIZE));
|
|
const payloadLen = Math.max(1, DOC_SIZE - 130); // bson overhead for _id/k/p/ts/payload
|
|
const payload = 'x'.repeat(payloadLen);
|
|
const { ObjectId } = require('mongodb');
|
|
const t0 = Date.now();
|
|
const logSamples = [{ t: 0, size: fs.existsSync(DBFILE) ? fs.statSync(DBFILE).size : 0, rss: 0 }];
|
|
const sampler = setInterval(async () => {
|
|
logSamples.push({ t: Date.now() - t0, size: fs.existsSync(DBFILE) ? fs.statSync(DBFILE).size : 0, rss: await rssMB() });
|
|
}, 2000);
|
|
|
|
// Rate curve: avg MB/s per progress chunk, to show how throughput changes
|
|
// as the log grows (compaction rewrites + fsync-per-record dominate).
|
|
const rates = [];
|
|
let lastProgressT = t0;
|
|
let lastProgressDocs = 0;
|
|
let midDocId = null; // ObjectId of the mid doc (oid mode), captured at insert
|
|
let lastDocId = null;
|
|
let inserted = 0;
|
|
const midIndex = Math.floor(nDocs / 2);
|
|
try {
|
|
while (inserted < nDocs) {
|
|
const n = Math.min(opt.batch, nDocs - inserted);
|
|
const docs = new Array(n);
|
|
for (let i = 0; i < n; i++) {
|
|
const id = inserted + i + 1;
|
|
const doc = opt.oid
|
|
? { _id: new ObjectId(), k: id % 1000, p: id % 5000, ts: new Date(Date.UTC(2024, 0, 1) + id * 1000), payload }
|
|
: { _id: id, k: id % 1000, p: id % 5000, ts: new Date(Date.UTC(2024, 0, 1) + id * 1000), payload };
|
|
if (id === midIndex) midDocId = doc._id;
|
|
if (id === nDocs) lastDocId = doc._id;
|
|
docs[i] = doc;
|
|
}
|
|
await coll.insertMany(docs, { ordered: false });
|
|
inserted += n;
|
|
if (inserted % Math.max(1000, Math.floor(nDocs / 20)) < n) {
|
|
const dt = (Date.now() - t0) / 1000;
|
|
const mb = inserted * DOC_SIZE / 1e6;
|
|
const chunkMb = (inserted - lastProgressDocs) * DOC_SIZE / 1e6;
|
|
const chunkT = (Date.now() - lastProgressT) / 1000;
|
|
rates.push(+(chunkMb / chunkT).toFixed(1));
|
|
lastProgressT = Date.now();
|
|
lastProgressDocs = inserted;
|
|
console.log(` ${inserted.toLocaleString()} docs · ${fmt(inserted * DOC_SIZE)} · ${(mb / dt).toFixed(1)} MB/s avg`);
|
|
}
|
|
}
|
|
} finally {
|
|
clearInterval(sampler);
|
|
}
|
|
const insertMs = Date.now() - t0;
|
|
const bytes = inserted * DOC_SIZE;
|
|
row('docs inserted', inserted.toLocaleString());
|
|
row('approx bytes', fmt(bytes));
|
|
row('wall time', `${(insertMs / 1000).toFixed(1)} s`);
|
|
row('throughput', `${(bytes / 1e6 / (insertMs / 1000)).toFixed(1)} MB/s (${(inserted / (insertMs / 1000)).toFixed(0)} docs/s)`);
|
|
row('rate curve (MB/s per chunk)', rates.join(' → ') || 'n/a');
|
|
|
|
const compactions = logSamples.filter((s, i) => i > 0 && s.size < logSamples[i - 1].size - 2 * 1024 * 1024).length;
|
|
const sizes = logSamples.map((s) => s.size);
|
|
row('log file size (min → final)', `${fmt(Math.min(...sizes))} → ${fmt(sizes[sizes.length - 1])}`);
|
|
row('compaction events observed', compactions, '(log shrank by >2MB between samples)');
|
|
const peakRss = Math.max(...logSamples.map((s) => s.rss));
|
|
row('peak server RSS', `${peakRss} MB`, '(in-memory engine: docs live in RAM)');
|
|
|
|
// ---- find / read -------------------------------------------------------
|
|
console.log('\n== find / read on full dataset ==');
|
|
const bench = async (label, fn, min = 1) => {
|
|
const a = Date.now();
|
|
const res = await fn();
|
|
const ms = Date.now() - a;
|
|
row(`${label}`, `${ms < 1000 ? ms + ' ms' : (ms / 1000).toFixed(2) + ' s'}${res !== undefined ? ` (${res})` : ''}`);
|
|
return ms;
|
|
};
|
|
await bench('countDocuments({})', async () => {
|
|
const n = await coll.countDocuments({});
|
|
if (n !== inserted) throw new Error(`count ${n} != ${inserted}`);
|
|
return `${n.toLocaleString()} docs`;
|
|
});
|
|
await bench('findOne({_id: mid}) — ' + (opt.oid ? 'docs-map fast path' : 'scan (int _id: fast path skipped)'), async () => {
|
|
const d = await coll.findOne({ _id: opt.oid ? midDocId : midIndex });
|
|
if (!d) throw new Error('miss');
|
|
});
|
|
await bench('findOne({_id: last})', async () => {
|
|
const d = await coll.findOne({ _id: opt.oid ? lastDocId : nDocs });
|
|
if (!d) throw new Error('miss');
|
|
});
|
|
if (opt.index) {
|
|
await bench(`find({${opt.index}: 4242}).count() — via index`, async () => {
|
|
const n = await coll.countDocuments({ k: 4242 });
|
|
if (n < 1) throw new Error('no hits');
|
|
return `${n} hits`;
|
|
});
|
|
}
|
|
await bench('find({k: 4242}).count() — scan', async () => {
|
|
const n = await coll.countDocuments({ k: 4242 });
|
|
return `${n} hits`;
|
|
});
|
|
await bench('find({p: {$gte, $lt}}).count() — range scan', async () => {
|
|
const lo = 1000, hi = 2000;
|
|
const n = await coll.countDocuments({ p: { $gte: lo, $lt: hi } });
|
|
return `${n} hits`;
|
|
});
|
|
await bench('find({}).sort({_id:-1}).limit(20) — full scan + sort', async () => {
|
|
const docs = await coll.find({}).sort({ _id: -1 }).limit(20).toArray();
|
|
if (docs.length !== 20) throw new Error('bad page');
|
|
});
|
|
await bench('find({}, {proj: _id,k,p}).limit(500) — page', async () => {
|
|
const docs = await coll.find({}, { projection: { payload: 0 } }).limit(500).toArray();
|
|
if (docs.length !== 500) throw new Error('short page');
|
|
});
|
|
|
|
// ---- write ops against the full dataset ---------------------------------
|
|
console.log('\n== point write ops ==');
|
|
const midId = opt.oid ? midDocId : midIndex;
|
|
await bench('updateOne({_id: mid}, {$set}) — 1 fsync', async () => {
|
|
const r = await coll.updateOne({ _id: midId }, { $set: { touch: Date.now() } });
|
|
if (r.modifiedCount !== 1) throw new Error('miss');
|
|
});
|
|
await bench('updateMany({k: 7}, {$inc}) — ~N/1000 fsyncs', async () => {
|
|
const r = await coll.updateMany({ k: 7 }, { $inc: { hits: 1 } });
|
|
return `${r.modifiedCount} modified`;
|
|
});
|
|
await bench('deleteOne({_id: mid}) + re-insert', async () => {
|
|
await coll.deleteOne({ _id: midId });
|
|
if (opt.oid) {
|
|
await coll.insertOne({ _id: new ObjectId(), k: midIndex % 1000, p: midIndex % 5000, payload });
|
|
} else {
|
|
await coll.insertOne({ _id: midIndex, k: midIndex % 1000, p: midIndex % 5000, payload });
|
|
}
|
|
});
|
|
await bench('aggregate $group by k', async () => {
|
|
const out = await coll.aggregate([{ $group: { _id: '$k', n: { $sum: 1 } } }]).toArray();
|
|
return `${out.length} groups`;
|
|
});
|
|
|
|
// ---- reopen (replay) ----------------------------------------------------
|
|
console.log('\n== durability ==');
|
|
await client.close();
|
|
await stopServer('SIGKILL');
|
|
const reopenMs = await startServer();
|
|
row('kill -9 then reopen (replay of full log)', `${(reopenMs / 1000).toFixed(1)} s`);
|
|
const c2 = new MongoClient(URL, { serverSelectionTimeoutMS: 10000 });
|
|
await c2.connect();
|
|
const db2 = c2.db('big');
|
|
const coll2 = db2.collection('items');
|
|
const afterRestart = await coll2.countDocuments({});
|
|
row('count after restart', `${afterRestart.toLocaleString()} (${afterRestart === inserted ? 'OK' : 'MISMATCH!'})`);
|
|
const spot = await coll2.findOne({ _id: opt.oid ? lastDocId : nDocs });
|
|
row('last doc intact after restart', spot ? `payload ${spot.payload.length}B` : 'MISSING!');
|
|
if (afterRestart !== inserted || !spot) throw new Error('durability check failed');
|
|
|
|
// Crash-durability: every write is fsynced before it is acknowledged, so a
|
|
// kill -9 right after an insert must not lose it.
|
|
const crash = db2.collection('crash');
|
|
await crash.drop().catch(() => {});
|
|
let committed = 0;
|
|
for (let i = 1; i <= 200; i++) {
|
|
await crash.insertOne({ _id: i, seq: i });
|
|
committed = i;
|
|
}
|
|
await c2.close();
|
|
await stopServer('SIGKILL');
|
|
await startServer();
|
|
const c3 = new MongoClient(URL, { serverSelectionTimeoutMS: 10000 });
|
|
await c3.connect();
|
|
const db3 = c3.db('big');
|
|
const crashN = await db3.collection('crash').countDocuments({});
|
|
row('kill -9 after 200 committed writes', `${crashN}/200 survived (${crashN === 200 ? 'OK' : 'MISMATCH!'})`);
|
|
await c3.close();
|
|
|
|
if (!opt.keep) fs.rmSync(DBFILE, { force: true });
|
|
await stopServer('SIGKILL');
|
|
|
|
console.log('\n== summary ==');
|
|
console.log(` mongo-lite handles a ${fmt(bytes)} collection fully in RAM (RSS ${peakRss} MB)`);
|
|
console.log(` insert: ${(bytes / 1e6 / (insertMs / 1000)).toFixed(1)} MB/s — fsync per write is by design (crash safety)`);
|
|
if (compactions > 0) {
|
|
console.log(` ${compactions} compaction rewrites observed: every 16MB of writes rewrites the whole log — for multi-GB loads the cumulative rewrite traffic dominates`);
|
|
}
|
|
console.log('BIG_OK');
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error('BIG_FAIL', e);
|
|
console.log('--- server log tail ---');
|
|
console.log(serverLog.split('\n').slice(-40).join('\n'));
|
|
process.exit(1);
|
|
});
|