Skip to main content

faucet_cli/serve/history/
fallback.rs

1//! Degradation wrapper around a persistent run-history backend (Phase 5 of
2//! #127). While the primary (Postgres/SQLite) backend is healthy, every call
3//! goes to it. The first time a call errors — or if the backend could not be
4//! reached at startup — the wrapper flips to **degraded**: it logs once, sets
5//! the `faucet_serve_history_degraded` gauge, surfaces `503` on `/readyz` via
6//! [`RunHistory::degraded`], and serves all subsequent calls from an in-memory
7//! backend so the server stays up (spec §11). Data already in the primary is not
8//! migrated — degraded mode is a stay-alive fallback, not a replica.
9
10use super::memory::MemoryHistory;
11use super::{Claim, DeleteOutcome, HistoryError, ListFilter, ListPage, RunHistory, RunRecord};
12use async_trait::async_trait;
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::time::Duration;
15
16pub struct FallbackHistory {
17    /// Persistent backend; `None` when it was unreachable at startup.
18    primary: Option<Box<dyn RunHistory>>,
19    fallback: MemoryHistory,
20    degraded: AtomicBool,
21    /// `"postgres"` / `"sqlite"` — for log + metric context.
22    label: &'static str,
23}
24
25impl FallbackHistory {
26    /// Wrap a healthy primary backend.
27    pub fn healthy(
28        primary: Box<dyn RunHistory>,
29        idem_retention: Duration,
30        label: &'static str,
31    ) -> Self {
32        Self {
33            primary: Some(primary),
34            fallback: MemoryHistory::new(idem_retention),
35            degraded: AtomicBool::new(false),
36            label,
37        }
38    }
39
40    /// Start already degraded: the primary backend was unreachable at startup.
41    pub fn degraded_at_startup(idem_retention: Duration, label: &'static str) -> Self {
42        crate::serve::metrics::set_history_degraded(true);
43        Self {
44            primary: None,
45            fallback: MemoryHistory::new(idem_retention),
46            degraded: AtomicBool::new(true),
47            label,
48        }
49    }
50
51    fn is_degraded(&self) -> bool {
52        self.primary.is_none() || self.degraded.load(Ordering::Acquire)
53    }
54
55    /// Record a primary-backend failure and flip to degraded (log + metric once).
56    fn trip(&self, err: &HistoryError) {
57        if !self.degraded.swap(true, Ordering::AcqRel) {
58            tracing::error!(
59                backend = self.label,
60                error = %err,
61                "run-history backend failed; falling back to in-memory store (DEGRADED — \
62                 persisted run records are no longer served; /readyz now reports 503)"
63            );
64            crate::serve::metrics::set_history_degraded(true);
65        }
66    }
67}
68
69/// Run `$call` against the primary backend; on the first error, trip into
70/// degraded mode and re-run it against the in-memory fallback. Once degraded,
71/// skip the primary entirely.
72macro_rules! via {
73    ($self:ident, $primary:ident => $pcall:expr, $fb:ident => $fcall:expr) => {{
74        if !$self.is_degraded()
75            && let Some($primary) = $self.primary.as_ref()
76        {
77            match $pcall.await {
78                Ok(v) => return Ok(v),
79                Err(e) => $self.trip(&e),
80            }
81        }
82        let $fb = &$self.fallback;
83        $fcall.await
84    }};
85}
86
87#[async_trait]
88impl RunHistory for FallbackHistory {
89    async fn claim_idempotency(
90        &self,
91        key: &str,
92        fingerprint: &str,
93        run_id: &str,
94        window: Duration,
95    ) -> Result<Claim, HistoryError> {
96        // Idempotency is correctness-critical, so it does NOT use the generic
97        // `via!` fall-through. While the primary is healthy it is the
98        // authoritative claim store; its first error trips degraded.
99        if !self.is_degraded()
100            && let Some(p) = self.primary.as_ref()
101        {
102            match p.claim_idempotency(key, fingerprint, run_id, window).await {
103                Ok(v) => return Ok(v),
104                Err(e) => self.trip(&e),
105            }
106        }
107        // Tripped from a healthy primary: the in-memory fallback cannot see
108        // claims persisted to the primary before the trip, so serving a `Fresh`
109        // from memory could duplicate a run the primary already claimed.
110        // Fail closed — reject idempotent submissions while degraded rather than
111        // risk a silent duplicate (#146 M5). A submission with no idempotency
112        // key never reaches here. When the wrapper *started* degraded (`primary`
113        // is `None` — no primary ever held a claim), the in-memory store is the
114        // sole authoritative store, so its claims are safe to serve.
115        if self.primary.is_some() {
116            return Err(HistoryError::Degraded(
117                "idempotency unavailable: the run-history backend is degraded; retry once it \
118                 recovers, or resubmit without an idempotency key"
119                    .into(),
120            ));
121        }
122        self.fallback
123            .claim_idempotency(key, fingerprint, run_id, window)
124            .await
125    }
126
127    async fn upsert(&self, rec: &RunRecord) -> Result<(), HistoryError> {
128        via!(self, p => p.upsert(rec), f => f.upsert(rec))
129    }
130
131    async fn get(&self, id: &str) -> Result<Option<RunRecord>, HistoryError> {
132        via!(self, p => p.get(id), f => f.get(id))
133    }
134
135    async fn list(&self, filter: &ListFilter) -> Result<ListPage, HistoryError> {
136        via!(self, p => p.list(filter), f => f.list(filter))
137    }
138
139    async fn delete(&self, id: &str) -> Result<DeleteOutcome, HistoryError> {
140        via!(self, p => p.delete(id), f => f.delete(id))
141    }
142
143    async fn purge_expired(&self, retain_for: Duration) -> Result<usize, HistoryError> {
144        via!(self, p => p.purge_expired(retain_for), f => f.purge_expired(retain_for))
145    }
146
147    async fn recover_orphans(&self) -> Result<usize, HistoryError> {
148        via!(self, p => p.recover_orphans(), f => f.recover_orphans())
149    }
150
151    async fn renew_leases(&self) -> Result<usize, HistoryError> {
152        via!(self, p => p.renew_leases(), f => f.renew_leases())
153    }
154
155    fn degraded(&self) -> bool {
156        self.is_degraded()
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::serve::history::RunStatus;
164
165    /// A primary backend whose every call fails — drives the degrade path.
166    struct AlwaysFail;
167
168    #[async_trait]
169    impl RunHistory for AlwaysFail {
170        async fn claim_idempotency(
171            &self,
172            _: &str,
173            _: &str,
174            _: &str,
175            _: Duration,
176        ) -> Result<Claim, HistoryError> {
177            Err(HistoryError::Backend("down".into()))
178        }
179        async fn upsert(&self, _: &RunRecord) -> Result<(), HistoryError> {
180            Err(HistoryError::Backend("down".into()))
181        }
182        async fn get(&self, _: &str) -> Result<Option<RunRecord>, HistoryError> {
183            Err(HistoryError::Backend("down".into()))
184        }
185        async fn list(&self, _: &ListFilter) -> Result<ListPage, HistoryError> {
186            Err(HistoryError::Backend("down".into()))
187        }
188        async fn delete(&self, _: &str) -> Result<DeleteOutcome, HistoryError> {
189            Err(HistoryError::Backend("down".into()))
190        }
191        async fn purge_expired(&self, _: Duration) -> Result<usize, HistoryError> {
192            Err(HistoryError::Backend("down".into()))
193        }
194        async fn recover_orphans(&self) -> Result<usize, HistoryError> {
195            Err(HistoryError::Backend("down".into()))
196        }
197        fn degraded(&self) -> bool {
198            false
199        }
200    }
201
202    #[tokio::test]
203    async fn trips_to_fallback_on_primary_error_and_serves_from_memory() {
204        let fb = FallbackHistory::healthy(Box::new(AlwaysFail), Duration::from_secs(60), "test");
205        assert!(!fb.degraded(), "starts healthy");
206
207        // First write fails on the primary, trips degraded, lands in memory.
208        let rec = RunRecord::queued(
209            "r1".into(),
210            None,
211            Default::default(),
212            None,
213            chrono::Utc::now(),
214        );
215        fb.upsert(&rec).await.unwrap();
216        assert!(fb.degraded(), "primary error must flip degraded");
217
218        // Subsequent reads are served from the in-memory fallback.
219        let got = fb.get("r1").await.unwrap();
220        assert_eq!(got.unwrap().run_id, "r1");
221    }
222
223    #[tokio::test]
224    async fn claim_fails_closed_once_degraded_from_a_healthy_primary() {
225        // M5 (#146): the primary may hold claims the in-memory fallback can't
226        // see, so once it trips, an idempotency claim must fail CLOSED rather
227        // than return a `Fresh` from the empty memory store and duplicate a run.
228        let fb = FallbackHistory::healthy(Box::new(AlwaysFail), Duration::from_secs(60), "test");
229        let err = fb
230            .claim_idempotency("k", "fp", "r1", Duration::from_secs(60))
231            .await
232            .unwrap_err();
233        assert!(matches!(err, HistoryError::Degraded(_)), "got {err:?}");
234        assert!(fb.degraded(), "the failed primary claim trips degraded");
235        // A second attempt (already degraded) also fails closed — never Fresh.
236        assert!(matches!(
237            fb.claim_idempotency("k", "fp", "r2", Duration::from_secs(60))
238                .await,
239            Err(HistoryError::Degraded(_))
240        ));
241    }
242
243    #[tokio::test]
244    async fn claim_uses_memory_when_started_degraded() {
245        // No primary ever existed → the in-memory store is authoritative, so
246        // idempotency works normally (no split is possible).
247        let fb = FallbackHistory::degraded_at_startup(Duration::from_secs(60), "test");
248        assert_eq!(
249            fb.claim_idempotency("k", "fp", "r1", Duration::from_secs(60))
250                .await
251                .unwrap(),
252            Claim::Fresh
253        );
254        assert_eq!(
255            fb.claim_idempotency("k", "fp", "r2", Duration::from_secs(60))
256                .await
257                .unwrap(),
258            Claim::Replay("r1".into())
259        );
260    }
261
262    #[tokio::test]
263    async fn degraded_at_startup_uses_memory_only() {
264        let fb = FallbackHistory::degraded_at_startup(Duration::from_secs(60), "test");
265        assert!(fb.degraded());
266        let mut rec = RunRecord::queued(
267            "r2".into(),
268            None,
269            Default::default(),
270            None,
271            chrono::Utc::now(),
272        );
273        rec.status = RunStatus::Completed;
274        rec.finished_at = Some(chrono::Utc::now());
275        fb.upsert(&rec).await.unwrap();
276        assert_eq!(
277            fb.get("r2").await.unwrap().unwrap().status,
278            RunStatus::Completed
279        );
280        assert_eq!(fb.delete("r2").await.unwrap(), DeleteOutcome::Deleted);
281    }
282}