Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -24,33 +24,51 @@ const allowed = new Set(['--scenario', '--runs', '--baseline-ref', '--json']);
|
||||
for (let index = 0; index < arguments_.length; index += 2) {
|
||||
const key = arguments_[index];
|
||||
const value = arguments_[index + 1];
|
||||
if (!allowed.has(key) || !value) throw new Error('Usage: --scenario small|large|all --runs N --baseline-ref COMMIT --json PATH');
|
||||
if (!allowed.has(key) || !value)
|
||||
throw new Error(
|
||||
'Usage: --scenario small|large|all --runs N --baseline-ref COMMIT --json PATH'
|
||||
);
|
||||
options[key.slice(2)] = value;
|
||||
}
|
||||
options.runs = Number(options.runs);
|
||||
if (!Number.isInteger(options.runs) || options.runs < 1 || options.runs > 20) throw new Error('--runs must be an integer from 1 to 20');
|
||||
if (!['small', 'large', 'all'].includes(options.scenario)) throw new Error('--scenario must be small, large, or all');
|
||||
if (!Number.isInteger(options.runs) || options.runs < 1 || options.runs > 20)
|
||||
throw new Error('--runs must be an integer from 1 to 20');
|
||||
if (!['small', 'large', 'all'].includes(options.scenario))
|
||||
throw new Error('--scenario must be small, large, or all');
|
||||
const baseline = options['baseline-ref']
|
||||
? execFileSync('git', ['rev-parse', '--verify', '--end-of-options', `${options['baseline-ref']}^{commit}`], { cwd: repo, encoding: 'utf8' }).trim()
|
||||
? execFileSync(
|
||||
'git',
|
||||
['rev-parse', '--verify', '--end-of-options', `${options['baseline-ref']}^{commit}`],
|
||||
{ cwd: repo, encoding: 'utf8' }
|
||||
).trim()
|
||||
: null;
|
||||
const definitions = [
|
||||
{ name: 'small', matches: 2000, players: 500 },
|
||||
{ name: 'large', matches: 50000, players: 10000 }
|
||||
].filter(item => options.scenario === 'all' || item.name === options.scenario);
|
||||
].filter((item) => options.scenario === 'all' || item.name === options.scenario);
|
||||
const temporary = mkdtempSync(join(tmpdir(), 'cherry-leaderboard-benchmark-'));
|
||||
const source = (file, ref) => ref
|
||||
? execFileSync('git', ['show', `${ref}:${file}`], { cwd: repo, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 })
|
||||
: readFileSync(join(repo, file), 'utf8');
|
||||
const percentile = (values, fraction) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * fraction) - 1];
|
||||
const source = (file, ref) =>
|
||||
ref
|
||||
? execFileSync('git', ['show', `${ref}:${file}`], {
|
||||
cwd: repo,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024
|
||||
})
|
||||
: readFileSync(join(repo, file), 'utf8');
|
||||
const percentile = (values, fraction) =>
|
||||
[...values].sort((a, b) => a - b)[Math.ceil(values.length * fraction) - 1];
|
||||
|
||||
function seed(handle, definition) {
|
||||
handle.exec(source('src/lib/server/db/schema.sql'));
|
||||
handle.exec("CREATE TABLE report_revisions(topic TEXT PRIMARY KEY, revision INTEGER NOT NULL); INSERT INTO report_revisions VALUES ('leaderboard',0)");
|
||||
handle.exec(
|
||||
"CREATE TABLE report_revisions(topic TEXT PRIMARY KEY, revision INTEGER NOT NULL); INSERT INTO report_revisions VALUES ('leaderboard',0)"
|
||||
);
|
||||
const match = handle.prepare(`INSERT INTO leaderboard_matches
|
||||
(id, server, game, map, map_key, map_name, started_at, ended_at, last_received_at,
|
||||
status, start_observed, start_players, highest_round, last_event, last_fingerprint)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 4, ?, ?, ?)`);
|
||||
const identity = handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||
const identity =
|
||||
handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?) ON CONFLICT(game, guid) DO UPDATE SET
|
||||
name = CASE WHEN excluded.last_seen_at >= last_seen_at THEN excluded.name ELSE name END,
|
||||
last_seen_at = MAX(last_seen_at, excluded.last_seen_at)`);
|
||||
@@ -60,19 +78,38 @@ function seed(handle, definition) {
|
||||
first_observed_round, present_at_start)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||
let state = 20260918;
|
||||
const random = () => { state = (Math.imul(state, 1664525) + 1013904223) >>> 0; return state / 4294967296; };
|
||||
const random = () => {
|
||||
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
|
||||
return state / 4294967296;
|
||||
};
|
||||
handle.transaction(() => {
|
||||
for (let id = 1; id <= definition.matches; id++) {
|
||||
const mapIndex = Math.floor(random() * 12);
|
||||
const game = mapIndex < 6 ? 't5' : 't6';
|
||||
const map = `map_${mapIndex}`;
|
||||
const status = id % 20 === 0 ? 'active' : id % 20 === 1 ? 'interrupted' : 'completed';
|
||||
const started = new Date(Date.UTC(2023, 0, 1) + Math.floor(id / definition.matches * 3 * 365 * 86400000)).toISOString();
|
||||
const ended = new Date(Date.parse(started) + (20 + Math.floor(random() * 130)) * 60000).toISOString();
|
||||
const started = new Date(
|
||||
Date.UTC(2023, 0, 1) + Math.floor((id / definition.matches) * 3 * 365 * 86400000)
|
||||
).toISOString();
|
||||
const ended = new Date(
|
||||
Date.parse(started) + (20 + Math.floor(random() * 130)) * 60000
|
||||
).toISOString();
|
||||
const round = id % 101 === 0 ? 1 : 2 + Math.floor(random() * 60);
|
||||
match.run(id, `server_${id % 24}`, game, map, `${game}:${map}`, `Map ${mapIndex}`,
|
||||
started, status === 'completed' ? ended : null, ended, status, round,
|
||||
status === 'completed' ? 'game_end' : 'heartbeat', `event_${id}`);
|
||||
match.run(
|
||||
id,
|
||||
`server_${id % 24}`,
|
||||
game,
|
||||
map,
|
||||
`${game}:${map}`,
|
||||
`Map ${mapIndex}`,
|
||||
started,
|
||||
status === 'completed' ? ended : null,
|
||||
ended,
|
||||
status,
|
||||
round,
|
||||
status === 'completed' ? 'game_end' : 'heartbeat',
|
||||
`event_${id}`
|
||||
);
|
||||
const roster = new Set();
|
||||
while (roster.size < 4) roster.add(Math.floor(random() ** 2 * definition.players));
|
||||
// Reserve one completed participation for an explicitly ineligible player.
|
||||
@@ -84,16 +121,47 @@ function seed(handle, definition) {
|
||||
const kills = Math.floor(round * 18 + random() * 800);
|
||||
const presentAtStart = random() > 0.1;
|
||||
const firstRound = presentAtStart ? 0 : Math.max(1, Math.floor(random() * round));
|
||||
participation.run(id, guid, round, Math.max(0, round - Math.max(1, firstRound)), kills,
|
||||
Math.floor(kills * random()), Math.floor(random() * 8), Math.floor(random() * 12),
|
||||
Math.floor(random() * 100000), Math.max(0, round - 1), Number(status === 'active'),
|
||||
Number(random() > 0.01), Number(status === 'completed' && random() > 0.15),
|
||||
started, ended, firstRound, Number(presentAtStart));
|
||||
participation.run(
|
||||
id,
|
||||
guid,
|
||||
round,
|
||||
Math.max(0, round - Math.max(1, firstRound)),
|
||||
kills,
|
||||
Math.floor(kills * random()),
|
||||
Math.floor(random() * 8),
|
||||
Math.floor(random() * 12),
|
||||
Math.floor(random() * 100000),
|
||||
Math.max(0, round - 1),
|
||||
Number(status === 'active'),
|
||||
Number(random() > 0.01),
|
||||
Number(status === 'completed' && random() > 0.15),
|
||||
started,
|
||||
ended,
|
||||
firstRound,
|
||||
Number(presentAtStart)
|
||||
);
|
||||
}
|
||||
if (id === definition.matches - 2) {
|
||||
identity.run(game, 'benchmark-low-sample', 'Low sample', started, ended);
|
||||
participation.run(id, 'benchmark-low-sample', 2, 1, 20, 5, 0, 0, 1000,
|
||||
1, 0, 1, 1, started, ended, 0, 1);
|
||||
participation.run(
|
||||
id,
|
||||
'benchmark-low-sample',
|
||||
2,
|
||||
1,
|
||||
20,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
1000,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
started,
|
||||
ended,
|
||||
0,
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -104,15 +172,27 @@ function loadImplementation(handle, ref) {
|
||||
const modules = new Map();
|
||||
const hashes = {};
|
||||
let recorded = null;
|
||||
const database = { prepare(sql) {
|
||||
const statement = handle.prepare(sql);
|
||||
return Object.fromEntries(['all', 'get'].map(method => [method, (...parameters) => {
|
||||
const start = performance.now();
|
||||
const result = statement[method](...parameters);
|
||||
if (recorded) recorded.push({ sql, parameters, milliseconds: performance.now() - start });
|
||||
return result;
|
||||
}]));
|
||||
} };
|
||||
const database = {
|
||||
prepare(sql) {
|
||||
const statement = handle.prepare(sql);
|
||||
return Object.fromEntries(
|
||||
['all', 'get'].map((method) => [
|
||||
method,
|
||||
(...parameters) => {
|
||||
const start = performance.now();
|
||||
const result = statement[method](...parameters);
|
||||
if (recorded)
|
||||
recorded.push({
|
||||
sql,
|
||||
parameters,
|
||||
milliseconds: performance.now() - start
|
||||
});
|
||||
return result;
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
};
|
||||
function load(file) {
|
||||
if (file === 'src/lib/server/db/index.ts') return { db: () => database };
|
||||
if (modules.has(file)) return modules.get(file);
|
||||
@@ -123,10 +203,14 @@ function loadImplementation(handle, ref) {
|
||||
}).outputText;
|
||||
const exports = {};
|
||||
modules.set(file, exports);
|
||||
const localRequire = specifier => {
|
||||
const localRequire = (specifier) => {
|
||||
if (!specifier.startsWith('.')) return require(specifier);
|
||||
const target = relative(repo, resolve(repo, dirname(file), specifier)).replace(/\.js$/, '.ts');
|
||||
if (!target.startsWith('src/lib/')) throw new Error(`Unexpected benchmark dependency: ${target}`);
|
||||
const target = relative(repo, resolve(repo, dirname(file), specifier)).replace(
|
||||
/\.js$/,
|
||||
'.ts'
|
||||
);
|
||||
if (!target.startsWith('src/lib/'))
|
||||
throw new Error(`Unexpected benchmark dependency: ${target}`);
|
||||
return load(target);
|
||||
};
|
||||
new Function('require', 'exports', compiled)(localRequire, exports);
|
||||
@@ -134,10 +218,14 @@ function loadImplementation(handle, ref) {
|
||||
}
|
||||
const api = load('src/lib/server/fleet/leaderboard.ts');
|
||||
return {
|
||||
name: ref ? 'baseline' : 'current', ref, hashes,
|
||||
metrics: load('src/lib/leaderboard.ts').leaderboardMetrics.map(metric => metric.key),
|
||||
name: ref ? 'baseline' : 'current',
|
||||
ref,
|
||||
hashes,
|
||||
metrics: load('src/lib/leaderboard.ts').leaderboardMetrics.map((metric) => metric.key),
|
||||
run(test) {
|
||||
return test.guid ? api.getPlayerLeaderboardEntry(test.guid, test.options) : api.getLeaderboard(test.options);
|
||||
return test.guid
|
||||
? api.getPlayerLeaderboardEntry(test.guid, test.options)
|
||||
: api.getLeaderboard(test.options);
|
||||
},
|
||||
measure(test) {
|
||||
recorded = [];
|
||||
@@ -152,8 +240,16 @@ function loadImplementation(handle, ref) {
|
||||
}
|
||||
|
||||
const report = {
|
||||
environment: { node: process.version, sqlite: null, platform: platform(), arch: arch(), cpu: cpus()[0]?.model },
|
||||
baselineRef: baseline, runs: options.runs, seed: 20260918,
|
||||
environment: {
|
||||
node: process.version,
|
||||
sqlite: null,
|
||||
platform: platform(),
|
||||
arch: arch(),
|
||||
cpu: cpus()[0]?.model
|
||||
},
|
||||
baselineRef: baseline,
|
||||
runs: options.runs,
|
||||
seed: 20260918,
|
||||
notes: 'Synthetic file-backed SQLite, one warmup per case, rotating execution order, warm OS cache. Includes SQL preparation and source-record lookup; excludes HTTP and browser rendering. Shared local CPU; timings are not production latency. Schema and indexes are current for both implementations.',
|
||||
scenarios: []
|
||||
};
|
||||
@@ -161,77 +257,148 @@ try {
|
||||
for (const definition of definitions) {
|
||||
const handle = new Database(join(temporary, `${definition.name}.sqlite`));
|
||||
try {
|
||||
report.environment.sqlite = handle.prepare('SELECT sqlite_version() AS version').get().version;
|
||||
report.environment.sqlite = handle
|
||||
.prepare('SELECT sqlite_version() AS version')
|
||||
.get().version;
|
||||
seed(handle, definition);
|
||||
const implementations = [...(baseline ? [loadImplementation(handle, baseline)] : []), loadImplementation(handle)];
|
||||
const implementations = [
|
||||
...(baseline ? [loadImplementation(handle, baseline)] : []),
|
||||
loadImplementation(handle)
|
||||
];
|
||||
const cases = [
|
||||
{ name: 'unfiltered', options: {} },
|
||||
{ name: 'kills', options: { metric: 'kills' } },
|
||||
{ name: 'game', options: { game: 't5', metric: 'kills' } },
|
||||
{ name: 'map', options: { game: 't6', map: 't6:map_8', metric: 'kills' } },
|
||||
{ name: 'search', options: { q: '00042', metric: 'kills' } },
|
||||
{ name: 'deep-page', options: { metric: 'kills', offset: Math.floor(definition.players / 2) } },
|
||||
{
|
||||
name: 'deep-page',
|
||||
options: { metric: 'kills', offset: Math.floor(definition.players / 2) }
|
||||
},
|
||||
{ name: 'efficiency', options: { metric: 'headshotRate' } },
|
||||
{ name: 'exact-player', guid: '10000000000000042', options: { metric: 'kills' } },
|
||||
{ name: 'empty-search', options: { q: 'No such player exists' } },
|
||||
{ name: 'past-end', options: { offset: 999999 } },
|
||||
{ name: 'empty-map', options: { map: 't5:missing' } }
|
||||
];
|
||||
const counts = handle.prepare(`SELECT COUNT(*) AS participationRows,
|
||||
COUNT(DISTINCT guid) AS uniquePlayers FROM leaderboard_match_players`).get();
|
||||
const scenario = { ...definition, ...counts, maps: 12, servers: 24, completedShare: 0.9, results: [] };
|
||||
const counts = handle
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS participationRows,
|
||||
COUNT(DISTINCT guid) AS uniquePlayers FROM leaderboard_match_players`
|
||||
)
|
||||
.get();
|
||||
const scenario = {
|
||||
...definition,
|
||||
...counts,
|
||||
maps: 12,
|
||||
servers: 24,
|
||||
completedShare: 0.9,
|
||||
results: []
|
||||
};
|
||||
report.scenarios.push(scenario);
|
||||
// Broad parity is separate from timings so every ratio and edge case is
|
||||
// checked without multiplying the larger fixture's timed request matrix.
|
||||
if (baseline && definition.name === 'small') {
|
||||
const scopes = [{}, { game: 't5' }, { game: 't6', map: 't6:map_8' }, { q: '00042' },
|
||||
{ q: 'No such player exists' }, { offset: 999999 }, { map: 't5:missing' }];
|
||||
const scopes = [
|
||||
{},
|
||||
{ game: 't5' },
|
||||
{ game: 't6', map: 't6:map_8' },
|
||||
{ q: '00042' },
|
||||
{ q: 'No such player exists' },
|
||||
{ offset: 999999 },
|
||||
{ map: 't5:missing' }
|
||||
];
|
||||
let checked = 0;
|
||||
for (const metric of implementations[1].metrics) {
|
||||
for (const scope of scopes) {
|
||||
const test = { options: { ...scope, metric } };
|
||||
assert.deepStrictEqual(implementations[1].run(test), implementations[0].run(test),
|
||||
`${definition.name}/${metric}/${JSON.stringify(scope)}: full-response parity`);
|
||||
assert.deepStrictEqual(
|
||||
implementations[1].run(test),
|
||||
implementations[0].run(test),
|
||||
`${definition.name}/${metric}/${JSON.stringify(scope)}: full-response parity`
|
||||
);
|
||||
checked++;
|
||||
}
|
||||
for (const guid of ['10000000000000042', 'benchmark-low-sample', 'benchmark-missing']) {
|
||||
for (const guid of [
|
||||
'10000000000000042',
|
||||
'benchmark-low-sample',
|
||||
'benchmark-missing'
|
||||
]) {
|
||||
const test = { guid, options: { metric } };
|
||||
assert.deepStrictEqual(implementations[1].run(test), implementations[0].run(test),
|
||||
`${definition.name}/${metric}/${guid}: exact-player parity`);
|
||||
assert.deepStrictEqual(
|
||||
implementations[1].run(test),
|
||||
implementations[0].run(test),
|
||||
`${definition.name}/${metric}/${guid}: exact-player parity`
|
||||
);
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
scenario.parityCases = checked;
|
||||
console.log(JSON.stringify({ scenario: definition.name, parityCases: checked, parity: true }));
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
scenario: definition.name,
|
||||
parityCases: checked,
|
||||
parity: true
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const test of cases) {
|
||||
const expected = implementations[0].run(test);
|
||||
for (const implementation of implementations.slice(1)) {
|
||||
assert.deepStrictEqual(implementation.run(test), expected, `${definition.name}/${test.name}: baseline parity`);
|
||||
assert.deepStrictEqual(
|
||||
implementation.run(test),
|
||||
expected,
|
||||
`${definition.name}/${test.name}: baseline parity`
|
||||
);
|
||||
}
|
||||
const samples = Object.fromEntries(implementations.map(item => [item.name, []]));
|
||||
const samples = Object.fromEntries(implementations.map((item) => [item.name, []]));
|
||||
const latestStatements = {};
|
||||
for (let index = 0; index < options.runs; index++) {
|
||||
const rotation = index % implementations.length;
|
||||
for (const implementation of [...implementations.slice(rotation), ...implementations.slice(0, rotation)]) {
|
||||
for (const implementation of [
|
||||
...implementations.slice(rotation),
|
||||
...implementations.slice(0, rotation)
|
||||
]) {
|
||||
const measured = implementation.measure(test);
|
||||
samples[implementation.name].push(measured.milliseconds);
|
||||
latestStatements[implementation.name] = measured.statements;
|
||||
}
|
||||
}
|
||||
const result = { ...test, parity: baseline ? true : null, implementations: implementations.map(implementation => ({
|
||||
name: implementation.name, sourceHashes: implementation.hashes,
|
||||
medianMs: percentile(samples[implementation.name], 0.5),
|
||||
p95Ms: percentile(samples[implementation.name], 0.95), samplesMs: samples[implementation.name],
|
||||
statements: latestStatements[implementation.name].map(statement => ({ ...statement,
|
||||
plan: handle.prepare(`EXPLAIN QUERY PLAN ${statement.sql}`).all(...statement.parameters)
|
||||
const result = {
|
||||
...test,
|
||||
parity: baseline ? true : null,
|
||||
implementations: implementations.map((implementation) => ({
|
||||
name: implementation.name,
|
||||
sourceHashes: implementation.hashes,
|
||||
medianMs: percentile(samples[implementation.name], 0.5),
|
||||
p95Ms: percentile(samples[implementation.name], 0.95),
|
||||
samplesMs: samples[implementation.name],
|
||||
statements: latestStatements[implementation.name].map((statement) => ({
|
||||
...statement,
|
||||
plan: handle
|
||||
.prepare(`EXPLAIN QUERY PLAN ${statement.sql}`)
|
||||
.all(...statement.parameters)
|
||||
}))
|
||||
}))
|
||||
})) };
|
||||
};
|
||||
scenario.results.push(result);
|
||||
console.log(JSON.stringify({ scenario: definition.name, case: test.name, parity: result.parity,
|
||||
medianMs: Object.fromEntries(result.implementations.map(item => [item.name, Math.round(item.medianMs)])) }));
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
scenario: definition.name,
|
||||
case: test.name,
|
||||
parity: result.parity,
|
||||
medianMs: Object.fromEntries(
|
||||
result.implementations.map((item) => [
|
||||
item.name,
|
||||
Math.round(item.medianMs)
|
||||
])
|
||||
)
|
||||
})
|
||||
);
|
||||
}
|
||||
} finally { handle.close(); }
|
||||
} finally {
|
||||
handle.close();
|
||||
}
|
||||
}
|
||||
if (options.json) {
|
||||
const target = resolve(options.json);
|
||||
@@ -239,4 +406,6 @@ try {
|
||||
writeFileSync(target, JSON.stringify(report, null, 2));
|
||||
console.log(`Detailed results: ${target}`);
|
||||
}
|
||||
} finally { rmSync(temporary, { recursive: true, force: true }); }
|
||||
} finally {
|
||||
rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user