Files
windmill/backend/tests/workspace_fairness.rs
Ruben Fiszel 42d2121af9 fix(queue): duration-weighted workspace fairness signal (#9329)
* fix(queue): bump EE ref to include worker_ping fairness signal

The current ee-repo-ref.txt pointed to 31cda7c (an unrelated merge
commit on the asset-graph-view-ee branch) instead of ddc9e80, which
contains the workspace-fairness fix that switches the active-share
signal from v2_job_queue.running=true to worker_ping. As a result
cloud was still computing overload off the legacy signal, so a
workspace with many in-flight/suspended flows (lancom01-prod, with
799 suspended flows × 3 v2_job_queue bookkeeping rows each = 2397
running-true rows) was flagged as 95% of cluster activity despite
consuming zero worker slots.

Bumping to ddc9e80 picks up the worker_ping-based signal, which
naturally excludes (a) suspended jobs (no worker pinging them),
(b) zombie running-rows from dead workers, and (c) flow/flownode
orchestration rows that never run on a worker in the first place.

* test(queue): seed v2_job rows + realistic durations for fairness helpers

The new duration-weighted fairness algorithm joins v2_job_queue and
v2_job_completed to v2_job for the `kind` filter (excluding flow
bookkeeping) and reads `duration_ms` for the completed contribution.
Update the test helpers to mirror that schema:

* `insert_completed` now inserts a matching v2_job row (kind=script)
  and writes `duration_ms = 1000` with a 1-second [started_at,
  completed_at] interval, so each completed row contributes ~1
  worker-second when fully inside the refresh window.
* `insert_queued` likewise pre-inserts v2_job, sets `started_at`
  to NOW() - 1s when running=true (so running rows contribute ~1
  worker-second by the time the refresh runs), and seeds
  v2_job_runtime.ping so the running side accrues real-time worker
  seconds (the algorithm bounds end-of-interval by ping).

The zombie/suspended insert helpers are intentionally left without
v2_job rows — the new algorithm's INNER JOIN excludes them, so they
still correctly contribute zero worker-seconds.

* chore(queue): bump EE ref to duration-weighted fairness algorithm

Companion to windmill-ee-private#<TBD>: switch the EE workspace
fairness aggregation from a count-based UNION (worker_ping snapshot
+ v2_job_completed count) to a worker-seconds aggregation sourced
directly from v2_job_queue and v2_job_completed, with kind/suspend
filters mirroring handle_zombie_jobs and per-row defenses against
zombie inflation on both halves.

* chore(queue): bump EE ref for fairness perf fix (inline window_start)

* chore(queue): bump EE ref for fairness perf rewrite (driver-side flip)

* update ee ref
2026-05-26 11:47:45 +00:00

1689 lines
63 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Tests for the workspace-fairness algorithm (Enterprise feature).
//!
//! Multi-tenant clusters with a single shared worker pool let one workspace
//! starve the others if it floods the queue. The algorithm in
//! `windmill_queue::workspace_fairness_ee` periodically aggregates
//! per-workspace activity and stochastically excludes any workspace whose
//! share of cluster activity exceeds `WORKSPACE_FAIRNESS_MAX_PERCENT`%.
//!
//! There are two layers of tests in this file:
//!
//! 1. **Unit-style tests** (the first six) exercise the algorithm's response
//! to fabricated activity tables and verify the audit-log writer. They are
//! deterministic and fast.
//!
//! 2. **Simulation tests** (`fairness_50_workers_diverse_workload`,
//! `fairness_oscillation_long_run`, `fairness_burst_then_stop`) spin up
//! 50 mock workers (async tasks doing the real pull → mark-running →
//! sleep → complete cycle over real `v2_job_queue` rows), drive sustained
//! diverse traffic from one noisy workspace + many victim workspaces, and
//! measure the per-workspace **quality of service**. They are marked
//! `#[ignore]` so the default `cargo test` stays fast — run with
//! `--ignored` to exercise them.
//!
//! The entire file is gated on `private` because the algorithm itself only
//! compiles into the binary in EE builds. In OSS the `workspace_fairness`
//! module is a thin set of no-op stubs, so a test against it would have
//! nothing to assert.
#![cfg(feature = "private")]
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use serial_test::serial;
use sqlx::{Pool, Postgres};
use tokio::sync::Mutex;
use uuid::Uuid;
use windmill_common::worker::{
WORKSPACE_FAIRNESS_DURATION_SECS, WORKSPACE_FAIRNESS_ENABLED,
WORKSPACE_FAIRNESS_LAST_REFRESH_MICROS, WORKSPACE_FAIRNESS_MAX_PERCENT,
WORKSPACE_FAIRNESS_MIN_TOTAL, WORKSPACE_FAIRNESS_OVERLOADED,
};
use windmill_queue::workspace_fairness::refresh_overloaded;
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
fn reset_fairness_state() {
WORKSPACE_FAIRNESS_OVERLOADED.store(Arc::new(vec![]));
WORKSPACE_FAIRNESS_LAST_REFRESH_MICROS.store(0, Ordering::Relaxed);
WORKSPACE_FAIRNESS_ENABLED.store(true, Ordering::Relaxed);
WORKSPACE_FAIRNESS_MAX_PERCENT.store(50, Ordering::Relaxed);
WORKSPACE_FAIRNESS_DURATION_SECS.store(10, Ordering::Relaxed);
WORKSPACE_FAIRNESS_MIN_TOTAL.store(4, Ordering::Relaxed);
}
async fn create_workspace(db: &Pool<Postgres>, id: &str) {
sqlx::query(
"INSERT INTO workspace (id, name, owner)
VALUES ($1, $1, 'test-user') ON CONFLICT (id) DO NOTHING",
)
.bind(id)
.execute(db)
.await
.unwrap();
sqlx::query(
"INSERT INTO workspace_settings (workspace_id) VALUES ($1)
ON CONFLICT (workspace_id) DO NOTHING",
)
.bind(id)
.execute(db)
.await
.unwrap();
}
/// Insert `n` completed jobs for `workspace_id`, each ending `secs_ago`
/// seconds in the past with a 1-second wall-clock duration. The fairness
/// algorithm weights contributions by `duration_ms` (clamped to the window),
/// so each job contributes ~1 worker-second when fully inside the window.
async fn insert_completed(db: &Pool<Postgres>, workspace_id: &str, n: usize, secs_ago: i32) {
for _ in 0..n {
let id: Uuid = sqlx::query_scalar(
"INSERT INTO v2_job (id, workspace_id, kind)
VALUES (gen_random_uuid(), $1, 'script'::job_kind) RETURNING id",
)
.bind(workspace_id)
.fetch_one(db)
.await
.unwrap();
sqlx::query(
"INSERT INTO v2_job_completed (id, workspace_id, duration_ms, status,
started_at, completed_at)
VALUES ($1, $2, 1000, 'success'::job_status,
NOW() - make_interval(secs => ($3::int + 1)),
NOW() - make_interval(secs => $3::int))",
)
.bind(id)
.bind(workspace_id)
.bind(secs_ago)
.execute(db)
.await
.unwrap();
}
}
async fn insert_queued(
db: &Pool<Postgres>,
workspace_id: &str,
n: usize,
running: bool,
tag: &str,
) -> Vec<Uuid> {
let mut ids = Vec::with_capacity(n);
for _ in 0..n {
let id: Uuid = sqlx::query_scalar(
"INSERT INTO v2_job (id, workspace_id, kind, tag)
VALUES (gen_random_uuid(), $1, 'script'::job_kind, $2) RETURNING id",
)
.bind(workspace_id)
.bind(tag)
.fetch_one(db)
.await
.unwrap();
// Running jobs need a `started_at` for the fairness algorithm to
// compute a positive elapsed-time contribution. Backdate by 1s so
// each running row contributes ~1 worker-second by the time the
// refresh runs, matching the `insert_completed` scale.
sqlx::query(
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, tag, started_at)
VALUES ($1, $2, NOW(), $3, $4,
CASE WHEN $3 THEN NOW() - interval '1 second' ELSE NULL END)",
)
.bind(id)
.bind(workspace_id)
.bind(running)
.bind(tag)
.execute(db)
.await
.unwrap();
if running {
// The fairness algorithm bounds the running contribution by the
// per-job `v2_job_runtime.ping`. Insert a fresh ping so each
// running row accrues real-time worker-seconds.
sqlx::query(
"INSERT INTO v2_job_runtime (id, ping) VALUES ($1, NOW())
ON CONFLICT (id) DO UPDATE SET ping = NOW()",
)
.bind(id)
.execute(db)
.await
.unwrap();
insert_live_worker_ping(db, workspace_id, id).await;
}
ids.push(id);
}
ids
}
/// Insert a live `worker_ping` row claiming the given job. Each insert uses
/// a fresh randomly-named worker so callers can stack multiple pings without
/// PK collisions on `worker`.
async fn insert_live_worker_ping(db: &Pool<Postgres>, workspace_id: &str, job_id: Uuid) {
let worker_name = format!("test-worker-{}", Uuid::new_v4());
sqlx::query(
"INSERT INTO worker_ping (worker, worker_instance, ping_at, ip, current_job_id, current_job_workspace_id)
VALUES ($1, 'test', NOW(), '127.0.0.1', $2, $3)",
)
.bind(&worker_name)
.bind(job_id)
.bind(workspace_id)
.execute(db)
.await
.unwrap();
}
/// Insert a "zombie" running row: a row in `v2_job_queue` with `running=true`
/// but **no** live `worker_ping` claiming it (no paired worker, or the worker
/// has stopped pinging). The fairness algorithm must NOT count these — they
/// don't consume any worker slot.
async fn insert_zombie_running(db: &Pool<Postgres>, workspace_id: &str, n: usize) -> Vec<Uuid> {
let mut ids = Vec::with_capacity(n);
for _ in 0..n {
let id: Uuid = sqlx::query_scalar(
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, tag, started_at)
VALUES (gen_random_uuid(), $1, NOW() - interval '1 hour', true, 'deno',
NOW() - interval '1 hour') RETURNING id",
)
.bind(workspace_id)
.fetch_one(db)
.await
.unwrap();
ids.push(id);
}
ids
}
/// Insert a concurrency-suspended row: `running=true` AND `suspend > 0`. These
/// rows are not being processed by any worker (the flow is paused), so the
/// algorithm must not count them as slot occupancy.
async fn insert_suspended_running(db: &Pool<Postgres>, workspace_id: &str, n: usize) -> Vec<Uuid> {
let mut ids = Vec::with_capacity(n);
for _ in 0..n {
let id: Uuid = sqlx::query_scalar(
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, suspend, tag)
VALUES (gen_random_uuid(), $1, NOW(), true, 1, 'deno') RETURNING id",
)
.bind(workspace_id)
.fetch_one(db)
.await
.unwrap();
ids.push(id);
}
ids
}
fn overloaded_set() -> Vec<String> {
(**WORKSPACE_FAIRNESS_OVERLOADED.load()).clone()
}
// ---------------------------------------------------------------------------
// Unit-style algorithm tests
// ---------------------------------------------------------------------------
#[sqlx::test(fixtures("base"))]
#[serial]
async fn fairness_caps_dominant_workspace(db: Pool<Postgres>) {
reset_fairness_state();
create_workspace(&db, "noisy").await;
create_workspace(&db, "victim_a").await;
create_workspace(&db, "victim_b").await;
insert_completed(&db, "noisy", 60, 2).await;
insert_completed(&db, "victim_a", 5, 3).await;
insert_completed(&db, "victim_b", 5, 1).await;
refresh_overloaded(&db).await.expect("refresh ok");
assert_eq!(overloaded_set(), vec!["noisy".to_string()]);
}
#[sqlx::test(fixtures("base"))]
#[serial]
async fn fairness_respects_min_total(db: Pool<Postgres>) {
reset_fairness_state();
create_workspace(&db, "lone").await;
insert_completed(&db, "lone", 3, 2).await;
refresh_overloaded(&db).await.expect("refresh ok");
assert!(overloaded_set().is_empty());
}
#[sqlx::test(fixtures("base"))]
#[serial]
async fn fairness_pull_query_skips_capped_workspace(db: Pool<Postgres>) {
reset_fairness_state();
create_workspace(&db, "noisy").await;
create_workspace(&db, "victim").await;
let noisy_ids = insert_queued(&db, "noisy", 1, false, "deno").await;
let victim_ids = insert_queued(&db, "victim", 1, false, "deno").await;
let regular_pick: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM v2_job_queue
WHERE running = false AND tag IN ('deno') AND scheduled_for <= now()
ORDER BY priority DESC NULLS LAST, scheduled_for LIMIT 1",
)
.fetch_optional(&db)
.await
.unwrap();
assert_eq!(regular_pick, Some(noisy_ids[0]));
let capped = vec!["noisy".to_string()];
let fairness_pick: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM v2_job_queue
WHERE running = false AND tag IN ('deno') AND scheduled_for <= now()
AND workspace_id <> ALL($1::text[])
ORDER BY priority DESC NULLS LAST, scheduled_for LIMIT 1",
)
.bind(&capped)
.fetch_optional(&db)
.await
.unwrap();
assert_eq!(fairness_pick, Some(victim_ids[0]));
}
#[sqlx::test(fixtures("base"))]
#[serial]
async fn fairness_lifts_when_load_drops(db: Pool<Postgres>) {
reset_fairness_state();
create_workspace(&db, "noisy").await;
create_workspace(&db, "victim_a").await;
create_workspace(&db, "victim_b").await;
insert_completed(&db, "noisy", 60, 2).await;
insert_completed(&db, "victim_a", 5, 3).await;
insert_completed(&db, "victim_b", 5, 1).await;
refresh_overloaded(&db).await.expect("refresh ok");
assert_eq!(overloaded_set(), vec!["noisy".to_string()]);
sqlx::query(
"UPDATE v2_job_completed
SET completed_at = NOW() - make_interval(secs => 60),
started_at = NOW() - make_interval(secs => 60)
WHERE workspace_id IN ('noisy', 'victim_a', 'victim_b')",
)
.execute(&db)
.await
.unwrap();
insert_completed(&db, "noisy", 10, 2).await;
insert_completed(&db, "victim_a", 10, 2).await;
insert_completed(&db, "victim_b", 10, 2).await;
// Roll updated_at back so the DB-side claim guard lets the next refresh win.
sqlx::query(
"UPDATE background_task_state
SET updated_at = NOW() - INTERVAL '1 hour'
WHERE name = 'workspace_fairness'",
)
.execute(&db)
.await
.unwrap();
refresh_overloaded(&db).await.expect("refresh ok");
assert!(overloaded_set().is_empty());
}
/// Ensure today's audit_partitioned partition exists — the migration only
/// creates partitions for the day it ran + 3 days, after which production
/// relies on `monitor::manage_audit_partitions` to roll new ones. That
/// maintenance task does not run in the test binary, so inserts silently
/// fail-and-warn without it.
async fn ensure_today_audit_partition(db: &Pool<Postgres>) {
let today: chrono::NaiveDate = chrono::Utc::now().date_naive();
let next = today + chrono::Duration::days(1);
let partition = format!("audit_{}", today.format("%Y%m%d"));
let sql = format!(
"CREATE TABLE IF NOT EXISTS \"{partition}\" PARTITION OF audit_partitioned \
FOR VALUES FROM ('{today}') TO ('{next}')"
);
let _ = sqlx::query(&sql).execute(db).await;
}
/// Both cap AND uncap transitions must produce audit-log rows. This test
/// drives a cap → uncap cycle and inspects `audit_partitioned` directly.
#[sqlx::test(fixtures("base"))]
#[serial]
async fn fairness_audit_records_both_cap_and_uncap(db: Pool<Postgres>) {
reset_fairness_state();
ensure_today_audit_partition(&db).await;
create_workspace(&db, "noisy").await;
create_workspace(&db, "victim_a").await;
create_workspace(&db, "victim_b").await;
// Phase 1 — push noisy to dominate, cap it.
insert_completed(&db, "noisy", 60, 2).await;
insert_completed(&db, "victim_a", 5, 3).await;
insert_completed(&db, "victim_b", 5, 1).await;
refresh_overloaded(&db).await.expect("refresh ok");
assert_eq!(overloaded_set(), vec!["noisy".to_string()]);
// Phase 2 — roll noisy's completions outside the window, push balanced
// load, force a refresh; noisy should be uncapped.
sqlx::query(
"UPDATE v2_job_completed
SET completed_at = NOW() - make_interval(secs => 60),
started_at = NOW() - make_interval(secs => 60)
WHERE workspace_id IN ('noisy', 'victim_a', 'victim_b')",
)
.execute(&db)
.await
.unwrap();
insert_completed(&db, "noisy", 10, 2).await;
insert_completed(&db, "victim_a", 10, 2).await;
insert_completed(&db, "victim_b", 10, 2).await;
sqlx::query(
"UPDATE background_task_state
SET updated_at = NOW() - INTERVAL '1 hour'
WHERE name = 'workspace_fairness'",
)
.execute(&db)
.await
.unwrap();
refresh_overloaded(&db).await.expect("refresh ok");
assert!(overloaded_set().is_empty());
// Verify both audit rows actually landed.
let capped_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM audit_partitioned
WHERE workspace_id = 'admins'
AND operation = 'workspace_fairness.capped'
AND resource = 'noisy'",
)
.fetch_one(&db)
.await
.unwrap();
let uncapped_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM audit_partitioned
WHERE workspace_id = 'admins'
AND operation = 'workspace_fairness.uncapped'
AND resource = 'noisy'",
)
.fetch_one(&db)
.await
.unwrap();
println!("audit rows: capped={capped_count}, uncapped={uncapped_count}");
assert_eq!(capped_count, 1, "expected exactly 1 capped audit for noisy");
assert_eq!(
uncapped_count, 1,
"expected exactly 1 uncapped audit for noisy — \
if this is 0, the uncap transition is not being recorded"
);
}
#[sqlx::test(fixtures("base"))]
#[serial]
async fn fairness_catches_slot_hoggers(db: Pool<Postgres>) {
reset_fairness_state();
create_workspace(&db, "hogger").await;
create_workspace(&db, "victim").await;
insert_queued(&db, "hogger", 10, true, "deno").await;
insert_completed(&db, "victim", 2, 1).await;
refresh_overloaded(&db).await.expect("refresh ok");
assert_eq!(overloaded_set(), vec!["hogger".to_string()]);
}
/// Regression: a workspace with a large backlog of `running = true` rows that
/// have **no live worker** claiming them (worker died, ping went stale, etc.)
/// must not be counted as "active". A previous version of the algorithm
/// counted `v2_job_queue.running = true` directly and was perpetually pinned
/// on the workspace with the most zombie rows, masking every other workspace.
#[sqlx::test(fixtures("base"))]
#[serial]
#[ignore = "flaky in CI"]
async fn fairness_ignores_zombie_running_rows(db: Pool<Postgres>) {
reset_fairness_state();
create_workspace(&db, "stuck_backlog").await;
create_workspace(&db, "real_noisy").await;
create_workspace(&db, "victim").await;
// 100 zombie running rows for `stuck_backlog`. No paired worker_ping ⇒
// no live worker is processing them. Old algorithm: 100 units of fake
// activity. New algorithm: 0 units.
insert_zombie_running(&db, "stuck_backlog", 100).await;
// `real_noisy` is genuinely flooding the cluster.
insert_completed(&db, "real_noisy", 60, 2).await;
insert_completed(&db, "victim", 5, 3).await;
refresh_overloaded(&db).await.expect("refresh ok");
let set = overloaded_set();
assert!(
!set.contains(&"stuck_backlog".to_string()),
"zombie running rows must not flag a workspace as overloaded; got {set:?}"
);
assert_eq!(
set,
vec!["real_noisy".to_string()],
"the actually noisy workspace must surface even when another workspace \
has a large backlog of zombie running rows; got {set:?}"
);
}
/// Regression: concurrency-suspended rows (`running = true AND suspend > 0`)
/// are not consuming worker slots — the flow is paused at a suspend step —
/// and must not contribute to the activity share.
#[sqlx::test(fixtures("base"))]
#[serial]
#[ignore = "flaky in CI"]
async fn fairness_ignores_concurrency_suspended_rows(db: Pool<Postgres>) {
reset_fairness_state();
create_workspace(&db, "concurrency_capped").await;
create_workspace(&db, "real_noisy").await;
create_workspace(&db, "victim").await;
// 100 concurrency-suspended rows. Each has `running = true` (the legacy
// signal) but `suspend > 0` (not actually on a worker).
insert_suspended_running(&db, "concurrency_capped", 100).await;
insert_completed(&db, "real_noisy", 60, 2).await;
insert_completed(&db, "victim", 5, 3).await;
refresh_overloaded(&db).await.expect("refresh ok");
let set = overloaded_set();
assert!(
!set.contains(&"concurrency_capped".to_string()),
"concurrency-suspended rows must not flag a workspace as overloaded; got {set:?}"
);
assert_eq!(
set,
vec!["real_noisy".to_string()],
"noisy workspace must still surface despite another workspace's large \
suspended backlog; got {set:?}"
);
}
// ---------------------------------------------------------------------------
// Simulation test
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
struct JobSpec {
duration_ms: u32,
}
#[derive(Debug)]
struct Stats {
/// Per-workspace observed latencies in milliseconds (enqueue → complete).
per_ws: HashMap<String, Vec<u64>>,
/// Per-workspace queued counts (pushed by the workload generator).
pushed: HashMap<String, u64>,
/// Per-workspace completion events as (elapsed_ms_since_scenario_start,
/// latency_ms). Used by the oscillation simulation to compute per-second
/// latency time series.
events: HashMap<String, Vec<(u64, u64)>>,
/// Reference t=0 for the current scenario, set by `run_scenario`.
started: Option<Instant>,
}
impl Stats {
fn new() -> Self {
Self {
per_ws: HashMap::new(),
pushed: HashMap::new(),
events: HashMap::new(),
started: None,
}
}
fn record(&mut self, ws: &str, latency_ms: u64) {
self.per_ws
.entry(ws.to_string())
.or_default()
.push(latency_ms);
if let Some(t0) = self.started {
let elapsed_ms = t0.elapsed().as_millis() as u64;
self.events
.entry(ws.to_string())
.or_default()
.push((elapsed_ms, latency_ms));
}
}
fn pushed_inc(&mut self, ws: &str) {
*self.pushed.entry(ws.to_string()).or_insert(0) += 1;
}
}
#[derive(Debug, Clone)]
struct WsSummary {
workspace: String,
pushed: u64,
completed: u64,
p50_ms: u64,
p95_ms: u64,
p99_ms: u64,
max_ms: u64,
}
fn percentile(sorted: &[u64], p: f64) -> u64 {
if sorted.is_empty() {
return 0;
}
let idx = ((sorted.len() as f64 - 1.0) * p / 100.0).round() as usize;
sorted[idx.min(sorted.len() - 1)]
}
fn summarize(stats: &Stats) -> Vec<WsSummary> {
let mut workspaces: Vec<&String> = stats.per_ws.keys().collect();
workspaces.sort();
workspaces
.into_iter()
.map(|ws| {
let mut lat = stats.per_ws.get(ws).cloned().unwrap_or_default();
lat.sort_unstable();
WsSummary {
workspace: ws.clone(),
pushed: stats.pushed.get(ws).copied().unwrap_or(0),
completed: lat.len() as u64,
p50_ms: percentile(&lat, 50.0),
p95_ms: percentile(&lat, 95.0),
p99_ms: percentile(&lat, 99.0),
max_ms: *lat.last().unwrap_or(&0),
}
})
.collect()
}
fn print_summary(label: &str, rows: &[WsSummary]) {
println!(
"\n=== {label} ===\n{:<14} {:>7} {:>7} {:>7} {:>7} {:>7} {:>7}",
"workspace", "pushed", "done", "p50", "p95", "p99", "max"
);
for r in rows {
println!(
"{:<14} {:>7} {:>7} {:>7} {:>7} {:>7} {:>7}",
r.workspace, r.pushed, r.completed, r.p50_ms, r.p95_ms, r.p99_ms, r.max_ms,
);
}
}
/// Mock worker. Loops pulling one job at a time, marking it running, sleeping
/// for the job's specified duration, then writing it to `v2_job_completed`.
/// Honors the overloaded-set bind if `fairness_on` is true. Stops when
/// `shutdown` flips.
async fn mock_worker(
worker_id: u32,
db: Pool<Postgres>,
fairness_on: Arc<AtomicBool>,
shutdown: Arc<AtomicBool>,
stats: Arc<Mutex<Stats>>,
completed_counter: Arc<AtomicU64>,
) {
let worker_name = format!("mock-worker-{worker_id}");
// Each mock worker maintains its own `worker_ping` row, the way a real
// worker would: `current_job_*` set on pick-up, cleared on completion.
// The fairness algorithm now reads slot occupancy from `worker_ping` (so
// that concurrency-suspended rows and zombies with no live ping do not
// inflate the denominator), so the simulation must keep this in sync.
sqlx::query(
"INSERT INTO worker_ping (worker, worker_instance, ping_at) VALUES ($1, 'sim', NOW())
ON CONFLICT (worker) DO UPDATE SET ping_at = NOW(),
current_job_id = NULL, current_job_workspace_id = NULL",
)
.bind(&worker_name)
.execute(&db)
.await
.unwrap();
let standard_sql = "WITH picked AS (
SELECT id FROM v2_job_queue
WHERE running = false AND scheduled_for <= now()
ORDER BY priority DESC NULLS LAST, scheduled_for
FOR UPDATE SKIP LOCKED LIMIT 1
)
UPDATE v2_job_queue q
SET running = true, started_at = now()
FROM picked
WHERE q.id = picked.id
RETURNING q.id, q.workspace_id, COALESCE((q.extras->>'duration_ms')::int, 30), q.created_at";
let fairness_sql = "WITH picked AS (
SELECT id FROM v2_job_queue
WHERE running = false AND scheduled_for <= now()
AND workspace_id <> ALL($1::text[])
ORDER BY priority DESC NULLS LAST, scheduled_for
FOR UPDATE SKIP LOCKED LIMIT 1
)
UPDATE v2_job_queue q
SET running = true, started_at = now()
FROM picked
WHERE q.id = picked.id
RETURNING q.id, q.workspace_id, COALESCE((q.extras->>'duration_ms')::int, 30), q.created_at";
while !shutdown.load(Ordering::Relaxed) {
// Snapshot the overloaded set at pull time so each pull reflects the
// latest refresh. Mirror the production dispatch: if there is anything
// capped, flip the same coin the real pull does to decide whether to
// admit it. Empty overloaded set => standard query unconditionally.
let overloaded = if fairness_on.load(Ordering::Relaxed) {
(**WORKSPACE_FAIRNESS_OVERLOADED.load()).clone()
} else {
vec![]
};
let exclude_capped =
!overloaded.is_empty() && !windmill_queue::workspace_fairness::should_admit_capped();
// Primary query (chosen by the coin flip).
let mut row: Option<(Uuid, String, i32, chrono::DateTime<chrono::Utc>)> = if exclude_capped
{
sqlx::query_as::<_, (Uuid, String, i32, chrono::DateTime<chrono::Utc>)>(fairness_sql)
.bind(&overloaded)
.fetch_optional(&db)
.await
.unwrap()
} else {
sqlx::query_as::<_, (Uuid, String, i32, chrono::DateTime<chrono::Utc>)>(standard_sql)
.fetch_optional(&db)
.await
.unwrap()
};
// Fallback: if the fairness query returned nothing (every non-capped
// workspace queue is empty), retry without the filter so workers
// don't idle when only capped jobs remain.
if row.is_none() && exclude_capped {
row = sqlx::query_as::<_, (Uuid, String, i32, chrono::DateTime<chrono::Utc>)>(
standard_sql,
)
.fetch_optional(&db)
.await
.unwrap();
}
match row {
Some((id, ws, dur_ms, created_at)) => {
// Claim the slot on this worker's ping so the fairness
// algorithm counts this workspace's slot occupancy.
sqlx::query(
"UPDATE worker_ping SET ping_at = NOW(),
current_job_id = $1, current_job_workspace_id = $2
WHERE worker = $3",
)
.bind(id)
.bind(&ws)
.bind(&worker_name)
.execute(&db)
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(dur_ms as u64)).await;
// Move to completed atomically: insert + delete in one query.
let completed_at: chrono::DateTime<chrono::Utc> = sqlx::query_scalar(
"WITH del AS (
DELETE FROM v2_job_queue WHERE id = $1 RETURNING id, workspace_id
)
INSERT INTO v2_job_completed (id, workspace_id, duration_ms, status, started_at, completed_at)
SELECT id, workspace_id, $2, 'success'::job_status, now(), now()
FROM del
RETURNING completed_at",
)
.bind(id)
.bind(dur_ms as i64)
.fetch_one(&db)
.await
.unwrap();
// Release the slot.
sqlx::query(
"UPDATE worker_ping SET ping_at = NOW(),
current_job_id = NULL, current_job_workspace_id = NULL
WHERE worker = $1",
)
.bind(&worker_name)
.execute(&db)
.await
.unwrap();
let latency_ms = (completed_at - created_at).num_milliseconds().max(0) as u64;
{
let mut s = stats.lock().await;
s.record(&ws, latency_ms);
}
completed_counter.fetch_add(1, Ordering::Relaxed);
}
None => {
// Empty queue (or every queued workspace is capped). Back off
// briefly so we don't hammer the DB. Refresh the heartbeat so
// this worker's ping doesn't go stale during long idle gaps.
sqlx::query("UPDATE worker_ping SET ping_at = NOW() WHERE worker = $1")
.bind(&worker_name)
.execute(&db)
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(2)).await;
}
}
}
}
/// Push a stream of jobs from `workspace` at `rate_per_sec`. Each job's
/// duration is sampled from `[min_dur_ms, max_dur_ms)` using the given RNG seed.
async fn pusher(
db: Pool<Postgres>,
workspace: String,
rate_per_sec: u32,
min_dur_ms: u32,
max_dur_ms: u32,
duration: Duration,
seed: u64,
stats: Arc<Mutex<Stats>>,
shutdown: Arc<AtomicBool>,
) {
let mut rng = StdRng::seed_from_u64(seed);
let interval = Duration::from_micros(1_000_000 / rate_per_sec.max(1) as u64);
let deadline = Instant::now() + duration;
while Instant::now() < deadline && !shutdown.load(Ordering::Relaxed) {
let dur = if min_dur_ms == max_dur_ms {
min_dur_ms
} else {
rng.random_range(min_dur_ms..max_dur_ms)
};
let spec = JobSpec { duration_ms: dur };
let extras = serde_json::json!({"duration_ms": spec.duration_ms});
let res = sqlx::query(
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, tag, extras)
VALUES (gen_random_uuid(), $1, NOW(), false, 'deno', $2)",
)
.bind(&workspace)
.bind(&extras)
.execute(&db)
.await;
if res.is_ok() {
let mut s = stats.lock().await;
s.pushed_inc(&workspace);
}
tokio::time::sleep(interval).await;
}
}
/// Like `pusher` but does NO inter-insert sleep — pushes flat out for
/// `duration`, batching every insert. Used to drive the noisy workspace into
/// genuine queue oversubscription. Multiple instances run in parallel to
/// exceed single-task push ceilings.
async fn noisy_pusher(
db: Pool<Postgres>,
workspace: String,
min_dur_ms: u32,
max_dur_ms: u32,
duration: Duration,
seed: u64,
stats: Arc<Mutex<Stats>>,
shutdown: Arc<AtomicBool>,
) {
let mut rng = StdRng::seed_from_u64(seed);
let deadline = Instant::now() + duration;
let mut local_pushed: u64 = 0;
while Instant::now() < deadline && !shutdown.load(Ordering::Relaxed) {
let dur = if min_dur_ms == max_dur_ms {
min_dur_ms
} else {
rng.random_range(min_dur_ms..max_dur_ms)
};
let extras = serde_json::json!({"duration_ms": dur});
let res = sqlx::query(
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, tag, extras)
VALUES (gen_random_uuid(), $1, NOW(), false, 'deno', $2)",
)
.bind(&workspace)
.bind(&extras)
.execute(&db)
.await;
if res.is_ok() {
local_pushed += 1;
// Batch stats updates to avoid lock contention with workers.
if local_pushed % 32 == 0 {
let mut s = stats.lock().await;
for _ in 0..32 {
s.pushed_inc(&workspace);
}
}
}
// Yield to the scheduler so other tasks (workers, refresh) can run.
tokio::task::yield_now().await;
}
// Flush remaining counter.
let leftover = local_pushed % 32;
if leftover > 0 {
let mut s = stats.lock().await;
for _ in 0..leftover {
s.pushed_inc(&workspace);
}
}
}
/// Background task that re-runs the fairness algorithm on a cadence so the
/// overloaded set tracks the live workload (mirrors what `maybe_refresh_overloaded`
/// does in production).
///
/// `force_refresh = true` rolls back the DB-side claim guard every iteration,
/// so each call re-runs the heavy aggregation. Use for short-running tests
/// that need fast adaptation. `force_refresh = false` leaves the natural 2 s
/// (`ACTIVE_REFRESH_SECS`) claim guard in place — this is what production
/// behaves like and what the oscillation/burst simulations want.
async fn refresh_loop(db: Pool<Postgres>, shutdown: Arc<AtomicBool>, force_refresh: bool) {
while !shutdown.load(Ordering::Relaxed) {
if force_refresh {
let _ = sqlx::query(
"UPDATE background_task_state
SET updated_at = NOW() - INTERVAL '1 hour'
WHERE name = 'workspace_fairness'",
)
.execute(&db)
.await;
}
let _ = refresh_overloaded(&db).await;
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
#[derive(Debug)]
struct ScenarioResult {
summary: Vec<WsSummary>,
/// Wall-clock duration the scenario actually ran for (push + drain).
elapsed: Duration,
/// Per-workspace completion events: (elapsed_ms, latency_ms). Used for
/// the oscillation time-series analysis.
events: HashMap<String, Vec<(u64, u64)>>,
}
async fn run_scenario(
sqlx_db: &Pool<Postgres>,
label: &'static str,
fairness_on: bool,
duration: Duration,
drain_timeout: Duration,
n_workers: u32,
fairness_window_secs: u32,
force_refresh: bool,
) -> ScenarioResult {
reset_fairness_state();
WORKSPACE_FAIRNESS_DURATION_SECS.store(fairness_window_secs, Ordering::Relaxed);
WORKSPACE_FAIRNESS_MIN_TOTAL.store(10, Ordering::Relaxed);
// The sqlx::test-provided pool is capped at 10 connections — way too few
// for 50 concurrent workers + pushers + refresh. Rebuild a wider pool
// against the same database so the simulation actually runs in parallel.
let opts = (*sqlx_db.connect_options()).clone();
let big_pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(80)
.min_connections(20)
.acquire_timeout(Duration::from_secs(10))
.connect_with(opts)
.await
.expect("build simulation pool");
let db = &big_pool;
// Ensure the simulation workspaces exist (idempotent across scenarios).
create_workspace(db, "noisy").await;
for i in 0..5 {
create_workspace(db, &format!("victim_{i}")).await;
}
// Truncate residual state from any prior scenario on the same DB.
sqlx::query("DELETE FROM v2_job_queue WHERE workspace_id IN ('noisy','victim_0','victim_1','victim_2','victim_3','victim_4')")
.execute(db).await.unwrap();
sqlx::query("DELETE FROM v2_job_completed WHERE workspace_id IN ('noisy','victim_0','victim_1','victim_2','victim_3','victim_4')")
.execute(db).await.unwrap();
sqlx::query("DELETE FROM background_task_state WHERE name = 'workspace_fairness'")
.execute(db)
.await
.unwrap();
let stats = Arc::new(Mutex::new(Stats::new()));
let shutdown = Arc::new(AtomicBool::new(false));
let fairness_flag = Arc::new(AtomicBool::new(fairness_on));
let completed = Arc::new(AtomicU64::new(0));
let started = Instant::now();
{
let mut s = stats.lock().await;
s.started = Some(started);
}
// Spawn workers.
let mut worker_handles = Vec::with_capacity(n_workers as usize);
for wid in 0..n_workers {
let db = db.clone();
let stats = stats.clone();
let shutdown = shutdown.clone();
let fairness_flag = fairness_flag.clone();
let completed = completed.clone();
worker_handles.push(tokio::spawn(async move {
mock_worker(wid, db, fairness_flag, shutdown, stats, completed).await
}));
}
// Spawn fairness refresh loop (a no-op when fairness_on is false, but we
// still drive it so the DB state stays consistent).
let refresh_handle = if fairness_on {
let db = db.clone();
let shutdown = shutdown.clone();
Some(tokio::spawn(async move {
refresh_loop(db, shutdown, force_refresh).await
}))
} else {
None
};
// Pre-populate the queue with a noisy backlog so workers start saturated
// from t=0 — the realistic case where a noisy workspace has already been
// flooding the queue before the simulation window begins.
let noisy_backlog: i64 = 1500;
sqlx::query(
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, tag, extras)
SELECT gen_random_uuid(), 'noisy', NOW(), false, 'deno',
jsonb_build_object('duration_ms', 60 + (random()*40)::int)
FROM generate_series(1, $1::int)",
)
.bind(noisy_backlog)
.execute(db)
.await
.unwrap();
{
let mut s = stats.lock().await;
for _ in 0..noisy_backlog {
s.pushed_inc("noisy");
}
}
// Pre-populate v2_job_completed with synthetic noisy completions so the
// first fairness refresh (running before any real completion arrives)
// already sees noisy as dominant. Without this, fairness has nothing to
// detect for ~1s and the comparison is contaminated by an unfair
// warmup phase.
sqlx::query(
"INSERT INTO v2_job_completed (id, workspace_id, duration_ms, status, started_at, completed_at)
SELECT gen_random_uuid(), 'noisy', 80, 'success'::job_status,
NOW() - INTERVAL '1 second', NOW() - INTERVAL '1 second'
FROM generate_series(1, 200)",
)
.execute(db).await.unwrap();
// Spawn pushers. Workload:
// - "noisy": FOUR sustained pushers with no inter-insert sleep,
// job durations 60100ms. Combined they aim to push >2000 jobs/s,
// well over the 50-worker capacity (~625 jobs/s @ 80ms avg).
// - 3 victim_high: 10 jobs/s each, 60100 ms (moderate workspaces)
// - 2 victim_low: 4 jobs/s each, 60100 ms (quiet workspaces)
// Total victim demand: 3*10 + 2*4 = 38 jobs/s, ~3 s/s of work —
// a rounding error against worker capacity, so under fairness their
// jobs should drain at near-zero queueing latency.
let pusher_specs: Vec<(String, u32, u32, u32, u64)> = vec![
// Victims
("victim_0".to_string(), 10, 60, 100, 11),
("victim_1".to_string(), 10, 60, 100, 12),
("victim_2".to_string(), 10, 60, 100, 13),
("victim_3".to_string(), 4, 60, 100, 21),
("victim_4".to_string(), 4, 60, 100, 22),
];
let mut pusher_handles = vec![];
for (ws, rate, mn, mx, seed) in pusher_specs {
let db = db.clone();
let stats = stats.clone();
let shutdown = shutdown.clone();
pusher_handles.push(tokio::spawn(async move {
pusher(db, ws, rate, mn, mx, duration, seed, stats, shutdown).await;
}));
}
// Four noisy pushers running flat out (no sleep). Each pushes
// continuously for `duration`, then drops.
for noisy_seed in 1..=4u64 {
let db = db.clone();
let stats = stats.clone();
let shutdown = shutdown.clone();
pusher_handles.push(tokio::spawn(async move {
noisy_pusher(
db,
"noisy".to_string(),
60,
100,
duration,
noisy_seed,
stats,
shutdown,
)
.await;
}));
}
// Wait for pushers to finish pushing.
for h in pusher_handles {
let _ = h.await;
}
// Drain phase: wait until VICTIM workspaces drain (or timeout). We
// deliberately do NOT wait for noisy to drain — when fairness is OFF the
// noisy backlog runs into tens of thousands of jobs and "fully drain"
// makes the test take minutes. Victim QoL is what we're measuring, and
// a victim job not completing inside the drain window is itself a
// signal of starvation that we want to capture in the latency record.
let drain_deadline = Instant::now() + drain_timeout;
loop {
let victim_remaining: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM v2_job_queue
WHERE workspace_id IN ('victim_0','victim_1','victim_2','victim_3','victim_4')",
)
.fetch_one(db)
.await
.unwrap();
if victim_remaining == 0 || Instant::now() >= drain_deadline {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
// Shut everything down.
shutdown.store(true, Ordering::Relaxed);
for h in worker_handles {
let _ = h.await;
}
if let Some(h) = refresh_handle {
let _ = h.await;
}
let elapsed = started.elapsed();
let stats = stats.lock().await;
let summary = summarize(&stats);
print_summary(label, &summary);
ScenarioResult { summary, elapsed, events: stats.events.clone() }
}
fn pick<'a>(rows: &'a [WsSummary], ws: &str) -> &'a WsSummary {
rows.iter()
.find(|r| r.workspace == ws)
.expect("workspace in summary")
}
/// **50-worker simulation.** Pushes one noisy + five victim workspaces with
/// diverse durations through a real mock-worker pool, with and without the
/// fairness algorithm enabled. Asserts the **QoL of victim workspaces** is
/// materially better with fairness on.
///
/// Marked `#[ignore]` so the default `cargo test` keeps a sub-second profile.
/// Run with: `cargo test --test workspace_fairness -- --ignored --nocapture`.
#[sqlx::test(fixtures("base"))]
#[ignore]
#[serial]
async fn fairness_50_workers_diverse_workload(db: Pool<Postgres>) {
let push_dur = Duration::from_secs(5);
// Cap drain at 12s. Under fairness the victim queue drains in <1s; with
// fairness off the victim jobs are stuck behind the noisy backlog and
// may never drain inside the cap — that's the point. Whichever victim
// jobs DO complete contribute to the p95 we assert against.
let drain_dur = Duration::from_secs(12);
// CONTROL: fairness OFF.
let control = run_scenario(
&db,
"control (fairness OFF)",
false,
push_dur,
drain_dur,
50,
3,
true,
)
.await;
// TREATMENT: fairness ON. Short test → force refresh every 250ms so the
// cap takes effect inside the 5s window. (Production rate is 2s, which
// would only give ~2 refresh cycles inside a 5s test.)
let treatment = run_scenario(
&db,
"treatment (fairness ON)",
true,
push_dur,
drain_dur,
50,
3,
true,
)
.await;
let victims = ["victim_0", "victim_1", "victim_2", "victim_3", "victim_4"];
println!("\n=== victim p95 latency comparison ===");
println!(
"{:<10} {:>10} {:>10} {:>10}",
"victim", "ctrl p95", "treat p95", "improvement"
);
let mut total_ctrl_p95 = 0u64;
let mut total_treat_p95 = 0u64;
let mut min_ratio = f64::INFINITY;
for v in &victims {
let c = pick(&control.summary, v);
let t = pick(&treatment.summary, v);
let ratio = if t.p95_ms == 0 {
f64::INFINITY
} else {
c.p95_ms as f64 / t.p95_ms as f64
};
min_ratio = min_ratio.min(ratio);
total_ctrl_p95 += c.p95_ms;
total_treat_p95 += t.p95_ms;
println!(
"{:<10} {:>10} {:>10} {:>9.2}x",
v, c.p95_ms, t.p95_ms, ratio
);
}
let avg_ctrl_p95 = total_ctrl_p95 / victims.len() as u64;
let avg_treat_p95 = total_treat_p95 / victims.len() as u64;
println!(
"avg victim p95: control={}ms treatment={}ms ratio={:.2}x",
avg_ctrl_p95,
avg_treat_p95,
avg_ctrl_p95 as f64 / avg_treat_p95.max(1) as f64,
);
println!(
"scenario elapsed: control={:?} treatment={:?}",
control.elapsed, treatment.elapsed,
);
// Treatment ran the algorithm: confirm the noisy workspace's completed
// count is no higher than its control count — fairness must not inflate
// throughput overall, it must reallocate slots away from noisy.
let noisy_ctrl = pick(&control.summary, "noisy");
let noisy_treat = pick(&treatment.summary, "noisy");
println!(
"noisy: control completed={} treatment completed={}",
noisy_ctrl.completed, noisy_treat.completed,
);
// Completion-rate comparison. Under fairness, victim queues drain inside
// the simulation window; without fairness, victim jobs sit behind the
// noisy backlog and many never complete inside the cap.
let ctrl_v_pushed: u64 = victims
.iter()
.map(|v| pick(&control.summary, v).pushed)
.sum();
let ctrl_v_done: u64 = victims
.iter()
.map(|v| pick(&control.summary, v).completed)
.sum();
let treat_v_pushed: u64 = victims
.iter()
.map(|v| pick(&treatment.summary, v).pushed)
.sum();
let treat_v_done: u64 = victims
.iter()
.map(|v| pick(&treatment.summary, v).completed)
.sum();
let ctrl_v_rate = ctrl_v_done as f64 / ctrl_v_pushed.max(1) as f64;
let treat_v_rate = treat_v_done as f64 / treat_v_pushed.max(1) as f64;
println!(
"victim completion rate: control={:.1}% ({}/{}) treatment={:.1}% ({}/{})",
ctrl_v_rate * 100.0,
ctrl_v_done,
ctrl_v_pushed,
treat_v_rate * 100.0,
treat_v_done,
treat_v_pushed,
);
// Treatment-side sanity: fairness should fully drain victim queues and
// keep their p95 well sub-second. If either of these fails, the workload
// is mis-sized or the algorithm has regressed.
for v in &victims {
let t = pick(&treatment.summary, v);
let rate = t.completed as f64 / t.pushed.max(1) as f64;
assert!(
rate > 0.95,
"victim {v} completion rate under fairness was {:.1}% ({}/{}) — \
fairness algorithm is not protecting victim throughput",
rate * 100.0,
t.completed,
t.pushed,
);
assert!(
t.p95_ms < 1500,
"victim {v} p95 latency under fairness is {}ms — should be \
sub-second when noisy is capped",
t.p95_ms,
);
}
// Headline assertion: fairness must improve victim QoL substantially.
// Either of these is sufficient:
// (a) victim p95 latency drops by ≥ 5x (slow service under starvation
// turns into fast service when the noisy workspace is capped), or
// (b) victim completion rate jumps by ≥ 1.5x (jobs that were never
// getting pulled finally complete).
// We accept either because the relative weights of (a) vs (b) shift with
// CI-machine speed: a fast box may complete more victim jobs in the
// control run (boosting completion rate, deflating p95 ratio), while a
// slow box will starve them more aggressively (boosting p95 ratio).
let p95_ratio = (avg_ctrl_p95 as f64) / (avg_treat_p95.max(1) as f64);
let rate_ratio = treat_v_rate / ctrl_v_rate.max(0.001);
println!("p95 ratio (ctrl/treat) = {p95_ratio:.2}x, completion-rate ratio (treat/ctrl) = {rate_ratio:.2}x");
assert!(
p95_ratio >= 5.0 || rate_ratio >= 1.5,
"fairness did not materially improve victim QoL: p95 ratio={p95_ratio:.2}x \
(want ≥5x), completion-rate ratio={rate_ratio:.2}x (want ≥1.5x)",
);
// Sanity: noisy must NOT be capped to zero — fairness only throttles, it
// does not exclude. Its completed count should stay > 0.
assert!(
noisy_treat.completed > 0,
"noisy was completely starved by fairness — should be throttled, not excluded",
);
}
/// Bucket events by 1-second windows of `elapsed_ms`. Returns
/// `Vec<(bucket_idx_seconds, count, p50, p95, max)>`.
fn time_series(events: &[(u64, u64)], buckets: usize) -> Vec<(usize, usize, u64, u64, u64)> {
let mut by_bucket: Vec<Vec<u64>> = vec![vec![]; buckets];
for (elapsed_ms, lat_ms) in events {
let b = (*elapsed_ms / 1000) as usize;
if b < buckets {
by_bucket[b].push(*lat_ms);
}
}
by_bucket
.into_iter()
.enumerate()
.map(|(i, mut v)| {
v.sort_unstable();
let n = v.len();
(
i,
n,
percentile(&v, 50.0),
percentile(&v, 95.0),
*v.last().unwrap_or(&0),
)
})
.collect()
}
/// **Oscillation test.** A capped workspace's stale completions roll out of
/// the rolling window after `WORKSPACE_FAIRNESS_DURATION_SECS` seconds — at
/// which point its share drops to 0%, the algorithm un-caps it, the noisy
/// queue (which has the oldest `scheduled_for`) jumps to the front of the
/// pull, and victims briefly wait until the next refresh cycle re-caps. Over
/// a long run this manifests as periodic spikes in victim latency, roughly
/// every `(window + refresh_interval)` seconds.
///
/// This test runs a 25-second sustained workload (long enough to cross at
/// least two cap/uncap cycles with the default 10s window) and prints
/// per-second victim p95 latency. It then asserts that the oscillation peaks
/// remain bounded — i.e. fairness still delivers good QoL on average even
/// though the cap is not perfectly stable.
///
/// Marked `#[ignore]`. Run with:
/// `cargo test --test workspace_fairness fairness_oscillation -- --ignored --nocapture`.
#[sqlx::test(fixtures("base"))]
#[ignore]
#[serial]
async fn fairness_oscillation_long_run(db: Pool<Postgres>) {
// Use the *production default* 10-second window so the cap/uncap cycle
// matches what the cluster actually sees. (Other tests use a 3s window
// to keep wall-clock short.)
reset_fairness_state();
WORKSPACE_FAIRNESS_DURATION_SECS.store(10, Ordering::Relaxed);
let push_dur = Duration::from_secs(25);
// No drain — we don't care about post-push tail; the time-series view
// already includes everything in the active window.
let drain_dur = Duration::from_secs(2);
// Use production refresh cadence (force_refresh=false) — the SQL claim's
// 2 s rate limit takes effect, so refresh runs every 2 s like on the
// real cluster instead of every 250 ms. This is what victims actually
// experience.
let treatment = run_scenario(
&db,
"treatment (fairness ON) — long run, 10s window, prod refresh",
true,
push_dur,
drain_dur,
50,
10,
false,
)
.await;
let total_buckets = (push_dur.as_secs() + drain_dur.as_secs() + 2) as usize;
let victims = ["victim_0", "victim_1", "victim_2", "victim_3", "victim_4"];
// Merge all victim events into one stream for the time-series view —
// QoL per-second across all victim workspaces is what we want to inspect.
let mut merged: Vec<(u64, u64)> = Vec::new();
for v in &victims {
if let Some(es) = treatment.events.get(*v) {
merged.extend_from_slice(es);
}
}
let series = time_series(&merged, total_buckets);
println!(
"\n=== victim latency per second (treatment, 10s window) ===\n{:>4} {:>6} {:>6} {:>6} {:>6}",
"sec", "count", "p50", "p95", "max"
);
for (sec, count, p50, p95, mx) in &series {
println!("{:>4} {:>6} {:>6} {:>6} {:>6}", sec, count, p50, p95, mx);
}
// Same view for noisy — visualises the cap on/off pattern. A capped
// bucket has near-zero completions; an uncapped bucket has many.
let noisy_events = treatment.events.get("noisy").cloned().unwrap_or_default();
let noisy_series = time_series(&noisy_events, total_buckets);
println!("\n=== noisy completions per second (treatment) ===");
for (sec, count, _, _, _) in &noisy_series {
println!("sec {:>3}: {:>5} noisy completions", sec, count);
}
// Aggregate p95 and worst-bucket p95 across the active window (skip the
// first second, which is dominated by warmup before the first refresh).
let active: Vec<&(usize, usize, u64, u64, u64)> = series
.iter()
.filter(|(sec, count, ..)| *sec >= 1 && *sec < push_dur.as_secs() as usize && *count > 0)
.collect();
let avg_p95: u64 = if active.is_empty() {
0
} else {
active.iter().map(|x| x.3).sum::<u64>() / active.len() as u64
};
let worst_p95: u64 = active.iter().map(|x| x.3).max().unwrap_or(0);
let buckets_over_2s = active.iter().filter(|x| x.3 > 2000).count();
let buckets_over_5s = active.iter().filter(|x| x.3 > 5000).count();
println!(
"\nactive window: {} sec, avg per-second victim p95 = {} ms, worst per-second p95 = {} ms",
active.len(),
avg_p95,
worst_p95,
);
println!(
"seconds with victim p95 > 2s: {} / {}, > 5s: {} / {}",
buckets_over_2s,
active.len(),
buckets_over_5s,
active.len(),
);
// The user's hypothesis under test: "10s latency on and off". The cycle
// period is ~window + refresh interval ≈ 12-15s; the oscillation peak
// (time spent in the uncapped state, which is when victims wait) is
// bounded by the refresh interval, NOT the window. So we expect:
// - average per-second victim p95 well under 1s (cap mostly holds)
// - worst-second p95 under 5s (oscillation peaks are bounded)
// - only a small minority of seconds spent in the high-latency regime
//
// If any of these break, the cap/uncap cycle is too long or too costly,
// and the algorithm needs to revisit the refresh cadence vs window size.
let summary_v: Vec<&WsSummary> = victims
.iter()
.map(|v| pick(&treatment.summary, v))
.collect();
let total_completed: u64 = summary_v.iter().map(|s| s.completed).sum();
let total_pushed: u64 = summary_v.iter().map(|s| s.pushed).sum();
println!(
"total victim completion rate: {:.1}% ({}/{})",
100.0 * total_completed as f64 / total_pushed.max(1) as f64,
total_completed,
total_pushed,
);
assert!(
avg_p95 < 1500,
"average per-second victim p95 = {} ms — cap is not holding most of the time",
avg_p95,
);
assert!(
worst_p95 < 5_000,
"worst-second victim p95 = {} ms — oscillation peak exceeds 5s, \
which means uncapped windows are too long. Reduce refresh interval \
or shorten the duration window.",
worst_p95,
);
assert!(
buckets_over_2s <= active.len() / 4,
"victims spent > 2s p95 in {} / {} buckets — oscillation is more \
frequent than expected (more than 25% of the simulation)",
buckets_over_2s,
active.len(),
);
}
/// **Burst-then-stop scenario.** A noisy workspace enqueues 10,000 jobs in a
/// single burst at t=0 (all with the same `scheduled_for = now()`, so they
/// sit at the front of the FIFO queue forever after) and then stops pushing.
/// Victim workspaces push modestly throughout.
///
/// This is the worst-case oscillation regime for the algorithm: once noisy is
/// uncapped, every worker grabs from its backlog because it has the lowest
/// `scheduled_for` in the queue — exactly the behavior the user pointed at.
/// The question is how much that costs victims.
///
/// Mechanics with `WORKSPACE_FAIRNESS_DURATION_SECS = 10` (production default):
/// 1. t ≈ 01 s: workers drain ~500 noisy jobs FIFO. First refresh sees
/// noisy at ~100% of activity → CAPPED.
/// 2. t ≈ 111 s: noisy capped. Workers serve victims only. Noisy queue
/// stays at ~9,500.
/// 3. t ≈ 11 s: the noisy completions from step 1 age out of the rolling
/// window. Noisy share drops to 0% → UNCAPPED.
/// 4. t ≈ 11 s 11 s + (refresh_interval): workers all switch to noisy
/// (oldest `scheduled_for`). Victims queue. Within ~1 refresh interval
/// the next refresh sees noisy dominant again → RE-CAPPED.
/// 5. Cycle repeats every ~(window + refresh_interval) ≈ 12 s.
///
/// Asserts:
/// - average per-second victim p95 stays well under 1 s
/// - worst per-second victim p95 stays under 3 s (uncapped bursts are bounded)
/// - the bulk of noisy is still drained (the cap is throttling, not excluding)
///
/// Run with:
/// `cargo test --test workspace_fairness fairness_burst -- --ignored --nocapture`.
#[sqlx::test(fixtures("base"))]
#[ignore]
#[serial]
async fn fairness_burst_then_stop(sqlx_db: Pool<Postgres>) {
reset_fairness_state();
WORKSPACE_FAIRNESS_DURATION_SECS.store(10, Ordering::Relaxed);
WORKSPACE_FAIRNESS_MIN_TOTAL.store(10, Ordering::Relaxed);
// Wider pool so 50 workers really run in parallel.
let opts = (*sqlx_db.connect_options()).clone();
let db = sqlx::postgres::PgPoolOptions::new()
.max_connections(80)
.min_connections(20)
.acquire_timeout(Duration::from_secs(10))
.connect_with(opts)
.await
.expect("build burst pool");
let db = &db;
create_workspace(db, "noisy").await;
for i in 0..3 {
create_workspace(db, &format!("victim_{i}")).await;
}
sqlx::query(
"DELETE FROM v2_job_queue WHERE workspace_id IN ('noisy','victim_0','victim_1','victim_2')",
)
.execute(db)
.await
.unwrap();
sqlx::query(
"DELETE FROM v2_job_completed WHERE workspace_id IN ('noisy','victim_0','victim_1','victim_2')",
)
.execute(db)
.await
.unwrap();
sqlx::query("DELETE FROM background_task_state WHERE name = 'workspace_fairness'")
.execute(db)
.await
.unwrap();
// The burst: 10_000 noisy queued jobs, all with same scheduled_for. They
// will hold the front-of-queue position for the entire simulation, which
// is the scenario under test.
let burst_size = 10_000_i64;
sqlx::query(
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, tag, extras)
SELECT gen_random_uuid(), 'noisy', NOW(), false, 'deno',
jsonb_build_object('duration_ms', 80)
FROM generate_series(1, $1::int)",
)
.bind(burst_size)
.execute(db)
.await
.unwrap();
let stats = Arc::new(Mutex::new(Stats::new()));
let shutdown = Arc::new(AtomicBool::new(false));
let fairness_flag = Arc::new(AtomicBool::new(true));
let completed = Arc::new(AtomicU64::new(0));
let started = Instant::now();
{
let mut s = stats.lock().await;
s.started = Some(started);
for _ in 0..burst_size {
s.pushed_inc("noisy");
}
}
// 50 workers.
let mut worker_handles = Vec::new();
for wid in 0..50u32 {
let db = db.clone();
let stats = stats.clone();
let shutdown = shutdown.clone();
let fairness_flag = fairness_flag.clone();
let completed = completed.clone();
worker_handles.push(tokio::spawn(async move {
mock_worker(wid, db, fairness_flag, shutdown, stats, completed).await
}));
}
// Refresh task. force_refresh=false → the SQL claim's 2 s rate limit
// takes effect, matching `ACTIVE_REFRESH_SECS` in production. This is
// the regime the cloud cluster actually sees.
let refresh_handle = {
let db = db.clone();
let shutdown = shutdown.clone();
tokio::spawn(async move { refresh_loop(db, shutdown, false).await })
};
// Victim pushers: 3 workspaces, 20 jobs/s each, 80 ms durations,
// sustained for the full simulation. Total victim demand: 60 jobs/s.
let sim_dur = Duration::from_secs(30);
let mut pusher_handles = vec![];
for i in 0..3 {
let db = db.clone();
let stats = stats.clone();
let shutdown = shutdown.clone();
let ws = format!("victim_{i}");
pusher_handles.push(tokio::spawn(async move {
pusher(db, ws, 20, 80, 81, sim_dur, 100 + i as u64, stats, shutdown).await;
}));
}
for h in pusher_handles {
let _ = h.await;
}
// Brief drain so any queued victim jobs at the end have a chance to land.
tokio::time::sleep(Duration::from_secs(2)).await;
shutdown.store(true, Ordering::Relaxed);
for h in worker_handles {
let _ = h.await;
}
let _ = refresh_handle.await;
let stats = stats.lock().await;
let summary = summarize(&stats);
print_summary("burst-then-stop (fairness ON, 10s window)", &summary);
let total_buckets = (sim_dur.as_secs() + 4) as usize;
let victims = ["victim_0", "victim_1", "victim_2"];
let mut merged: Vec<(u64, u64)> = Vec::new();
for v in &victims {
if let Some(es) = stats.events.get(*v) {
merged.extend_from_slice(es);
}
}
let series = time_series(&merged, total_buckets);
println!("\n=== victim latency per second (burst-then-stop) ===");
println!(
"{:>4} {:>6} {:>6} {:>6} {:>6}",
"sec", "count", "p50", "p95", "max"
);
for (sec, count, p50, p95, mx) in &series {
println!("{:>4} {:>6} {:>6} {:>6} {:>6}", sec, count, p50, p95, mx);
}
let noisy_events = stats.events.get("noisy").cloned().unwrap_or_default();
let noisy_series = time_series(&noisy_events, total_buckets);
println!("\n=== noisy completions per second (burst-then-stop) ===");
for (sec, count, _, _, _) in &noisy_series {
let bar = "#".repeat((count / 10).min(60) as usize);
println!("sec {:>3}: {:>5} {}", sec, count, bar);
}
let active: Vec<&(usize, usize, u64, u64, u64)> = series
.iter()
.filter(|(sec, count, ..)| *sec >= 1 && *sec < sim_dur.as_secs() as usize && *count > 0)
.collect();
let avg_p95: u64 = if active.is_empty() {
0
} else {
active.iter().map(|x| x.3).sum::<u64>() / active.len() as u64
};
let worst_p95: u64 = active.iter().map(|x| x.3).max().unwrap_or(0);
let buckets_over_1s = active.iter().filter(|x| x.3 > 1000).count();
println!(
"\nburst-then-stop summary: avg per-second victim p95 = {} ms, worst = {} ms, \
seconds with p95 > 1s: {} / {}",
avg_p95,
worst_p95,
buckets_over_1s,
active.len(),
);
let noisy_drained = noisy_events.len();
println!(
"noisy jobs drained over simulation: {} / {} ({:.1}%)",
noisy_drained,
burst_size,
100.0 * noisy_drained as f64 / burst_size as f64,
);
// Sanity: every victim still completes (cap is throttling not excluding).
for v in &victims {
let t = summary.iter().find(|s| s.workspace == *v).unwrap();
let rate = t.completed as f64 / t.pushed.max(1) as f64;
assert!(
rate > 0.95,
"victim {v} completion rate {:.1}% — fairness should protect victims even under burst",
rate * 100.0,
);
}
// The actual QoL claim we're testing against the user's hypothesis:
// even though workers fully switch to noisy during each uncapped
// interval, the uncap is bounded by `ACTIVE_REFRESH_SECS` (2 s in
// production). Empirically with the prod-realistic refresh cadence
// the avg per-second p95 stays under 1.5 s and the worst-second p95
// stays under 4 s. If either of these blows out, the oscillation is
// worse than acceptable and the algorithm needs a softer rate limit
// (e.g. stochastic admission of capped workspaces).
assert!(
avg_p95 < 1_500,
"average per-second victim p95 under burst was {} ms — \
oscillation is degrading victim QoL more than expected",
avg_p95,
);
assert!(
worst_p95 < 4_000,
"worst per-second victim p95 under burst was {} ms — \
uncapped bursts are too long; check ACTIVE_REFRESH_SECS",
worst_p95,
);
}