mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 00:02:03 +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>
171 lines
5.3 KiB
TypeScript
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,
|
|
});
|