Skip to main content

faucet_cli/sla/
mod.rs

1//! Data-freshness & volume SLA monitoring (#202).
2//!
3//! The top-level `sla:` block declares freshness/volume expectations for a
4//! pipeline; the executor evaluates them after every **root** invocation
5//! (`faucet run`, `schedule`, `serve`, and `replicate` all flow through
6//! [`crate::executor::run_expanded`], so every runtime gets the same
7//! evaluation). Violations emit
8//! `faucet_pipeline_sla_violations_total{pipeline,row,kind}` and a structured
9//! warning — they never fail or abort the run. `faucet doctor` additionally
10//! reports staleness / baseline health read-only.
11//!
12//! Module layout (mirrors `schedule/` / `replication/`):
13//! - [`spec`] — serde config types + validation (`faucet schema sla`).
14//! - [`state`] — the persisted history (`{state_key}::__sla__`).
15//! - [`eval`] — pure staleness / floor / anomaly math.
16//! - [`metrics`] — the Prometheus surface.
17
18pub 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/// How the run being evaluated ended.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum RunOutcome {
35    /// The pipeline (and final flush) succeeded, writing `rows` records.
36    Success { rows: u64 },
37    /// The pipeline failed; staleness is measured against the prior success.
38    Failure,
39}
40
41/// Post-run SLA evaluation for one root invocation: load prior history,
42/// evaluate, persist the updated baseline on success, and emit
43/// metrics/warnings for every violation. Returns the violations found.
44///
45/// This is monitoring — it must never take down the run it observes. State
46/// I/O errors are logged and swallowed; a failed *read* also skips the
47/// baseline update so a transient state-store outage cannot clobber the
48/// accumulated history with a fresh one.
49pub 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        // No state store: only the stateless floor check can run (the expand
61        // gate guarantees staleness/volume checks come with a `state:` block;
62        // this is the defensive path).
63        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
108/// Read-only SLA probes for `faucet doctor` (and serve's `doctor_first`):
109/// staleness of the last recorded success and volume-baseline warm-up state.
110/// `min_rows_per_run` has nothing to probe without a run, so it is not
111/// represented here.
112pub 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            // Only reachable when every configured check is stateless.
122            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        // Three successful runs at t=0, 10, 20 warm the baseline.
205        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        // A failure 2h later is stale.
224        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        // A failure within the window is not.
238        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        // rows=2: below the floor of 5 AND anomalous vs the constant baseline.
268        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        // The anomalous volume still folds into the (adaptive) baseline.
281        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        // Failure with no store and no staleness config → nothing.
305        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        // Cold start: staleness + baseline both skip.
324        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        // Warm history: both pass while fresh.
330        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        // Long after the last success the staleness probe fails.
347        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        // Stateless spec → no probes at all.
357        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        // Stateful spec, missing store (defensive) → a single skip.
364        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}