use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Instant;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy)]
pub struct DrainConfig {
pub tau_secs: f64,
pub min_samples: u32,
pub noise_floor: f64,
}
impl Default for DrainConfig {
fn default() -> Self {
Self {
tau_secs: 120.0,
min_samples: 3,
noise_floor: 0.01,
}
}
}
impl DrainConfig {
pub fn from_env() -> Self {
let d = Self::default();
let tau_secs = std::env::var("QRUSTY_DRAIN_TAU_SECS")
.ok()
.and_then(|v| v.parse::<f64>().ok())
.filter(|&v| v.is_finite() && v > 0.0)
.unwrap_or(d.tau_secs);
let min_samples = std::env::var("QRUSTY_DRAIN_MIN_SAMPLES")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.filter(|&v| v > 0)
.unwrap_or(d.min_samples);
let noise_floor = std::env::var("QRUSTY_DRAIN_NOISE_FLOOR")
.ok()
.and_then(|v| v.parse::<f64>().ok())
.filter(|&v| v.is_finite() && v >= 0.0)
.unwrap_or(d.noise_floor);
Self {
tau_secs,
min_samples,
noise_floor,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum DrainState {
Draining { eta_secs: f64 },
Growing,
Stalled,
Unknown,
}
#[derive(Debug, Clone, Copy)]
struct QueueDrain {
rate: f64,
last_depth: u64,
last_ts: f64,
samples: u32,
}
impl QueueDrain {
fn new(depth: u64, now_secs: f64) -> Self {
Self {
rate: 0.0,
last_depth: depth,
last_ts: now_secs,
samples: 1,
}
}
fn observe(&mut self, depth: u64, now_secs: f64, cfg: &DrainConfig) {
let dt = now_secs - self.last_ts;
if dt <= 0.0 {
self.last_depth = depth;
return;
}
let inst = (self.last_depth as f64 - depth as f64) / dt; let alpha = 1.0 - (-dt / cfg.tau_secs).exp();
self.rate = alpha * inst + (1.0 - alpha) * self.rate;
self.last_depth = depth;
self.last_ts = now_secs;
self.samples = self.samples.saturating_add(1);
}
fn classify(&self, cfg: &DrainConfig) -> DrainState {
if self.samples < cfg.min_samples {
return DrainState::Unknown;
}
if self.rate > cfg.noise_floor {
let eta = self.last_depth as f64 / self.rate;
DrainState::Draining { eta_secs: eta }
} else if self.rate < -cfg.noise_floor {
DrainState::Growing
} else {
DrainState::Stalled
}
}
}
#[derive(Debug)]
pub struct DrainEstimates {
inner: Mutex<HashMap<String, QueueDrain>>,
cfg: DrainConfig,
base: Instant,
}
impl DrainEstimates {
pub fn new(cfg: DrainConfig) -> Self {
Self {
inner: Mutex::new(HashMap::new()),
cfg,
base: Instant::now(),
}
}
fn now_secs(&self) -> f64 {
self.base.elapsed().as_secs_f64()
}
pub fn observe(&self, queue: &str, depth: u64) {
let now = self.now_secs();
self.observe_at(queue, depth, now);
}
fn observe_at(&self, queue: &str, depth: u64, now_secs: f64) {
let mut map = self.inner.lock().expect("drain estimates mutex poisoned");
map.entry(queue.to_string())
.and_modify(|q| q.observe(depth, now_secs, &self.cfg))
.or_insert_with(|| QueueDrain::new(depth, now_secs));
}
pub fn state(&self, queue: &str) -> DrainState {
let map = self.inner.lock().expect("drain estimates mutex poisoned");
map.get(queue)
.map(|q| q.classify(&self.cfg))
.unwrap_or(DrainState::Unknown)
}
pub fn retain_queues(&self, live: &[String]) {
let mut map = self.inner.lock().expect("drain estimates mutex poisoned");
map.retain(|k, _| live.iter().any(|l| l == k));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> DrainConfig {
DrainConfig {
tau_secs: 60.0,
min_samples: 3,
noise_floor: 0.01,
}
}
fn est() -> DrainEstimates {
DrainEstimates::new(cfg())
}
#[test]
fn unknown_until_min_samples() {
let e = est();
e.observe_at("q", 100, 0.0);
assert_eq!(e.state("q"), DrainState::Unknown); e.observe_at("q", 90, 1.0);
assert_eq!(e.state("q"), DrainState::Unknown); e.observe_at("q", 80, 2.0);
assert!(!matches!(e.state("q"), DrainState::Unknown)); }
#[test]
fn unknown_for_never_observed_queue() {
assert_eq!(est().state("nope"), DrainState::Unknown);
}
#[test]
fn steady_drain_reports_eta() {
let e = est();
let mut depth = 100i64;
for t in 0..=10 {
e.observe_at("q", depth.max(0) as u64, t as f64);
depth -= 10;
}
match e.state("q") {
DrainState::Draining { eta_secs } => {
assert!(eta_secs >= 0.0 && eta_secs.is_finite(), "eta={eta_secs}");
}
other => panic!("expected Draining, got {other:?}"),
}
}
#[test]
fn eta_tracks_depth_over_rate() {
let e = DrainEstimates::new(DrainConfig {
tau_secs: 5.0,
min_samples: 3,
noise_floor: 0.01,
});
for t in 0..=40 {
let depth = 1000 - 5 * t;
e.observe_at("q", depth as u64, t as f64);
}
match e.state("q") {
DrainState::Draining { eta_secs } => {
assert!(
(eta_secs - 160.0).abs() < 20.0,
"eta={eta_secs}, expected ~160s (800 / 5)"
);
}
other => panic!("expected Draining, got {other:?}"),
}
}
#[test]
fn growing_queue_reports_growing_never_eta() {
let e = est();
let mut depth = 10u64;
for t in 0..=10 {
e.observe_at("q", depth, t as f64);
depth += 10; }
assert_eq!(e.state("q"), DrainState::Growing);
}
#[test]
fn idle_queue_reports_stalled() {
let e = est();
for t in 0..=10 {
e.observe_at("q", 50, t as f64); }
assert_eq!(e.state("q"), DrainState::Stalled);
}
#[test]
fn zero_dt_is_ignored_no_nan() {
let e = est();
e.observe_at("q", 100, 5.0);
e.observe_at("q", 90, 5.0); e.observe_at("q", 80, 6.0);
e.observe_at("q", 70, 7.0);
match e.state("q") {
DrainState::Draining { eta_secs } => assert!(eta_secs.is_finite()),
DrainState::Stalled => {} other => panic!("unexpected {other:?}"),
}
}
#[test]
fn irregular_dt_handled() {
let e = est();
for (t, d) in [
(0.0, 1000),
(0.5, 990),
(30.0, 700),
(31.0, 695),
(90.0, 400),
] {
e.observe_at("q", d, t);
}
match e.state("q") {
DrainState::Draining { eta_secs } => assert!(eta_secs.is_finite() && eta_secs > 0.0),
other => panic!("expected Draining, got {other:?}"),
}
}
#[test]
fn retain_drops_dead_queues() {
let e = est();
e.observe_at("keep", 10, 0.0);
e.observe_at("drop", 10, 0.0);
e.retain_queues(&["keep".to_string()]);
assert_eq!(e.state("drop"), DrainState::Unknown);
e.observe_at("keep", 5, 1.0);
e.observe_at("keep", 1, 2.0);
assert!(!matches!(e.state("keep"), DrainState::Unknown));
}
#[test]
fn drain_state_serializes_tagged() {
let j = serde_json::to_string(&DrainState::Draining { eta_secs: 12.5 }).unwrap();
assert!(j.contains("\"state\":\"draining\""), "{j}");
assert!(j.contains("\"eta_secs\":12.5"), "{j}");
let g = serde_json::to_string(&DrainState::Growing).unwrap();
assert_eq!(g, "{\"state\":\"growing\"}");
}
#[test]
fn slow_clean_drain_yields_multiday_eta() {
let e = DrainEstimates::new(DrainConfig::default());
let dt = 30.0;
let mut depth = 50_000i64;
for k in 0..=240 {
e.observe_at("slow", depth.max(0) as u64, k as f64 * dt);
depth -= 2;
}
match e.state("slow") {
DrainState::Draining { eta_secs } => {
let days = eta_secs / 86_400.0;
assert!(
(1.0..=30.0).contains(&days),
"eta={eta_secs}s ({days} days)"
);
}
other => panic!("clean slow drain should be Draining, got {other:?}"),
}
}
#[test]
fn slow_drain_under_churn_is_unreliable() {
let e = DrainEstimates::new(DrainConfig::default());
let dt = 30.0;
let mut depth = 50_000i64;
let mut seed = 0x1234_5678u64;
let (mut draining, mut samples) = (0u32, 0u32);
for k in 0..=240 {
seed = seed
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
let churn = (seed >> 33) as i64 % 81 - 40; let observed = (depth + churn).max(0) as u64;
e.observe_at("churn", observed, k as f64 * dt);
if k > 10 {
samples += 1;
if matches!(e.state("churn"), DrainState::Draining { .. }) {
draining += 1;
}
}
depth -= 2;
}
let frac = draining as f64 / samples as f64;
println!("slow+churn: Draining {draining}/{samples} = {frac:.2} of samples");
assert!(
frac < 0.9,
"single-tau unexpectedly reliable under churn (frac={frac:.2}) — revisit the bound"
);
}
}