mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 00:02:23 +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>
285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
// Pre-bench readiness check. Catches the "fire bench on broken cluster" trap
|
|
// that wasted multiple runs this session — pods Pending, samplers in
|
|
// CrashLoopBackOff, queue full of leftover jobs from a prior bench, PG not
|
|
// responding fast enough. Each of those silently produced a useless report.
|
|
//
|
|
// Usage from main.ts:
|
|
// const r = await checkReadiness(prov);
|
|
// if (!r.ready) {
|
|
// console.error("[bench] cluster NOT ready:");
|
|
// for (const issue of r.issues) console.error(" - " + issue);
|
|
// Deno.exit(2);
|
|
// }
|
|
//
|
|
// Optional `waitForReady` polls every 5s up to a timeout, printing transient
|
|
// issues until they clear. Use it when the cluster might be mid-reconcile
|
|
// (e.g. right after a helm upgrade).
|
|
|
|
import { MinikubeProvisioner } from "./k8s_provisioner.ts";
|
|
|
|
export type ReadinessReport = {
|
|
ready: boolean;
|
|
issues: string[];
|
|
details: {
|
|
samplers_running: number;
|
|
samplers_total: number;
|
|
workers_ready: number;
|
|
workers_total: number;
|
|
pg_phase: string;
|
|
pg_responsive: boolean;
|
|
queue_depth: number;
|
|
toxiproxy_ready: boolean;
|
|
app_ready: boolean;
|
|
};
|
|
};
|
|
|
|
type PodSnapshot = {
|
|
metadata?: { name?: string };
|
|
spec?: { nodeName?: string };
|
|
status?: {
|
|
phase?: string;
|
|
containerStatuses?: Array<{
|
|
ready?: boolean;
|
|
restartCount?: number;
|
|
state?: { running?: unknown; waiting?: { reason?: string }; terminated?: unknown };
|
|
}>;
|
|
};
|
|
};
|
|
|
|
async function listPods(
|
|
prov: MinikubeProvisioner,
|
|
namespace: string,
|
|
selector: string,
|
|
): Promise<PodSnapshot[]> {
|
|
const res = await prov.kubectl([
|
|
"-n", namespace, "get", "pods", "-l", selector, "-o", "json",
|
|
]);
|
|
if (res.code !== 0) return [];
|
|
try {
|
|
return (JSON.parse(res.stdout) as { items?: PodSnapshot[] }).items ?? [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function countReady(pods: PodSnapshot[]): number {
|
|
return pods.filter((p) => p.status?.containerStatuses?.[0]?.ready === true).length;
|
|
}
|
|
|
|
function countRunning(pods: PodSnapshot[]): number {
|
|
return pods.filter((p) => p.status?.phase === "Running").length;
|
|
}
|
|
|
|
export async function checkReadiness(
|
|
prov: MinikubeProvisioner,
|
|
opts: {
|
|
expectSamplers?: number;
|
|
expectWorkers?: number;
|
|
namespace?: string;
|
|
samplerNamespace?: string;
|
|
requireEmptyQueue?: boolean;
|
|
} = {},
|
|
): Promise<ReadinessReport> {
|
|
const namespace = opts.namespace ?? "default";
|
|
const samplerNs = opts.samplerNamespace ?? "kube-system";
|
|
const expectSamplers = opts.expectSamplers ?? 4;
|
|
const requireEmptyQueue = opts.requireEmptyQueue ?? true;
|
|
|
|
const issues: string[] = [];
|
|
|
|
// --- Samplers ---
|
|
// Not just "Running" — must have been Running long enough for kubelet's
|
|
// log buffer to have stable data covering the start of the bench. A
|
|
// sampler that was just-deployed 5s ago is technically Running, but its
|
|
// stdout buffer hasn't reached the kubelet log file yet, so a kubectl-
|
|
// logs --since-time at bench-end gets nothing for the first ~30s of the
|
|
// bench window. Require startTime to be at least MIN_STABLE_S in the past.
|
|
const MIN_STABLE_S = 30;
|
|
const samplerPods = await listPods(prov, samplerNs, "app=wm-sim-cpu-sampler");
|
|
const samplersRunning = countRunning(samplerPods);
|
|
if (samplersRunning < expectSamplers) {
|
|
const bad = samplerPods
|
|
.filter((p) => p.status?.phase !== "Running")
|
|
.map((p) => {
|
|
const reason = (p.status as { containerStatuses?: Array<{ state?: { waiting?: { reason?: string } } }> } | undefined)
|
|
?.containerStatuses?.[0]?.state?.waiting?.reason;
|
|
return `${p.metadata?.name}${reason ? `(${reason})` : ""}`;
|
|
})
|
|
.join(", ");
|
|
issues.push(
|
|
`samplers ${samplersRunning}/${expectSamplers} Running — not Running: ${bad || "(none listed)"}`,
|
|
);
|
|
} else {
|
|
const nowMs = Date.now();
|
|
const tooYoung = samplerPods.filter((p) => {
|
|
const startStr = (p.status as { startTime?: string } | undefined)?.startTime;
|
|
if (!startStr) return true;
|
|
const ageS = (nowMs - Date.parse(startStr)) / 1000;
|
|
return ageS < MIN_STABLE_S;
|
|
});
|
|
if (tooYoung.length > 0) {
|
|
const tags = tooYoung.map((p) => {
|
|
const startStr = (p.status as { startTime?: string } | undefined)?.startTime;
|
|
const ageS = startStr ? Math.floor((nowMs - Date.parse(startStr)) / 1000) : -1;
|
|
return `${p.metadata?.name}(age=${ageS}s)`;
|
|
}).join(", ");
|
|
issues.push(
|
|
`samplers Running but not yet stable (need ≥ ${MIN_STABLE_S}s uptime): ${tags}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// --- Workers ---
|
|
const workerPods = await listPods(prov, namespace, "app=windmill-workers");
|
|
const workersReady = countReady(workerPods);
|
|
const workersTotal = workerPods.length;
|
|
if (opts.expectWorkers !== undefined) {
|
|
if (workersReady < opts.expectWorkers) {
|
|
issues.push(`workers ${workersReady}/${opts.expectWorkers} ready (${workersTotal} pods total)`);
|
|
}
|
|
} else if (workersReady < workersTotal) {
|
|
issues.push(`workers ${workersReady}/${workersTotal} ready`);
|
|
}
|
|
|
|
// --- Worker Deployments: rollout must be fully complete ---
|
|
// Pod-level readiness above passes during rolling-updates (200 ready while
|
|
// 50 old pods still being torn down). That worker churn floods the kernel's
|
|
// cgroup_mutex and starves the CPU sampler — m04 had a 96s data gap last
|
|
// bench because of exactly this. Bench MUST wait until each deployment's
|
|
// status reflects: observedGeneration == metadata.generation AND
|
|
// updatedReplicas == spec.replicas AND availableReplicas == spec.replicas
|
|
// AND no leftover replicas from the old ReplicaSet.
|
|
const deplRes = await prov.kubectl([
|
|
"-n", namespace, "get", "deploy", "-l", "app=windmill-workers", "-o", "json",
|
|
]);
|
|
if (deplRes.code === 0) {
|
|
try {
|
|
const list = JSON.parse(deplRes.stdout) as {
|
|
items?: Array<{
|
|
metadata?: { name?: string; generation?: number };
|
|
spec?: { replicas?: number };
|
|
status?: {
|
|
observedGeneration?: number;
|
|
updatedReplicas?: number;
|
|
availableReplicas?: number;
|
|
replicas?: number;
|
|
};
|
|
}>;
|
|
};
|
|
for (const d of list.items ?? []) {
|
|
const name = d.metadata?.name ?? "?";
|
|
const gen = d.metadata?.generation ?? 0;
|
|
const obsGen = d.status?.observedGeneration ?? -1;
|
|
const desired = d.spec?.replicas ?? 0;
|
|
const updated = d.status?.updatedReplicas ?? 0;
|
|
const avail = d.status?.availableReplicas ?? 0;
|
|
const total = d.status?.replicas ?? 0;
|
|
if (obsGen < gen) {
|
|
issues.push(`deploy/${name} controller behind: observedGen=${obsGen} < gen=${gen}`);
|
|
continue;
|
|
}
|
|
if (updated < desired) {
|
|
issues.push(`deploy/${name} rollout incomplete: updated=${updated}/${desired}`);
|
|
}
|
|
if (avail < desired) {
|
|
issues.push(`deploy/${name} rollout incomplete: available=${avail}/${desired}`);
|
|
}
|
|
if (total > desired) {
|
|
issues.push(`deploy/${name} old replicas not yet terminated: total=${total} > desired=${desired}`);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
issues.push(`worker deploy parse failed: ${(e as Error).message}`);
|
|
}
|
|
} else {
|
|
issues.push(`worker deploy lookup failed (code ${deplRes.code})`);
|
|
}
|
|
|
|
// --- PG pod + responsiveness ---
|
|
const pgPods = await listPods(prov, namespace, "app=windmill-postgresql-demo-app");
|
|
const pgPhase = pgPods[0]?.status?.phase ?? "absent";
|
|
if (pgPhase !== "Running") {
|
|
issues.push(`PG phase=${pgPhase}`);
|
|
}
|
|
let pgResponsive = false;
|
|
let queueDepth = -1;
|
|
if (pgPods.length > 0 && pgPhase === "Running") {
|
|
// Force a fast statement_timeout so a wedged PG doesn't hang the check.
|
|
const r = await prov.kubectl([
|
|
"-n", namespace, "exec", pgPods[0].metadata!.name!, "--",
|
|
"psql", "-U", "postgres", "-d", "windmill", "-tAc",
|
|
"SET statement_timeout=3000; SELECT count(*) FROM v2_job_queue;",
|
|
]);
|
|
if (r.code === 0) {
|
|
const last = r.stdout.trim().split("\n").pop() ?? "";
|
|
const n = parseInt(last);
|
|
if (Number.isFinite(n)) {
|
|
pgResponsive = true;
|
|
queueDepth = n;
|
|
if (requireEmptyQueue && n > 0) {
|
|
issues.push(`queue has ${n} leftover job(s) — drain or DELETE FROM v2_job_queue before benching`);
|
|
}
|
|
} else {
|
|
issues.push(`PG returned non-numeric queue count: ${last.slice(0, 60)}`);
|
|
}
|
|
} else {
|
|
issues.push(`PG psql failed (statement_timeout=3s): ${(r.stderr || r.stdout).slice(0, 100)}`);
|
|
}
|
|
}
|
|
|
|
// --- toxiproxy + app ---
|
|
const toxPods = await listPods(prov, namespace, "app=toxiproxy");
|
|
const toxReady = countReady(toxPods) > 0;
|
|
if (!toxReady) issues.push(`toxiproxy not Ready (${toxPods.length} pod(s))`);
|
|
|
|
const appPods = await listPods(prov, namespace, "app=windmill-app");
|
|
const appReady = countReady(appPods) > 0;
|
|
if (!appReady) issues.push(`windmill-app not Ready (${appPods.length} pod(s))`);
|
|
|
|
return {
|
|
ready: issues.length === 0,
|
|
issues,
|
|
details: {
|
|
samplers_running: samplersRunning,
|
|
samplers_total: samplerPods.length,
|
|
workers_ready: workersReady,
|
|
workers_total: workersTotal,
|
|
pg_phase: pgPhase,
|
|
pg_responsive: pgResponsive,
|
|
queue_depth: queueDepth,
|
|
toxiproxy_ready: toxReady,
|
|
app_ready: appReady,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Poll checkReadiness every N seconds until ready or timeout. Transient
|
|
// issues that clear on a subsequent tick aren't treated as failures.
|
|
export async function waitForReady(
|
|
prov: MinikubeProvisioner,
|
|
opts: {
|
|
timeoutMs?: number;
|
|
pollMs?: number;
|
|
expectSamplers?: number;
|
|
expectWorkers?: number;
|
|
requireEmptyQueue?: boolean;
|
|
} = {},
|
|
): Promise<ReadinessReport> {
|
|
const timeoutMs = opts.timeoutMs ?? 180_000;
|
|
const pollMs = opts.pollMs ?? 5_000;
|
|
const start = Date.now();
|
|
let last: ReadinessReport | undefined;
|
|
while (true) {
|
|
last = await checkReadiness(prov, {
|
|
expectSamplers: opts.expectSamplers,
|
|
expectWorkers: opts.expectWorkers,
|
|
requireEmptyQueue: opts.requireEmptyQueue,
|
|
});
|
|
if (last.ready) return last;
|
|
if (Date.now() - start >= timeoutMs) return last;
|
|
console.log(`[readiness] not ready (${last.issues.length} issue(s)) — polling again in ${pollMs / 1000}s`);
|
|
for (const issue of last.issues) console.log(` - ${issue}`);
|
|
await new Promise((r) => setTimeout(r, pollMs));
|
|
}
|
|
}
|