mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 08:01:25 +00:00
74b662d8de
Stand up a minikube-backed simulation subsystem for benching Windmill under realistic multi-node load, with a per-bench measurement pipeline and a dashboard renderer that consolidates throughput, queue depth, per-node CPU, PG latency/conns, OOM events, and per-node CPU-util-vs-oversaturation into one SVG report. Sim infrastructure (sim/): - k8s_provisioner: minikube up + heterogeneous node sizing from topology JSON - helm_deploy: helm install Windmill with smoke.yaml + local.yaml overlays - image_cache: pre-load required images so bench bringup is offline-safe - toxiproxy_k8s: per-node toxiproxy DaemonSet for cross-node latency injection - cpu_sampler_k8s: privileged DS reading per-cgroup cpu.stat at 10Hz, dual- writes to stdout AND a host-mounted log file (/var/log/wm-sim-cpu-sampler/ sampler.tsv) so heavy benches no longer lose early samples to kubelet log rotation - pg_logging: ALTER SYSTEM + SIGHUP to enable verbose PG logging without restart - pgbadger: post-bench PG log analysis HTML report - readiness: pre-bench cluster health check (samplers stable ≥30s, workers ready, PG responsive, queue empty, **deploy.status rollout-complete**) — the rollout-complete check catches mid-rolling-update fires that previously starved m04's sampler under cgroup_mutex contention Per-bench JSONL pollers, started/finalized alongside the bench loop: - pod_timeline: 1Hz workers-per-node Ready counts (used for the workers panel) - oom_poller: live OOM event capture (kernel + kubelet evictions + cgroup) - pg_latency_poller: 4Hz psql \\timing on SELECT 1 vs kubectl-exec roundtrip - pg_conn_poller: 1Hz pg_stat_activity by state (active/idle/idle_in_xact) - node_load_poller: 2Hz /proc/loadavg + /proc/stat procs_running per node Dashboard renderer (sim/render_report.ts + graph.ts): - Util group: one panel per node with translucent orange oversaturation area BEHIND solid blue CPU-util area, 100% reference line, phase-boundary verticals. cols:2 grid wraps after 2 panels per row. - PG node tinted with [PG] flag in legend across the dashboard. - Phase-boundary verticals + push-window shaded zones layered consistently. - All x-axes switched from wall-clock HH:MM to relative seconds-from-bench- start. Shared origin sourced from meta.json's bench_start_ms so 0s on every panel = the same wall-clock moment (previously each chart picked its own earliest sample as origin, causing drift between panels). Oversaturation metric, with explicit fallback: - Primary: (procs_running - ncpu) / ncpu × 100 — true CPU run-queue pressure. - Fallback to load1 when procs_running is missing (older reports). - load1 overcounted previously because it includes uninterruptible D-state procs (PG backends in disk I/O, cgroup_mutex waits), inflating "saturation" by 5-10x under load. - Pure helper extracted to sim/util_metrics.ts; 8 unit tests cover the procs_running > load1 preference, the clamp-at-zero, invalid-ncpu cases. Sampler reliability: - HostPath log file in addition to stdout so the bench's scp-based collector bypasses kubelet log rotation entirely. - main.ts truncates the host log file on every node before pushers start (parallel ssh, best-effort) so it doesn't grow unbounded across runs. - Collector falls back to kubectl-logs when scp fails for any node. Workloads (workloads/): - io_4phase: four-phase IO step (idle → 2.5s → 500ms → 150ms jobs) - io_150ms_flood / io_300ms_flood / io_1s_flood / io_2s_flood: single-phase flood configs to isolate the worker-host CFS context-switch storm vs PG contention regime - burst, ops_day, cpu_*, etc. for other scenarios Tests: - sim/util_metrics_test.ts — 8 cases for computeOversatPct - sim/util_panel_snapshot_test.ts — 5 assertions guarding util-panel SVG invariants (orange behind blue, 100% ref line, relative-time ticks NOT wall-clock, phase-boundary verticals, shared-origin override) Helm values: - sim/values/smoke.yaml — bench-tuned: workers w/ no CPU limit & low mem request, PG w/ 3-core request + wm-critical priorityClass + oomImmune + maxConnections, app w/ wm-critical + oomImmune + no resource limits. - sim/values/local.example.yaml — template for the gitignored local.yaml that carries the EE license key. - Depends on the wm-critical PriorityClass + oomImmune + maxConnections knobs landing in windmill-helm-charts (separate PR). graph.ts additions: - areaFills param: ordered list of per-kind translucent area fills drawn before lines, used by the util panel for orange-behind-blue layering - lineColorOverrides: pin per-kind line colors so oversaturation reliably renders orange regardless of d3 ordinal-color insertion order - highlightKindToken: substring-match flag for the PG-node tint in Node CPU - xRelativeOriginMs: shared bench-start origin for the relative-time x-axis - DataPointMulti is now exported for downstream tests Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
964 lines
35 KiB
TypeScript
964 lines
35 KiB
TypeScript
/// <reference no-default-lib="true" />
|
|
/// <reference lib="deno.window" />
|
|
|
|
import { Command } from "https://deno.land/x/cliffy@v0.25.7/command/mod.ts";
|
|
import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
|
|
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
|
|
import { Action } from "./action.ts";
|
|
import { UpgradeCommand } from "https://deno.land/x/cliffy@v0.25.7/command/upgrade/upgrade_command.ts";
|
|
import { DenoLandProvider } from "https://deno.land/x/cliffy@v0.25.7/command/upgrade/mod.ts";
|
|
import { VERSION, createBenchScript } from "./lib.ts";
|
|
import { MinikubeProvisioner } from "./sim/k8s_provisioner.ts";
|
|
import { capturePgLog, enableVerbosePgLogging } from "./sim/pg_logging.ts";
|
|
import { runPgbadger } from "./sim/pgbadger.ts";
|
|
import { collectCpuSamples } from "./sim/cpu_sampler_k8s.ts";
|
|
import { capturePodInventory } from "./sim/pod_inventory.ts";
|
|
import { startPodTimeline, type PodTimelinePoller } from "./sim/pod_timeline.ts";
|
|
import { checkReadiness, waitForReady } from "./sim/readiness.ts";
|
|
import { startOomPoller, type OomPoller } from "./sim/oom_poller.ts";
|
|
import { startPgLatencyPoller, type PgLatencyPoller } from "./sim/pg_latency_poller.ts";
|
|
import { startPgConnPoller, type PgConnPoller } from "./sim/pg_conn_poller.ts";
|
|
import { startNodeLoadPoller, type NodeLoadPoller } from "./sim/node_load_poller.ts";
|
|
import { collectOomEvents } from "./sim/oom_events.ts";
|
|
import { collectFailedJobs } from "./sim/failed_jobs.ts";
|
|
import { renderReport } from "./sim/render_report.ts";
|
|
export {
|
|
DenoLandProvider,
|
|
UpgradeCommand,
|
|
} from "https://deno.land/x/cliffy@v0.25.7/command/upgrade/mod.ts";
|
|
|
|
async function login(email: string, password: string): Promise<string> {
|
|
return await windmill.UserService.login({
|
|
requestBody: {
|
|
email: email,
|
|
password: password,
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function main({
|
|
host,
|
|
workers: num_workers,
|
|
seconds,
|
|
timeout,
|
|
email,
|
|
password,
|
|
token,
|
|
workspace,
|
|
metrics,
|
|
exportJson,
|
|
exportCsv,
|
|
exportHistograms,
|
|
exportSimple,
|
|
histogramBuckets,
|
|
maximumThroughput,
|
|
useFlows,
|
|
flowPattern,
|
|
scriptPattern,
|
|
zombieTimeout,
|
|
continous,
|
|
max,
|
|
custom,
|
|
minikubeProfile,
|
|
workloadConfig: workloadConfigPath,
|
|
waitReady,
|
|
}: {
|
|
host: string;
|
|
workers: number;
|
|
seconds: number;
|
|
timeout?: number;
|
|
email?: string;
|
|
password?: string;
|
|
token?: string;
|
|
workspace: string;
|
|
metrics: string;
|
|
exportJson?: string;
|
|
exportCsv?: string;
|
|
exportHistograms?: string[];
|
|
exportSimple?: string[];
|
|
histogramBuckets: string[];
|
|
maximumThroughput: number;
|
|
useFlows?: boolean;
|
|
flowPattern?: string;
|
|
scriptPattern?: string;
|
|
zombieTimeout: number;
|
|
continous?: boolean;
|
|
max?: number;
|
|
custom?: string;
|
|
// When set, bench captures cluster-side measurements (per-node CPU/mem,
|
|
// pod inventory, PG log) via kubectl scoped to the bench window, and
|
|
// composes the full dashboard. Without it, only Throughput + Queue depth.
|
|
minikubeProfile?: string;
|
|
// Path to a JSON workload config — required for `--script-pattern random`.
|
|
// Defines per-parameter distributions (ram_mb, duration_ms, mode).
|
|
workloadConfig?: string;
|
|
// Seconds to poll cluster readiness before firing. 0 = single check, fail
|
|
// immediately on any unmet condition. >0 = poll every 5s until ready or
|
|
// timeout. See sim/readiness.ts.
|
|
waitReady?: number;
|
|
}) {
|
|
windmill.setClient("", host);
|
|
const versionResp = await fetch(`${host}/api/version`);
|
|
console.log("Backend version: " + (await versionResp.text()));
|
|
|
|
const custom_content: Action | undefined = custom
|
|
? JSON.parse(await Deno.readTextFile(custom))
|
|
: undefined;
|
|
|
|
if (!Array.isArray(histogramBuckets)) {
|
|
histogramBuckets = [];
|
|
}
|
|
|
|
if (!Array.isArray(exportHistograms)) {
|
|
exportHistograms = [];
|
|
}
|
|
|
|
if (!Array.isArray(exportSimple)) {
|
|
exportSimple = [];
|
|
}
|
|
|
|
let metrics_worker: Worker | undefined = undefined;
|
|
if (!continous) {
|
|
if (exportJson || exportCsv) {
|
|
metrics_worker = new Worker(
|
|
new URL("./scraper.ts", import.meta.url).href,
|
|
{
|
|
type: "module",
|
|
}
|
|
);
|
|
|
|
metrics_worker.postMessage({
|
|
exportHistograms,
|
|
histogramBuckets,
|
|
exportSimple,
|
|
host: metrics,
|
|
});
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
"Started with options",
|
|
JSON.stringify(
|
|
{
|
|
host,
|
|
num_workers,
|
|
seconds,
|
|
email,
|
|
workspace,
|
|
metrics,
|
|
exportJson,
|
|
exportCsv,
|
|
exportHistograms,
|
|
exportSimple,
|
|
maximumThroughput,
|
|
useFlows,
|
|
flowPattern,
|
|
scriptPattern,
|
|
zombieTimeout,
|
|
continous,
|
|
},
|
|
null,
|
|
4
|
|
)
|
|
);
|
|
|
|
const config = {
|
|
token: "",
|
|
server: host,
|
|
workspace_id: workspace,
|
|
};
|
|
|
|
let final_token: string;
|
|
if (!token) {
|
|
if (email && password) {
|
|
console.log("Logging in with email and password...");
|
|
final_token = await login(email, password);
|
|
console.log("Logged in!");
|
|
} else {
|
|
console.error("Token or email with password are required.");
|
|
return;
|
|
}
|
|
} else {
|
|
final_token = token;
|
|
}
|
|
|
|
console.log("Using token", final_token);
|
|
|
|
config.token = final_token;
|
|
windmill.setClient(final_token, host);
|
|
|
|
const per_worker_throughput = maximumThroughput / num_workers;
|
|
const max_per_worker = max ? max / num_workers : undefined;
|
|
// For `--script-pattern random`: load the workload config so each worker
|
|
// can sample (ram_mb, duration_ms, mode) per job push from the configured
|
|
// distributions. Other patterns ignore this.
|
|
let workloadConfig: unknown = undefined;
|
|
if (scriptPattern === "random") {
|
|
if (!workloadConfigPath) {
|
|
throw new Error("--script-pattern random requires --workload-config <path>");
|
|
}
|
|
workloadConfig = JSON.parse(await Deno.readTextFile(workloadConfigPath));
|
|
console.log(`Loaded workload config from ${workloadConfigPath}`);
|
|
}
|
|
|
|
// Phased workload: take ownership of --seconds and --timeout so every phase
|
|
// gets to run. --seconds becomes the sum of phase durations; --timeout gets
|
|
// bumped to phase-sum + 30s drain budget if the user passed a smaller value.
|
|
// Without this, a short --timeout would silently cut off later phases.
|
|
const phasedCfg = workloadConfig as { phases?: Array<{ duration_s: number; pushers: number; name?: string }> } | undefined;
|
|
if (phasedCfg?.phases && phasedCfg.phases.length > 0) {
|
|
const phaseSumS = phasedCfg.phases.reduce((a, p) => a + p.duration_s, 0);
|
|
const maxPushers = phasedCfg.phases.reduce((a, p) => Math.max(a, p.pushers), 0);
|
|
console.log(`[phased] ${phasedCfg.phases.length} phase(s), total push window = ${phaseSumS}s`);
|
|
for (const p of phasedCfg.phases) {
|
|
console.log(` - ${p.name ?? "(unnamed)"}: ${p.duration_s}s @ ${p.pushers} pushers`);
|
|
}
|
|
if (num_workers < maxPushers) {
|
|
console.warn(`[phased] --workers ${num_workers} is below max phase pushers (${maxPushers}); some phases will be undersaturated. Bumping --workers to ${maxPushers}.`);
|
|
num_workers = maxPushers;
|
|
}
|
|
if (seconds !== phaseSumS) {
|
|
console.log(`[phased] overriding --seconds ${seconds} -> ${phaseSumS} (phase total)`);
|
|
seconds = phaseSumS;
|
|
}
|
|
const minTimeoutS = phaseSumS + 30; // 30s drain budget for queue to empty
|
|
if (timeout !== undefined && timeout < minTimeoutS) {
|
|
console.warn(`[phased] --timeout ${timeout}s would cut off later phases (need >= ${minTimeoutS}s for phases + drain). Bumping to ${minTimeoutS}s.`);
|
|
timeout = minTimeoutS;
|
|
}
|
|
}
|
|
|
|
const shared_config = {
|
|
server: host,
|
|
token: final_token,
|
|
workspace_id: config.workspace_id,
|
|
per_worker_throughput,
|
|
max_per_worker,
|
|
useFlows,
|
|
flowPattern,
|
|
scriptPattern,
|
|
continous,
|
|
custom: custom_content,
|
|
workloadConfig,
|
|
};
|
|
|
|
if (
|
|
!useFlows &&
|
|
(scriptPattern === undefined ||
|
|
["deno", "deno_sleep_150", "random", "python", "go", "bash", "bun", "dedicated"].includes(
|
|
scriptPattern
|
|
))
|
|
) {
|
|
await createBenchScript(scriptPattern || "deno", workspace);
|
|
}
|
|
|
|
let workers: Worker[] = new Array(num_workers);
|
|
for (let i = 0; i < num_workers; i++) {
|
|
workers[i] = new Worker(new URL("./worker.ts", import.meta.url).href, {
|
|
type: "module",
|
|
});
|
|
}
|
|
|
|
let start: number | undefined = undefined;
|
|
|
|
const jobsSent = Array(num_workers).fill(0);
|
|
const enc = (s: string) => new TextEncoder().encode(s);
|
|
|
|
async function getQueueCount() {
|
|
return (
|
|
await (
|
|
await fetch(
|
|
config.server + "/api/w/" + config.workspace_id + "/jobs/queue/count",
|
|
{ headers: { ["Authorization"]: "Bearer " + config.token } }
|
|
)
|
|
).json()
|
|
).database_length;
|
|
}
|
|
|
|
const initial_queue_length = await getQueueCount();
|
|
|
|
console.log("Initial queue length:", initial_queue_length);
|
|
|
|
// Throughput-over-time samples captured at each updateState tick. Stored as
|
|
// raw cumulative counts; the report renderer computes the windowed rate.
|
|
// Window-from-start cumulative numbers get diluted by warmup → useless for
|
|
// dashboard purposes; the renderer does a rolling-window diff instead.
|
|
const throughputSamples: { ts: number; processed: number; sum: number; queue: number }[] = [];
|
|
|
|
const updateState = setInterval(async () => {
|
|
const elapsed = start ? Math.ceil((Date.now() - start) / 1000) : 0;
|
|
const sum = jobsSent.reduce((a, b) => a + b, 0);
|
|
let queue_length = -1;
|
|
while (queue_length === -1) {
|
|
try {
|
|
queue_length = await getQueueCount();
|
|
} catch (e) {
|
|
console.log(
|
|
`queue count not reachable. waiting... `
|
|
);
|
|
await sleep(0.5);
|
|
continue;
|
|
}
|
|
}
|
|
// Only sample after the start clock is armed so warmup ticks aren't
|
|
// pulled into the windowed series.
|
|
if (start !== undefined) {
|
|
throughputSamples.push({
|
|
ts: Date.now(),
|
|
processed: sum - queue_length,
|
|
sum,
|
|
queue: queue_length,
|
|
});
|
|
}
|
|
await Deno.stdout.write(
|
|
enc(
|
|
`elapsed: ${elapsed}/${seconds} | jobs sent: ${JSON.stringify(
|
|
jobsSent
|
|
)} (sum: ${sum} thr: ${(sum / elapsed).toFixed(2)}) - processed (sum: ${
|
|
sum - queue_length
|
|
} thr: ${((sum - queue_length) / elapsed).toFixed(
|
|
2
|
|
)}) | queue: ${queue_length} \r`
|
|
)
|
|
);
|
|
}, 100);
|
|
|
|
// Re-apply pgBadger PG settings before each bench. They live in PGDATA's
|
|
// postgresql.auto.conf, which a fresh PVC (helm upgrade flipping persistence,
|
|
// chart-bundled PG image upgrade) silently wipes. Settings are SIGHUP-able,
|
|
// so this is a no-cost no-op when they're already set.
|
|
if (minikubeProfile) {
|
|
try {
|
|
const provForPg = new MinikubeProvisioner({ profile: minikubeProfile });
|
|
await enableVerbosePgLogging(provForPg);
|
|
} catch (e) {
|
|
console.warn(`[bench] could not enable verbose PG logging: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
|
|
// Pre-flight readiness check — MUST run BEFORE worker.postMessage() below
|
|
// (which triggers pushers). Earlier versions ran this after postMessage and
|
|
// the workers pushed thousands of jobs into PG during the wait window,
|
|
// making `queue=0` impossible to ever observe → the check timed out
|
|
// every time even on a perfectly healthy cluster.
|
|
if (minikubeProfile) {
|
|
const provForReady = new MinikubeProvisioner({ profile: minikubeProfile });
|
|
const waitSec = (waitReady ?? 0) as number;
|
|
const report = waitSec > 0
|
|
? await waitForReady(provForReady, { timeoutMs: waitSec * 1000 })
|
|
: await checkReadiness(provForReady);
|
|
console.log(`[readiness] samplers=${report.details.samplers_running}/${report.details.samplers_total}, workers=${report.details.workers_ready}/${report.details.workers_total}, pg=${report.details.pg_phase}(responsive=${report.details.pg_responsive}), queue=${report.details.queue_depth}, tox=${report.details.toxiproxy_ready}, app=${report.details.app_ready}`);
|
|
if (!report.ready) {
|
|
console.error("[readiness] cluster NOT ready — refusing to start bench:");
|
|
for (const issue of report.issues) console.error(` - ${issue}`);
|
|
console.error("(re-run with --wait-ready <seconds> to poll until ready, or fix the issues and retry.)");
|
|
Deno.exit(2);
|
|
}
|
|
console.log("[readiness] cluster ready, proceeding");
|
|
}
|
|
|
|
// Truncate the sampler host-log file on every worker node so this bench
|
|
// gets a clean file. The sampler dual-writes to /var/log/wm-sim-cpu-sampler/
|
|
// sampler.tsv via hostPath; without rotation it grows unbounded across runs
|
|
// (~1.7 GB/day per node under load) and stale data from prior benches mixes
|
|
// with the current run's collection. The collector still filters by
|
|
// ts_ns ≥ bench_start_ms, so failure to truncate is recoverable — best-
|
|
// effort only.
|
|
if (minikubeProfile) {
|
|
try {
|
|
const provForRotate = new MinikubeProvisioner({ profile: minikubeProfile });
|
|
const nodesRes = await provForRotate.kubectl([
|
|
"get", "nodes",
|
|
"-o", "jsonpath={range .items[*]}{.metadata.name}{\"|\"}{.status.addresses[?(@.type==\"InternalIP\")].address}{\"\\n\"}{end}",
|
|
]);
|
|
if (nodesRes.code === 0) {
|
|
const home = Deno.env.get("HOME") ?? "";
|
|
await Promise.all(
|
|
nodesRes.stdout.split("\n").filter(Boolean).map(async (line) => {
|
|
const [name, ip] = line.split("|");
|
|
if (!name || !ip) return;
|
|
const key = `${home}/.minikube/machines/${name.trim()}/id_rsa`;
|
|
const cmd = new Deno.Command("ssh", {
|
|
args: [
|
|
"-o", "StrictHostKeyChecking=no",
|
|
"-o", "UserKnownHostsFile=/dev/null",
|
|
"-o", "ConnectTimeout=5",
|
|
"-o", "LogLevel=ERROR",
|
|
"-i", key,
|
|
`docker@${ip.trim()}`,
|
|
"sudo truncate -s 0 /var/log/wm-sim-cpu-sampler/sampler.tsv 2>/dev/null || true",
|
|
],
|
|
stdout: "null",
|
|
stderr: "null",
|
|
});
|
|
await cmd.output();
|
|
}),
|
|
);
|
|
console.log("[bench] sampler host-log truncated on all nodes");
|
|
}
|
|
} catch (e) {
|
|
console.warn(`[bench] sampler host-log truncate failed (continuing): ${(e as Error).message}`);
|
|
}
|
|
}
|
|
|
|
workers.forEach((worker, i) => {
|
|
worker.addEventListener("message", (evt: MessageEvent<any>) => {
|
|
if (evt.data.type === "jobs_sent") {
|
|
jobsSent[i] = evt.data.jobs_sent;
|
|
}
|
|
});
|
|
worker.postMessage({ ...shared_config, i });
|
|
});
|
|
|
|
// outDir is created BEFORE the bench begins so the in-bench pollers (pod
|
|
// timeline, OOM live) can stream JSONL into it as the run progresses.
|
|
// Previously this was created post-bench and the pollers crashed with a
|
|
// TDZ "Cannot access 'outDir' before initialization" — silently failing,
|
|
// which is why pod_timeline.jsonl was missing on every report and the
|
|
// Workers-per-node panel fell back to the (wrong) cgroup-derived count.
|
|
const benchStartIso = new Date(start ?? Date.now()).toISOString();
|
|
const isoStamp = new Date().toISOString().replace(/[:.]/g, "-").replace(/Z$/, "");
|
|
const outDir = `reports/${isoStamp}`;
|
|
await Deno.mkdir(outDir, { recursive: true });
|
|
console.log(`[bench] reports dir: ${outDir}`);
|
|
|
|
// Pin bench-context metadata into the report so the dashboard header can
|
|
// surface it. Includes paths so the report dir is self-describing.
|
|
try {
|
|
const meta = {
|
|
topology: minikubeProfile ?? "n/a",
|
|
host,
|
|
workspace,
|
|
bench_cmd: ["main.ts", ...Deno.args].join(" "),
|
|
workload_path: workloadConfigPath ?? null,
|
|
script_pattern: scriptPattern ?? null,
|
|
ts_iso: new Date().toISOString(),
|
|
// Shared relative-time origin. Every chart in render_report.ts uses this
|
|
// as "0s" on its x-axis, so identical x positions across panels mean the
|
|
// same wall-clock moment. Without this, each panel picked its own origin
|
|
// from its earliest sample and panels drifted by tens of seconds (poller
|
|
// startup vs first-push vs first-CPU-sample timings).
|
|
bench_start_ms: Date.parse(benchStartIso),
|
|
};
|
|
await Deno.writeTextFile(`${outDir}/meta.json`, JSON.stringify(meta, null, 2));
|
|
} catch (e) {
|
|
console.warn(`[bench] meta.json write failed: ${(e as Error).message}`);
|
|
}
|
|
|
|
// Start the kubectl-based pod-timeline poller (1Hz) so the renderer can plot
|
|
// a truly-Ready workers-per-node line — the cgroup sampler's view conflates
|
|
// "container slice exists" with "worker is healthy", so a CrashLoopBackOff
|
|
// cluster looks fully staffed when it isn't.
|
|
let podTimelinePoller: PodTimelinePoller | undefined;
|
|
let oomPoller: OomPoller | undefined;
|
|
let pgLatencyPoller: PgLatencyPoller | undefined;
|
|
let pgConnPoller: PgConnPoller | undefined;
|
|
let nodeLoadPoller: NodeLoadPoller | undefined;
|
|
if (minikubeProfile) {
|
|
try {
|
|
const provForPoll = new MinikubeProvisioner({ profile: minikubeProfile });
|
|
podTimelinePoller = startPodTimeline(provForPoll, `${outDir}/pod_timeline.jsonl`);
|
|
} catch (e) {
|
|
console.warn(`[bench] pod timeline poller failed to start: ${(e as Error).message}`);
|
|
}
|
|
// OOM poller — catches container OOMKills as they happen so the end-of-
|
|
// bench scan (which only sees the MOST RECENT lastState.terminated) can
|
|
// merge in mid-bench kills that have already been overwritten by the
|
|
// post-restart Running state.
|
|
try {
|
|
const provForOom = new MinikubeProvisioner({ profile: minikubeProfile });
|
|
oomPoller = startOomPoller(provForOom, `${outDir}/oom_events_live.jsonl`);
|
|
} catch (e) {
|
|
console.warn(`[bench] OOM poller failed to start: ${(e as Error).message}`);
|
|
}
|
|
// PG latency poller — every 1s runs `SELECT 1` against PG, records wall-
|
|
// clock latency. Surfaces PG-responsiveness over time on a dedicated
|
|
// panel: flat baseline when healthy, spikes when connection storm/
|
|
// contention hits.
|
|
try {
|
|
const provForPg = new MinikubeProvisioner({ profile: minikubeProfile });
|
|
pgLatencyPoller = startPgLatencyPoller(provForPg, `${outDir}/pg_latency.jsonl`);
|
|
} catch (e) {
|
|
console.warn(`[bench] PG latency poller failed to start: ${(e as Error).message}`);
|
|
}
|
|
try {
|
|
const provForConn = new MinikubeProvisioner({ profile: minikubeProfile });
|
|
pgConnPoller = startPgConnPoller(provForConn, `${outDir}/pg_connections.jsonl`);
|
|
} catch (e) {
|
|
console.warn(`[bench] PG conn poller failed to start: ${(e as Error).message}`);
|
|
}
|
|
try {
|
|
const provForLoad = new MinikubeProvisioner({ profile: minikubeProfile });
|
|
nodeLoadPoller = startNodeLoadPoller(provForLoad, `${outDir}/node_load.jsonl`);
|
|
} catch (e) {
|
|
console.warn(`[bench] node-load poller failed to start: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
|
|
start = Date.now();
|
|
|
|
// Hard wall-clock deadline for the whole bench (push + drain). When --timeout
|
|
// is set, every blocking await below caps at the remaining time so the bench
|
|
// always terminates predictably, regardless of how long the queue takes to
|
|
// drain. Whatever was sampled by then is what the report uses.
|
|
const deadlineMs = timeout !== undefined ? start + timeout * 1000 : Infinity;
|
|
const remainingS = () => Math.max(0, (deadlineMs - Date.now()) / 1000);
|
|
|
|
console.log("collecting samples...");
|
|
if (continous) {
|
|
while (true) {
|
|
await sleep(Infinity);
|
|
}
|
|
}
|
|
|
|
await sleep(Math.min(seconds, remainingS()));
|
|
|
|
let sum = jobsSent.reduce((a, b) => a + b, 0);
|
|
await Deno.stdout.write(
|
|
enc(" ".padStart(30) + `\rduration: ${seconds} | jobs sent: ${sum}\n`)
|
|
);
|
|
|
|
// Tell pusher workers to stop. Sampler interval keeps running through the
|
|
// drain phase so the throughput / queue panels show the queue actually
|
|
// emptying instead of stopping at push-end.
|
|
const shutdown_start = Date.now();
|
|
workers.forEach((worker, i) => {
|
|
const l = (evt: MessageEvent<any>) => {
|
|
if (evt.data.type === "done") {
|
|
worker.removeEventListener("message", l);
|
|
workers = workers.filter((w) => w != worker);
|
|
jobsSent[i] = evt.data.jobs_sent;
|
|
worker.terminate();
|
|
}
|
|
};
|
|
worker.addEventListener("message", l);
|
|
worker.postMessage("done");
|
|
});
|
|
|
|
console.log("waiting for shutdown\n");
|
|
while (workers.length > 0 && remainingS() > 0) {
|
|
await sleep(0.1);
|
|
}
|
|
if (workers.length > 0) {
|
|
console.log(`\n[timeout] ${workers.length} driver-worker(s) still running — terminating`);
|
|
workers.forEach((w) => w.terminate());
|
|
workers = [];
|
|
}
|
|
|
|
// Drain phase: keep waiting until the queue is empty so the metric reflects
|
|
// sustained throughput, not "jobs queued / push window." Backstop is 10x the
|
|
// push duration — bails if the workload genuinely can't keep up so the run
|
|
// doesn't hang forever. --timeout still applies as the outer wallclock cap.
|
|
const drainStart = Date.now();
|
|
const drainDeadlineMs = drainStart + seconds * 10 * 1000;
|
|
let queue_length = await getQueueCount();
|
|
while (queue_length > 0 && remainingS() > 0 && Date.now() < drainDeadlineMs) {
|
|
await sleep(0.1);
|
|
try {
|
|
queue_length = await getQueueCount();
|
|
} catch (_) { /* transient — keep going */ }
|
|
await Deno.stdout.write(enc(`draining: queue=${queue_length} \r`));
|
|
}
|
|
clearInterval(updateState);
|
|
if (queue_length > 0) {
|
|
const reason = remainingS() <= 0 ? "--timeout reached" : "10x push duration safety cap";
|
|
console.log(`\n[drain] ${queue_length} job(s) still pending (${reason}) — reporting what's done`);
|
|
}
|
|
|
|
sum = jobsSent.reduce((a, b) => a + b, 0);
|
|
const drainTime = (Date.now() - drainStart) / 1000;
|
|
const totalTime = (Date.now() - start) / 1000;
|
|
const processed = sum - queue_length;
|
|
|
|
const pushRate = sum / seconds;
|
|
const sustainedRate = processed / totalTime;
|
|
|
|
console.log("\nshutdown wait (s):", (drainStart - shutdown_start) / 1000);
|
|
console.log("drain time (s):", drainTime);
|
|
console.log("total wall time (s):", totalTime);
|
|
console.log("jobs sent:", sum, " processed:", processed, " remaining:", queue_length);
|
|
console.log("push rate (sent/push_seconds):", pushRate.toFixed(2));
|
|
console.log("sustained throughput (processed/total_time):", sustainedRate.toFixed(2));
|
|
|
|
console.log(
|
|
"queue length:",
|
|
(
|
|
await (
|
|
await fetch(
|
|
host + "/api/w/" + config.workspace_id + "/jobs/queue/count",
|
|
{ headers: { ["Authorization"]: "Bearer " + config.token } }
|
|
)
|
|
).json()
|
|
).database_length
|
|
);
|
|
|
|
if (metrics_worker) {
|
|
metrics_worker.postMessage("stop");
|
|
console.log("waiting for metrics");
|
|
const { columns, transfer_values } = await new Promise<{
|
|
columns: string[];
|
|
transfer_values: ArrayBufferLike[];
|
|
}>((resolve, _reject) => {
|
|
if (metrics_worker) {
|
|
metrics_worker.onmessage = (e) => {
|
|
resolve(e.data);
|
|
metrics_worker?.terminate();
|
|
};
|
|
}
|
|
});
|
|
const values = transfer_values.map((x) => new Float32Array(x));
|
|
|
|
if (exportJson) {
|
|
console.log("exporting mean & stdev to json");
|
|
const obj: any = {};
|
|
for (let i = 0; i < columns.length; i++) {
|
|
const name = columns[i]!;
|
|
const value = values[i]!;
|
|
const mean = value.reduce((acc, e) => acc + e, 0) / values.length;
|
|
const stdev = Math.sqrt(
|
|
value.reduce((acc, e) => acc + (e - mean) ** 2) / values.length
|
|
);
|
|
obj[name] = { mean, stdev };
|
|
}
|
|
|
|
await Deno.writeTextFile(exportJson, JSON.stringify(obj));
|
|
}
|
|
|
|
if (exportCsv) {
|
|
const f = await Deno.open(exportCsv, {
|
|
write: true,
|
|
create: true,
|
|
truncate: true,
|
|
});
|
|
const encoder = new TextEncoder();
|
|
const newline = new Uint8Array(1);
|
|
newline[0] = 0x0a;
|
|
await f.write(encoder.encode(columns.join(",")));
|
|
await f.write(newline);
|
|
|
|
for (let i = 0; i < values.length; i++) {
|
|
await f.write(encoder.encode(values[i].join(",")));
|
|
await f.write(newline);
|
|
}
|
|
|
|
f.close();
|
|
}
|
|
}
|
|
// ---------- post-bench: capture cluster measurements + render report ----------
|
|
// outDir + benchStartIso were declared above (pre-bench) so pollers could
|
|
// stream into them as the run progressed.
|
|
|
|
if (throughputSamples.length > 0) {
|
|
await Deno.writeTextFile(
|
|
`${outDir}/throughput_samples.json`,
|
|
JSON.stringify(throughputSamples),
|
|
);
|
|
}
|
|
|
|
// Persist the workload config alongside the report so the renderer can show
|
|
// the per-parameter distributions.
|
|
if (workloadConfig) {
|
|
await Deno.writeTextFile(
|
|
`${outDir}/workload.json`,
|
|
JSON.stringify(workloadConfig, null, 2),
|
|
);
|
|
}
|
|
|
|
// Cluster-side capture only if the caller pointed us at a minikube profile.
|
|
let cpusPerNode: number | Record<string, number> = 1;
|
|
if (minikubeProfile) {
|
|
const prov = new MinikubeProvisioner({ profile: minikubeProfile });
|
|
// Cores per node — used by the renderer to scale Node CPU to % of VM.
|
|
// Heterogeneous topologies (small tainted control plane + larger workers)
|
|
// need a per-node map so each node is normalized against its own capacity.
|
|
try {
|
|
const r = await prov.kubectl([
|
|
"get", "nodes",
|
|
"-o", "jsonpath={range .items[*]}{.metadata.name}={.status.capacity.cpu}{\"\\n\"}{end}",
|
|
]);
|
|
const byNode: Record<string, number> = {};
|
|
for (const line of r.stdout.split("\n")) {
|
|
const [name, cpuStr] = line.split("=");
|
|
const n = parseInt((cpuStr ?? "").trim());
|
|
if (name && Number.isFinite(n) && n > 0) byNode[name.trim()] = n;
|
|
}
|
|
if (Object.keys(byNode).length > 0) cpusPerNode = byNode;
|
|
} catch { /* leave default 1 */ }
|
|
// Stop the pod-timeline poller before grabbing the final inventory — both
|
|
// write to outDir and we want the poller's last sample on disk first.
|
|
if (podTimelinePoller) {
|
|
podTimelinePoller.cont.value = false;
|
|
try {
|
|
await podTimelinePoller.done;
|
|
} catch (e) {
|
|
console.warn(`[bench] pod timeline finalize failed: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
if (oomPoller) {
|
|
oomPoller.cont.value = false;
|
|
try {
|
|
await oomPoller.done;
|
|
} catch (e) {
|
|
console.warn(`[bench] OOM poller finalize failed: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
if (pgLatencyPoller) {
|
|
pgLatencyPoller.cont.value = false;
|
|
try {
|
|
await pgLatencyPoller.done;
|
|
} catch (e) {
|
|
console.warn(`[bench] PG latency poller finalize failed: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
if (pgConnPoller) {
|
|
pgConnPoller.cont.value = false;
|
|
try {
|
|
await pgConnPoller.done;
|
|
} catch (e) {
|
|
console.warn(`[bench] PG conn poller finalize failed: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
if (nodeLoadPoller) {
|
|
nodeLoadPoller.cont.value = false;
|
|
try {
|
|
await nodeLoadPoller.done;
|
|
} catch (e) {
|
|
console.warn(`[bench] node-load poller finalize failed: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
try {
|
|
await capturePodInventory(prov, `${outDir}/pods.json`);
|
|
} catch (e) {
|
|
console.warn(`[bench] pod inventory failed: ${(e as Error).message}`);
|
|
}
|
|
try {
|
|
await collectCpuSamples(prov, `${outDir}/cpu_samples.tsv`, { sinceTime: benchStartIso });
|
|
} catch (e) {
|
|
console.warn(`[bench] CPU sample capture failed: ${(e as Error).message}`);
|
|
}
|
|
try {
|
|
await collectOomEvents(prov, `${outDir}/oom_events.json`, { sinceMs: start });
|
|
} catch (e) {
|
|
console.warn(`[bench] OOM event capture failed: ${(e as Error).message}`);
|
|
}
|
|
try {
|
|
await collectFailedJobs({
|
|
host,
|
|
token: final_token,
|
|
workspace,
|
|
sinceMs: start,
|
|
outPath: `${outDir}/failed_jobs.jsonl`,
|
|
});
|
|
} catch (e) {
|
|
console.warn(`[bench] failed-jobs capture failed: ${(e as Error).message}`);
|
|
}
|
|
} else {
|
|
console.log(`[bench] --minikube-profile not set — skipping cluster-side panels (CPU/mem/workers/PG)`);
|
|
}
|
|
|
|
// Render the dashboard FIRST — it depends only on cpu_samples + pods +
|
|
// throughput. pgBadger is slow (~30s parse on a busy run's 50MB log) and
|
|
// produces a separate HTML; do it last so the dashboard is openable
|
|
// immediately.
|
|
try {
|
|
const benchWalltimeS = (Date.now() - start) / 1000;
|
|
await renderReport({
|
|
outDir,
|
|
topology: "bench",
|
|
walltimeS: benchWalltimeS,
|
|
finalThroughput: sustainedRate,
|
|
cpusPerNode,
|
|
});
|
|
} catch (e) {
|
|
console.warn(`[bench] report render failed: ${(e as Error).message}`);
|
|
}
|
|
|
|
// Background the pg-log capture + pgBadger render — the kubectl logs pull
|
|
// on a busy run takes ~40s and is dominated by network/log volume, not the
|
|
// bench. Spawn a detached subshell so wm-bench can exit while pgbadger
|
|
// continues; the report dir gets `pg.log` then `pgbadger.html` when ready.
|
|
if (minikubeProfile) {
|
|
const minikube = Deno.env.get("SIM_MINIKUBE_BIN") ?? "minikube";
|
|
const pgbadger = Deno.env.get("SIM_PGBADGER_BIN") ?? "pgbadger";
|
|
const logPath = `${outDir}/pg.log`;
|
|
const htmlPath = `${outDir}/pgbadger.html`;
|
|
const since = benchStartIso;
|
|
// Subshell — kubectl logs piped to file, then pgbadger; backgrounded with
|
|
// `&` so the parent sh exits immediately and the child becomes orphaned
|
|
// (and inherited by init). Output muted so a closed terminal doesn't kill
|
|
// it via SIGPIPE.
|
|
const script =
|
|
`( ${minikube} kubectl -p ${minikubeProfile} -- -n default logs ` +
|
|
`-l app=windmill-postgresql-demo-app --all-containers=true --tail=-1 ` +
|
|
`--since-time=${since} > "${logPath}" 2>/dev/null && ` +
|
|
`${pgbadger} "${logPath}" -o "${htmlPath}" -q >/dev/null 2>&1 ) </dev/null >/dev/null 2>&1 &`;
|
|
const cmd = new Deno.Command("sh", {
|
|
args: ["-c", script],
|
|
stdin: "null", stdout: "null", stderr: "null",
|
|
});
|
|
await cmd.output();
|
|
console.log(`[bench] pg.log + pgbadger.html will be written to ${outDir}/ in the background.`);
|
|
}
|
|
|
|
console.log("done");
|
|
return {
|
|
throughput: sustainedRate,
|
|
throughputSamples,
|
|
};
|
|
}
|
|
|
|
// runWithTopology has moved entirely to `wm_sim up` — wm-bench no longer
|
|
// provisions clusters or installs helm. Point it at a running cluster via
|
|
// `--host` (and optionally `--minikube-profile` for the cluster-side panels).
|
|
|
|
if (import.meta.main) {
|
|
await new Command()
|
|
.name("wmillbench")
|
|
.description("Run Benchmark to measure throughput of windmill.")
|
|
.version(VERSION)
|
|
.option("--host <url:string>", "The windmill host to benchmark.", {
|
|
default: "http://127.0.0.1:8000",
|
|
})
|
|
.option(
|
|
"--workers <workers:number>",
|
|
"The number of workers to run at once.",
|
|
{
|
|
default: 1,
|
|
}
|
|
)
|
|
.option(
|
|
"-s --seconds <seconds:number>",
|
|
"How long the workers push jobs for (in seconds).",
|
|
{
|
|
default: 30,
|
|
}
|
|
)
|
|
.option(
|
|
"--timeout <timeout:number>",
|
|
"Hard wall-clock cap on the whole bench (push + drain). On hit, sampling stops and the report is rendered from what was collected. Default 120s.",
|
|
{ default: 120 }
|
|
)
|
|
.option("--max <max:number>", "Maximum number of operations performed.")
|
|
.option("-e --email <email:string>", "The email to use to login.")
|
|
.option("-p --password <password:string>", "The password to use to login.")
|
|
.env(
|
|
"WM_TOKEN=<token:string>",
|
|
"The token to use when talking to the API server. Preferred over manual login."
|
|
)
|
|
.option(
|
|
"-t --token <token:string>",
|
|
"The token to use when talking to the API server. Preferred over manual login."
|
|
)
|
|
.env(
|
|
"WM_WORKSPACE=<workspace:string>",
|
|
"The workspace to spawn scripts from."
|
|
)
|
|
.option(
|
|
"-w --workspace <workspace:string>",
|
|
"The workspace to spawn scripts from.",
|
|
{ default: "admins" }
|
|
)
|
|
.option(
|
|
"-m --metrics <metrics:string>",
|
|
"The url to scrape metrics from.",
|
|
{
|
|
default: "http://localhost:8001/metrics",
|
|
}
|
|
)
|
|
.option(
|
|
"--export-json <export_json:string>",
|
|
"If set, exports will be into a JSON file."
|
|
)
|
|
.option(
|
|
"--export-csv <export_csv:string>",
|
|
"If set, exports will be into a csv file."
|
|
)
|
|
.option(
|
|
"--export-histograms <export_histograms:string[]>",
|
|
"Mark metrics (without label) that are reported as histograms to export."
|
|
)
|
|
.option(
|
|
"--export-simple <export_simple:string[]>",
|
|
"Mark metrics (without label) that are reported as simple values."
|
|
)
|
|
.option(
|
|
"--maximum-throughput <maximum_throughput:number>",
|
|
"Maximum number of jobs/flows to start in one second.",
|
|
{
|
|
default: Infinity,
|
|
}
|
|
)
|
|
.option("--use-flows", "Run flows instead of jobs.")
|
|
.option(
|
|
"--flow-pattern <pattern:string>",
|
|
"Use a different flow pattern among: 2steps, onebranch (Default 2steps)"
|
|
)
|
|
.option(
|
|
"--script-pattern <pattern:string>",
|
|
"Use a different script pattern among: deno, identity, python, go, bash, dedicated, bun (Default deno)"
|
|
)
|
|
.option("--custom <custom_path:string>", "Use custom actions during bench")
|
|
.option(
|
|
"--zombie-timeout <zombie_timeout:number>",
|
|
"The maximum time in ms to wait for jobs to complete.",
|
|
{
|
|
default: 90000,
|
|
}
|
|
)
|
|
.option(
|
|
"-c --continuous",
|
|
"Run the benchmark forever. This effectively disables metric collection & exports. No zombie jobs will be tracked."
|
|
)
|
|
.option(
|
|
"--histogram-buckets <histogram_buckets:string[]>",
|
|
"Define what buckets to collect from histograms.",
|
|
{
|
|
default: [
|
|
"+Inf",
|
|
"10",
|
|
"5",
|
|
"2.5",
|
|
"2.5",
|
|
"1",
|
|
"0.5",
|
|
"0.25",
|
|
"0.1",
|
|
"0.05",
|
|
"0.025",
|
|
"0.01",
|
|
"0.005",
|
|
],
|
|
}
|
|
)
|
|
.option("--hide-progress", "Hide worker progress logs")
|
|
.option(
|
|
"--minikube-profile <name:string>",
|
|
"Enable cluster-side panels (per-node CPU/mem, workers per node, PG/pgBadger) by giving the minikube profile name. Required for the full dashboard; without it only Throughput + Queue depth are rendered.",
|
|
)
|
|
.option(
|
|
"--workload-config <path:string>",
|
|
"Path to a JSON workload config (distributions for ram_mb, duration_ms, mode). Required when --script-pattern is `random`. Examples in benchmarks/workloads/.",
|
|
)
|
|
.option(
|
|
"--wait-ready <seconds:number>",
|
|
"Before firing the bench, poll the cluster every 5s and only start once samplers (4/4 Running), workers (ready==replicas), PG (responsive), toxiproxy + app are healthy AND the queue is empty. Errors with the unmet conditions on timeout. Skip the check entirely with --wait-ready 0.",
|
|
{ default: 0 },
|
|
)
|
|
.action((opts: any) => main(opts))
|
|
.command(
|
|
"upgrade",
|
|
new UpgradeCommand({
|
|
main: "main.ts",
|
|
args: [
|
|
"--allow-net",
|
|
"--allow-read",
|
|
"--allow-write",
|
|
"--allow-env",
|
|
"--unstable",
|
|
],
|
|
provider: new DenoLandProvider({ name: "wmillbench" }),
|
|
})
|
|
)
|
|
.parse();
|
|
}
|