test(perf): measure what concurrent hashing actually costs a bystander request (#163)
#163 claimed `bcryptjs` blocks the event loop and that every login stalls every other request in flight. That was asserted without measurement and is wrong: the asynchronous API chunks its work and yields between rounds, and all six call sites in `routes/customers.ts` use it — there is no `hashSync` or `compareSync` anywhere in `src`. A smaller effect is real, though, and this measures it instead of arguing about it. The probe is `GET /api/customers/me` with no cookie, chosen for doing almost nothing: it rejects before touching the database, so nearly all of its latency is time spent waiting for the event loop rather than work of its own. The load is real registrations against the real route, because the question is what a deployed server does rather than what bcrypt does on a bench. Measured on the dev machine, Node 24, cost 12: | Concurrent registrations | Each registration (p50) | Bystander p95 | Bystander worst case | | --- | --- | --- | --- | | idle | — | 0.3 ms | 5.4 ms | | 1 | 213 ms | 0.6 ms | 102 ms | | 4 | 803 ms | 101.9 ms | 405 ms | | 8 | 1626 ms | 15.6 ms | 808 ms | Both columns are linear in the number of queued hashes. Registration is roughly 200 ms times the concurrency, because the hashes serialize onto the one thread. The bystander's worst case is roughly 100 ms times the concurrency, which matches the coarseness of the chunks measured earlier — about 100 ms of un-yielding time per hash. The median stays under a millisecond throughout, so this is a tail-latency characteristic and not the stall the issue described. The benchmark creates real customers and deletes them again, because it is normally pointed at a development database that nothing truncates. Lint now covers `scripts` as well as `src`, so the one file in it is held to the same standard as the rest.
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint": "eslint src",
|
||||
"lint": "eslint src scripts",
|
||||
"start": "node dist/server.js",
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"test": "npm run test:unit",
|
||||
@@ -15,6 +15,7 @@
|
||||
"test:integration": "jest -c jest.integration.config.js --runInBand",
|
||||
"test:integration:json": "jest -c jest.integration.config.js --runInBand --json --outputFile=integration-results.json",
|
||||
"test:integration:cov": "jest -c jest.integration.config.js --runInBand --coverage --forceExit",
|
||||
"bench:hashing": "tsx scripts/bench-hash-latency.ts",
|
||||
"db:test:up": "docker compose -f docker-compose.test.yml up -d",
|
||||
"db:test:down": "docker compose -f docker-compose.test.yml down -v",
|
||||
"migrate:up": "node migrate.js up",
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* What does concurrent password hashing cost a request that is not hashing?
|
||||
*
|
||||
* #163 originally claimed `bcryptjs` blocks the event loop and that every login
|
||||
* stalls every other request in flight. That claim was wrong — the asynchronous
|
||||
* API chunks its work and yields between rounds — but a smaller effect is real:
|
||||
* the chunks are coarse, and N simultaneous registrations still queue N hashes
|
||||
* worth of CPU that has to come from somewhere.
|
||||
*
|
||||
* This measures that effect rather than arguing about it, so any future change
|
||||
* to hashing is justified by a number and can be checked by re-running this.
|
||||
*
|
||||
* Method
|
||||
* ------
|
||||
* The probe is `GET /api/customers/me` with no session cookie. It is chosen for
|
||||
* doing almost nothing: it rejects on a missing cookie before touching the
|
||||
* database, so nearly all of its measured latency is time spent waiting for the
|
||||
* event loop rather than work of its own. A heavier probe would measure the
|
||||
* database instead, which is not the question.
|
||||
*
|
||||
* The load is real registrations against the real route, because the point is
|
||||
* what a deployed server does, not what bcrypt does on a bench.
|
||||
*
|
||||
* Registrations create real rows. They are deleted afterwards — this is
|
||||
* normally pointed at a development database that nothing truncates, and a
|
||||
* benchmark that quietly adds hundreds of customers every run would be its own
|
||||
* small problem.
|
||||
*
|
||||
* Usage
|
||||
* -----
|
||||
* npm run bench:hashing
|
||||
*
|
||||
* Honours BENCH_URL, BENCH_CONCURRENCY, BENCH_ROUNDS, and the usual PG* vars
|
||||
* for the cleanup connection.
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
|
||||
const BASE_URL = process.env.BENCH_URL ?? 'http://localhost:3001';
|
||||
|
||||
// Eight, because that is what Playwright uses on this machine — half the cores
|
||||
// — and the end-to-end suite registering customers in parallel is one of the
|
||||
// two places #163 suggested the cost might actually show up.
|
||||
const CONCURRENCY = Number(process.env.BENCH_CONCURRENCY ?? 8);
|
||||
const ROUNDS = Number(process.env.BENCH_ROUNDS ?? 5);
|
||||
|
||||
// Long enough for the baseline to see past a single slow sample, short enough
|
||||
// that the whole run stays under a minute.
|
||||
const BASELINE_MS = 3000;
|
||||
|
||||
// Marks every row this script creates, so cleanup can be exact rather than
|
||||
// date-based. Anything left behind by a crashed run is removed by the next one.
|
||||
const EMAIL_PREFIX = 'bench-hash-';
|
||||
|
||||
interface Stats {
|
||||
count: number;
|
||||
p50: number;
|
||||
p95: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
function summarise(samples: number[]): Stats {
|
||||
const sorted = [...samples].sort((a, b) => a - b);
|
||||
const at = (fraction: number): number => {
|
||||
// Nearest-rank, which needs no interpolation and cannot invent a value that
|
||||
// was never measured.
|
||||
const index = Math.min(sorted.length - 1, Math.ceil(fraction * sorted.length) - 1);
|
||||
return sorted[Math.max(0, index)] ?? 0;
|
||||
};
|
||||
return {
|
||||
count: sorted.length,
|
||||
p50: at(0.5),
|
||||
p95: at(0.95),
|
||||
max: sorted[sorted.length - 1] ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
async function probe(): Promise<number> {
|
||||
const started = performance.now();
|
||||
const res = await fetch(`${BASE_URL}/api/customers/me`);
|
||||
// Drained rather than ignored: leaving the body unread would stop the clock
|
||||
// before the response has actually arrived.
|
||||
await res.arrayBuffer();
|
||||
return performance.now() - started;
|
||||
}
|
||||
|
||||
async function register(index: number): Promise<number> {
|
||||
const started = performance.now();
|
||||
const res = await fetch(`${BASE_URL}/api/customers/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: `${EMAIL_PREFIX}${Date.now().toString(36)}-${index}@example.com`,
|
||||
// Not a credential: it hashes these accounts into existence and deletes
|
||||
// them again at the end of the run. The cost of hashing is the whole
|
||||
// point, so it cannot be shortened or faked.
|
||||
// eslint-disable-next-line sonarjs/no-hardcoded-passwords
|
||||
password: 'benchmark-password',
|
||||
firstName: 'Bench',
|
||||
lastName: 'Mark'
|
||||
})
|
||||
});
|
||||
await res.arrayBuffer();
|
||||
if (!res.ok) throw new Error(`registration failed with ${res.status}`);
|
||||
return performance.now() - started;
|
||||
}
|
||||
|
||||
/** Probes continuously until `until` resolves, so the samples span the load. */
|
||||
async function probeUntil(until: Promise<unknown>): Promise<number[]> {
|
||||
const samples: number[] = [];
|
||||
let done = false;
|
||||
void until.then(
|
||||
() => { done = true; },
|
||||
() => { done = true; }
|
||||
);
|
||||
while (!done) {
|
||||
samples.push(await probe());
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
async function measureBaseline(): Promise<number[]> {
|
||||
const samples: number[] = [];
|
||||
const deadline = performance.now() + BASELINE_MS;
|
||||
while (performance.now() < deadline) {
|
||||
samples.push(await probe());
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<number> {
|
||||
const pool = new Pool();
|
||||
try {
|
||||
// Sessions and tokens reference the customer, so they go first — the
|
||||
// registration route creates one of each.
|
||||
await pool.query(
|
||||
`DELETE FROM customer_sessions WHERE customer_id IN (SELECT id FROM customers WHERE email LIKE $1)`,
|
||||
[`${EMAIL_PREFIX}%`]
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM customer_tokens WHERE customer_id IN (SELECT id FROM customers WHERE email LIKE $1)`,
|
||||
[`${EMAIL_PREFIX}%`]
|
||||
);
|
||||
const { rowCount } = await pool.query(`DELETE FROM customers WHERE email LIKE $1`, [`${EMAIL_PREFIX}%`]);
|
||||
return rowCount ?? 0;
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
function report(label: string, stats: Stats): void {
|
||||
console.info(
|
||||
`${label.padEnd(28)} n=${String(stats.count).padStart(4)} ` +
|
||||
`p50=${stats.p50.toFixed(1).padStart(7)} ms ` +
|
||||
`p95=${stats.p95.toFixed(1).padStart(7)} ms ` +
|
||||
`max=${stats.max.toFixed(1).padStart(7)} ms`
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.info(`[bench] ${BASE_URL}, ${CONCURRENCY} concurrent registrations x ${ROUNDS} rounds`);
|
||||
|
||||
// A cold first request pays for connection setup and JIT, which would land
|
||||
// entirely in the baseline and flatter the comparison.
|
||||
for (let i = 0; i < 10; i++) await probe();
|
||||
|
||||
const baseline = await measureBaseline();
|
||||
report('idle', summarise(baseline));
|
||||
|
||||
const underLoad: number[] = [];
|
||||
const registrations: number[] = [];
|
||||
|
||||
for (let round = 0; round < ROUNDS; round++) {
|
||||
const load = Promise.all(
|
||||
Array.from({ length: CONCURRENCY }, (_unused, index) => register(round * CONCURRENCY + index))
|
||||
);
|
||||
const [samples, times] = await Promise.all([probeUntil(load), load]);
|
||||
underLoad.push(...samples);
|
||||
registrations.push(...times);
|
||||
}
|
||||
|
||||
report(`under ${CONCURRENCY} registrations`, summarise(underLoad));
|
||||
report('the registrations', summarise(registrations));
|
||||
|
||||
const idle = summarise(baseline);
|
||||
const loaded = summarise(underLoad);
|
||||
console.info(
|
||||
`\n[bench] a bystander request costs ` +
|
||||
`${(loaded.p50 - idle.p50).toFixed(1)} ms more at p50, ` +
|
||||
`${(loaded.p95 - idle.p95).toFixed(1)} ms more at p95, ` +
|
||||
`worst case ${loaded.max.toFixed(1)} ms`
|
||||
);
|
||||
|
||||
const removed = await cleanup();
|
||||
console.info(`[bench] removed ${removed} benchmark customers`);
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error('[bench] failed:', error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user