1use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
4
5const READY_MISSED_PASSES: i64 = 3;
6
7pub 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 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 pub fn prefix(&self) -> &str {
46 &self.prefix
47 }
48
49 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 pub fn record_pass_failure(&self) {
62 self.pass_failures_total.fetch_add(1, Ordering::Relaxed);
63 }
64
65 pub fn record_overrun(&self) {
67 self.overruns_total.fetch_add(1, Ordering::Relaxed);
68 }
69
70 pub fn record_backlog(&self) {
72 self.backlog_events_total.fetch_add(1, Ordering::Relaxed);
73 }
74
75 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct Readiness {
183 pub ready: bool,
185 pub reason: Option<String>,
187 pub last_pass_age_secs: i64,
189 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}