mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
#!/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;
|
||||
}
|
||||
Reference in New Issue
Block a user