mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-23 16:00:38 +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>
199 lines
5.5 KiB
TypeScript
199 lines
5.5 KiB
TypeScript
import { Command } from "https://deno.land/x/cliffy@v0.25.7/command/mod.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 { main as runBenchmark } from "./benchmark_oneoff.ts";
|
|
|
|
import { VERSION, loadJsonConfig } from "./lib.ts";
|
|
|
|
// Two accepted on-disk shapes:
|
|
// - array of benchmark entries (the original shape; runs unchanged here).
|
|
// - object with optional `topology` + `benchmarks`. If `topology` is set the
|
|
// suite needs the sim layer, so we redirect; if it's an array-equivalent
|
|
// object (no topology) we accept it and run the same as the array form.
|
|
// `normaliseConfig` returns the array form so the run loop stays unchanged.
|
|
type BenchmarkEntry = {
|
|
kind: string;
|
|
jobs: number;
|
|
noSave?: boolean;
|
|
};
|
|
type Config = BenchmarkEntry[];
|
|
type SuiteFile = Config | {
|
|
topology?: string;
|
|
benchmarks: BenchmarkEntry[];
|
|
};
|
|
|
|
function normaliseConfig(raw: SuiteFile, configPath: string): Config {
|
|
if (Array.isArray(raw)) return raw;
|
|
if (raw && typeof raw === "object" && Array.isArray(raw.benchmarks)) {
|
|
if (raw.topology) {
|
|
console.error(
|
|
`[benchmark_suite] Suite ${configPath} declares a topology ` +
|
|
`(${raw.topology}). benchmark_suite.ts only runs against an already-` +
|
|
`running Windmill; for provisioned topologies use sim/sim.ts instead.`,
|
|
);
|
|
Deno.exit(2);
|
|
}
|
|
return raw.benchmarks;
|
|
}
|
|
throw new Error(
|
|
`Suite ${configPath}: expected either an array or an object with ` +
|
|
`a "benchmarks" array.`,
|
|
);
|
|
}
|
|
|
|
async function warmUp(
|
|
host: string,
|
|
email: string | undefined,
|
|
password: string | undefined,
|
|
token: string | undefined,
|
|
workspace: string
|
|
) {
|
|
console.log("%cWarming up...", "font-weight: bold;");
|
|
await runBenchmark({
|
|
host,
|
|
email,
|
|
password,
|
|
token,
|
|
workspace,
|
|
kind: "noop",
|
|
jobs: 50000,
|
|
});
|
|
}
|
|
|
|
async function main({
|
|
host,
|
|
email,
|
|
password,
|
|
token,
|
|
workspace,
|
|
configPath,
|
|
workers,
|
|
factor
|
|
}: {
|
|
host: string;
|
|
email?: string;
|
|
password?: string;
|
|
token?: string;
|
|
workspace: string;
|
|
configPath: string;
|
|
workers: number;
|
|
factor?: number;
|
|
}) {
|
|
if (!Deno.args.includes("--no-warm-up")) {
|
|
await warmUp(host, email, password, token, workspace);
|
|
}
|
|
|
|
try {
|
|
const raw = await loadJsonConfig<SuiteFile>(configPath);
|
|
const config = normaliseConfig(raw, configPath);
|
|
for (const benchmark of config) {
|
|
try {
|
|
console.log(
|
|
"%cRunning benchmark " + benchmark.kind,
|
|
"font-weight: bold;"
|
|
);
|
|
|
|
const result = await runBenchmark({
|
|
host,
|
|
email,
|
|
password,
|
|
token,
|
|
workspace,
|
|
kind: benchmark.kind,
|
|
jobs: benchmark.jobs * (factor ?? 1),
|
|
});
|
|
|
|
if (benchmark.noSave) {
|
|
continue;
|
|
}
|
|
|
|
if (!result) {
|
|
throw new Error("No result returned");
|
|
}
|
|
const stat = {
|
|
value: result.throughput,
|
|
ts: Date.now(),
|
|
};
|
|
let data: (typeof stat)[] = [];
|
|
const benchmarkName =
|
|
benchmark.kind + (workers > 1 ? `_${workers}workers` : "");
|
|
const jsonFilePath = `${benchmarkName}_benchmark.json`;
|
|
try {
|
|
const remotePath =
|
|
"https://raw.githubusercontent.com/windmill-labs/windmill/benchmarks/" +
|
|
jsonFilePath;
|
|
data = await fetch(remotePath).then((r) => r.json());
|
|
} catch (_) {
|
|
console.log("No existing data file found, creating new one.");
|
|
}
|
|
data.push(stat);
|
|
await Deno.writeTextFile(jsonFilePath, JSON.stringify(data, null, 4));
|
|
} catch (err) {
|
|
console.error("Failed to run benchmark", benchmark.kind, err);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error(`Failed to read config file ${configPath}: ${err}`);
|
|
}
|
|
}
|
|
|
|
await new Command()
|
|
.name("wmillbenchsuite")
|
|
.description("Run benchmark suite to measure throughput of windmill.")
|
|
.version(VERSION)
|
|
.option("--host <url:string>", "The windmill host to benchmark.", {
|
|
default: "http://127.0.0.1:8000",
|
|
})
|
|
.option("-e --email <email:string>", "The email to use to login.", {
|
|
default: "admin@windmill.dev",
|
|
})
|
|
.option("-p --password <password:string>", "The password to use to login.", {
|
|
default: "changeme",
|
|
})
|
|
.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("-c --config-path <config:string>", "The path of the config file", {
|
|
required: true,
|
|
})
|
|
.option("--no-warm-up", "Skip the warm up phase.")
|
|
.option(
|
|
"--workers <workers:number>",
|
|
"Number of workers that are used to run the benchmarks (only affect graph title)",
|
|
{ default: 1 }
|
|
)
|
|
.option("--factor <factor:number>", "Factor to multiply the number of jobs by.", {
|
|
default: 1,
|
|
})
|
|
.action(main)
|
|
.command(
|
|
"upgrade",
|
|
new UpgradeCommand({
|
|
main: "main.ts",
|
|
args: [
|
|
"--allow-net",
|
|
"--allow-read",
|
|
"--allow-write",
|
|
"--allow-env",
|
|
"--unstable",
|
|
],
|
|
provider: new DenoLandProvider({ name: "wmillbench" }),
|
|
})
|
|
)
|
|
.parse();
|