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