1pub mod eval;
19pub mod metrics;
20pub mod spec;
21pub mod state;
22
23pub use eval::SlaViolation;
24pub use spec::{AnomalyMethod, SlaSpec, VolumeAnomalySpec};
25pub use state::{SLA_STATE_SUFFIX, SlaState, sla_state_key};
26
27use faucet_core::StateStore;
28use faucet_core::check::Probe;
29use std::sync::Arc;
30use std::time::Instant;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum RunOutcome {
35 Success { rows: u64 },
37 Failure,
39}
40
41pub async fn evaluate_post_run(
50 spec: &SlaSpec,
51 store: Option<&Arc<dyn StateStore>>,
52 base_state_key: &str,
53 pipeline: &str,
54 row: &str,
55 outcome: RunOutcome,
56 now_unix: i64,
57) -> Vec<SlaViolation> {
58 let key = sla_state_key(base_state_key);
59 let (mut history, store) = match store {
60 None => (SlaState::default(), None),
64 Some(s) => match s.get(&key).await {
65 Ok(v) => (v.map(SlaState::from_value).unwrap_or_default(), Some(s)),
66 Err(e) => {
67 tracing::warn!(
68 pipeline,
69 row,
70 key,
71 error = %e,
72 "reading SLA state failed — skipping SLA evaluation for this run"
73 );
74 return Vec::new();
75 }
76 },
77 };
78
79 let violations = match outcome {
80 RunOutcome::Success { rows } => {
81 let violations = eval::evaluate_success(spec, &history, rows);
82 if let Some(s) = store {
83 history.record_success(rows, now_unix, spec.window());
84 if let Err(e) = s.put(&key, &history.to_value()).await {
85 tracing::warn!(
86 pipeline,
87 row,
88 key,
89 error = %e,
90 "persisting SLA state failed — baseline not updated"
91 );
92 } else {
93 metrics::set_baseline_runs(pipeline, row, history.volumes.len());
94 }
95 }
96 violations
97 }
98 RunOutcome::Failure => eval::evaluate_failure(spec, &history, now_unix),
99 };
100
101 for v in &violations {
102 metrics::record_violation(pipeline, row, v.kind());
103 tracing::warn!(pipeline, row, kind = v.kind(), "SLA violation: {v}");
104 }
105 violations
106}
107
108pub async fn doctor_probes(
113 spec: &SlaSpec,
114 store: Option<&Arc<dyn StateStore>>,
115 base_state_key: &str,
116 now_unix: i64,
117) -> Vec<Probe> {
118 let start = Instant::now();
119 let history = match store {
120 None => {
121 return if spec.needs_state() {
123 vec![Probe::skip("history", "no state store configured")]
124 } else {
125 Vec::new()
126 };
127 }
128 Some(s) => match s.get(&sla_state_key(base_state_key)).await {
129 Ok(v) => v.map(SlaState::from_value).unwrap_or_default(),
130 Err(e) => {
131 return vec![Probe::fail(
132 "history",
133 start.elapsed(),
134 format!("reading SLA state: {e}"),
135 )];
136 }
137 },
138 };
139
140 let mut probes = Vec::new();
141 if let Some(max_secs) = spec.max_staleness_secs {
142 probes.push(match history.last_success_unix {
143 None => Probe::skip("staleness", "no successful run recorded yet"),
144 Some(last) => {
145 let since = now_unix.saturating_sub(last).max(0) as u64;
146 if since > max_secs {
147 Probe::fail_hint(
148 "staleness",
149 start.elapsed(),
150 format!("last success {since}s ago exceeds max_staleness_secs {max_secs}"),
151 "check the pipeline's schedule and recent run failures",
152 )
153 } else {
154 Probe::pass("staleness", start.elapsed())
155 }
156 }
157 });
158 }
159 if let Some(va) = &spec.volume_anomaly {
160 let n = history.volumes.len();
161 probes.push(if n < va.min_history as usize {
162 Probe::skip(
163 "baseline",
164 format!(
165 "volume baseline warming up: {n}/{} successful runs",
166 va.min_history
167 ),
168 )
169 } else {
170 Probe::pass("baseline", start.elapsed())
171 });
172 }
173 probes
174}
175
176#[cfg(test)]
177mod tests {
178 use super::spec::{AnomalyMethod, VolumeAnomalySpec};
179 use super::*;
180 use faucet_core::MemoryStateStore;
181 use faucet_core::check::ProbeStatus;
182
183 fn full_spec() -> SlaSpec {
184 SlaSpec {
185 max_staleness_secs: Some(3600),
186 min_rows_per_run: Some(5),
187 volume_anomaly: Some(VolumeAnomalySpec {
188 method: AnomalyMethod::Zscore,
189 sensitivity: None,
190 min_history: 3,
191 window: 10,
192 }),
193 }
194 }
195
196 fn mem() -> Arc<dyn StateStore> {
197 Arc::new(MemoryStateStore::new())
198 }
199
200 #[tokio::test]
201 async fn success_updates_baseline_and_failure_reads_it() {
202 let spec = full_spec();
203 let store = mem();
204 for (i, rows) in [100u64, 101, 99].iter().enumerate() {
206 let v = evaluate_post_run(
207 &spec,
208 Some(&store),
209 "p::default",
210 "p",
211 "default",
212 RunOutcome::Success { rows: *rows },
213 (i as i64) * 10,
214 )
215 .await;
216 assert!(v.is_empty(), "warm-up run {i} should not violate: {v:?}");
217 }
218 let stored = store.get("p::default::__sla__").await.unwrap().unwrap();
219 let st = SlaState::from_value(stored);
220 assert_eq!(st.volumes, vec![100, 101, 99]);
221 assert_eq!(st.last_success_unix, Some(20));
222
223 let v = evaluate_post_run(
225 &spec,
226 Some(&store),
227 "p::default",
228 "p",
229 "default",
230 RunOutcome::Failure,
231 20 + 7200,
232 )
233 .await;
234 assert_eq!(v.len(), 1);
235 assert_eq!(v[0].kind(), "staleness");
236
237 let v = evaluate_post_run(
239 &spec,
240 Some(&store),
241 "p::default",
242 "p",
243 "default",
244 RunOutcome::Failure,
245 20 + 60,
246 )
247 .await;
248 assert!(v.is_empty(), "{v:?}");
249 }
250
251 #[tokio::test]
252 async fn success_detects_floor_and_anomaly_against_prior_baseline() {
253 let spec = full_spec();
254 let store = mem();
255 for (i, rows) in [100u64, 100, 100].iter().enumerate() {
256 evaluate_post_run(
257 &spec,
258 Some(&store),
259 "p::default",
260 "p",
261 "default",
262 RunOutcome::Success { rows: *rows },
263 i as i64,
264 )
265 .await;
266 }
267 let v = evaluate_post_run(
269 &spec,
270 Some(&store),
271 "p::default",
272 "p",
273 "default",
274 RunOutcome::Success { rows: 2 },
275 100,
276 )
277 .await;
278 let kinds: Vec<_> = v.iter().map(|x| x.kind()).collect();
279 assert_eq!(kinds, vec!["min_rows", "volume"]);
280 let st = SlaState::from_value(store.get("p::default::__sla__").await.unwrap().unwrap());
282 assert_eq!(st.volumes, vec![100, 100, 100, 2]);
283 }
284
285 #[tokio::test]
286 async fn no_store_runs_only_stateless_checks() {
287 let spec = SlaSpec {
288 max_staleness_secs: None,
289 min_rows_per_run: Some(10),
290 volume_anomaly: None,
291 };
292 let v = evaluate_post_run(
293 &spec,
294 None,
295 "p::default",
296 "p",
297 "default",
298 RunOutcome::Success { rows: 1 },
299 0,
300 )
301 .await;
302 assert_eq!(v.len(), 1);
303 assert_eq!(v[0].kind(), "min_rows");
304 let v = evaluate_post_run(
306 &spec,
307 None,
308 "p::default",
309 "p",
310 "default",
311 RunOutcome::Failure,
312 0,
313 )
314 .await;
315 assert!(v.is_empty());
316 }
317
318 #[tokio::test]
319 async fn doctor_probes_cover_cold_fresh_and_stale() {
320 let spec = full_spec();
321 let store = mem();
322
323 let probes = doctor_probes(&spec, Some(&store), "p::default", 0).await;
325 assert_eq!(probes.len(), 2);
326 assert!(matches!(probes[0].status, ProbeStatus::Skip { .. }));
327 assert!(matches!(probes[1].status, ProbeStatus::Skip { .. }));
328
329 for i in 0..3i64 {
331 evaluate_post_run(
332 &spec,
333 Some(&store),
334 "p::default",
335 "p",
336 "default",
337 RunOutcome::Success { rows: 100 },
338 i,
339 )
340 .await;
341 }
342 let probes = doctor_probes(&spec, Some(&store), "p::default", 10).await;
343 assert!(matches!(probes[0].status, ProbeStatus::Pass), "{probes:?}");
344 assert!(matches!(probes[1].status, ProbeStatus::Pass), "{probes:?}");
345
346 let probes = doctor_probes(&spec, Some(&store), "p::default", 2 + 7200).await;
348 assert!(
349 matches!(probes[0].status, ProbeStatus::Fail { .. }),
350 "{probes:?}"
351 );
352 }
353
354 #[tokio::test]
355 async fn doctor_probes_without_store() {
356 let stateless = SlaSpec {
358 max_staleness_secs: None,
359 min_rows_per_run: Some(1),
360 volume_anomaly: None,
361 };
362 assert!(doctor_probes(&stateless, None, "k", 0).await.is_empty());
363 let probes = doctor_probes(&full_spec(), None, "k", 0).await;
365 assert_eq!(probes.len(), 1);
366 assert!(matches!(probes[0].status, ProbeStatus::Skip { .. }));
367 }
368}