mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 08:02:26 +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>
114 lines
3.8 KiB
TypeScript
114 lines
3.8 KiB
TypeScript
// Toxiproxy admin client + per-node proxy planning.
|
|
//
|
|
// One toxiproxy container is run for the whole sim. For every node that
|
|
// declares db_latency_ms > 0, we create a proxy with a unique listener inside
|
|
// the toxiproxy container; the node's DATABASE_URL points at
|
|
// `toxiproxy:<port>` instead of `postgres:5432`. The provisioner is
|
|
// responsible for starting the container; this module only configures it.
|
|
//
|
|
// Wire format docs: https://github.com/Shopify/toxiproxy#http-api
|
|
|
|
export type ProxyPlan = {
|
|
proxyName: string; // toxiproxy proxy id, e.g. "node-far"
|
|
listenPort: number; // port inside the toxiproxy container
|
|
upstream: string; // e.g. "pg:5432"
|
|
latencyMs: number; // 0 means no toxic, plain pass-through
|
|
};
|
|
|
|
// Allocate ports starting at this offset so they don't collide with toxiproxy's
|
|
// admin port (8474) or anything else common. 15400 is unused in well-known
|
|
// service registries.
|
|
export const FIRST_PROXY_PORT = 15400;
|
|
|
|
export function buildProxyPlans(
|
|
nodes: { id: string; db_latency_ms?: number }[],
|
|
upstream: string,
|
|
): ProxyPlan[] {
|
|
const plans: ProxyPlan[] = [];
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
const n = nodes[i];
|
|
if (!n.db_latency_ms || n.db_latency_ms <= 0) continue;
|
|
plans.push({
|
|
proxyName: `node-${n.id}`,
|
|
listenPort: FIRST_PROXY_PORT + i,
|
|
upstream,
|
|
latencyMs: n.db_latency_ms,
|
|
});
|
|
}
|
|
return plans;
|
|
}
|
|
|
|
export class ToxiproxyClient {
|
|
constructor(public adminUrl: string) {}
|
|
|
|
async waitReady(timeoutMs = 30_000): Promise<void> {
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
try {
|
|
const r = await fetch(`${this.adminUrl}/version`);
|
|
if (r.ok) {
|
|
await r.body?.cancel();
|
|
return;
|
|
}
|
|
await r.body?.cancel();
|
|
} catch (_) { /* not ready yet */ }
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
}
|
|
throw new Error(`toxiproxy admin API not reachable at ${this.adminUrl}`);
|
|
}
|
|
|
|
async reset(): Promise<void> {
|
|
const r = await fetch(`${this.adminUrl}/reset`, { method: "POST" });
|
|
if (!r.ok) throw new Error(`toxiproxy reset failed: ${r.status} ${await r.text()}`);
|
|
await r.body?.cancel();
|
|
}
|
|
|
|
async createProxy(plan: ProxyPlan): Promise<void> {
|
|
const r = await fetch(`${this.adminUrl}/proxies`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
name: plan.proxyName,
|
|
listen: `0.0.0.0:${plan.listenPort}`,
|
|
upstream: plan.upstream,
|
|
enabled: true,
|
|
}),
|
|
});
|
|
if (!r.ok) {
|
|
const body = await r.text();
|
|
throw new Error(`createProxy(${plan.proxyName}) failed: ${r.status} ${body}`);
|
|
}
|
|
await r.body?.cancel();
|
|
}
|
|
|
|
async addLatencyToxic(proxyName: string, latencyMs: number): Promise<void> {
|
|
const r = await fetch(`${this.adminUrl}/proxies/${proxyName}/toxics`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
// The default downstream direction adds latency to traffic going from
|
|
// upstream (PG) to client (worker), which is what matters for query RTT.
|
|
type: "latency",
|
|
attributes: { latency: latencyMs, jitter: 0 },
|
|
}),
|
|
});
|
|
if (!r.ok) {
|
|
const body = await r.text();
|
|
throw new Error(`addLatencyToxic(${proxyName}) failed: ${r.status} ${body}`);
|
|
}
|
|
await r.body?.cancel();
|
|
}
|
|
|
|
// Convenience: clear all proxies and recreate the plan. Idempotent across
|
|
// sim runs that reuse a long-lived toxiproxy container.
|
|
async apply(plans: ProxyPlan[]): Promise<void> {
|
|
await this.reset();
|
|
for (const p of plans) {
|
|
await this.createProxy(p);
|
|
if (p.latencyMs > 0) {
|
|
await this.addLatencyToxic(p.proxyName, p.latencyMs);
|
|
}
|
|
}
|
|
}
|
|
}
|