mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 00:02:19 +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>
200 lines
7.4 KiB
TypeScript
200 lines
7.4 KiB
TypeScript
// Per-job parameter distributions for the synthetic `random` script pattern.
|
||
//
|
||
// Three supported distribution types — picked because they cover the common
|
||
// shapes for a job-scheduler workload:
|
||
// uniform — flat range, predictable for stress runs
|
||
// lognormal — heavy-tail; matches real durations (lots of small + a few big)
|
||
// categorical — discrete weighted choice (e.g. sleep vs busy mode)
|
||
//
|
||
// Add more (pareto, normal, etc.) here if a workload needs them.
|
||
|
||
// Optional clamp on the sampled value. `cap` and `floor` are post-sample —
|
||
// any value above `cap` becomes `cap`, below `floor` becomes `floor`. Use
|
||
// these to prevent lognormal's long tail from sampling impossible values
|
||
// (e.g. 50 GB RAM, 60 s durations).
|
||
type ClampOpts = { cap?: number; floor?: number };
|
||
|
||
export type DistSpec =
|
||
| ({ dist: "uniform"; min: number; max: number } & ClampOpts)
|
||
| ({ dist: "normal"; mean: number; stddev: number } & ClampOpts)
|
||
| ({ dist: "lognormal"; median: number; shape: number } & ClampOpts)
|
||
| { dist: "categorical"; weights: Record<string, number> }
|
||
// Weighted mix of N sub-distributions. With 2 components this is the
|
||
// classic bimodal shape (e.g. 70% fast CRUD jobs + 30% slow ETL).
|
||
// Weights need not sum to 1 — they're relative.
|
||
| { dist: "mixture"; components: { weight: number; spec: DistSpec }[] };
|
||
|
||
// Static (legacy) workload — one set of distributions for the whole bench.
|
||
export type StaticWorkloadConfig = {
|
||
ram_mb: DistSpec;
|
||
duration_ms: DistSpec;
|
||
mode: DistSpec; // expected to be categorical with "sleep" / "busy"
|
||
};
|
||
|
||
// Phased workload — time-bounded blocks, each with its own distributions
|
||
// and an active-pusher count. Useful for simulating diurnal patterns or
|
||
// step-function load changes (warmup → peak → cooldown).
|
||
export type WorkloadPhase = StaticWorkloadConfig & {
|
||
name?: string;
|
||
duration_s: number;
|
||
pushers: number; // max active pushers in this phase (others sleep)
|
||
};
|
||
|
||
export type PhasedWorkloadConfig = {
|
||
phases: WorkloadPhase[];
|
||
};
|
||
|
||
export type WorkloadConfig = StaticWorkloadConfig | PhasedWorkloadConfig;
|
||
|
||
export function isPhasedConfig(cfg: WorkloadConfig): cfg is PhasedWorkloadConfig {
|
||
return (cfg as PhasedWorkloadConfig).phases !== undefined;
|
||
}
|
||
|
||
// Total bench wall-time covered by a phased config (sum of phase durations).
|
||
// Static configs return undefined — caller uses --seconds for their window.
|
||
export function totalPhasedDurationS(cfg: WorkloadConfig): number | undefined {
|
||
if (!isPhasedConfig(cfg)) return undefined;
|
||
return cfg.phases.reduce((a, p) => a + p.duration_s, 0);
|
||
}
|
||
|
||
// Find which phase covers a given elapsed time. Past the last phase end,
|
||
// returns the last phase (caller decides whether to keep pushing or stop).
|
||
export function findActivePhase(
|
||
cfg: PhasedWorkloadConfig,
|
||
elapsed_s: number,
|
||
): { phase: WorkloadPhase; index: number; phase_elapsed_s: number } {
|
||
let acc = 0;
|
||
for (let i = 0; i < cfg.phases.length; i++) {
|
||
const p = cfg.phases[i];
|
||
if (elapsed_s < acc + p.duration_s) {
|
||
return { phase: p, index: i, phase_elapsed_s: elapsed_s - acc };
|
||
}
|
||
acc += p.duration_s;
|
||
}
|
||
const last = cfg.phases.length - 1;
|
||
return { phase: cfg.phases[last], index: last, phase_elapsed_s: elapsed_s - acc };
|
||
}
|
||
|
||
export type JobParams = {
|
||
ram_mb: number;
|
||
duration_ms: number;
|
||
mode: "sleep" | "busy";
|
||
};
|
||
|
||
// Box-Muller — one normal sample per call, plenty for per-job use.
|
||
function gauss(): number {
|
||
let u1 = Math.random();
|
||
while (u1 === 0) u1 = Math.random();
|
||
const u2 = Math.random();
|
||
return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
|
||
}
|
||
|
||
function clamp(v: number, spec: { cap?: number; floor?: number }): number {
|
||
if (spec.cap !== undefined && v > spec.cap) v = spec.cap;
|
||
if (spec.floor !== undefined && v < spec.floor) v = spec.floor;
|
||
return v;
|
||
}
|
||
|
||
function sample(spec: DistSpec): number | string {
|
||
switch (spec.dist) {
|
||
case "uniform":
|
||
return clamp(spec.min + Math.random() * (spec.max - spec.min), spec);
|
||
case "normal":
|
||
return clamp(spec.mean + spec.stddev * gauss(), spec);
|
||
case "lognormal":
|
||
// median = e^μ, shape = σ. log(median) gives μ, then add σ·gauss().
|
||
return clamp(Math.exp(Math.log(spec.median) + spec.shape * gauss()), spec);
|
||
case "categorical": {
|
||
const total = Object.values(spec.weights).reduce((a, b) => a + b, 0);
|
||
let r = Math.random() * total;
|
||
for (const [k, w] of Object.entries(spec.weights)) {
|
||
r -= w;
|
||
if (r <= 0) return k;
|
||
}
|
||
// fallthrough on rounding — return the last key
|
||
const keys = Object.keys(spec.weights);
|
||
return keys[keys.length - 1];
|
||
}
|
||
case "mixture": {
|
||
const total = spec.components.reduce((a, c) => a + c.weight, 0);
|
||
let r = Math.random() * total;
|
||
for (const c of spec.components) {
|
||
r -= c.weight;
|
||
if (r <= 0) return sample(c.spec);
|
||
}
|
||
// fallthrough on rounding — sample the last component
|
||
return sample(spec.components[spec.components.length - 1].spec);
|
||
}
|
||
}
|
||
}
|
||
|
||
function asNumber(v: number | string, ctx: string): number {
|
||
if (typeof v !== "number" || !Number.isFinite(v)) {
|
||
throw new Error(`workload[${ctx}]: expected numeric distribution, got ${typeof v}`);
|
||
}
|
||
return v;
|
||
}
|
||
|
||
export function sampleJobParams(
|
||
cfg: WorkloadConfig,
|
||
opts: { elapsed_s?: number } = {},
|
||
): JobParams {
|
||
const spec: StaticWorkloadConfig = isPhasedConfig(cfg)
|
||
? findActivePhase(cfg, opts.elapsed_s ?? 0).phase
|
||
: cfg;
|
||
const mode = sample(spec.mode);
|
||
if (mode !== "sleep" && mode !== "busy") {
|
||
throw new Error(`workload[mode]: expected "sleep" or "busy", got ${JSON.stringify(mode)}`);
|
||
}
|
||
return {
|
||
ram_mb: Math.max(0, Math.round(asNumber(sample(spec.ram_mb), "ram_mb"))),
|
||
duration_ms: Math.max(0, Math.round(asNumber(sample(spec.duration_ms), "duration_ms"))),
|
||
mode,
|
||
};
|
||
}
|
||
|
||
// Whether pusher index `i` should be sending jobs at this moment, given the
|
||
// phased config's per-phase `pushers` cap. Static configs: always active.
|
||
export function isPusherActive(
|
||
cfg: WorkloadConfig,
|
||
workerIndex: number,
|
||
elapsed_s: number,
|
||
): boolean {
|
||
if (!isPhasedConfig(cfg)) return true;
|
||
return workerIndex < findActivePhase(cfg, elapsed_s).phase.pushers;
|
||
}
|
||
|
||
function validatePhase(p: unknown, ctx: string): void {
|
||
const obj = p as Record<string, unknown>;
|
||
for (const k of ["ram_mb", "duration_ms", "mode"] as const) {
|
||
const v = obj[k] as { dist?: unknown } | undefined;
|
||
if (!v || typeof v.dist !== "string") {
|
||
throw new Error(`workload ${ctx} missing or malformed field "${k}"`);
|
||
}
|
||
}
|
||
if (typeof obj.duration_s !== "number" || obj.duration_s <= 0) {
|
||
throw new Error(`workload ${ctx}: "duration_s" must be a positive number`);
|
||
}
|
||
if (typeof obj.pushers !== "number" || obj.pushers < 0) {
|
||
throw new Error(`workload ${ctx}: "pushers" must be a non-negative number`);
|
||
}
|
||
}
|
||
|
||
export async function loadWorkloadConfig(path: string): Promise<WorkloadConfig> {
|
||
const raw = await Deno.readTextFile(path);
|
||
const parsed = JSON.parse(raw);
|
||
if (Array.isArray(parsed.phases)) {
|
||
if (parsed.phases.length === 0) {
|
||
throw new Error("workload config: phases array is empty");
|
||
}
|
||
parsed.phases.forEach((p: unknown, i: number) => validatePhase(p, `phase[${i}]`));
|
||
return parsed as PhasedWorkloadConfig;
|
||
}
|
||
for (const k of ["ram_mb", "duration_ms", "mode"] as const) {
|
||
if (!parsed[k] || typeof parsed[k].dist !== "string") {
|
||
throw new Error(`workload config missing or malformed field "${k}"`);
|
||
}
|
||
}
|
||
return parsed as WorkloadConfig;
|
||
}
|