Files
windmill/benchmarks/worker.ts
pyranota 74b662d8de feat(benchmarks): k8s sim mode + util-group dashboard + reliability fixes
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>
2026-06-08 11:43:47 +02:00

171 lines
5.3 KiB
TypeScript

/// <reference no-default-lib="true" />
/// <reference lib="deno.worker" />
import { sleep } from "https://deno.land/x/sleep@v1.2.1/sleep.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
import { Action, evaluate } from "./action.ts";
import { getFlowPayload } from "./lib.ts";
import { sampleJobParams, isPusherActive, type WorkloadConfig } from "./workloads/distribution.ts";
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 promise = new Promise<{
workspace_id: string;
per_worker_throughput: number;
useFlows: boolean;
flowPattern: string;
scriptPattern: string;
continous: boolean;
max_per_worker: number;
custom: Action | undefined;
server: string;
token: string;
hideProgress: boolean;
workloadConfig?: WorkloadConfig;
i: number; // 0-based pusher index — used by phased workloads to gate
// which pushers are active in each phase (others idle).
}>((resolve, _reject) => {
self.onmessage = (evt) => {
const sharedConfig = evt.data;
windmill.setClient(sharedConfig.token, sharedConfig.server);
const config = {
workspace_id: sharedConfig.workspace_id,
per_worker_throughput: sharedConfig.per_worker_throughput,
useFlows: sharedConfig.useFlows,
flowPattern: sharedConfig.flowPattern,
scriptPattern: sharedConfig.scriptPattern,
continous: sharedConfig.continous,
max_per_worker: sharedConfig.max_per_worker,
custom: sharedConfig.custom,
server: sharedConfig.server,
token: sharedConfig.token,
hideProgress: sharedConfig.hideProgress,
workloadConfig: sharedConfig.workloadConfig,
i: sharedConfig.i,
};
self.name = "Worker " + sharedConfig.i;
resolve(config);
self.onmessage = null;
};
});
const config = await promise;
const outstanding: string[] = [];
let cont = true;
let total_spawned = 0;
const start_time: number = Date.now();
// let complete_timeout = Infinity;
self.onmessage = (evt) => {
cont = false;
// complete_timeout = evt.data;
};
const updateStatusInterval = setInterval(() => {
self.postMessage({ type: "jobs_sent", jobs_sent: total_spawned });
}, 100);
while (cont) {
try {
// Phased workloads cap the number of active pushers per phase. Inactive
// pushers wait without sending jobs — this is how "low load" phases
// (warmup, cooldown) produce real low load even though all N workers
// were spawned at bench start.
const elapsed_s = (Date.now() - start_time) / 1000;
if (
config.workloadConfig &&
!isPusherActive(config.workloadConfig, config.i, elapsed_s)
) {
await sleep(0.5);
continue;
}
const queue_length = await getQueueCount();
if (queue_length > 2500) {
console.log(
`queue length: ${queue_length} > 2500. waiting... `
);
await sleep(0.5);
continue;
}
if (
(total_spawned * 1000) / (Date.now() - start_time) >
config.per_worker_throughput
) {
console.log("at maximum throughput. waiting...");
await sleep(0.1);
continue;
}
total_spawned++;
if (total_spawned > config.max_per_worker) {
break;
}
let uuid: string;
if (config.custom) {
await evaluate(config.custom);
continue;
} else if (config.useFlows) {
const payload = getFlowPayload(config.flowPattern);
uuid = await windmill.JobService.runFlowPreview({
workspace: config.workspace_id,
requestBody: payload,
});
} else {
try {
if (config.scriptPattern === "identity") {
uuid = await windmill.JobService.runScriptPreview({
workspace: config.workspace_id,
requestBody: {
path: "identity",
kind: api.Preview.kind.IDENTITY,
args: {
identity: "itsme",
},
},
});
} else {
// `random` pattern: sample per-job args from the workload config so
// each push carries its own (ram_mb, duration_ms, mode). Phased
// configs sample from the currently-active phase's distributions.
const args = (config.scriptPattern === "random" && config.workloadConfig)
? sampleJobParams(config.workloadConfig, { elapsed_s })
: undefined;
uuid = await windmill.JobService.runScriptByPath({
workspace: config.workspace_id,
path: "f/benchmarks/" + (config.scriptPattern || "deno"),
requestBody: args ?? {},
});
}
} catch (e) {
console.error("error running script: " + e.body);
Deno.exit(1);
}
}
if (!config.continous) outstanding.push(uuid);
} catch (e) {
console.log(
`error while sending job: ${e} `
);
await sleep(0.5);
continue;
}
}
clearInterval(updateStatusInterval);
self.postMessage({
type: "done",
jobs_sent: total_spawned,
});