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>
Windmill benchmark sim
Provisioning layer on top of the existing benchmark harness. Reuses everything
in benchmarks/ (workload kinds, runBenchmark, graph primitives) and adds:
declarative topology, per-node resource limits, optional per-node DB latency,
optional CPU pinning for benchmark accuracy, and per-worker metrics collection.
Three node modes
Every node in a topology runs in one of three modes:
| Mode | What it is | How it's provisioned |
|---|---|---|
native |
Windmill MODE=standalone — API server + workers in one process. The harness pushes jobs at its API. |
One docker (or podman) container per node, --cpus / --memory / optional --cpuset-cpus. |
agent |
Windmill MODE=worker — worker-only, no API. Models a worker fleet that scales separately from the API. |
Same as native, minus the API port mapping. Needs a peer native node so the harness has somewhere to push. |
k8s |
A k3d cluster with Windmill deployed via the helm chart (upstream or a locally-cloned copy). | k3d cluster create provisions k3s in containers, then helm install deploys Windmill onto it. Planned for M5 — schema and design are in place. |
The provisioner is a single class that handles all three. The container
runtime is configurable via SIM_CONTAINER_CMD (defaults to docker,
swap to podman if you prefer). For k8s mode, k3d and helm need to be
on PATH.
Layered design
sim.ts (this layer)
→ loads suite + topology
→ SimProvisioner: PG + toxiproxy (if needed) + per-node provisioning
→ calls existing runBenchmark() against the chosen API node
→ Collector queries v2_job_completed / v2_job_queue from outside the cluster
→ writes JSON + markdown report
→ teardown
│
▼
benchmarks/benchmark_oneoff.ts (existing, unchanged)
→ login, createBenchScript, push jobs, poll for completion
│
▼
Windmill instance(s) (containers or k8s pods)
→ BENCHMARK_KIND env loads fixtures via backend bench.rs
→ workers run normal pull loop
│
▼
Postgres container (shared by every node)
Two config files per run
- Topology (e.g.
topologies/single_node.json) — pure infra description. - Suite (e.g.
configs/baseline.json) — workload, optionally references a topology by relative path.
Suites without a topology reference are first-class and continue to run via
benchmark_suite.ts against any existing Windmill at --host. Suites that
declare a topology run via sim.ts (this directory), which provisions infra
before delegating to the same underlying runner.
Example topologies
topologies/single_node.json— 1 PG + 1 native node. Baseline smoke test.topologies/heterogeneous_latency.json— 1 native + 1 agent at 50ms DB RTT. The "does the pull race self-level?" experiment.topologies/k8s_3node.json— k3d cluster shape: 3 worker pods. Helm chart selection is passed on the CLI (see below); the topology only describes the cluster.
k8s mode: chart selection via CLI
The topology specifies cluster shape (pods, workers_per_pod, cluster_agents). The helm chart itself is selected at the sim CLI:
# Upstream chart
deno run -A sim.ts -c configs/k8s.json \
--helm-chart windmill/windmill \
--helm-repo https://windmill-labs.github.io/windmill-helm-charts
# Locally-cloned chart (relative to cwd)
deno run -A sim.ts -c configs/k8s.json \
--helm-chart ../windmill-helm-charts/charts/windmill
# Inline value overrides
deno run -A sim.ts -c configs/k8s.json --helm-chart windmill/windmill \
--helm-set worker.replicaCount=5 \
--helm-set windmill.numWorkers=2 \
--helm-values-file ./extra-values.yaml
Prerequisites
- A container runtime:
docker(default) orpodman(setSIM_CONTAINER_CMD=podman). - A Windmill image built with the
benchmarkCargo feature, taggedwindmill-bench:latest. Override the tag viaSIM_WM_IMAGE. The image must honourDATABASE_URL,NUM_WORKERS,MODE(standaloneorworker),BENCHMARK_KIND, andWORKER_TAGS. - Postgres 17 image (default
postgres:17, override viaSIM_PG_IMAGE). - For k8s mode only:
k3dandhelmonPATH.
Run
cd benchmarks/sim
deno run -A sim.ts -c configs/baseline.json
Useful flags:
--factor N— multiply every benchmark'sjobscount by N.--keep-infra— leave containers running after the run.--snapshot-interval <ms>— queue-depth sampling cadence (default 1000).
Outputs
Each run writes two files in the working directory:
sim_results_<ts>.json— raw structured data: workload results, per-worker stats, queue snapshots, derived throughput.sim_results_<ts>.md— human-readable summary: throughput table, per-worker table sorted by jobs handled, utilization spread headline, ASCII sparkline of queue depth over time.
The headline metric for the question this sim is built to answer ("are workers balanced?") is the utilization spread line. A small spread (e.g. ±5pp) means the queue is self-balancing. A large spread (e.g. close worker 95%, far worker 40%) is the smoking gun for uneven pull dynamics.
Resource validation
sim.ts refuses to run if the topology's total CPU or memory exceeds the
host's available budget after the reserved buffer. host_resources is
optional in the topology — when omitted, the validator falls back to the
live host's logical core count and MemTotal. Declare it explicitly when
you want the topology to be portable to a specific target host.
Set pin_cpus: true on the topology to assign each container a disjoint
--cpuset-cpus range. This eliminates CFS time-slicing between containers
entirely; useful when two nodes' throughput numbers must not be
artificially correlated.
Tests
nix develop --command bash -c "cd benchmarks && deno test -A sim/*_test.ts"
38 unit tests cover the validator, config loader, proxy planner, schema modes, and report writer. The provisioner and Postgres collector are intentionally not unit-tested — they need real infrastructure to exercise.
Roadmap
- M1 ✅: scaffold + single-node baseline + container provisioner + resource validator.
- M2 ✅: multi-node + per-node toxiproxy latency.
- M3 ✅: metrics collection from Postgres (per-worker utilization, queue depth over time).
- M4 ✅: markdown + JSON report. SVG graphs deferred until needed.
- M5: k3d-based k8s mode with helm install (upstream or local chart).
- M6+: chaos timeline, assertion engine, resilience test corpus.