mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:38:14 +08:00
266 lines
8.4 KiB
JavaScript
266 lines
8.4 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
// This benchmark intentionally uses direct node:sqlite statements. It validates
|
|
// the runtime/host boundary only; production repositories remain governed by
|
|
// the typed-schema and adapter decisions in ADR-0004.
|
|
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { performance } = require('node:perf_hooks');
|
|
|
|
const MIB = 1024 * 1024;
|
|
const DEFAULT_ITERATIONS = 250;
|
|
const DEFAULT_BATCH_SIZE = 10;
|
|
|
|
function parsePositiveInteger(value, name) {
|
|
const parsed = Number(value);
|
|
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
|
throw new Error(`${name} must be a positive safe integer`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function parsePositiveNumber(value, name) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
throw new Error(`${name} must be a positive number`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function parseArguments(argv) {
|
|
const options = {
|
|
batchSize: DEFAULT_BATCH_SIZE,
|
|
iterations: DEFAULT_ITERATIONS,
|
|
json: false,
|
|
};
|
|
for (const argument of argv) {
|
|
if (argument === '--') {
|
|
continue;
|
|
}
|
|
if (argument === '--json') {
|
|
options.json = true;
|
|
continue;
|
|
}
|
|
const [name, value] = argument.split('=', 2);
|
|
if (value === undefined) {
|
|
throw new Error(`Unsupported argument: ${argument}`);
|
|
}
|
|
if (name === '--iterations') {
|
|
options.iterations = parsePositiveInteger(value, name);
|
|
} else if (name === '--batch-size') {
|
|
options.batchSize = parsePositiveInteger(value, name);
|
|
} else if (name === '--max-transaction-p95-ms') {
|
|
options.maxTransactionP95Ms = parsePositiveNumber(value, name);
|
|
} else if (name === '--max-batch-stall-ms') {
|
|
options.maxBatchStallMs = parsePositiveNumber(value, name);
|
|
} else if (name === '--max-rss-delta-mb') {
|
|
options.maxRssDeltaMb = parsePositiveNumber(value, name);
|
|
} else {
|
|
throw new Error(`Unsupported argument: ${argument}`);
|
|
}
|
|
}
|
|
if (options.batchSize > options.iterations) {
|
|
throw new Error('--batch-size cannot exceed --iterations');
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function percentile(sortedValues, percentileValue) {
|
|
const index = Math.min(
|
|
sortedValues.length - 1,
|
|
Math.ceil((percentileValue / 100) * sortedValues.length) - 1,
|
|
);
|
|
return sortedValues[Math.max(0, index)];
|
|
}
|
|
|
|
function round(value) {
|
|
return Math.round(value * 1_000) / 1_000;
|
|
}
|
|
|
|
function enforceThreshold(actual, maximum, label) {
|
|
if (maximum !== undefined && actual > maximum) {
|
|
throw new Error(`${label} ${round(actual)} exceeded limit ${maximum}`);
|
|
}
|
|
}
|
|
|
|
function runBenchmark(options) {
|
|
const nodeMajor = Number(process.versions.node.split('.')[0]);
|
|
if (nodeMajor < 24) {
|
|
throw new Error('ql3-node-sqlite-benchmark requires Node.js 24 or newer');
|
|
}
|
|
const { DatabaseSync } = require('node:sqlite');
|
|
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-db-bench-'));
|
|
const databasePath = path.join(temporaryRoot, 'benchmark.sqlite');
|
|
const rssBefore = process.memoryUsage().rss;
|
|
const transactionDurations = [];
|
|
const batchDurations = [];
|
|
let database;
|
|
|
|
try {
|
|
database = new DatabaseSync(databasePath, {
|
|
allowExtension: false,
|
|
allowUnknownNamedParameters: false,
|
|
defensive: true,
|
|
enableDoubleQuotedStringLiterals: false,
|
|
enableForeignKeyConstraints: true,
|
|
timeout: 1_000,
|
|
});
|
|
database.exec(`
|
|
PRAGMA journal_mode = DELETE;
|
|
PRAGMA synchronous = FULL;
|
|
CREATE TABLE benchmark_runs (
|
|
id INTEGER PRIMARY KEY,
|
|
status TEXT NOT NULL,
|
|
version INTEGER NOT NULL,
|
|
event_sequence INTEGER NOT NULL,
|
|
created_at_ms INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE benchmark_events (
|
|
id INTEGER PRIMARY KEY,
|
|
run_id INTEGER NOT NULL,
|
|
sequence INTEGER NOT NULL,
|
|
type TEXT NOT NULL,
|
|
payload TEXT NOT NULL,
|
|
created_at_ms INTEGER NOT NULL,
|
|
UNIQUE(run_id, sequence),
|
|
FOREIGN KEY(run_id) REFERENCES benchmark_runs(id) ON DELETE CASCADE
|
|
);
|
|
`);
|
|
const insertRun = database.prepare(
|
|
'INSERT INTO benchmark_runs(id, status, version, event_sequence, created_at_ms) VALUES (?, ?, ?, ?, ?)',
|
|
);
|
|
const insertEvent = database.prepare(
|
|
'INSERT INTO benchmark_events(id, run_id, sequence, type, payload, created_at_ms) VALUES (?, ?, ?, ?, ?, ?)',
|
|
);
|
|
const updateRun = database.prepare(
|
|
'UPDATE benchmark_runs SET status = ?, version = ?, event_sequence = ? WHERE id = ? AND version = ?',
|
|
);
|
|
let batchStartedAt = performance.now();
|
|
|
|
for (let index = 1; index <= options.iterations; index += 1) {
|
|
const startedAt = performance.now();
|
|
const now = 1_750_000_000_000 + index;
|
|
database.exec('BEGIN IMMEDIATE');
|
|
try {
|
|
insertRun.run(index, 'created', 0, 0, now);
|
|
insertEvent.run(
|
|
index * 2 - 1,
|
|
index,
|
|
1,
|
|
'run.created',
|
|
'{"source":"node-sqlite-benchmark"}',
|
|
now,
|
|
);
|
|
const update = updateRun.run('queued', 1, 2, index, 0);
|
|
if (update.changes !== 1) {
|
|
throw new Error('benchmark compare-and-set did not update one Run');
|
|
}
|
|
insertEvent.run(
|
|
index * 2,
|
|
index,
|
|
2,
|
|
'run.queued',
|
|
'{}',
|
|
now,
|
|
);
|
|
database.exec('COMMIT');
|
|
} catch (error) {
|
|
database.exec('ROLLBACK');
|
|
throw error;
|
|
}
|
|
transactionDurations.push(performance.now() - startedAt);
|
|
|
|
if (index % options.batchSize === 0 || index === options.iterations) {
|
|
batchDurations.push(performance.now() - batchStartedAt);
|
|
batchStartedAt = performance.now();
|
|
}
|
|
}
|
|
|
|
const integrity = database.prepare('PRAGMA integrity_check').get();
|
|
if (integrity.integrity_check !== 'ok') {
|
|
throw new Error(`integrity_check failed: ${integrity.integrity_check}`);
|
|
}
|
|
const runCount = database
|
|
.prepare('SELECT COUNT(*) AS count FROM benchmark_runs')
|
|
.get().count;
|
|
const eventCount = database
|
|
.prepare('SELECT COUNT(*) AS count FROM benchmark_events')
|
|
.get().count;
|
|
if (runCount !== options.iterations || eventCount !== options.iterations * 2) {
|
|
throw new Error('benchmark row counts do not match committed transactions');
|
|
}
|
|
database.close();
|
|
database = undefined;
|
|
|
|
const sortedTransactions = [...transactionDurations].sort((a, b) => a - b);
|
|
const rssDeltaMb = (process.memoryUsage().rss - rssBefore) / MIB;
|
|
const result = {
|
|
node: process.version,
|
|
arch: process.arch,
|
|
platform: process.platform,
|
|
iterations: options.iterations,
|
|
batchSize: options.batchSize,
|
|
journalMode: 'delete',
|
|
synchronous: 'full',
|
|
transactionMs: {
|
|
p50: round(percentile(sortedTransactions, 50)),
|
|
p95: round(percentile(sortedTransactions, 95)),
|
|
p99: round(percentile(sortedTransactions, 99)),
|
|
max: round(sortedTransactions[sortedTransactions.length - 1]),
|
|
},
|
|
maxBatchStallMs: round(Math.max(...batchDurations)),
|
|
rssDeltaMb: round(rssDeltaMb),
|
|
databaseBytes: fs.statSync(databasePath).size,
|
|
integrityCheck: 'ok',
|
|
};
|
|
|
|
enforceThreshold(
|
|
result.transactionMs.p95,
|
|
options.maxTransactionP95Ms,
|
|
'transaction p95 ms',
|
|
);
|
|
enforceThreshold(
|
|
result.maxBatchStallMs,
|
|
options.maxBatchStallMs,
|
|
'batch stall ms',
|
|
);
|
|
enforceThreshold(result.rssDeltaMb, options.maxRssDeltaMb, 'RSS delta MiB');
|
|
return result;
|
|
} finally {
|
|
if (database) {
|
|
database.close();
|
|
}
|
|
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function main() {
|
|
const options = parseArguments(process.argv.slice(2));
|
|
const result = runBenchmark(options);
|
|
if (options.json) {
|
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
} else {
|
|
process.stdout.write(
|
|
[
|
|
`Node SQLite benchmark (${result.node}, ${result.arch})`,
|
|
`transactions: ${result.iterations}`,
|
|
`p50/p95/p99: ${result.transactionMs.p50}/${result.transactionMs.p95}/${result.transactionMs.p99} ms`,
|
|
`max batch stall: ${result.maxBatchStallMs} ms`,
|
|
`RSS delta: ${result.rssDeltaMb} MiB`,
|
|
`database: ${result.databaseBytes} bytes`,
|
|
].join('\n') + '\n',
|
|
);
|
|
}
|
|
}
|
|
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
process.stderr.write(
|
|
`${error instanceof Error ? error.message : String(error)}\n`,
|
|
);
|
|
process.exitCode = 1;
|
|
}
|