Files
windmill/backend/windmill-common/src/queue_metrics.rs
T
Ruben FiszelandClaude Opus 5 569adb85c1 feat: live queue status per tag and bounded queue metric charts (#11067)
* feat: live per-tag queue status and bounded charts in the queues drawer

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011fYMqh7R8FnePmbYzirJXZ

* fix: drop stale chart failures and test the queue metrics series query

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011fYMqh7R8FnePmbYzirJXZ

* fix: name the queue status refresh, skip overlapping polls, soften the no-worker warning

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011fYMqh7R8FnePmbYzirJXZ

* feat: draw a stuck tag's queue delay exactly as it climbs (#11071)

* feat: store a stuck tag's queue delay as its head's wait start

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011fYMqh7R8FnePmbYzirJXZ

* fix: keep a climb's top inside a slot and stamp held delays exactly

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011fYMqh7R8FnePmbYzirJXZ

* fix: redraw a climb as soon as its head leaves, and document the lookup slack

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011fYMqh7R8FnePmbYzirJXZ

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 18:40:54 +00:00

483 lines
17 KiB
Rust

//! The queue metrics the monitor samples into `metrics` (`queue_count_{tag}` and
//! `queue_delay_{tag}`), and how a stored series is drawn back.
//!
//! A stored value is a number, held until the next sample, or, for a delay, `{"since": <epoch
//! seconds>}`: the job at the head of the queue has been waiting since then and was still there
//! when sampled, so the delay climbs one second per second until the next sample. Besides
//! [`QueueSample`], the SQL in [`read_queue_metrics_series`] and in `GET /workers/queue_metrics`
//! decodes both shapes.
use std::collections::BTreeMap;
use serde::Serialize;
use sqlx::{Pool, Postgres};
pub const QUEUE_COUNT_PREFIX: &str = "queue_count_";
pub const QUEUE_DELAY_PREFIX: &str = "queue_delay_";
/// A backlogged tag whose value has not moved is re-sampled only this often. A longer heartbeat
/// writes fewer rows, but keeps a tag whose drain was never recorded (no server was up when it
/// drained) drawn as backlogged for longer.
pub const QUEUE_METRIC_HEARTBEAT_SECS: f64 = 5.0 * 60.0;
/// A series silent for longer than this has drained: the sampler stops looking for it, so no
/// closing zero will come, and it is drawn as zero from there. Heartbeats land up to a monitor
/// tick and a sampling slot late, so this must stay well above their real spacing.
pub const QUEUE_METRIC_STALE_SECS: f64 = 3.0 * QUEUE_METRIC_HEARTBEAT_SECS;
/// Heads that started waiting within this of each other are one wait: jobs queued together
/// leave the head one after another without the delay dropping.
pub const QUEUE_DELAY_SAME_HEAD_SECS: f64 = 1.0;
/// Slots a series is split into, whatever the window. A slot draws at most four vertices, and a
/// climb one more at each slot boundary it crosses, so a line stays under about 600 points
/// however many rows the window holds.
const QUEUE_METRICS_SERIES_SLOTS: f64 = 120.0;
/// A stored sample, as it is drawn from the moment it was written until the next one.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum QueueSample {
/// A count, or a delay while the head keeps changing, which hovers.
Held(f64),
/// A delay while the job that started waiting at `since` (epoch seconds) stays at the head.
Climbing { since: f64 },
}
impl QueueSample {
pub fn parse(value: &serde_json::Value) -> Option<Self> {
match value.get("since") {
Some(since) => since.as_f64().map(|since| Self::Climbing { since }),
None => value.as_f64().map(Self::Held),
}
}
pub fn to_json(self) -> serde_json::Value {
match self {
Self::Held(value) => serde_json::json!(value),
Self::Climbing { since } => serde_json::json!({ "since": since }),
}
}
/// Its value at `t`, in epoch seconds.
pub fn value_at(self, t: f64) -> f64 {
match self {
Self::Held(value) => value,
Self::Climbing { since } => t - since,
}
}
/// When the job at the head of a delay sample written at `at` started waiting.
pub fn head_since(self, at: f64) -> f64 {
match self {
Self::Held(delay) => at - delay,
Self::Climbing { since } => since,
}
}
}
#[derive(Serialize)]
pub struct QueueMetricsSeries {
/// The window drawn, in epoch milliseconds.
pub from: i64,
pub to: i64,
pub tags: Vec<QueueTagSeries>,
}
#[derive(Serialize)]
pub struct QueueTagSeries {
pub tag: String,
/// Vertices `[epoch ms, value]` of a line joined by straight segments.
pub count: Vec<(i64, f64)>,
pub delay: Vec<(i64, f64)>,
}
/// The queue metrics of the last `window_secs`, each series aggregated per slot by the database
/// and drawn by [`render_series`], so the size is bounded by the number of tags rather than by
/// how many rows they wrote.
///
/// Reads the metrics of every workspace's tags: a caller exposing the result MUST restrict it to
/// devops users, as `GET /workers/queue_metrics_series` does.
pub async fn read_queue_metrics_series(
db: &Pool<Postgres>,
window_secs: f64,
) -> crate::error::Result<QueueMetricsSeries> {
let to = sqlx::query_scalar!("SELECT EXTRACT(EPOCH FROM now())::double precision AS \"now!\"")
.fetch_one(db)
.await?;
let from = to - window_secs;
let slot_secs = window_secs / QUEUE_METRICS_SERIES_SLOTS;
// Slot -1 holds the samples written before the window, of which only the last is used: it
// sets the value in force at the left edge. A series silent for longer than the stale window
// reads as zero, so nothing older can matter. Arrays compare element by element, so
// `max(ARRAY[t, v])` is the slot's latest sample, found without sorting every row. `v` is a
// sample's value when it was written: for a climbing delay, how long its head had waited.
//
// A climb keeps rising until the next sample, so when that sample lands in the same slot
// (the tag drained, or its head moved), the climb's top is higher than any `v`. Looking the
// next sample up for the slot's last climb, rather than ordering every row, keeps the pass a
// plain aggregate; an earlier climb in the same slot still shows up to its last heartbeat.
// `t` round-trips through `to_timestamp` to within a microsecond either way, so both bounds
// carry a millisecond of slack, far less than two distinct samples of a series are apart:
// without it the climbing sample can match itself, or the one at `last` fall outside.
let rows = sqlx::query!(
"WITH slots AS (
SELECT id, slot, min(t) AS first, max(t) AS last, max(v) AS peak,
(min(ARRAY[t, v]))[2] AS first_value, (max(ARRAY[t, v]))[2] AS last_value,
(max(ARRAY[t, climbing]))[2] = 1 AS last_climbing,
COALESCE(bool_and(climbing = 1) AND max(since) - min(since) < $4, false) AS ramp,
max(ARRAY[t, since]) FILTER (WHERE climbing = 1) AS last_climb
FROM (
SELECT id, t,
CASE jsonb_typeof(value)
WHEN 'number' THEN value::double precision
WHEN 'object' THEN t - (value->>'since')::double precision
END AS v,
(value->>'since')::double precision AS since,
(jsonb_typeof(value) = 'object')::int::double precision AS climbing,
greatest(floor((t - $1::double precision) / $2::double precision), -1)::int
AS slot
FROM (
SELECT id, value, EXTRACT(EPOCH FROM created_at)::double precision AS t
FROM metrics
WHERE id LIKE 'queue_%'
AND created_at > to_timestamp($1::double precision - $3::double precision)
) m
) s
WHERE v IS NOT NULL
GROUP BY id, slot
)
SELECT id AS \"id!\", slot AS \"slot!\", first AS \"first!\", last AS \"last!\",
greatest(peak, CASE WHEN last_climb[1] < last THEN (
SELECT EXTRACT(EPOCH FROM min(n.created_at))::double precision
FROM metrics n
WHERE n.id = slots.id AND n.id LIKE 'queue_%'
AND n.created_at > to_timestamp(last_climb[1] + 0.001)
AND n.created_at <= to_timestamp(last + 0.001)
) - last_climb[2] END) AS \"peak!\",
first_value AS \"first_value!\", last_value AS \"last_value!\",
last_climbing AS \"last_climbing!\", ramp AS \"ramp!\"
FROM slots
ORDER BY id, slot",
from,
slot_secs,
QUEUE_METRIC_STALE_SECS,
QUEUE_DELAY_SAME_HEAD_SECS,
)
.fetch_all(db)
.await?;
#[derive(Default)]
struct Stored {
carried: Option<MetricSlot>,
slots: Vec<MetricSlot>,
}
// [count, delay] per tag.
let mut stored: BTreeMap<String, [Stored; 2]> = BTreeMap::new();
for row in rows {
let (series, tag) = if let Some(tag) = row.id.strip_prefix(QUEUE_COUNT_PREFIX) {
(0, tag)
} else if let Some(tag) = row.id.strip_prefix(QUEUE_DELAY_PREFIX) {
(1, tag)
} else {
continue;
};
let series = &mut stored.entry(tag.to_string()).or_default()[series];
let slot = MetricSlot {
first: row.first,
last: row.last,
peak: row.peak,
first_value: row.first_value,
last_value: row.last_value,
last_climbing: row.last_climbing,
ramp: row.ramp,
};
if row.slot < 0 {
series.carried = Some(slot);
} else {
series.slots.push(slot);
}
}
let tags = stored
.into_iter()
.map(|(tag, [count, delay])| {
let draw =
|s: &Stored| render_series(s.carried.as_ref(), &s.slots, from, to, slot_secs);
QueueTagSeries { count: draw(&count), delay: draw(&delay), tag }
})
// A tag that drained before the window has nothing to draw in it.
.filter(|s| s.count.iter().chain(&s.delay).any(|(_, v)| *v != 0.0))
.collect();
Ok(QueueMetricsSeries {
from: (from * 1000.0).round() as i64,
to: (to * 1000.0).round() as i64,
tags,
})
}
/// The stored samples of one series that fall in one time slot.
#[derive(Debug, Clone, Copy)]
pub struct MetricSlot {
/// When the first and the last sample of the slot were written, in epoch seconds.
pub first: f64,
pub last: f64,
/// The highest value the series drew over the slot, a climb that ends inside it included.
pub peak: f64,
pub first_value: f64,
/// The value of the last sample, which holds (or climbs, for a climbing delay) until the next.
pub last_value: f64,
pub last_climbing: bool,
/// Every sample of the slot climbs from the same head, so the slot is one exact ramp.
pub ramp: bool,
}
/// Draw a stored series over `[from, to]` (epoch seconds), split into slots of `slot_secs`, as
/// the vertices of a line joined by straight segments, each `(epoch ms, value)`.
///
/// A sample holds its value, or a climbing delay keeps climbing, until the next sample or until
/// the series has been silent for [`QUEUE_METRIC_STALE_SECS`]. `carried` is the slot before
/// `from`, whose last sample sets the left edge. A slot draws its peak across the span of its
/// samples, so a spike shorter than a slot still shows at full height, unless it is a single
/// climb, drawn exactly. A climb gets a vertex at every slot boundary it crosses: the delay axis
/// is logarithmic, so one straight segment across many slots would misplace it.
pub fn render_series(
carried: Option<&MetricSlot>,
slots: &[MetricSlot],
from: f64,
to: f64,
slot_secs: f64,
) -> Vec<(i64, f64)> {
let mut line = Line { points: vec![], from, slot_secs };
let mut held = carried
.map(Held::after)
.filter(|h| from - h.at <= QUEUE_METRIC_STALE_SECS);
if let Some(h) = held {
line.push(from, h.value_at(from));
}
for slot in slots {
let entering = line.advance(&mut held, slot.first);
line.push(slot.first, entering);
if slot.ramp {
line.push(slot.first, slot.first_value);
} else {
line.push(slot.first, slot.peak);
line.push(slot.last, slot.peak);
}
line.push(slot.last, slot.last_value);
held = Some(Held::after(slot));
}
if !line.points.is_empty() {
let value = line.advance(&mut held, to);
line.push(to, value);
}
line.points
}
/// The last sample drawn: when it was written, its value then, and whether it climbs from there.
#[derive(Clone, Copy)]
struct Held {
at: f64,
value: f64,
climbing: bool,
}
impl Held {
fn after(slot: &MetricSlot) -> Self {
Self { at: slot.last, value: slot.last_value, climbing: slot.last_climbing }
}
fn value_at(self, t: f64) -> f64 {
if self.climbing {
self.value + (t - self.at)
} else {
self.value
}
}
}
struct Line {
points: Vec<(i64, f64)>,
from: f64,
slot_secs: f64,
}
impl Line {
/// The value `held` has at `t`, drawing the climb that leads there and, when the series went
/// silent for too long first, its drop to zero, after which it is forgotten.
fn advance(&mut self, held: &mut Option<Held>, t: f64) -> f64 {
let Some(h) = *held else {
return 0.0;
};
let stale_at = h.at + QUEUE_METRIC_STALE_SECS;
if h.climbing {
let end = t.min(stale_at);
let start = h.at.max(self.from);
let mut boundary = self.from
+ ((start - self.from) / self.slot_secs).floor() * self.slot_secs
+ self.slot_secs;
while boundary < end {
self.push(boundary, h.value_at(boundary));
boundary += self.slot_secs;
}
}
if t <= stale_at {
return h.value_at(t);
}
self.push(stale_at, h.value_at(stale_at));
self.push(stale_at, 0.0);
*held = None;
0.0
}
fn push(&mut self, t: f64, value: f64) {
let point = ((t * 1000.0).round() as i64, value);
match self.points.as_mut_slice() {
[.., last] if *last == point => {}
// A horizontal run only needs its two ends.
[.., a, b] if a.1 == value && b.1 == value => b.0 = point.0,
_ => self.points.push(point),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const FROM: f64 = 1_000_000.0;
const TO: f64 = FROM + 3600.0;
const SLOT: f64 = 30.0;
fn held(first: f64, last: f64, peak: f64, last_value: f64) -> MetricSlot {
MetricSlot {
first: FROM + first,
last: FROM + last,
peak,
first_value: peak,
last_value,
last_climbing: false,
ramp: false,
}
}
/// A slot whose samples all climb from a head that started waiting 30s before `FROM`.
fn climbing(first: f64, last: f64) -> MetricSlot {
MetricSlot {
first: FROM + first,
last: FROM + last,
peak: last + 30.0,
first_value: first + 30.0,
last_value: last + 30.0,
last_climbing: true,
ramp: true,
}
}
fn at(secs: f64, value: f64) -> (i64, f64) {
(((FROM + secs) * 1000.0) as i64, value)
}
#[test]
fn a_value_holds_until_the_next_sample_and_a_drain_drops_where_it_was_written() {
let line = render_series(
None,
&[
held(60.0, 60.0, 3.0, 3.0),
held(600.0, 600.0, 2.0, 2.0),
held(900.0, 900.0, 0.0, 0.0),
],
FROM,
TO,
SLOT,
);
assert_eq!(
line,
vec![
at(60.0, 0.0),
at(60.0, 3.0),
at(600.0, 3.0),
at(600.0, 2.0),
at(900.0, 2.0),
at(900.0, 0.0),
at(3600.0, 0.0),
]
);
}
#[test]
fn a_series_silent_past_the_stale_window_drops_to_zero() {
let line = render_series(None, &[held(60.0, 60.0, 3.0, 3.0)], FROM, TO, SLOT);
let dropped = 60.0 + QUEUE_METRIC_STALE_SECS;
assert_eq!(
line,
vec![
at(60.0, 0.0),
at(60.0, 3.0),
at(dropped, 3.0),
at(dropped, 0.0),
at(3600.0, 0.0)
]
);
}
#[test]
fn a_slot_draws_its_peak_then_continues_from_its_last_sample() {
// Samples at 60 (5), 70 (9), 80 (4) collapsed into one slot.
let line = render_series(
Some(&held(-30.0, -30.0, 2.0, 2.0)),
&[held(60.0, 80.0, 9.0, 4.0)],
FROM,
FROM + 120.0,
SLOT,
);
assert_eq!(
line,
vec![
at(0.0, 2.0),
at(60.0, 2.0),
at(60.0, 9.0),
at(80.0, 9.0),
at(80.0, 4.0),
at(120.0, 4.0)
]
);
}
#[test]
fn a_climbing_delay_is_drawn_exactly_up_to_its_drain() {
// 300s slots: one holds two climbing samples, and heartbeats follow until the drain.
let line = render_series(
None,
&[
climbing(60.0, 120.0),
climbing(360.0, 360.0),
climbing(660.0, 660.0),
held(900.0, 900.0, 0.0, 0.0),
],
FROM,
TO,
300.0,
);
assert_eq!(
line,
vec![
at(60.0, 0.0),
// The slot is one climb, not its peak held across it.
at(60.0, 90.0),
at(120.0, 150.0),
// A vertex at each slot boundary the climb crosses.
at(300.0, 330.0),
at(360.0, 390.0),
at(600.0, 630.0),
at(660.0, 690.0),
// Still climbing right up to the closing zero.
at(900.0, 930.0),
at(900.0, 0.0),
at(3600.0, 0.0),
]
);
}
}