mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-28 19:45:09 +02:00
641 lines
21 KiB
TypeScript
641 lines
21 KiB
TypeScript
/**
|
||
* Aggregated sizing-matrix artifact emitted from N `run-report.json` files.
|
||
*
|
||
* Schema lock: DEVP-185. The matrix is two-axis — scale (S0–S4 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)}`;
|
||
}
|