// Post-run report renderer for the k8s sim. // // Reads measurement artifacts from a reports dir (cpu_samples.tsv, // pods.json, throughput_samples.json) and writes: // - .svg per panel (throughput, node_cpu, node_memory, workers_per_node) // - dashboard.svg composed via dashboard.ts (brand-styled grid) // - report.md with summary + embedded SVG refs // // All four panels are time-series (multi-series, one line per node). Reuses // benchmarks/graph.ts:drawGraphMulti as the chart primitive (same code path // the CI bench uses for trend graphs). import { drawGraphMulti, drawBars, drawDonut } from "../graph.ts"; import { computeOversatPct } from "./util_metrics.ts"; import { sampleJobParams, type WorkloadConfig, type DistSpec, } from "../workloads/distribution.ts"; import { renderDashboard, type DashboardMeta, type DashboardPanel, type DataPointMulti, } from "./dashboard.ts"; import type { PodEntry } from "./pod_inventory.ts"; import type { OomEvent } from "./oom_events.ts"; import { renderSvgToPdf } from "./svg_to_pdf.ts"; type CpuSample = { ts_ms: number; // wall clock (CLOCK_REALTIME) — used for x-axis placement. mono_ms: number; // monotonic (CLOCK_MONOTONIC) — used for delta math. // 0 for samples emitted by the pre-monotonic sampler; // consumers fall back to ts_ms in that case. node: string; pod_slice: string; container_scope: string; // "-" when the row is the pod-level cgroup cpu_usec: number; mem_bytes: number | null; }; type ThroughputSample = { ts: number; processed: number; sum: number; queue: number; }; // kubectl logs --prefix=true prepends `[pod//] ` to each line. const PREFIX_RE = /^\[pod\/[^\]]+\]\s+/; function parseCpuSamples(text: string): CpuSample[] { // Two on-wire formats are accepted (auto-detected per row by column count): // v1 (legacy, 6 cols): ts_ns node pod_slice container_scope usage_usec mem_bytes // v2 (current, 7 cols): ts_ns mono_ns node pod_slice container_scope usage_usec mem_bytes // v2 added a monotonic timestamp column to dodge NTP wall-clock backwards // jumps that produced impossible >100% per-VM CPU spikes in v1 reports. const out: CpuSample[] = []; for (const raw of text.split("\n")) { if (!raw) continue; const line = raw.replace(PREFIX_RE, ""); const parts = line.split(/\s+/); if (parts[0] === "ts_ns") continue; // header line(s) — one per sampler pod let ts_ns: number, mono_ns: number, node: string, pod_slice: string, container_scope: string, cpu_usec: number, memField: string; if (parts.length >= 7) { ts_ns = Number(parts[0]); mono_ns = Number(parts[1]); node = parts[2]; pod_slice = parts[3]; container_scope = parts[4]; cpu_usec = Number(parts[5]); memField = parts[6]; } else if (parts.length >= 6) { ts_ns = Number(parts[0]); mono_ns = 0; // unavailable in v1 node = parts[1]; pod_slice = parts[2]; container_scope = parts[3]; cpu_usec = Number(parts[4]); memField = parts[5]; } else { continue; } if (!Number.isFinite(ts_ns)) continue; const mem_bytes = memField === "-" ? null : Number(memField); out.push({ ts_ms: Math.floor(ts_ns / 1_000_000), mono_ms: Number.isFinite(mono_ns) ? Math.floor(mono_ns / 1_000_000) : 0, node, pod_slice, container_scope, cpu_usec, mem_bytes, }); } return out; } const NODE_ROOT_TAG = "__node_root__"; // Node-root rows — one per node per tick from the VM's root cgroup // (`/sys/fs/cgroup/cpu.stat` / `memory.current`). Covers everything on the // VM: kubelet, containerd, system daemons, every pod. This is "VM CPU/mem". function nodeRootOnly(samples: CpuSample[]): CpuSample[] { return samples.filter((s) => s.pod_slice === NODE_ROOT_TAG); } // `kubepods-besteffort-pod.slice` → uid (with underscores → hyphens). function uidFromPodSlice(slice: string): string | null { const m = slice.match(/-pod([0-9a-f_]{30,})\.slice$/i); if (!m) return null; return m[1].replace(/_/g, "-"); } // Per-node CPU rate as % of the VM (0-100). Computed from consecutive deltas // of the node-root cgroup `usage_usec`, then divided by `cpusPerNode` so 100% // = whole VM saturated regardless of core count. Pass cpusPerNode=1 to keep // the raw "% of one core" units (values can exceed 100% on multi-core VMs). function buildNodeCpuSeries( samples: CpuSample[], cpusByNode: Map, fallbackCpus = 1, ): DataPointMulti[] { const grouped = new Map(); for (const s of samples) { const k = `${s.node}|${s.pod_slice}`; let arr = grouped.get(k); if (!arr) { arr = []; grouped.set(k, arr); } arr.push(s); } const buckets = new Map>(); for (const [k, arr] of grouped) { // Sort by mono_ms when available (monotonic, NTP-immune); fall back to // wall ts_ms for v1 (pre-monotonic) sampler output. Mixing v1/v2 within // one series is not expected — sampler-pod rollouts get matched format. const useMono = arr.length > 0 && arr[0].mono_ms > 0; arr.sort((a, b) => useMono ? a.mono_ms - b.mono_ms : a.ts_ms - b.ts_ms); const node = k.split("|")[0]; const cpus = Math.max(1, cpusByNode.get(node) ?? fallbackCpus); let m = buckets.get(node); if (!m) { m = new Map(); buckets.set(node, m); } for (let i = 1; i < arr.length; i++) { const prev = arr[i - 1]; const cur = arr[i]; // Delta math uses MONO (immune to NTP wall-clock corrections); x-axis // placement still uses wall ts_ms so this panel aligns with throughput, // OOM event markers, etc. on the shared timeline. const dWall_ms = useMono ? cur.mono_ms - prev.mono_ms : cur.ts_ms - prev.ts_ms; if (dWall_ms <= 0) continue; const dCpu_us = cur.cpu_usec - prev.cpu_usec; // % of one core: dCpu(us) / dWall(us) * 100 = dCpu / dWall_ms / 10. // % of VM: divide by this node's core count so a fully-saturated VM reads 100. const pct = dCpu_us / dWall_ms / 10 / cpus; const acc = m.get(cur.ts_ms) ?? 0; m.set(cur.ts_ms, acc + pct); } } return flattenBuckets(buckets); } // Per-node arithmetic mean CPU %, starting from the first sample where the // value crossed `thresholdPct` (treated as "the moment work started on this // node"). Falls back to the full series mean if the threshold was never hit. // Used to bake "avg from first job" into the Node CPU legend. function perNodeAvgFromFirstJob( data: DataPointMulti[], thresholdPct: number, ): Map { const byNode = new Map(); for (const d of data) { let arr = byNode.get(d.kind); if (!arr) { arr = []; byNode.set(d.kind, arr); } arr.push(d.value); } const out = new Map(); for (const [node, vals] of byNode) { const start = vals.findIndex((v) => v >= thresholdPct); const slice = start >= 0 ? vals.slice(start) : vals; if (slice.length === 0) continue; out.set(node, slice.reduce((a, b) => a + b, 0) / slice.length); } return out; } // Trim the topology's verbose `wm-sim-k8s-4node-m02` to a chart-friendly // `m02` (control plane stays as "n0" by convention). Keeps the legend // readable when avg numbers are baked into the same string. function shortNode(full: string): string { const m = full.match(/-m(\d+)$/); if (m) return `m${m[1]}`; return full.endsWith("-4node") ? "n0" : full; } // Per-node memory (MiB) — `memory.current` from the node-root row. function buildNodeMemSeries(samples: CpuSample[]): DataPointMulti[] { const buckets = new Map>(); for (const s of samples) { if (s.mem_bytes === null) continue; let m = buckets.get(s.node); if (!m) { m = new Map(); buckets.set(s.node, m); } const acc = m.get(s.ts_ms) ?? 0; m.set(s.ts_ms, acc + s.mem_bytes); } return flattenBuckets(buckets, (b) => b / (1024 * 1024)); } // PG cgroup memory over time, from per-pod sampler rows. Filters samples to // the postgres pod (matching by name → UID via pods.json). Y axis in GiB // because PG memory grows into multi-GiB territory and MiB digits get noisy. function buildPgMemSeries(samples: CpuSample[], pods: PodEntry[]): DataPointMulti[] { const pgUids = new Set(); for (const p of pods) { if (p.name.startsWith("windmill-postgresql")) pgUids.add(p.uid); } if (pgUids.size === 0) return []; // Single series labelled "pg" — cleanest legend. const buckets = new Map>(); const m = new Map(); buckets.set("pg", m); for (const s of samples) { if (s.mem_bytes === null) continue; const uid = uidFromPodSlice(s.pod_slice); if (!uid || !pgUids.has(uid)) continue; const acc = m.get(s.ts_ms) ?? 0; m.set(s.ts_ms, acc + s.mem_bytes); } return flattenBuckets(buckets, (b) => b / (1024 ** 3)); } // Classify a pod name into a coarse app-type bucket. With 80+ worker pods // individually plotted, the legend becomes unreadable; rolling up by family // keeps the chart to ~5 lines. function podAppType(name: string): string { if (name.startsWith("windmill-workers-")) return "workers"; if (name.startsWith("windmill-postgresql")) return "postgres"; if (name.startsWith("windmill-app")) return "app"; if (name.startsWith("toxiproxy")) return "toxiproxy"; if (name.startsWith("wm-sim-cpu-sampler")) return "sampler"; return "other"; } // Per-pod cgroup memory over time. With 80+ worker pods, plotting EVERY pod // as its own line makes the legend unreadable, so we keep the top-N by peak // memory as named series and roll the rest into a single "others (sum)" // aggregate. Worker pod names get shortened to their last hash chunk for the // legend (`windmill-workers-default-6fd...-2fhx9` → `wk-2fhx9`). function buildPodMemSeries( samples: CpuSample[], pods: PodEntry[], topN = 20, ): DataPointMulti[] { const uidToName = new Map(); for (const p of pods) uidToName.set(p.uid, p.name); // First pass: build per-pod timeline + remember peak const perPod = new Map>(); const peakByPod = new Map(); for (const s of samples) { if (s.mem_bytes === null) continue; const uid = uidFromPodSlice(s.pod_slice); if (!uid) continue; const name = uidToName.get(uid); if (!name) continue; let m = perPod.get(name); if (!m) { m = new Map(); perPod.set(name, m); } const next = (m.get(s.ts_ms) ?? 0) + s.mem_bytes; m.set(s.ts_ms, next); if (next > (peakByPod.get(name) ?? 0)) peakByPod.set(name, next); } // Pick top-N pods by peak; everyone else aggregates into "others (sum)". const ranked = [...peakByPod.entries()].sort((a, b) => b[1] - a[1]); const topNames = new Set(ranked.slice(0, topN).map(([n]) => n)); const out = new Map>(); const others = new Map(); for (const [name, timeline] of perPod) { if (topNames.has(name)) { out.set(shortPodName(name), timeline); } else { for (const [ts, v] of timeline) { others.set(ts, (others.get(ts) ?? 0) + v); } } } if (others.size > 0) out.set(`others (sum of ${ranked.length - topN} pods)`, others); return flattenBuckets(out, (b) => b / (1024 ** 3)); } // Trim verbose pod names down to a chart-legend friendly form. function shortPodName(name: string): string { if (name.startsWith("windmill-workers-default-")) { return "wk-" + name.split("-").slice(-1)[0]; } if (name.startsWith("wm-sim-cpu-sampler-")) { return "sampler-" + name.split("-").slice(-1)[0]; } if (name.startsWith("windmill-postgresql")) return "postgres"; if (name.startsWith("windmill-app")) return "app"; if (name.startsWith("toxiproxy")) return "toxiproxy"; return name; } // Bar bins: per (node, app-type) pod count, for the "Pod inventory by node" // panel. Order: node ascending, then type by count descending within node. function buildPodInventoryBins(pods: PodEntry[]): { label: string; count: number; color: string }[] { const buckets = new Map>(); for (const p of pods) { if (!p.node) continue; let perType = buckets.get(p.node); if (!perType) { perType = new Map(); buckets.set(p.node, perType); } const type = podAppType(p.name); perType.set(type, (perType.get(type) ?? 0) + 1); } // Color per node so all bars belonging to the same node share a hue. Reuses // the same colorbrewer ramp d3 uses for line charts so the legend visually // matches Node CPU / Node memory if put side-by-side. const NODE_PALETTE = ["#e41a1c", "#377eb8", "#4daf4a", "#984ea3", "#ff7f00"]; const nodes = [...buckets.keys()].sort(); const colorByNode = new Map(); nodes.forEach((n, i) => colorByNode.set(n, NODE_PALETTE[i % NODE_PALETTE.length])); const out: { label: string; count: number; color: string }[] = []; for (const node of nodes) { const nodeShort = node.replace(/^wm-sim-k8s-4node-?/, "") || "n0"; const perType = buckets.get(node)!; const types = [...perType.entries()].sort((a, b) => b[1] - a[1]); for (const [type, count] of types) { out.push({ label: `${nodeShort}: ${type}`, count, color: colorByNode.get(node)! }); } } return out; } // Workers per node — Ready count from the kubectl-based pod_timeline.jsonl. // This is the authoritative version: it sees CrashLoopBackOff pods as NOT // ready (which the cgroup sampler can't, because the failed container's slice // often persists in /sys/fs/cgroup for several seconds after the crash). // Format of each JSONL row: // {"ts": , "pods": [{"name","node","phase","ready"}, ...]} function buildReadyWorkersSeriesFromTimeline(text: string): DataPointMulti[] { const out: DataPointMulti[] = []; for (const line of text.split("\n")) { if (!line.trim()) continue; let row: { ts: number; pods: Array<{ name: string; node: string; ready: boolean }> }; try { row = JSON.parse(line); } catch { continue; } const perNode = new Map(); for (const p of row.pods) { if (!p.name.startsWith("windmill-workers-") || !p.ready) continue; perNode.set(p.node, (perNode.get(p.node) ?? 0) + 1); } for (const [node, count] of perNode) { out.push({ value: count, date: new Date(row.ts), kind: node }); } } out.sort((a, b) => a.date.getTime() - b.date.getTime()); return out; } // Pod restart timeline — for each tick, count the cumulative number of // container restarts across all worker pods, grouped by node. Detects // EVERY pod restart regardless of reason (preemption, OOM, liveness probe // failure, helm rollouts) — fills the gap between the L0/L1/L2 panels // (which only catch specific kill modes) and the actual "how many workers // died this bench" question. // // Uses pod_timeline.jsonl which now includes restartCount per pod. The // number plotted is CUMULATIVE restart count per node since bench start // — climbs as restarts accumulate, so the slope shows the kill rate. // Count of restart events per pod-family during the bench window, derived // purely from pod_timeline.jsonl's restartCount transitions. This is the // ground-truth source for "how many things got killed/restarted" — it // catches what containerd-Status-based collectors miss (e.g. toxiproxy's // "Error" exit 137 that isn't labeled OOMKilled). Each (pod, container) // whose restartCount went up between two ticks counts as one event. function buildRestartEventBins(text: string): { label: string; count: number }[] { const counts = new Map(); // family -> restart events const last = new Map(); // pod_name -> last seen restartCount for (const line of text.split("\n")) { if (!line.trim()) continue; let row: { ts: number; pods: Array<{ name: string; restarts?: number }> }; try { row = JSON.parse(line); } catch { continue; } for (const p of row.pods) { const rc = p.restarts ?? 0; const prev = last.get(p.name); if (prev !== undefined && rc > prev) { const family = podAppType(p.name); counts.set(family, (counts.get(family) ?? 0) + (rc - prev)); } last.set(p.name, rc); } } return [...counts.entries()] .map(([label, count]) => ({ label, count })) .sort((a, b) => b.count - a.count); } function buildRestartTimelineSeries(text: string): DataPointMulti[] { // Track the first-seen restart count per pod, so the plotted value is // RESTARTS DURING THE BENCH WINDOW (not the pre-existing baseline). const baseline = new Map(); const out: DataPointMulti[] = []; for (const line of text.split("\n")) { if (!line.trim()) continue; let row: { ts: number; pods: Array<{ name: string; node: string; restarts?: number }> }; try { row = JSON.parse(line); } catch { continue; } const perNode = new Map(); for (const p of row.pods) { if (!p.name.startsWith("windmill-workers-")) continue; const restarts = p.restarts ?? 0; const base = baseline.get(p.name); if (base === undefined) { baseline.set(p.name, restarts); continue; } const delta = Math.max(0, restarts - base); if (delta > 0) { perNode.set(p.node, (perNode.get(p.node) ?? 0) + delta); } } for (const [node, count] of perNode) { out.push({ value: count, date: new Date(row.ts), kind: node }); } } out.sort((a, b) => a.date.getTime() - b.date.getTime()); return out; } // Workers per node over time — count of distinct UIDs at each tick whose pod // name starts with "windmill-workers-" (i.e. our worker Deployment). // Fallback used when pod_timeline.jsonl is missing. function buildWorkersSeries(samples: CpuSample[], pods: PodEntry[]): DataPointMulti[] { const uid2name = new Map(); for (const p of pods) uid2name.set(p.uid, p.name); const isWorker = (uid: string): boolean => { const n = uid2name.get(uid); return !!n && n.startsWith("windmill-workers-"); }; const buckets = new Map>>(); for (const s of samples) { const uid = uidFromPodSlice(s.pod_slice); if (!uid || !isWorker(uid)) continue; let m = buckets.get(s.node); if (!m) { m = new Map(); buckets.set(s.node, m); } let set = m.get(s.ts_ms); if (!set) { set = new Set(); m.set(s.ts_ms, set); } set.add(uid); } const out: DataPointMulti[] = []; for (const [node, m] of buckets) { for (const [ts, set] of m) { out.push({ value: set.size, date: new Date(ts), kind: node }); } } out.sort((a, b) => a.date.getTime() - b.date.getTime()); return out; } // Rolling-window throughput in jobs/s. For each sample at time T, look back // `windowS` seconds and divide processed-delta by the window. // // Two normalizations: (1) skip windows shorter than MIN_DWALL_S — sub-100ms // dt over a noisy processed counter produces huge fake spikes (50 j/s real // throughput appearing as 30,000+ j/s peaks because dt was 5ms). (2) floor // negative deltas at 0 — `processed = sum - queue_length` is non-monotonic // due to a sampling race between the two counters, so adjacent samples can // show Δprocessed < 0 even though no jobs were "un-processed". Both make // the chart show the real shape without auto-scaling to glitch spikes. const MIN_DWALL_S = 0.5; function buildThroughputSeries( samples: ThroughputSample[], windowS = 1, ): DataPointMulti[] { const out: DataPointMulti[] = []; for (let i = 0; i < samples.length; i++) { const cur = samples[i]; const target_ts = cur.ts - windowS * 1000; let j = i; while (j > 0 && samples[j - 1].ts >= target_ts) j--; const ref = samples[j]; const dWall_s = (cur.ts - ref.ts) / 1000; if (dWall_s < MIN_DWALL_S) continue; const dProcessed = cur.processed - ref.processed; const rate = Math.max(0, dProcessed / dWall_s); out.push({ value: rate, date: new Date(cur.ts), kind: "processed" }); } return out; } // Queue depth (pending jobs) over time — straight from the bench sampler. function buildQueueSeries(samples: ThroughputSample[]): DataPointMulti[] { return samples.map((s) => ({ value: s.queue, date: new Date(s.ts), kind: "queue" })); } // Rolling-average smoothing for multi-series time data. For each series // (grouped by .kind), each output point at time T = mean of input values // whose timestamps fall in (T - windowS, T]. Preserves x-axis placement so // the series still aligns with verticalLines / horizontalLines. function smoothMultiSeries(data: DataPointMulti[], windowS: number): DataPointMulti[] { if (windowS <= 0 || data.length === 0) return data; const windowMs = windowS * 1000; const byKind = new Map(); for (const d of data) { let arr = byKind.get(d.kind); if (!arr) { arr = []; byKind.set(d.kind, arr); } arr.push(d); } const out: DataPointMulti[] = []; for (const [kind, arr] of byKind) { arr.sort((a, b) => a.date.getTime() - b.date.getTime()); let head = 0; for (let i = 0; i < arr.length; i++) { const cur = arr[i].date.getTime(); const cutoff = cur - windowMs; while (head < i && arr[head].date.getTime() < cutoff) head++; let sum = 0; for (let j = head; j <= i; j++) sum += arr[j].value; out.push({ value: sum / (i - head + 1), date: arr[i].date, kind }); } } return out; } function flattenBuckets( buckets: Map>, transform: (v: number) => number = (v) => v, ): DataPointMulti[] { const out: DataPointMulti[] = []; for (const [node, m] of buckets) { for (const [ts, v] of m) { out.push({ value: transform(v), date: new Date(ts), kind: node }); } } out.sort((a, b) => a.date.getTime() - b.date.getTime()); return out; } export type RenderInput = { outDir: string; topology: string; walltimeS: number; finalThroughput?: number; // Cores per node — used to scale CPU to "% of VM" (0-100) instead of raw // "% of one core" (0-N×100). Pass a map for heterogeneous clusters (e.g. a // 2-vCPU control plane + 4-vCPU workers); otherwise pass a single number // applied to every node, or leave undefined for "% of one core". cpusPerNode?: number | Record; }; export async function renderReport(input: RenderInput): Promise { const { outDir, topology, walltimeS } = input; const cpusByNode = new Map(); let fallbackCpus = 1; if (typeof input.cpusPerNode === "number") { fallbackCpus = input.cpusPerNode; } else if (input.cpusPerNode) { for (const [n, c] of Object.entries(input.cpusPerNode)) cpusByNode.set(n, c); } const cpuRaw = await safeReadText(`${outDir}/cpu_samples.tsv`); const podsRaw = await safeReadText(`${outDir}/pods.json`) ?? "[]"; const thrRaw = await safeReadText(`${outDir}/throughput_samples.json`); // optional // Shared relative-time origin: 0s on every panel = bench start (from // meta.json's bench_start_ms). Without this, each chart anchors at its // own earliest sample and panels drift by tens of seconds. let benchStartMs: number | undefined; try { const metaEarly = await safeReadText(`${outDir}/meta.json`); if (metaEarly) { const parsed = JSON.parse(metaEarly) as { bench_start_ms?: unknown }; if (typeof parsed.bench_start_ms === "number" && Number.isFinite(parsed.bench_start_ms)) { benchStartMs = parsed.bench_start_ms; } } } catch { /* fall back to per-chart origin */ } if (!cpuRaw) { console.warn(`[report] no cpu_samples.tsv at ${outDir} — skipping report`); return; } // Compute phase boundary timestamps for phased workloads. // // PULL semantics — not push: a boundary marks when the FIRST job of the // next phase actually starts processing (not when its pushers started // pushing). When push N+1 begins, its first job sits behind everything // from phase ≤N in the FIFO queue. Workers only pull phase N+1's first // job after `sum_at_phase_N_push_end` jobs have been processed. So: // pull_boundary_{N+1} = earliest ts where `processed >= sum_at_t_push_end_N` // For N phases we emit N-1 boundary lines. const wlRaw = await safeReadText(`${outDir}/workload.json`); const phaseBoundaries: Date[] = []; if (wlRaw && thrRaw) { try { const wl = JSON.parse(wlRaw) as { phases?: Array<{ duration_s: number }> }; const thrSamples = JSON.parse(thrRaw) as ThroughputSample[]; if (wl.phases && wl.phases.length > 1 && thrSamples.length > 0) { const tStart = thrSamples[0].ts; let acc = 0; for (let i = 0; i < wl.phases.length - 1; i++) { acc += wl.phases[i].duration_s; const pushBoundaryMs = tStart + acc * 1000; // 1) sum (total pushed) at the push boundary const atPush = thrSamples.find((s) => s.ts >= pushBoundaryMs); if (!atPush) continue; const targetProcessed = atPush.sum; // 2) ts when processed catches up to targetProcessed — that's when // the first job of the next phase actually starts running const pullSample = thrSamples.find((s) => s.processed >= targetProcessed); if (pullSample) phaseBoundaries.push(new Date(pullSample.ts)); } } } catch { /* ignore */ } } // Two variants — Throughput gets the labeled boundaries (P1>P2 etc.), // every other chart gets the lines only. Eliminates the perception of // duplicate labels when 9 charts each rendered the same 3 labels. const lineOpts = phaseBoundaries.length > 0 ? phaseBoundaries : undefined; const lineOptsNoLabels = phaseBoundaries.length > 0 ? { dates: phaseBoundaries, hideLabels: true } : undefined; // Push window — translucent shaded zone covering the time when the bench // is actively pushing jobs. Starts at the first phase with pushers > 0 // (skipping leading idle phases) and ends at the sum of all phase // durations. Drawn on Throughput / Queue depth / Node CPU so it's // visually obvious "this is when jobs were being pushed" vs "drain only". let pushZones: { from: Date; to: Date; label?: string }[] | undefined; // Use thrRaw directly here — `thr` isn't initialized yet at this point in // the function (this code was added above its declaration). Earlier this // was a hard TDZ error and the whole report render failed. if (wlRaw && thrRaw) { try { const wl = JSON.parse(wlRaw) as { phases?: Array<{ duration_s: number; pushers: number }> }; const earlyThr = JSON.parse(thrRaw) as ThroughputSample[]; if (wl.phases && wl.phases.length > 0 && earlyThr.length > 0) { const tStart = earlyThr[0].ts; let acc = 0; let pushStartS: number | null = null; for (const p of wl.phases) { if (pushStartS === null && (p.pushers ?? 0) > 0) { pushStartS = acc; } acc += p.duration_s; } const pushEndS = acc; if (pushStartS !== null && pushEndS > pushStartS) { pushZones = [{ from: new Date(tStart + pushStartS * 1000), to: new Date(tStart + pushEndS * 1000), label: "push window", }]; } } } catch { /* leave zones undefined */ } } const cpu = parseCpuSamples(cpuRaw); const nodeCpu = nodeRootOnly(cpu); const pods: PodEntry[] = JSON.parse(podsRaw); const thr: ThroughputSample[] = thrRaw ? JSON.parse(thrRaw) : []; const scaled = cpusByNode.size > 0 || fallbackCpus > 1; const cpuYLabel = scaled ? "[% of VM]" : "[% of 1 core]"; const panels: DashboardPanel[] = []; if (thr.length > 0) { // 7.5-second rolling window. 5s was still bumpy on P3 (etl_storm) // because the bench's queue-count polls become irregular when the API // is under load, so per-sample Δt varies and 5 s isn't long enough to // average it out. 7.5 s smooths the curve without losing phase shape // (each phase is 8-15 s, so we still see them as distinct shapes). panels.push({ title: "Throughput", yLabel: "[jobs/s]", data: buildThroughputSeries(thr, 7.5), verticalLines: lineOpts, shadedZones: pushZones }); panels.push({ title: "Queue depth", yLabel: "[pending jobs]", data: buildQueueSeries(thr), verticalLines: lineOptsNoLabels, shadedZones: pushZones }); } // Node CPU panel — clamped at 150% so sampler noise can't compress the rest // of the chart, and per-node avg-from-first-job baked into the legend so you // can read "is this cluster actually being used" at a glance. // 2-second rolling average applied so the per-sample 100ms-tick noise is // smoothed out. Title carries the smoothing window so it's not invisible. const CPU_SMOOTH_S = 2; const cpuSeries = smoothMultiSeries(buildNodeCpuSeries(nodeCpu, cpusByNode, fallbackCpus), CPU_SMOOTH_S); const avgsByNode = perNodeAvgFromFirstJob(cpuSeries, 5); // Flag the node that hosts PG so it stands out in the legend — when the user // asks "how much CPU does PG pull at this scale" they need to read it off // a specific line, not guess which of m02/m03/m04 has the database. const pgNodes = new Set(); for (const p of pods) { if (p.name.startsWith("windmill-postgresql")) pgNodes.add(p.node); } const cpuSeriesLabeled = cpuSeries.map((d) => { const avg = avgsByNode.get(d.kind); const pgFlag = pgNodes.has(d.kind) ? " [PG]" : ""; const tag = avg !== undefined ? `${shortNode(d.kind)}${pgFlag} avg ${avg.toFixed(0)}%` : `${shortNode(d.kind)}${pgFlag}`; return { ...d, kind: tag }; }); // Horizontal reference line at 100% (= one whole VM saturated). Without // it, eyes can't tell whether a 90% reading is "lots of headroom" or // "about to peg" because the y-axis goes to 150 (the clamp). const cpuHLines = scaled ? [{ y: 100, label: "100% (full VM)" }] : undefined; // yMax was 150 (room for sampler noise spikes); dropped to 110 so the // idle 0-3% range isn't compressed to a 2px band at the bottom of the // chart. The 100% horizontal reference line still shows the ceiling. const nodeCpuSvg = drawGraphMulti( cpuSeriesLabeled, `Node CPU (${CPU_SMOOTH_S}s smoothed)`, cpuYLabel, 110, lineOpts, cpuHLines, pushZones, "[PG]", undefined, benchStartMs, ); panels.push({ title: "Node CPU", yLabel: cpuYLabel, data: [], svg: nodeCpuSvg, verticalLines: lineOpts }); // Util group: one panel per node, layered: // 1) orange oversaturation area BEHIND — max(0, load1/ncpu - 1) × 100, // capped at 100% so the panel y-axis stays comparable across nodes // 2) CPU util area on top — same per-node util series as Node CPU, capped // at 100% (% of VM) // The combo answers "is this node maxed AND is there extra queued work". // cols: 2 → 2 panels per row, wraps when nodes >2. const nodeLoadRaw = await safeReadText(`${outDir}/node_load.jsonl`); if (nodeLoadRaw) { // Oversaturation is NOT capped — let the y-axis autoscale up to whatever // peak the bench produced. CPU util IS capped at 100% (it's already // "% of VM" so values >100 are sampler noise). The panel y-axis then // covers [0, max(100, peak_oversat)] which is what we want: CPU util // visible against the 100% ceiling, oversaturation shown at full magnitude. // Oversaturation = (procs_running - ncpu) / ncpu × 100, lower-clamped at 0. // procs_running is the runnable count from /proc/stat — actual CPU run- // queue pressure. Earlier we used load1 which includes D-state procs and // wildly overestimated oversaturation when PG backends were in disk wait. // Fallback to load1 only if procs_running is missing (older poller schema). const loadByNode = new Map(); for (const line of nodeLoadRaw.split("\n")) { if (!line.trim()) continue; try { const r = JSON.parse(line) as { ts: number; node: string; load1: number; ncpu: number; procs_running?: number | null; }; if (!Number.isFinite(r.ts)) continue; const oversat = computeOversatPct(r.ncpu, { procs_running: r.procs_running, load1: r.load1, }); const arr = loadByNode.get(r.node) ?? []; arr.push({ ts: r.ts, oversatPct: oversat }); loadByNode.set(r.node, arr); } catch { /* ignore malformed */ } } const cpuUtilByNode = new Map(); for (const d of cpuSeries) { const arr = cpuUtilByNode.get(d.kind) ?? []; arr.push({ ts: d.date.getTime(), utilPct: Math.min(100, d.value) }); cpuUtilByNode.set(d.kind, arr); } const utilGroup = { index: 150, label: "Util — per node: CPU util (capped 100%, blue, 2s smoothed) + oversaturation behind (orange, (procs_running − ncpu) / ncpu × 100, 5s smoothed)", cols: 2, }; const sortedNodes = [...cpuUtilByNode.keys()].sort(); for (const nodeName of sortedNodes) { const utilPts = cpuUtilByNode.get(nodeName) ?? []; const loadPts = loadByNode.get(nodeName) ?? []; // 5s rolling smooth on oversaturation — procs_running is sampled at 2Hz // and bounces noisily as workers fire. CPU util is already 2s-smoothed // (CPU_SMOOTH_S above), leave as-is. const OVERSAT_SMOOTH_S = 5; const oversatRaw: DataPointMulti[] = loadPts.map((p) => ({ value: p.oversatPct, date: new Date(p.ts), kind: "oversaturation", })); const oversatSmoothed = smoothMultiSeries(oversatRaw, OVERSAT_SMOOTH_S); const data: DataPointMulti[] = []; for (const p of utilPts) data.push({ value: p.utilPct, date: new Date(p.ts), kind: "cpu util" }); for (const d of oversatSmoothed) data.push(d); if (data.length === 0) continue; panels.push({ title: `${shortNode(nodeName)}${pgNodes.has(nodeName) ? " [PG]" : ""} — CPU + oversat`, yLabel: "[%]", data, // No yMax → panel autoscales to max(oversat). // No shadedZones → push window removed per user request. verticalLines: lineOptsNoLabels, horizontalLines: [{ y: 100, label: "100% (full VM)" }], phaseGroup: utilGroup, areaFills: [ // Oversaturation drawn first (backmost). Orange fills the full // height to the curve, so above the CPU-util area it shows as the // "extra queued work" band. { kind: "oversaturation", color: "#ff8c00", opacity: 0.55 }, // CPU util drawn on top, SOLID — gives a crisp "CPU is busy here" // floor. Oversaturation above the util ceiling shows on top of it. { kind: "cpu util", color: "#377eb8", opacity: 1.0 }, ], lineColorOverrides: { "oversaturation": "#ff8c00", "cpu util": "#377eb8", }, }); } } // Node memory gets its own boxed section: time-series + per-phase // average. User explicitly wanted the per-phase avg view so memory // pressure can be compared phase-to-phase (e.g. is etl_storm consistently // worse than morning_rush) at a glance. // Section uses cols=2 so the time-series (colSpan:2) hogs the first // row, and the per-phase bars below pair up 2 per row. // Section order (by index ascending): 100 memory, 200 pg latency, 300+ // workload phases, 400 kill events. const nodeMemGroup = { index: 100, label: "Node memory — over time + per-phase averages", cols: 2 }; const nodeMemSeries = buildNodeMemSeries(nodeCpu); panels.push({ title: "Node memory (over time)", yLabel: "[MiB]", data: nodeMemSeries, verticalLines: lineOptsNoLabels, phaseGroup: nodeMemGroup, colSpan: 2 }); // Per-phase per-node memory averages. Read phase windows from workload.json // (push boundaries are clean enough for "what did each phase typically // pull?"; pull-based boundaries from phaseBoundaries[] are also OK but // they shift behind push by the queue lag). const wlForMemAvg = wlRaw ? (() => { try { return JSON.parse(wlRaw) as { phases?: Array<{ name?: string; duration_s: number }> }; } catch { return null; } })() : null; const phaseDefs = wlForMemAvg?.phases ?? []; if (phaseDefs.length > 0 && thr.length > 0) { const tStart = thr[0].ts; let acc = 0; const phaseWindows = phaseDefs.map((p, i) => { const start = tStart + acc * 1000; acc += p.duration_s; const end = tStart + acc * 1000; return { name: p.name ?? `phase${i + 1}`, index: i, start, end }; }); // Per-(node, phase): mean memory_mib. Then for each phase emit a bar // chart showing node→mean (skipping cp because we never care about // its memory and it makes the bars unreadable). const memByNodeTs = new Map>(); for (const s of nodeCpu) { if (s.mem_bytes === null) continue; let m = memByNodeTs.get(s.node); if (!m) { m = new Map(); memByNodeTs.set(s.node, m); } m.set(s.ts_ms, s.mem_bytes); } const workerNodes = [...memByNodeTs.keys()] .filter((n) => !n.endsWith("4node") || n.endsWith("m02") || n.endsWith("m03") || n.endsWith("m04")) .sort(); for (const w of phaseWindows) { const bins: { label: string; count: number }[] = []; for (const node of workerNodes) { const samples = memByNodeTs.get(node)!; let sum = 0; let n = 0; for (const [ts, v] of samples) { if (ts >= w.start && ts <= w.end) { sum += v; n++; } } if (n === 0) continue; const meanMiB = sum / n / (1024 * 1024); const nodeShort = node.replace(/^wm-sim-k8s-4node-?/, "") || "n0"; bins.push({ label: nodeShort, count: Math.round(meanMiB) }); } const svg = drawBars( bins.length > 0 ? bins : [{ label: "(no data)", count: 0 }], `Phase ${w.index + 1} (${w.name})`, "avg mem [MiB] per node", ); panels.push({ title: `Phase ${w.index + 1} (${w.name})`, yLabel: "[MiB]", data: [], svg, phaseGroup: nodeMemGroup, }); } } // PG memory over time (cgroup memory.current). Sampled by the per-pod sampler. // The single most-watched signal for "is PG about to die" in our benches. const pgMemSeries = buildPgMemSeries(cpu, pods); if (pgMemSeries.length > 0) { panels.push({ title: "PG memory", yLabel: "[GiB]", data: pgMemSeries, verticalLines: lineOptsNoLabels }); } // PG response latency over time — from pg_latency.jsonl, written by the // 1Hz poller. Flat near zero when PG is healthy; spikes when the // connection pool / backend forks / autovacuum stalls under load. const pgLatRaw = await safeReadText(`${outDir}/pg_latency.jsonl`); if (pgLatRaw) { // Split into TWO panels in their own boxed group because the y-axis // scales differ by ~3 orders of magnitude: // - pg query time (psql `\timing on`) is sub-ms when healthy // - kubectl-exec roundtrip is ~200ms baseline (control-plane overhead) // On a shared axis the pg-query line is a flat zero. Splitting lets // each have its own auto-scale. const pgQueryPts: DataPointMulti[] = []; const kubectlPts: DataPointMulti[] = []; for (const line of pgLatRaw.split("\n")) { if (!line.trim()) continue; try { const r = JSON.parse(line) as { ts: number; latency_ms: number; pg_query_ms?: number }; if (Number.isFinite(r.ts) && Number.isFinite(r.latency_ms)) { kubectlPts.push({ value: r.latency_ms, date: new Date(r.ts), kind: "kubectl roundtrip" }); } if (Number.isFinite(r.ts) && Number.isFinite(r.pg_query_ms)) { pgQueryPts.push({ value: r.pg_query_ms!, date: new Date(r.ts), kind: "pg query" }); } } catch { /* ignore malformed line */ } } if (pgQueryPts.length > 0 || kubectlPts.length > 0) { const PG_LAT_SMOOTH_S = 1; const pgQuerySmoothed = smoothMultiSeries(pgQueryPts, PG_LAT_SMOOTH_S); const kubectlSmoothed = smoothMultiSeries(kubectlPts, PG_LAT_SMOOTH_S); const pgLatGroup = { index: 200, label: "PG response latency — pure PG query vs kubectl-exec roundtrip" }; if (pgQuerySmoothed.length > 0) { panels.push({ title: `PG query time — psql \\timing (4Hz, ${PG_LAT_SMOOTH_S}s smoothed)`, yLabel: "[ms]", data: pgQuerySmoothed, verticalLines: lineOptsNoLabels, shadedZones: pushZones, phaseGroup: pgLatGroup, }); } if (kubectlSmoothed.length > 0) { panels.push({ title: `kubectl exec roundtrip (4Hz, ${PG_LAT_SMOOTH_S}s smoothed)`, yLabel: "[ms]", data: kubectlSmoothed, verticalLines: lineOptsNoLabels, shadedZones: pushZones, phaseGroup: pgLatGroup, }); } } } // PG connection counts over time — pg_stat_activity broken down by state. // Tells you when sqlx pools saturate (active climbs to N_workers * pool_size), // and exposes leaks (idle_in_transaction climbing without bound). const pgConnRaw = await safeReadText(`${outDir}/pg_connections.jsonl`); if (pgConnRaw) { const pts: DataPointMulti[] = []; for (const line of pgConnRaw.split("\n")) { if (!line.trim()) continue; try { const r = JSON.parse(line) as { ts: number; active?: number; idle?: number; idle_in_xact?: number; total?: number }; if (!Number.isFinite(r.ts)) continue; const d = new Date(r.ts); if (Number.isFinite(r.active)) pts.push({ value: r.active!, date: d, kind: "active" }); if (Number.isFinite(r.idle)) pts.push({ value: r.idle!, date: d, kind: "idle" }); if (Number.isFinite(r.idle_in_xact)) pts.push({ value: r.idle_in_xact!, date: d, kind: "idle_in_xact" }); if (Number.isFinite(r.total)) pts.push({ value: r.total!, date: d, kind: "total" }); } catch { /* ignore malformed line */ } } if (pts.length > 0) { const pgConnGroup = { index: 250, label: "PG connections — pg_stat_activity by state (1Hz)" }; panels.push({ title: "PG connections over time", yLabel: "[#]", data: pts, verticalLines: lineOptsNoLabels, shadedZones: pushZones, phaseGroup: pgConnGroup, }); } } // Pod memory PEAK as a bar chart — one bar per pod, 90° rotated labels // so all ~100 pods fit horizontally. A time-series with 80+ lines would // be unreadable; peak-per-pod tells you "which pods got biggest" at a // glance, which is what matters for OOM analysis. { const uidToName = new Map(); for (const p of pods) uidToName.set(p.uid, p.name); const peakByPod = new Map(); for (const s of cpu) { if (s.mem_bytes === null) continue; const uid = uidFromPodSlice(s.pod_slice); if (!uid) continue; const name = uidToName.get(uid); if (!name) continue; const cur = (peakByPod.get(name) ?? 0); const next = s.mem_bytes; if (next > cur) peakByPod.set(name, next); } const bins = [...peakByPod.entries()] .map(([name, bytes]) => ({ label: shortPodName(name), count: Math.round(bytes / (1024 * 1024)) })) .sort((a, b) => b.count - a.count); if (bins.length > 0) { const svg = drawBars( bins, "Pod memory peak (per pod)", `[MiB] — ${bins.length} pods sorted by peak`, undefined, { rotateDeg: -90, fontSize: "7px" }, ); panels.push({ title: "Pod memory peak (per pod)", yLabel: "[MiB]", data: [], svg }); } } // Pod inventory by node — bar chart, one bar per (node, app-type) pair. // Quick read on how the cluster's pod families are distributed: // "m02 has PG + 1 worker + sampler", "m03 has toxi + 35 workers + sampler", // etc. Captured from end-of-bench pods.json so it reflects the final // (post-preemption) layout. { const bins = buildPodInventoryBins(pods); const svg = drawBars( bins.length > 0 ? bins : [{ label: "(no pods)", count: 0 }], "Pod inventory by node", `${pods.length} pods across ${new Set(pods.map((p) => p.node).filter(Boolean)).size} node(s)`, ); panels.push({ title: "Pod inventory by node", yLabel: "[count]", data: [], svg }); } // Failed jobs over time — cumulative count from the per-bench failed_jobs.jsonl // file populated at end-of-bench. Same phase boundary lines as the other // time-series so a spike can be attributed to the right phase. Always // rendered (even when the file is missing or empty) — a flat line at 0 is a // positive signal that no jobs failed, which we want on every dashboard. const failedRaw = await safeReadText(`${outDir}/failed_jobs.jsonl`); const failures: Array<{ ts_ms: number; category?: string; exit_code?: number }> = []; if (failedRaw) { for (const line of failedRaw.split("\n")) { if (!line.trim()) continue; try { const r = JSON.parse(line); if (Number.isFinite(r.ts_ms)) failures.push({ ts_ms: r.ts_ms, category: r.category, exit_code: r.exit_code }); } catch { /* ignore */ } } } // Failed jobs by failure category — the missing-context panel: catches the // "1000+ jobs failed, what killed them" question. Exit 137 / SIGKILL is // the smoking gun for "deno subprocess SIGKILLed by something OTHER than // cgroup OOM" (most often: worker's heartbeat-reaper killing its own deno // child because the worker→app ping timed out). { const byCat = new Map(); for (const f of failures) { const cat = f.category ?? "unknown"; byCat.set(cat, (byCat.get(cat) ?? 0) + 1); } const bins = [...byCat.entries()] .map(([label, count]) => ({ label, count })) .sort((a, b) => b.count - a.count); const svg = drawBars( bins.length > 0 ? bins : [{ label: "(no failures)", count: 0 }], "Failed jobs by category", `${failures.length} total failure(s)`, ); panels.push({ title: "Failed jobs by category", yLabel: "[count]", data: [], svg }); } if (failures.length > 0) { failures.sort((a, b) => a.ts_ms - b.ts_ms); const series: DataPointMulti[] = failures.map((f, i) => ({ value: i + 1, date: new Date(f.ts_ms), kind: "failed", })); panels.push({ title: "Failed jobs (cumulative)", yLabel: "[count]", data: series, verticalLines: lineOptsNoLabels }); } else { const t0 = thr.length > 0 ? thr[0].ts : Date.now(); const t1 = thr.length > 0 ? thr[thr.length - 1].ts : t0 + 1000; panels.push({ title: "Failed jobs (cumulative)", yLabel: "[count]", data: [ { value: 0, date: new Date(t0), kind: "failed" }, { value: 0, date: new Date(t1), kind: "failed" }, ], verticalLines: lineOptsNoLabels, }); } // Workers per node — prefer kubectl-based pod_timeline.jsonl (truthy Ready // signal) over the cgroup-based fallback (counts cgroup slices regardless of // pod health, which makes a crashlooping fleet look fully staffed). const timelineRaw = await safeReadText(`${outDir}/pod_timeline.jsonl`); const workersSeries = timelineRaw ? buildReadyWorkersSeriesFromTimeline(timelineRaw) : buildWorkersSeries(cpu, pods); panels.push({ title: "Workers per node (Ready)", yLabel: "[count]", data: workersSeries, verticalLines: lineOptsNoLabels }); // Pod restart timeline — cumulative count of container restarts per node, // captured by the 1Hz pod_timeline poller. Catches kills the L0/L1/L2 // panels miss: helm rollouts, liveness probe failures, generic exit-137 // SIGKILLs, anything that increments containerStatus.restartCount. if (timelineRaw) { const restartSeries = buildRestartTimelineSeries(timelineRaw); panels.push({ title: "Worker pod restarts (cumulative)", yLabel: "[count]", data: restartSeries, verticalLines: lineOpts, }); } // Kill events captured during the bench window. Three discrete panels — // always rendered, even when zero, so absence is a positive signal: // - L1: kubelet evictions (graceful, under node memory pressure) // - L2a: cgroup OOM kills (per-pod limit hit — limits.memory) // - L2b: node-kernel OOM kills (whole VM OOM, kernel picks a process) // L1 events carry memory-at-kill in the kubelet message; L2 events don't // (kernel dmesg doesn't log RSS), so we only render bytes on the L1 panel. // Empty-state panels show a "no kills" bar so the slot is visible in the grid. const oomRaw = await safeReadText(`${outDir}/oom_events.json`); let events: OomEvent[] = []; if (oomRaw) { try { events = JSON.parse(oomRaw); } catch (e) { console.warn(`[report] couldn't parse oom_events.json: ${(e as Error).message}`); } } const l0Events = events.filter(e => e.source === "scheduler_preemption"); const l1Events = events.filter(e => e.source === "kubelet_eviction"); const l2cEvents = events.filter(e => e.source === "cgroup"); const l2kEvents = events.filter(e => e.source === "node_kernel"); // L0/L1/L2 kill panels: grouped into ONE row inside a boxed section so the // four kill modes can be compared side-by-side. Reusing phaseGroup with a // high index ensures these render AFTER the phase distribution sections. // // The cascade (when does each fire): // L0 — Scheduler preemption (priorityClass collision; before pod runs) // L1 — Kubelet eviction (node memory/disk pressure; pod-level) // L2 — cgroup OOM kill (container hits its limits.memory) // L2 — node-kernel OOM kill (subprocess inside a cgroup gets oom-killed) const oomGroup = { index: 400, label: "Kill events — L0 preempt / L1 evict / L2 cgroup / L2 node-kernel" }; // L0 — Scheduler preemptions (new — was missing entirely) { const bins = l0Events.length > 0 ? buildOomBins(l0Events) : [{ label: "(none)", count: 0 }]; const xLabel = l0Events.length > 0 ? `victim (${l0Events.length} preempt(s))` : "victim (no preemptions)"; const svg = drawBars(bins, "L0 — Scheduler preemptions", xLabel); panels.push({ title: "L0 — Scheduler preemptions", yLabel: "[count]", data: [], svg, phaseGroup: oomGroup }); } // L1 — Kubelet evictions { const bins = l1Events.length > 0 ? buildL1Bins(l1Events) : [{ label: "(none)", count: 0 }]; const totalBytes = l1Events.reduce((a, e) => a + (e.bytes_at_kill ?? 0), 0); const sizeStr = totalBytes > 0 ? `, ${(totalBytes / (1024 ** 3)).toFixed(1)} GiB total` : ""; const xLabel = l1Events.length > 0 ? `pod (${l1Events.length} evict(s)${sizeStr})` : "pod (no evictions)"; const svg = drawBars(bins, "L1 — Kubelet evictions", xLabel); panels.push({ title: "L1 — Kubelet evictions", yLabel: "[count]", data: [], svg, phaseGroup: oomGroup }); } // L2a — cgroup OOM kills (per-pod limits.memory) { const bins = l2cEvents.length > 0 ? buildOomBins(l2cEvents) : [{ label: "(none)", count: 0 }]; const xLabel = l2cEvents.length > 0 ? `victim (${l2cEvents.length} cgroup OOM(s))` : "victim (no cgroup OOMs)"; const svg = drawBars(bins, "L2 — cgroup OOM kills", xLabel); panels.push({ title: "L2 — cgroup OOM kills", yLabel: "[count]", data: [], svg, phaseGroup: oomGroup }); } // L2b — node-kernel OOM kills (whole-VM OOM) { const bins = l2kEvents.length > 0 ? buildOomBins(l2kEvents) : [{ label: "(none)", count: 0 }]; const xLabel = l2kEvents.length > 0 ? `victim (${l2kEvents.length} node-kernel OOM(s))` : "victim (no node-kernel OOMs)"; const svg = drawBars(bins, "L2 — node-kernel OOM kills", xLabel); panels.push({ title: "L2 — node-kernel OOM kills", yLabel: "[count]", data: [], svg, phaseGroup: oomGroup }); } // Restart events from pod_timeline.jsonl. Counts every restartCount // increment per pod-family during the bench window. THIS IS THE CATCH-ALL // — works regardless of how containerd labels the termination (Error, // OOMKilled, ContainerCannotRun, whatever), how dmesg parsing performed, // or whether kubelet logs were reachable. Verified on the previous bench // to catch 4 toxiproxy restarts that all the other panels missed. if (timelineRaw) { const bins = buildRestartEventBins(timelineRaw); const total = bins.reduce((a, b) => a + b.count, 0); const xLabel = total > 0 ? `pod family (${total} restart event(s) total)` : "pod family (no restarts in window)"; const svg = drawBars( bins.length > 0 ? bins : [{ label: "(no restarts)", count: 0 }], "Restart events (from pod_timeline)", xLabel, ); panels.push({ title: "Restart events (from pod_timeline)", yLabel: "[count]", data: [], svg, phaseGroup: oomGroup }); } // Container terminations — the ground-truth source. Reads pods.json // (captured at end of bench) directly: any container with restartCount > 0 // whose lastState.terminated.finishedAt falls inside the bench window is // counted, regardless of containerd's "reason" label (which often says // "Error" for what was actually cgroup-OOM exit 137, dropping it off the // L1/L2 panels). This is what answers "did anything die during the bench" // without depending on event collectors, dmesg parsing, or kubelet log // reachability. // benchStartMs from meta.json is the shared chart-time origin (declared at // top of function). For the bench-window terminations check below we want // the actual first/last throughput sample — those bookend "during the // bench" more precisely than meta.json's wall-clock start (which can be // a few seconds before any throughput data exists). const termWindowStartMs = thr.length > 0 ? thr[0].ts : 0; const benchEndMs = thr.length > 0 ? thr[thr.length - 1].ts : Date.now(); type Term = { pod: string; container: string; reason: string; exitCode: number; ts: number }; const terms: Term[] = []; for (const p of pods) { for (const c of p.containers ?? []) { const t = c.lastState?.terminated; if (!t || !t.finishedAt) continue; const ts = Date.parse(t.finishedAt); if (!Number.isFinite(ts) || ts < termWindowStartMs || ts > benchEndMs + 5000) continue; terms.push({ pod: p.name, container: c.name, reason: t.reason ?? "?", exitCode: t.exitCode ?? 0, ts, }); } } { const counts = new Map(); for (const t of terms) { // Group by pod-deployment prefix (strip the random hash tail) + // reason+exit so we can see "60 worker pods, Error exit 137" as one bar // instead of 60 separate bars. const family = t.pod.replace(/-[a-z0-9]{5,10}$/, "").replace(/-[a-f0-9]{8,}$/, ""); const key = `${family} (${t.reason}/${t.exitCode})`; counts.set(key, (counts.get(key) ?? 0) + 1); } const bins = counts.size > 0 ? [...counts.entries()] .map(([label, count]) => ({ label, count })) .sort((a, b) => b.count - a.count) : [{ label: "(no terminations in bench window)", count: 0 }]; const xLabel = terms.length > 0 ? `container (${terms.length} termination(s) in window)` : "container (no terminations)"; const svg = drawBars(bins, "Container terminations (all reasons)", xLabel); panels.push({ title: "Container terminations (all reasons)", yLabel: "[count]", data: [], svg, phaseGroup: oomGroup }); } // Workload distribution panels — only when the bench saved a workload.json // (i.e. `--script-pattern random` was used). Synthesises 10k samples from // each distribution and renders bar/histogram panels. const workloadRaw = await safeReadText(`${outDir}/workload.json`); if (workloadRaw) { try { const cfg = JSON.parse(workloadRaw) as WorkloadConfig; panels.push(...buildWorkloadPanels(cfg)); } catch (e) { console.warn(`[report] couldn't parse workload.json: ${(e as Error).message}`); } } // Per-panel SVG. for (const p of panels) { if (p.data.length === 0 && !p.svg) continue; const svg = p.svg ?? drawGraphMulti(p.data, p.title, p.yLabel, undefined, p.verticalLines); await Deno.writeTextFile(`${outDir}/${slug(p.title)}.svg`, svg); } // Run-context summary — pinned at the top so you can tell at a glance // which bench/topology/workload produced this dashboard. Includes paths // so you can re-derive everything from the report dir alone. const summary: Array<{ label: string; value: string }> = []; const metaJsonRaw = await safeReadText(`${outDir}/meta.json`); if (metaJsonRaw) { try { const m = JSON.parse(metaJsonRaw) as { topology?: string; host?: string; workspace?: string; bench_cmd?: string; workload_path?: string; helm_values?: string[]; }; if (m.topology) summary.push({ label: "Topology", value: m.topology }); if (m.workload_path) summary.push({ label: "Workload", value: m.workload_path }); if (m.host) summary.push({ label: "Host", value: m.host }); if (m.workspace) summary.push({ label: "Workspace", value: m.workspace }); if (m.helm_values?.length) summary.push({ label: "Helm values", value: m.helm_values.join(", ") }); if (m.bench_cmd) summary.push({ label: "Bench cmd", value: m.bench_cmd }); } catch { /* fall through */ } } // Always also show the report dir path so you know where to find raw data // (cpu_samples.tsv, pg.log, pgbadger.html, JSONLs). summary.push({ label: "Report dir", value: outDir }); // Phased workload summary — list each phase with its time window, pusher // count, and dominant mode, so reading the dashboard side-by-side with the // throughput chart, you can attribute dips/spikes to the right phase. if (workloadRaw) { try { const wcfg = JSON.parse(workloadRaw) as { phases?: Array<{ name?: string; duration_s: number; pushers: number; mode?: { weights?: Record }; }> }; if (Array.isArray(wcfg.phases) && wcfg.phases.length > 0) { let t = 0; for (let i = 0; i < wcfg.phases.length; i++) { const p = wcfg.phases[i]; const t0 = t; const t1 = t + p.duration_s; t = t1; const weights = p.mode?.weights ?? {}; const total = Object.values(weights).reduce((a, b) => a + b, 0); const top = Object.entries(weights).sort((a, b) => b[1] - a[1])[0]; const modeStr = top && total > 0 ? `${(top[1] / total * 100).toFixed(0)}% ${top[0]}` : "—"; summary.push({ label: `Phase ${i + 1} ${p.name ?? ""}`.trim(), value: `${t0}-${t1}s | ${p.pushers} pushers | mode: ${modeStr}`, }); } } } catch { /* ignore — fall through to no summary */ } } // Composed dashboard. const meta: DashboardMeta = { topology, suite: outDir, generated: new Date().toISOString(), walltime_s: walltimeS, jobs_completed: thr.length > 0 ? thr[thr.length - 1].processed : 0, throughput_per_s: input.finalThroughput ?? 0, summary: summary.length > 0 ? summary : undefined, }; await Deno.writeTextFile( `${outDir}/dashboard.svg`, renderDashboard(meta, panels, { xRelativeOriginMs: benchStartMs }), ); // Best-effort PDF render alongside the SVG. Some viewers prefer PDF; this // gives both for free. Failures don't abort the report. try { await renderSvgToPdf(`${outDir}/dashboard.svg`, `${outDir}/dashboard.pdf`); } catch (e) { console.warn(`[report] PDF render failed (svg still written): ${(e as Error).message}`); } // Markdown report. const md: string[] = []; md.push(`# ${topology}`); md.push(""); md.push(`- Wall time: ${walltimeS.toFixed(1)}s`); if (input.finalThroughput !== undefined) { md.push(`- Final throughput: ${input.finalThroughput.toFixed(2)} jobs/s`); } md.push(`- Generated: ${meta.generated}`); md.push(""); md.push("## Dashboard"); md.push(""); md.push("![Dashboard](./dashboard.svg)"); md.push(""); md.push("## Panels"); md.push(""); for (const p of panels) { if (p.data.length === 0 && !p.svg) continue; md.push(`### ${p.title}`); md.push(""); md.push(`![${p.title}](./${slug(p.title)}.svg)`); md.push(""); } await Deno.writeTextFile(`${outDir}/report.md`, md.join("\n") + "\n"); const rendered = panels.filter((p) => p.data.length > 0 || p.svg).length; console.log(`[report] dashboard.svg + report.md + ${rendered} panel SVG(s) -> ${outDir}`); } // Synthesise 10k samples from a single distribution and bucket them into // histogram bins. For categorical distributions, returns one bin per category // in declaration order (counts proportional to weights × N). function sampleDist(spec: DistSpec, n = 10000): { values: number[]; categorical: { label: string; count: number }[] | null } { if (spec.dist === "categorical") { const total = Object.values(spec.weights).reduce((a, b) => a + b, 0); return { values: [], categorical: Object.entries(spec.weights).map(([label, w]) => ({ label, count: Math.round((w / total) * n), })), }; } // Reuse sampleJobParams's underlying sampler by constructing a throwaway // config: pick one parameter at a time. const dummyCat: DistSpec = { dist: "categorical", weights: { sleep: 1 } }; const cfg = { ram_mb: spec, duration_ms: { dist: "uniform", min: 0, max: 1 } as DistSpec, mode: dummyCat, }; const values: number[] = []; for (let i = 0; i < n; i++) values.push(sampleJobParams(cfg).ram_mb); return { values, categorical: null }; } // Bucket continuous samples into ~20 bins. Uses log-space bins when values // span >2 orders of magnitude (typical for lognormal) so the heavy tail // doesn't squash the bulk of the distribution into one bar. function histogramBins(values: number[], binCount = 20): { label: string; count: number }[] { if (values.length === 0) return []; const min = Math.min(...values); const max = Math.max(...values); if (min === max) return [{ label: String(min), count: values.length }]; // Log binning kicks in when min > 0 and we span >100x — otherwise linear. const useLog = min > 0 && max / min > 100; const edges: number[] = []; if (useLog) { const logMin = Math.log(min); const logMax = Math.log(max); const step = (logMax - logMin) / binCount; for (let i = 0; i <= binCount; i++) edges.push(Math.exp(logMin + i * step)); } else { const step = (max - min) / binCount; for (let i = 0; i <= binCount; i++) edges.push(min + i * step); } const bins = Array.from({ length: binCount }, (_, i) => ({ label: String(Math.round((edges[i] + edges[i + 1]) / 2)), count: 0, })); for (const v of values) { // Binary search would be tidier but binCount is tiny. let idx = binCount - 1; for (let i = 0; i < binCount; i++) { if (v < edges[i + 1]) { idx = i; break; } } bins[idx].count++; } return bins; } // One bar per (victim, source) pair, sorted by count desc so the noisiest // victims are leftmost. cgroup OOMs are the "right kind" — show them with the // pod name as-is. Node-kernel OOMs name the process (e.g. "deno") — tagged // so the source distinction stays visible at a glance. // One bar per evicted pod, label includes total bytes evicted across all // eviction events for that pod. Sorted by event count desc — most-frequently // evicted at the left. function buildL1Bins(events: OomEvent[]): { label: string; count: number }[] { const counts = new Map(); const bytes = new Map(); for (const e of events) { counts.set(e.victim, (counts.get(e.victim) ?? 0) + 1); if (e.bytes_at_kill) { bytes.set(e.victim, (bytes.get(e.victim) ?? 0) + e.bytes_at_kill); } } return [...counts.entries()] .map(([pod, count]) => { const b = bytes.get(pod); const sizeStr = b ? b >= 1024 ** 3 ? `${(b / 1024 ** 3).toFixed(1)}G` : `${(b / 1024 ** 2).toFixed(0)}M` : ""; const label = sizeStr ? `${pod} (${sizeStr})` : pod; return { label, count }; }) .sort((a, b) => b.count - a.count); } function buildOomBins(events: OomEvent[]): { label: string; count: number }[] { const counts = new Map(); for (const e of events) { const tag = e.source === "cgroup" ? "cgroup" : "kernel"; const label = `${e.victim} (${tag})`; counts.set(label, (counts.get(label) ?? 0) + 1); } return [...counts.entries()] .map(([label, count]) => ({ label, count })) .sort((a, b) => b.count - a.count); } // Cast helper — TS would otherwise narrow `WorkloadConfig` to either branch // at the use site and complain. The phased path is checked first so a // `phases:` field shorts out the static-config interpretation. function workloadPhases(cfg: WorkloadConfig): Array<{ name?: string; ram_mb?: DistSpec; duration_ms?: DistSpec; mode?: DistSpec; duration_s?: number; pushers?: number }> | null { const maybePhased = cfg as unknown as { phases?: Array<{ name?: string; ram_mb?: DistSpec; duration_ms?: DistSpec; mode?: DistSpec; duration_s?: number; pushers?: number }> }; return Array.isArray(maybePhased.phases) ? maybePhased.phases : null; } function buildOneStaticDistributionPanels( cfg: { ram_mb?: DistSpec; duration_ms?: DistSpec; mode?: DistSpec }, titlePrefix: string, phaseGroup?: { index: number; label: string }, ): DashboardPanel[] { const out: DashboardPanel[] = []; for (const [field, baseLabel, xLabel] of [ ["ram_mb", "RAM distribution", "ram_mb"], ["duration_ms", "Duration distribution", "duration_ms"], ["mode", "Mode distribution", "mode"], ] as const) { const spec = cfg[field]; if (!spec) continue; const { values, categorical } = sampleDist(spec); // For phased panels the section header already names the phase; keep // the per-panel title concise so titles don't get truncated in the // narrower per-panel slot. const label = phaseGroup ? baseLabel : titlePrefix ? `${titlePrefix} — ${baseLabel}` : baseLabel; let svg: string; if (categorical) { svg = drawDonut(categorical, label); } else { const stats = { min: Math.min(...values), max: Math.max(...values), avg: values.reduce((a, b) => a + b, 0) / values.length, }; svg = drawBars(histogramBins(values), label, xLabel, stats); } out.push({ title: label, yLabel: "[count]", data: [], svg, phaseGroup }); } return out; } function buildWorkloadPanels(cfg: WorkloadConfig): DashboardPanel[] { const phases = workloadPhases(cfg); if (phases) { // Phased — emit three distribution panels per phase, all tagged with the // same phaseGroup so the dashboard renders them as a boxed single-row // section. The user explicitly wants per-phase comparison, which means // ALL panels for one phase must be on one row without wrapping. const out: DashboardPanel[] = []; phases.forEach((p, i) => { const label = `Phase ${i + 1}${p.name ? ` — ${p.name}` : ""}`; // Index offset of 300 so phase sections render AFTER nodeMem (100) + // PG latency (200), and BEFORE kill events (400). out.push(...buildOneStaticDistributionPanels(p, label, { index: 300 + i, label })); }); return out; } return buildOneStaticDistributionPanels(cfg as { ram_mb?: DistSpec; duration_ms?: DistSpec; mode?: DistSpec }, ""); } async function safeReadText(path: string): Promise { try { return await Deno.readTextFile(path); } catch { return ""; } } function slug(s: string): string { return s.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_-]/g, ""); }