Skip to main content

af_workflow/
metrics.rs

1//! Dependency-free scheduler metrics and readiness.
2
3use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
4
5const READY_MISSED_PASSES: i64 = 3;
6
7/// Bounded-label Prometheus metrics and readiness for a supervisor.
8pub struct RuntimeMetrics {
9    prefix: String,
10    passes_total: AtomicU64,
11    pass_failures_total: AtomicU64,
12    claimed_total: AtomicU64,
13    evaluation_failures_total: AtomicU64,
14    overruns_total: AtomicU64,
15    backlog_events_total: AtomicU64,
16    last_pass_unix_ms: AtomicI64,
17    last_pass_duration_ms: AtomicU64,
18    active_instances: AtomicI64,
19    due_instances: AtomicI64,
20    stalled_instances: AtomicI64,
21    started_unix_ms: AtomicI64,
22}
23
24impl RuntimeMetrics {
25    /// Metrics named with `prefix`, initialised at `now_ms`.
26    pub fn new(now_ms: i64, prefix: impl Into<String>) -> Self {
27        Self {
28            prefix: prefix.into(),
29            passes_total: AtomicU64::new(0),
30            pass_failures_total: AtomicU64::new(0),
31            claimed_total: AtomicU64::new(0),
32            evaluation_failures_total: AtomicU64::new(0),
33            overruns_total: AtomicU64::new(0),
34            backlog_events_total: AtomicU64::new(0),
35            last_pass_unix_ms: AtomicI64::new(0),
36            last_pass_duration_ms: AtomicU64::new(0),
37            active_instances: AtomicI64::new(0),
38            due_instances: AtomicI64::new(0),
39            stalled_instances: AtomicI64::new(0),
40            started_unix_ms: AtomicI64::new(now_ms),
41        }
42    }
43
44    /// Metric name prefix.
45    pub fn prefix(&self) -> &str {
46        &self.prefix
47    }
48
49    /// Record one completed supervisor pass.
50    pub fn record_pass(&self, now_ms: i64, duration_ms: u64, claimed: u64, failed: u64) {
51        self.passes_total.fetch_add(1, Ordering::Relaxed);
52        self.claimed_total.fetch_add(claimed, Ordering::Relaxed);
53        self.evaluation_failures_total
54            .fetch_add(failed, Ordering::Relaxed);
55        self.last_pass_unix_ms.store(now_ms, Ordering::Relaxed);
56        self.last_pass_duration_ms
57            .store(duration_ms, Ordering::Relaxed);
58    }
59
60    /// Count a pass that failed as a whole.
61    pub fn record_pass_failure(&self) {
62        self.pass_failures_total.fetch_add(1, Ordering::Relaxed);
63    }
64
65    /// Count a pass that exceeded its interval.
66    pub fn record_overrun(&self) {
67        self.overruns_total.fetch_add(1, Ordering::Relaxed);
68    }
69
70    /// Count a pass that left due work unclaimed.
71    pub fn record_backlog(&self) {
72        self.backlog_events_total.fetch_add(1, Ordering::Relaxed);
73    }
74
75    /// Set the instance gauges.
76    pub fn set_gauges(&self, active: i64, due: i64, stalled: i64) {
77        self.active_instances.store(active, Ordering::Relaxed);
78        self.due_instances.store(due, Ordering::Relaxed);
79        self.stalled_instances.store(stalled, Ordering::Relaxed);
80    }
81
82    /// Readiness derived from the age of the last pass.
83    pub fn readiness(&self, now_ms: i64, interval_secs: u64) -> Readiness {
84        let active = self.active_instances.load(Ordering::Relaxed);
85        let last = self.last_pass_unix_ms.load(Ordering::Relaxed);
86        let reference = if last > 0 {
87            last
88        } else {
89            self.started_unix_ms.load(Ordering::Relaxed)
90        };
91        let age_ms = now_ms.saturating_sub(reference).max(0);
92        let budget_ms = (interval_secs as i64)
93            .saturating_mul(1_000)
94            .saturating_mul(READY_MISSED_PASSES);
95        let ready = active == 0 || age_ms <= budget_ms;
96        Readiness {
97            ready,
98            reason: (!ready).then(|| {
99                format!(
100                    "{active} active instance(s) but no completed pass in {}s",
101                    age_ms / 1_000
102                )
103            }),
104            last_pass_age_secs: age_ms / 1_000,
105            active,
106        }
107    }
108
109    /// Text exposition format.
110    pub fn render_prometheus(&self, now_ms: i64) -> String {
111        let last = self.last_pass_unix_ms.load(Ordering::Relaxed);
112        let age = if last > 0 {
113            now_ms.saturating_sub(last).max(0) / 1_000
114        } else {
115            -1
116        };
117        let values = [
118            (
119                "passes_total",
120                "counter",
121                self.passes_total.load(Ordering::Relaxed) as i64,
122            ),
123            (
124                "pass_failures_total",
125                "counter",
126                self.pass_failures_total.load(Ordering::Relaxed) as i64,
127            ),
128            (
129                "claimed_total",
130                "counter",
131                self.claimed_total.load(Ordering::Relaxed) as i64,
132            ),
133            (
134                "evaluation_failures_total",
135                "counter",
136                self.evaluation_failures_total.load(Ordering::Relaxed) as i64,
137            ),
138            (
139                "overruns_total",
140                "counter",
141                self.overruns_total.load(Ordering::Relaxed) as i64,
142            ),
143            (
144                "backlog_events_total",
145                "counter",
146                self.backlog_events_total.load(Ordering::Relaxed) as i64,
147            ),
148            (
149                "last_pass_duration_ms",
150                "gauge",
151                self.last_pass_duration_ms.load(Ordering::Relaxed) as i64,
152            ),
153            ("last_pass_age_seconds", "gauge", age),
154            (
155                "active_instances",
156                "gauge",
157                self.active_instances.load(Ordering::Relaxed),
158            ),
159            (
160                "due_instances",
161                "gauge",
162                self.due_instances.load(Ordering::Relaxed),
163            ),
164            (
165                "stalled_instances",
166                "gauge",
167                self.stalled_instances.load(Ordering::Relaxed),
168            ),
169        ];
170        values
171            .into_iter()
172            .map(|(suffix, kind, value)| {
173                let name = format!("{}_{suffix}", self.prefix);
174                format!("# TYPE {name} {kind}\n{name} {value}\n")
175            })
176            .collect()
177    }
178}
179
180/// Readiness probe result.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct Readiness {
183    /// Whether the supervisor passed recently enough.
184    pub ready: bool,
185    /// Human-readable reason.
186    pub reason: Option<String>,
187    /// Seconds since the last pass.
188    pub last_pass_age_secs: i64,
189    /// Active instances.
190    pub active: i64,
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn readiness_detects_an_idle_active_runtime() {
199        let metrics = RuntimeMetrics::new(0, "jobs");
200        metrics.set_gauges(2, 2, 0);
201        assert!(!metrics.readiness(31_000, 10).ready);
202        metrics.record_pass(30_000, 25, 2, 1);
203        assert!(metrics.readiness(31_000, 10).ready);
204    }
205
206    #[test]
207    fn prometheus_output_is_namespaced_and_complete() {
208        let metrics = RuntimeMetrics::new(0, "jobs");
209        metrics.set_gauges(2, 1, 1);
210        metrics.record_pass_failure();
211        metrics.record_overrun();
212        metrics.record_backlog();
213        let output = metrics.render_prometheus(5_000);
214        assert!(output.contains("jobs_active_instances 2"));
215        assert!(output.contains("jobs_pass_failures_total 1"));
216        assert!(output.contains("jobs_overruns_total 1"));
217        assert!(output.contains("jobs_backlog_events_total 1"));
218        assert_eq!(metrics.prefix(), "jobs");
219    }
220}