n8n/packages/testing/playwright/utils/benchmark/sizing-matrix.ts
Declan Carroll e5435ddf91
fix: Map sizing-matrix run-reports to cells via spec-file path (no-changelog) (#32860)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 10:31:38 +00:00

641 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Aggregated sizing-matrix artifact emitted from N `run-report.json` files.
*
* Schema lock: DEVP-185. The matrix is two-axis — scale (S0S4 throughput
* slices) × shape (L/D/A/X workload archetypes). Each cell carries a
* topology (output, not input) and a per-shape result that collapses ≥3
* cold runs into `{p50, p95, max, n}`. v0 tolerates `n=1` cells and marks
* verdict='amber' so the renderer can flag low-confidence data.
*/
import type { RunReport, ServiceMetrics } from './run-report';
export type Scale = 'S0' | 'S1' | 'S2' | 'S3' | 'S4';
export type Shape = 'L' | 'D' | 'A' | 'X';
export type Verdict = 'green' | 'amber' | 'red';
export type Bottleneck = 'main-cpu' | 'pg-cpu' | 'worker-cpu' | 'queue' | 'network';
export interface Topology {
mains: number;
webhookProcs: number;
workers: number;
/**
* Total in-flight job slots across the queue workers — `workers ×` the
* per-worker `--concurrency`. `0` when there are no dedicated workers
* (single-main topologies run executions in-process).
*/
concurrency: number;
mainVcpu: number;
mainRamGb: number;
workerVcpu?: number;
workerRamGb?: number;
pgVcpu: number;
pgRamGb: number;
pgIops?: number;
redisVcpu: number;
redisRamGb: number;
}
export interface HardwareInfo {
runner: string;
vcpu: number;
ramGb: number;
}
export interface SourceRun {
runReportPath: string;
commitSha: string;
spec: string;
}
export interface PercentileSummary {
p50: number;
p95: number;
max: number;
n: number;
}
export interface ShapeResult {
ceilingExecPerSec: PercentileSummary;
/** Steady-state ceiling — closer to the architectural limit than total exec/sec. */
tailExecPerSec?: PercentileSummary;
reqPerSec?: PercentileSummary;
latency: { p50: number; p975: number; p99: number };
headroomAtCeiling: {
mainCpuPct: number;
workerCpuPct?: number;
pgCpuPct: number;
pgCpuPctPeak: number;
pgBufferHit: number;
eventLoopLagMs: number;
};
io: {
/** `pg_stat_io` `client backend` (reads+writes+extends) / sec. */
workloadIopsPerSec: number;
/** Sum of bgwriter + checkpointer + walwriter + autovacuum + standalone, per sec. */
overheadIopsPerSec: number;
/** Ratio total / workload. ~1.0 means workload-dominated; >1.5 means overhead-heavy. */
overheadFactor: number;
walMbPerSec: number;
walRecordsPerSec: number;
};
bottleneck: Bottleneck;
verdict: Verdict;
/** Derived: throughput at which all headroom signals would stay green. */
greenSustainedExecPerSec: number;
sourceRuns: SourceRun[];
}
export interface SizingCell {
scale: Scale;
topology: Topology;
shapes: Partial<Record<Shape, ShapeResult>>;
}
export interface SizingMatrix {
schemaVersion: 1;
n8nVersion: string;
commitSha: string;
runDate: string;
hardware: HardwareInfo;
cells: SizingCell[];
}
/** Maps each benchmark spec path to its cell coordinates. */
export interface SpecMapping {
[specPath: string]: {
scale: Scale;
shape: Shape;
topology: Topology;
};
}
export interface AggregateInput {
reports: Array<{ path: string; report: RunReport }>;
mapping: SpecMapping;
hardware: HardwareInfo;
n8nVersion: string;
commitSha: string;
runDate?: string;
}
const GREEN_THRESHOLDS = {
mainCpuPct: 75,
pgCpuPct: 70,
eventLoopLagMs: 25,
httpP99Ms: 100,
} as const;
const AMBER_THRESHOLDS = {
mainCpuPct: 85,
pgCpuPct: 85,
eventLoopLagMs: 100,
httpP99Ms: 500,
} as const;
export function aggregate(input: AggregateInput): SizingMatrix {
const groups = groupByCell(input.reports, input.mapping);
const cells = Array.from(groups.entries())
.map(([key, entries]) => buildCell(key, entries))
.sort(byScale);
// Run-reports carry no commit of their own — attribute sources to the run's.
if (input.commitSha) {
for (const cell of cells) {
for (const shape of Object.values(cell.shapes)) {
for (const source of shape?.sourceRuns ?? []) {
if (source.commitSha === 'unknown') source.commitSha = input.commitSha;
}
}
}
}
return {
schemaVersion: 1,
n8nVersion: input.n8nVersion,
commitSha: input.commitSha,
runDate: input.runDate ?? new Date().toISOString(),
hardware: input.hardware,
cells,
};
}
interface CellGroupEntry {
scale: Scale;
shape: Shape;
topology: Topology;
path: string;
report: RunReport;
}
function groupByCell(
reports: Array<{ path: string; report: RunReport }>,
mapping: SpecMapping,
): Map<Scale, CellGroupEntry[]> {
const byScale = new Map<Scale, CellGroupEntry[]>();
for (const { path, report } of reports) {
const cellCoord = findMapping(path, report, mapping);
if (!cellCoord) {
console.warn(
`[sizing-matrix] No mapping for spec "${report.scenario.spec}" (file: ${path}) — skipping.`,
);
continue;
}
const entry: CellGroupEntry = { ...cellCoord, path, report };
const existing = byScale.get(cellCoord.scale) ?? [];
existing.push(entry);
byScale.set(cellCoord.scale, existing);
}
return byScale;
}
// First-match-wins: tries path, spec title, and normalised title against each key.
function findMapping(path: string, report: RunReport, mapping: SpecMapping) {
const candidates = [path, report.scenario.spec, normaliseSpecTitle(report.scenario.spec)];
for (const [key, value] of Object.entries(mapping)) {
const fragment = key.toLowerCase();
const fileStem =
key
.split('/')
.pop()
?.replace(/\.spec\.ts$/, '') ?? '';
const stemFragment = fileStem.toLowerCase();
for (const candidate of candidates) {
const haystack = candidate.toLowerCase();
if (haystack.includes(fragment)) return value;
if (stemFragment && haystack.includes(stemFragment)) return value;
}
}
return undefined;
}
function normaliseSpecTitle(title: string): string {
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-');
}
function buildCell(scale: Scale, entries: CellGroupEntry[]): SizingCell {
const topology = entries[0]?.topology;
if (!topology) throw new Error(`No entries for scale ${scale}`);
const byShape = new Map<Shape, CellGroupEntry[]>();
for (const entry of entries) {
const list = byShape.get(entry.shape) ?? [];
list.push(entry);
byShape.set(entry.shape, list);
}
const shapes: SizingCell['shapes'] = {};
for (const [shape, group] of byShape.entries()) {
shapes[shape] = buildShapeResult(group);
}
return { scale, topology, shapes };
}
function buildShapeResult(group: CellGroupEntry[]): ShapeResult {
const perRun = group.map((entry) => projectRun(entry));
const execs = perRun.map((r) => r.execPerSec).filter((v) => v > 0);
const tailExecs = perRun
.map((r) => r.tailExecPerSec)
.filter((v): v is number => v !== undefined && v > 0);
const reqs = perRun.map((r) => r.reqPerSec).filter((v): v is number => v !== undefined && v > 0);
const latP50 = mean(perRun.map((r) => r.latency.p50).filter((v) => v > 0));
const latP975 = mean(perRun.map((r) => r.latency.p975).filter((v) => v > 0));
const latP99 = mean(perRun.map((r) => r.latency.p99).filter((v) => v > 0));
const headroom = headroomFromGroup(group);
const ioMetrics = ioFromGroup(group);
const bottleneck = detectBottleneck(headroom, latP99);
const verdict = scoreVerdict(headroom, latP99, group.length);
// Cap at the p50 ceiling, not the max: a widely-varying cell must not report
// a sustained figure above its own representative saturation point.
const ceiling = summarise(execs);
const perRunGreens = perRun.map((r) => r.greenProjection).filter((v) => v > 0);
const greenMedian = perRunGreens.length ? median(perRunGreens) : 0;
const greenSustainedExecPerSec = Math.min(greenMedian, ceiling.p50);
return {
ceilingExecPerSec: ceiling,
tailExecPerSec: tailExecs.length ? summarise(tailExecs) : undefined,
reqPerSec: reqs.length ? summarise(reqs) : undefined,
latency: { p50: latP50, p975: latP975, p99: latP99 },
headroomAtCeiling: headroom,
io: ioMetrics,
bottleneck,
verdict,
greenSustainedExecPerSec,
sourceRuns: group.map((g) => ({
runReportPath: g.path,
commitSha: extractSha(g.report) ?? 'unknown',
spec: g.report.scenario.spec,
})),
};
}
interface PerRunProjection {
execPerSec: number;
tailExecPerSec?: number;
reqPerSec?: number;
latency: { p50: number; p975: number; p99: number };
greenProjection: number;
}
// Projects "rate sustainable at green headroom" using this run's own CPU/lag.
function projectRun(entry: CellGroupEntry): PerRunProjection {
const t = entry.report.throughput;
const execPerSec = t.execPerSec ?? 0;
const mainCpu = entry.report.containers.find((c) => c.name === 'n8n-main')?.cpuPct ?? 0;
const pgCpu = entry.report.containers.find((c) => c.name === 'postgres')?.cpuPct ?? 0;
const lag =
entry.report.services
.filter((s): s is Extract<ServiceMetrics, { kind: 'n8n-main' }> => s.kind === 'n8n-main')
.map((s) => (s.eventLoopLagSec ?? 0) * 1000)[0] ?? 0;
const mainFactor = mainCpu > 0 ? GREEN_THRESHOLDS.mainCpuPct / mainCpu : Infinity;
const pgFactor = pgCpu > 0 ? GREEN_THRESHOLDS.pgCpuPct / pgCpu : Infinity;
const lagFactor = lag > 0 ? GREEN_THRESHOLDS.eventLoopLagMs / lag : Infinity;
const limit = Math.min(mainFactor, pgFactor, lagFactor);
const greenProjection = Number.isFinite(limit) ? execPerSec * limit : execPerSec;
return {
execPerSec,
tailExecPerSec: t.tailExecPerSec,
reqPerSec: t.reqPerSec,
latency: {
p50: t.p50Ms ?? 0,
p975: t.p97_5Ms ?? 0,
p99: t.p99Ms ?? 0,
},
greenProjection,
};
}
function headroomFromGroup(group: CellGroupEntry[]): ShapeResult['headroomAtCeiling'] {
const mainCpu = mean(definedNumbers(group, (g) => containerValues(g, 'n8n-main', 'cpuPct')));
const workerVals = group.flatMap((g) => containerValues(g, 'n8n-worker', 'cpuPct'));
const workerDefined = workerVals.filter(isNumber);
const workerCpu = workerDefined.length ? mean(workerDefined) : undefined;
const pgCpuAvg = mean(definedNumbers(group, (g) => containerValues(g, 'postgres', 'cpuPct')));
const pgCpuPeak = mean(
definedNumbers(group, (g) => containerValues(g, 'postgres', 'cpuPctPeak')),
);
const eventLoopLagMs = mean(
group.flatMap((g) =>
g.report.services
.filter((s): s is Extract<ServiceMetrics, { kind: 'n8n-main' }> => s.kind === 'n8n-main')
.map((s) => (s.eventLoopLagSec ?? 0) * 1000),
),
);
const pgBufferHit = mean(
group.flatMap((g) =>
g.report.services
.filter((s): s is Extract<ServiceMetrics, { kind: 'postgres' }> => s.kind === 'postgres')
.map((s) => s.saturation.bufferHitRatio ?? 0),
),
);
return {
mainCpuPct: mainCpu,
workerCpuPct: workerCpu,
pgCpuPct: pgCpuAvg,
pgCpuPctPeak: pgCpuPeak,
pgBufferHit,
eventLoopLagMs,
};
}
function ioFromGroup(group: CellGroupEntry[]): ShapeResult['io'] {
const perRun = group.map((g) => derivePerRunIo(g.report));
const workloadIopsPerSec = mean(perRun.map((r) => r.workload));
const overheadIopsPerSec = mean(perRun.map((r) => r.overhead));
const overheadFactor =
workloadIopsPerSec > 0 ? (workloadIopsPerSec + overheadIopsPerSec) / workloadIopsPerSec : 0;
const walMbPerSec = mean(perRun.map((r) => r.walMbPerSec));
const walRecordsPerSec = mean(perRun.map((r) => r.walRecordsPerSec));
return {
workloadIopsPerSec,
overheadIopsPerSec,
overheadFactor,
walMbPerSec,
walRecordsPerSec,
};
}
function derivePerRunIo(report: RunReport): {
workload: number;
overhead: number;
walMbPerSec: number;
walRecordsPerSec: number;
} {
const elapsedSec = (report.duration.totalMs ?? 1) / 1000;
const pg = report.services.find(
(s): s is Extract<ServiceMetrics, { kind: 'postgres' }> => s.kind === 'postgres',
);
if (!pg) {
return { workload: 0, overhead: 0, walMbPerSec: 0, walRecordsPerSec: 0 };
}
const sumIo = (filter: (b: string) => boolean) =>
pg.io
.filter((row) => filter(row.backendType))
.reduce((acc, row) => acc + row.reads + row.writes + row.extends, 0);
const workloadOps = sumIo((b) => b === 'client backend');
const overheadOps = sumIo(
(b) =>
b === 'background writer' ||
b === 'checkpointer' ||
b === 'walwriter' ||
b === 'autovacuum worker' ||
b === 'autovacuum launcher' ||
b === 'standalone backend',
);
return {
workload: workloadOps / elapsedSec,
overhead: overheadOps / elapsedSec,
walMbPerSec: pg.saturation.walMbPerSec ?? 0,
walRecordsPerSec: pg.saturation.walRecordsPerSec ?? 0,
};
}
function detectBottleneck(headroom: ShapeResult['headroomAtCeiling'], _p99: number): Bottleneck {
// Classify on resource saturation, not p99 — for async modes p99 is
// dominated by Bull queue wait and misranked PG-CPU as "network".
const candidates: Array<[Bottleneck, number]> = [
['main-cpu', headroom.mainCpuPct / AMBER_THRESHOLDS.mainCpuPct],
['pg-cpu', headroom.pgCpuPct / AMBER_THRESHOLDS.pgCpuPct],
];
if (headroom.workerCpuPct !== undefined) {
candidates.push(['worker-cpu', headroom.workerCpuPct / AMBER_THRESHOLDS.mainCpuPct]);
}
candidates.sort((a, b) => b[1] - a[1]);
return candidates[0]?.[0] ?? 'main-cpu';
}
function scoreVerdict(
headroom: ShapeResult['headroomAtCeiling'],
_p99: number,
sampleCount: number,
): Verdict {
// Verdict gates on topology health (CPU/PG/event-loop), not p99 — same
// rationale as detectBottleneck. p99 still surfaces in the cell display.
if (sampleCount < 3) return 'amber';
const anyRed =
headroom.mainCpuPct > AMBER_THRESHOLDS.mainCpuPct ||
headroom.pgCpuPct > AMBER_THRESHOLDS.pgCpuPct ||
headroom.eventLoopLagMs > AMBER_THRESHOLDS.eventLoopLagMs;
if (anyRed) return 'red';
const anyAmber =
headroom.mainCpuPct > GREEN_THRESHOLDS.mainCpuPct ||
headroom.pgCpuPct > GREEN_THRESHOLDS.pgCpuPct ||
headroom.eventLoopLagMs > GREEN_THRESHOLDS.eventLoopLagMs;
return anyAmber ? 'amber' : 'green';
}
function median(values: number[]): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 1) return sorted[mid] ?? 0;
return ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2;
}
function summarise(values: number[]): PercentileSummary {
if (values.length === 0) return { p50: 0, p95: 0, max: 0, n: 0 };
const sorted = [...values].sort((a, b) => a - b);
return {
p50: percentile(sorted, 0.5),
p95: percentile(sorted, 0.95),
max: sorted[sorted.length - 1] ?? 0,
n: values.length,
};
}
function percentile(sortedAsc: number[], p: number): number {
if (sortedAsc.length === 0) return 0;
if (sortedAsc.length === 1) return sortedAsc[0] ?? 0;
const idx = Math.min(sortedAsc.length - 1, Math.floor(p * sortedAsc.length));
return sortedAsc[idx] ?? 0;
}
function mean(values: number[]): number {
if (values.length === 0) return 0;
return values.reduce((acc, v) => acc + v, 0) / values.length;
}
function isNumber(value: number | undefined): value is number {
return typeof value === 'number' && !Number.isNaN(value);
}
function containerValues(
entry: CellGroupEntry,
name: string,
field: 'cpuPct' | 'cpuPctPeak',
): Array<number | undefined> {
return entry.report.containers.filter((c) => c.name === name).map((c) => c[field]);
}
function definedNumbers(
group: CellGroupEntry[],
pick: (entry: CellGroupEntry) => Array<number | undefined>,
): number[] {
return group.flatMap(pick).filter(isNumber);
}
function byScale(a: SizingCell, b: SizingCell): number {
const order: Scale[] = ['S0', 'S1', 'S2', 'S3', 'S4'];
return order.indexOf(a.scale) - order.indexOf(b.scale);
}
function extractSha(report: RunReport): string | undefined {
const value = report.scenario.dimensions['commitSha'];
return typeof value === 'string' ? value : undefined;
}
/**
* Renders the SizingMatrix as a raw internal data table — NOT the customer
* sizing guide. Interpretation (recommendations, confidence, quoting rules)
* lives in the SE guide and the customer calculator that consume the JSON.
*/
export function renderMarkdown(matrix: SizingMatrix): string {
const lines: string[] = [];
lines.push('# n8n Sizing — Benchmark Substrate (raw data)');
lines.push('');
lines.push(
`*Generated for n8n \`${matrix.n8nVersion}\` · commit \`${matrix.commitSha}\` · ${matrix.runDate.slice(0, 10)}*`,
);
lines.push('');
lines.push(
`*Hardware baseline:* \`${matrix.hardware.runner}\` (${matrix.hardware.vcpu} vCPU / ${matrix.hardware.ramGb} GB).`,
);
lines.push('');
lines.push(
'> **What this is:** the raw aggregation of the benchmark substrate — measured ' +
'cells only, regenerated each run. It reports numbers; it does not interpret ' +
'them. Sizing recommendations, confidence calls, and quoting rules live in the ' +
'SE sizing guide and the customer calculator that consume this data, not here. ' +
'Each cell shows the saturation **ceiling** and a headroom-derived **sustained** ' +
'figure across `n` cold runs; expand a cell for the full distribution and signals.',
);
lines.push('');
const shapes: Shape[] = ['L', 'D', 'A', 'X'];
for (const shape of shapes) {
const cellsWithShape = matrix.cells.filter((c) => c.shapes[shape]);
if (cellsWithShape.length === 0) continue;
lines.push(`## Shape ${shape}`);
lines.push('');
lines.push(renderShapeTable(cellsWithShape, shape));
lines.push('');
lines.push(renderShapeDetail(cellsWithShape, shape));
lines.push('');
}
lines.push('---');
lines.push('');
lines.push('## Methodology');
lines.push('');
lines.push(
'Cells are aggregated by the substrate at `packages/testing/playwright/utils/benchmark/sizing-matrix.ts` from per-run `run-report.json` files. ' +
'The headline **ceiling** is the p50 `execPerSec` across cold runs; the full p50/p95/max is in cell detail. ' +
'**sustained** is a headroom-derived figure (throughput at which main CPU < 75%, PG CPU < 70%, event-loop lag < 25 ms would hold), capped at the ceiling p50. ' +
'It is a raw computed signal, not a vetted recommendation. ' +
'**Workload IOPS** is `pg_stat_io` `client backend` reads+writes+extends per second; ' +
'**overhead factor** is `(workload + bgwriter + checkpointer + walwriter + autovacuum) / workload`. ' +
'WAL is reported separately (`MB/s`, `records/s`).',
);
lines.push('');
return lines.join('\n');
}
function renderShapeTable(cells: SizingCell[], shape: Shape): string {
const header =
'| Scale | Mains | Webhook procs | Workers | PG (vCPU/RAM) | Redis | Ceiling exec/s (p50) | Sustained exec/s | Req/s | p99 ms | n | Bottleneck |';
const sep = '|---|---:|---:|---:|---|---|---:|---:|---:|---:|---:|---|';
const rows: string[] = [header, sep];
for (const cell of cells) {
const result = cell.shapes[shape];
if (!result) continue;
rows.push(
[
`**${cell.scale}**`,
`${cell.topology.mains}× (${cell.topology.mainVcpu} vCPU / ${cell.topology.mainRamGb} GB)`,
cell.topology.webhookProcs > 0 ? `${cell.topology.webhookProcs}×` : '(in main)',
cell.topology.workers > 0 && cell.topology.workerVcpu
? `${cell.topology.workers}× (${cell.topology.workerVcpu} vCPU / ${cell.topology.workerRamGb} GB)`
: '—',
`${cell.topology.pgVcpu} vCPU / ${cell.topology.pgRamGb} GB`,
`${cell.topology.redisVcpu} vCPU / ${cell.topology.redisRamGb} GB`,
result.ceilingExecPerSec.p50.toFixed(1),
result.greenSustainedExecPerSec.toFixed(1),
result.reqPerSec ? result.reqPerSec.p50.toFixed(1) : '—',
result.latency.p99 > 0 ? result.latency.p99.toFixed(0) : '—',
`${result.ceilingExecPerSec.n}`,
`\`${result.bottleneck}\``,
].join(' | '),
);
}
return rows.join('\n');
}
function renderShapeDetail(cells: SizingCell[], shape: Shape): string {
const lines: string[] = [];
lines.push('<details>');
lines.push(`<summary>Cell detail — Shape ${shape}</summary>`);
lines.push('');
for (const cell of cells) {
const result = cell.shapes[shape];
if (!result) continue;
lines.push(`### ${cell.scale}-${shape} (n=${result.ceilingExecPerSec.n})`);
lines.push('');
lines.push('| Field | Value |');
lines.push('|---|---|');
lines.push(`| Ceiling exec/s (p50/p95/max) | ${fmtPercentile(result.ceilingExecPerSec)} |`);
if (result.tailExecPerSec) {
lines.push(`| Tail exec/s (steady-state) | ${fmtPercentile(result.tailExecPerSec)} |`);
}
lines.push(
`| Sustained exec/s (headroom-derived) | ${result.greenSustainedExecPerSec.toFixed(1)} |`,
);
lines.push(
`| Latency p50 / p97.5 / p99 | ${result.latency.p50.toFixed(0)} / ${result.latency.p975.toFixed(0)} / ${result.latency.p99.toFixed(0)} ms |`,
);
lines.push(`| Main CPU at ceiling | ${result.headroomAtCeiling.mainCpuPct.toFixed(1)}% |`);
if (result.headroomAtCeiling.workerCpuPct !== undefined) {
lines.push(
`| Worker CPU at ceiling | ${result.headroomAtCeiling.workerCpuPct.toFixed(1)}% |`,
);
}
lines.push(
`| PG CPU avg / peak | ${result.headroomAtCeiling.pgCpuPct.toFixed(1)}% / ${result.headroomAtCeiling.pgCpuPctPeak.toFixed(1)}% |`,
);
lines.push(
`| PG buffer-hit ratio | ${(result.headroomAtCeiling.pgBufferHit * 100).toFixed(2)}% |`,
);
lines.push(`| Event-loop lag | ${result.headroomAtCeiling.eventLoopLagMs.toFixed(1)} ms |`);
lines.push(`| **Workload IOPS** | ${result.io.workloadIopsPerSec.toFixed(0)} ops/s |`);
lines.push(
`| Overhead IOPS (autovac + bgwriter + checkpointer + walwriter) | ${result.io.overheadIopsPerSec.toFixed(0)} ops/s |`,
);
lines.push(`| **Overhead factor** | ${result.io.overheadFactor.toFixed(2)}× workload |`);
lines.push(
`| WAL throughput | ${result.io.walMbPerSec.toFixed(2)} MB/s · ${result.io.walRecordsPerSec.toFixed(0)} records/s |`,
);
lines.push(`| Bottleneck | \`${result.bottleneck}\` |`);
lines.push(`| Source runs | ${result.sourceRuns.length} |`);
lines.push('');
for (const src of result.sourceRuns) {
lines.push(`- \`${src.spec}\``);
}
lines.push('');
}
lines.push('</details>');
return lines.join('\n');
}
function fmtPercentile(p: PercentileSummary): string {
return `${p.p50.toFixed(1)} / ${p.p95.toFixed(1)} / ${p.max.toFixed(1)}`;
}