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    AuditEntry, AuditFilter, Claim, DeleteOutcome, HistoryError, InstanceHeartbeat, InstanceRecord,
13    ListFilter, ListPage, ReclaimReport, RunHistory, RunRecord, RunStatus,
14};
15use async_trait::async_trait;
16use chrono::{DateTime, Utc};
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::time::Duration;
19
20pub struct FallbackHistory {
21    /// Persistent backend; `None` when it was unreachable at startup.
22    primary: Option<Box<dyn RunHistory>>,
23    fallback: MemoryHistory,
24    degraded: AtomicBool,
25    /// `"postgres"` / `"sqlite"` — for log + metric context.
26    label: &'static str,
27}
28
29impl FallbackHistory {
30    /// Wrap a healthy primary backend.
31    pub fn healthy(
32        primary: Box<dyn RunHistory>,
33        idem_retention: Duration,
34        label: &'static str,
35    ) -> Self {
36        Self {
37            primary: Some(primary),
38            fallback: MemoryHistory::new(idem_retention),
39            degraded: AtomicBool::new(false),
40            label,
41        }
42    }
43
44    /// Start already degraded: the primary backend was unreachable at startup.
45    pub fn degraded_at_startup(idem_retention: Duration, label: &'static str) -> Self {
46        crate::serve::metrics::set_history_degraded(true);
47        Self {
48            primary: None,
49            fallback: MemoryHistory::new(idem_retention),
50            degraded: AtomicBool::new(true),
51            label,
52        }
53    }
54
55    fn is_degraded(&self) -> bool {
56        self.primary.is_none() || self.degraded.load(Ordering::Acquire)
57    }
58
59    /// Record a primary-backend failure and flip to degraded (log + metric once).
60    fn trip(&self, err: &HistoryError) {
61        if !self.degraded.swap(true, Ordering::AcqRel) {
62            tracing::error!(
63                backend = self.label,
64                error = %err,
65                "run-history backend failed; falling back to in-memory store (DEGRADED — \
66                 persisted run records are no longer served; /readyz now reports 503)"
67            );
68            crate::serve::metrics::set_history_degraded(true);
69        }
70    }
71}
72
73/// Run `$call` against the primary backend; on the first error, trip into
74/// degraded mode and re-run it against the in-memory fallback. Once degraded,
75/// skip the primary entirely.
76macro_rules! via {
77    ($self:ident, $primary:ident => $pcall:expr, $fb:ident => $fcall:expr) => {{
78        if !$self.is_degraded()
79            && let Some($primary) = $self.primary.as_ref()
80        {
81            match $pcall.await {
82                Ok(v) => return Ok(v),
83                Err(e) => $self.trip(&e),
84            }
85        }
86        let $fb = &$self.fallback;
87        $fcall.await
88    }};
89}
90
91#[async_trait]
92impl RunHistory for FallbackHistory {
93    async fn claim_idempotency(
94        &self,
95        key: &str,
96        fingerprint: &str,
97        run_id: &str,
98        window: Duration,
99    ) -> Result<Claim, HistoryError> {
100        // Idempotency is correctness-critical, so it does NOT use the generic
101        // `via!` fall-through. While the primary is healthy it is the
102        // authoritative claim store; its first error trips degraded.
103        if !self.is_degraded()
104            && let Some(p) = self.primary.as_ref()
105        {
106            match p.claim_idempotency(key, fingerprint, run_id, window).await {
107                Ok(v) => return Ok(v),
108                Err(e) => self.trip(&e),
109            }
110        }
111        // Tripped from a healthy primary: the in-memory fallback cannot see
112        // claims persisted to the primary before the trip, so serving a `Fresh`
113        // from memory could duplicate a run the primary already claimed.
114        // Fail closed — reject idempotent submissions while degraded rather than
115        // risk a silent duplicate (#146 M5). A submission with no idempotency
116        // key never reaches here. When the wrapper *started* degraded (`primary`
117        // is `None` — no primary ever held a claim), the in-memory store is the
118        // sole authoritative store, so its claims are safe to serve.
119        if self.primary.is_some() {
120            return Err(HistoryError::Degraded(
121                "idempotency unavailable: the run-history backend is degraded; retry once it \
122                 recovers, or resubmit without an idempotency key"
123                    .into(),
124            ));
125        }
126        self.fallback
127            .claim_idempotency(key, fingerprint, run_id, window)
128            .await
129    }
130
131    async fn upsert(&self, rec: &RunRecord) -> Result<(), HistoryError> {
132        via!(self, p => p.upsert(rec), f => f.upsert(rec))
133    }
134
135    async fn get(&self, id: &str) -> Result<Option<RunRecord>, HistoryError> {
136        via!(self, p => p.get(id), f => f.get(id))
137    }
138
139    async fn list(&self, filter: &ListFilter) -> Result<ListPage, HistoryError> {
140        via!(self, p => p.list(filter), f => f.list(filter))
141    }
142
143    async fn delete(&self, id: &str) -> Result<DeleteOutcome, HistoryError> {
144        via!(self, p => p.delete(id), f => f.delete(id))
145    }
146
147    async fn purge_expired(&self, retain_for: Duration) -> Result<usize, HistoryError> {
148        via!(self, p => p.purge_expired(retain_for), f => f.purge_expired(retain_for))
149    }
150
151    async fn release_idempotency(&self, run_id: &str) -> Result<(), HistoryError> {
152        via!(self, p => p.release_idempotency(run_id), f => f.release_idempotency(run_id))
153    }
154
155    async fn recover_orphans(&self) -> Result<usize, HistoryError> {
156        via!(self, p => p.recover_orphans(), f => f.recover_orphans())
157    }
158
159    async fn renew_leases(&self) -> Result<usize, HistoryError> {
160        via!(self, p => p.renew_leases(), f => f.renew_leases())
161    }
162
163    async fn claim_pending(&self, limit: usize) -> Result<Vec<RunRecord>, HistoryError> {
164        via!(self, p => p.claim_pending(limit), f => f.claim_pending(limit))
165    }
166    async fn reclaim_orphans(&self, max_attempts: u32) -> Result<ReclaimReport, HistoryError> {
167        via!(self, p => p.reclaim_orphans(max_attempts), f => f.reclaim_orphans(max_attempts))
168    }
169    async fn finalize_owned(&self, rec: &RunRecord) -> Result<bool, HistoryError> {
170        via!(self, p => p.finalize_owned(rec), f => f.finalize_owned(rec))
171    }
172    async fn finalize_sharded_parent(
173        &self,
174        run_id: &str,
175        status: RunStatus,
176        finished_at: DateTime<Utc>,
177        error: Option<String>,
178    ) -> Result<bool, HistoryError> {
179        via!(
180            self,
181            p => p.finalize_sharded_parent(run_id, status, finished_at, error.clone()),
182            f => f.finalize_sharded_parent(run_id, status, finished_at, error.clone())
183        )
184    }
185    async fn cancel_pending(&self, run_id: &str) -> Result<bool, HistoryError> {
186        via!(self, p => p.cancel_pending(run_id), f => f.cancel_pending(run_id))
187    }
188    async fn request_cancel(&self, run_id: &str) -> Result<(), HistoryError> {
189        via!(self, p => p.request_cancel(run_id), f => f.request_cancel(run_id))
190    }
191    async fn pending_cancellations(&self) -> Result<Vec<String>, HistoryError> {
192        via!(self, p => p.pending_cancellations(), f => f.pending_cancellations())
193    }
194    async fn heartbeat_instance(&self, beat: &InstanceHeartbeat) -> Result<(), HistoryError> {
195        via!(self, p => p.heartbeat_instance(beat), f => f.heartbeat_instance(beat))
196    }
197    async fn live_instances(&self, ttl: Duration) -> Result<Vec<InstanceRecord>, HistoryError> {
198        via!(self, p => p.live_instances(ttl), f => f.live_instances(ttl))
199    }
200
201    // ── Source shards (Mode B, #230) ─────────────────────────────────────────
202
203    async fn insert_shards(
204        &self,
205        run_id: &str,
206        shards: &[crate::serve::history::ShardInsert],
207    ) -> Result<usize, HistoryError> {
208        via!(self, p => p.insert_shards(run_id, shards), f => f.insert_shards(run_id, shards))
209    }
210    async fn claim_shards(
211        &self,
212        limit: usize,
213    ) -> Result<Vec<crate::serve::history::ClaimedShard>, HistoryError> {
214        via!(self, p => p.claim_shards(limit), f => f.claim_shards(limit))
215    }
216    async fn renew_shard_leases(&self) -> Result<usize, HistoryError> {
217        via!(self, p => p.renew_shard_leases(), f => f.renew_shard_leases())
218    }
219    async fn reclaim_shards(&self, max_attempts: u32) -> Result<ReclaimReport, HistoryError> {
220        via!(self, p => p.reclaim_shards(max_attempts), f => f.reclaim_shards(max_attempts))
221    }
222    async fn finalize_shard(
223        &self,
224        run_id: &str,
225        shard_id: &str,
226        success: bool,
227    ) -> Result<bool, HistoryError> {
228        via!(self, p => p.finalize_shard(run_id, shard_id, success), f => f.finalize_shard(run_id, shard_id, success))
229    }
230    async fn shard_progress(
231        &self,
232        run_id: &str,
233    ) -> Result<crate::serve::history::ShardProgress, HistoryError> {
234        via!(self, p => p.shard_progress(run_id), f => f.shard_progress(run_id))
235    }
236    async fn pending_shard_cancellations(&self) -> Result<Vec<String>, HistoryError> {
237        via!(self, p => p.pending_shard_cancellations(), f => f.pending_shard_cancellations())
238    }
239    async fn finalize_completed_sharded_parents(&self) -> Result<usize, HistoryError> {
240        via!(self, p => p.finalize_completed_sharded_parents(), f => f.finalize_completed_sharded_parents())
241    }
242
243    async fn record_audit(&self, entry: &AuditEntry) -> Result<(), HistoryError> {
244        via!(self, p => p.record_audit(entry), f => f.record_audit(entry))
245    }
246    async fn list_audit(&self, filter: &AuditFilter) -> Result<Vec<AuditEntry>, HistoryError> {
247        via!(self, p => p.list_audit(filter), f => f.list_audit(filter))
248    }
249
250    // ── Data Movement Catalog (#279) ─────────────────────────────────────────
251
252    async fn catalog_record(
253        &self,
254        update: &crate::serve::history::catalog::CatalogUpdate,
255    ) -> Result<(), HistoryError> {
256        via!(self, p => p.catalog_record(update), f => f.catalog_record(update))
257    }
258    async fn catalog_list_datasets(
259        &self,
260        filter: &crate::serve::history::catalog::CatalogListFilter,
261    ) -> Result<crate::serve::history::catalog::CatalogDatasetPage, HistoryError> {
262        via!(self, p => p.catalog_list_datasets(filter), f => f.catalog_list_datasets(filter))
263    }
264    async fn catalog_get_dataset(
265        &self,
266        id: &str,
267    ) -> Result<Option<crate::serve::history::catalog::CatalogDatasetDetail>, HistoryError> {
268        via!(self, p => p.catalog_get_dataset(id), f => f.catalog_get_dataset(id))
269    }
270    async fn catalog_lineage(
271        &self,
272        root: Option<&str>,
273        depth: u32,
274    ) -> Result<Vec<crate::serve::history::catalog::CatalogLineageEdge>, HistoryError> {
275        via!(self, p => p.catalog_lineage(root, depth), f => f.catalog_lineage(root, depth))
276    }
277
278    fn degraded(&self) -> bool {
279        self.is_degraded()
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use crate::serve::history::RunStatus;
287
288    /// A primary backend whose every call fails — drives the degrade path.
289    struct AlwaysFail;
290
291    #[async_trait]
292    impl RunHistory for AlwaysFail {
293        async fn claim_idempotency(
294            &self,
295            _: &str,
296            _: &str,
297            _: &str,
298            _: Duration,
299        ) -> Result<Claim, HistoryError> {
300            Err(HistoryError::Backend("down".into()))
301        }
302        async fn upsert(&self, _: &RunRecord) -> Result<(), HistoryError> {
303            Err(HistoryError::Backend("down".into()))
304        }
305        async fn get(&self, _: &str) -> Result<Option<RunRecord>, HistoryError> {
306            Err(HistoryError::Backend("down".into()))
307        }
308        async fn list(&self, _: &ListFilter) -> Result<ListPage, HistoryError> {
309            Err(HistoryError::Backend("down".into()))
310        }
311        async fn delete(&self, _: &str) -> Result<DeleteOutcome, HistoryError> {
312            Err(HistoryError::Backend("down".into()))
313        }
314        async fn purge_expired(&self, _: Duration) -> Result<usize, HistoryError> {
315            Err(HistoryError::Backend("down".into()))
316        }
317        async fn recover_orphans(&self) -> Result<usize, HistoryError> {
318            Err(HistoryError::Backend("down".into()))
319        }
320        fn degraded(&self) -> bool {
321            false
322        }
323    }
324
325    #[tokio::test]
326    async fn trips_to_fallback_on_primary_error_and_serves_from_memory() {
327        let fb = FallbackHistory::healthy(Box::new(AlwaysFail), Duration::from_secs(60), "test");
328        assert!(!fb.degraded(), "starts healthy");
329
330        // First write fails on the primary, trips degraded, lands in memory.
331        let rec = RunRecord::queued(
332            "r1".into(),
333            None,
334            Default::default(),
335            None,
336            chrono::Utc::now(),
337        );
338        fb.upsert(&rec).await.unwrap();
339        assert!(fb.degraded(), "primary error must flip degraded");
340
341        // Subsequent reads are served from the in-memory fallback.
342        let got = fb.get("r1").await.unwrap();
343        assert_eq!(got.unwrap().run_id, "r1");
344    }
345
346    #[tokio::test]
347    async fn claim_fails_closed_once_degraded_from_a_healthy_primary() {
348        // M5 (#146): the primary may hold claims the in-memory fallback can't
349        // see, so once it trips, an idempotency claim must fail CLOSED rather
350        // than return a `Fresh` from the empty memory store and duplicate a run.
351        let fb = FallbackHistory::healthy(Box::new(AlwaysFail), Duration::from_secs(60), "test");
352        let err = fb
353            .claim_idempotency("k", "fp", "r1", Duration::from_secs(60))
354            .await
355            .unwrap_err();
356        assert!(matches!(err, HistoryError::Degraded(_)), "got {err:?}");
357        assert!(fb.degraded(), "the failed primary claim trips degraded");
358        // A second attempt (already degraded) also fails closed — never Fresh.
359        assert!(matches!(
360            fb.claim_idempotency("k", "fp", "r2", Duration::from_secs(60))
361                .await,
362            Err(HistoryError::Degraded(_))
363        ));
364    }
365
366    #[tokio::test]
367    async fn claim_uses_memory_when_started_degraded() {
368        // No primary ever existed → the in-memory store is authoritative, so
369        // idempotency works normally (no split is possible).
370        let fb = FallbackHistory::degraded_at_startup(Duration::from_secs(60), "test");
371        assert_eq!(
372            fb.claim_idempotency("k", "fp", "r1", Duration::from_secs(60))
373                .await
374                .unwrap(),
375            Claim::Fresh
376        );
377        assert_eq!(
378            fb.claim_idempotency("k", "fp", "r2", Duration::from_secs(60))
379                .await
380                .unwrap(),
381            Claim::Replay("r1".into())
382        );
383    }
384
385    #[tokio::test]
386    async fn degraded_at_startup_uses_memory_only() {
387        let fb = FallbackHistory::degraded_at_startup(Duration::from_secs(60), "test");
388        assert!(fb.degraded());
389        let mut rec = RunRecord::queued(
390            "r2".into(),
391            None,
392            Default::default(),
393            None,
394            chrono::Utc::now(),
395        );
396        rec.status = RunStatus::Completed;
397        rec.finished_at = Some(chrono::Utc::now());
398        fb.upsert(&rec).await.unwrap();
399        assert_eq!(
400            fb.get("r2").await.unwrap().unwrap().status,
401            RunStatus::Completed
402        );
403        assert_eq!(fb.delete("r2").await.unwrap(), DeleteOutcome::Deleted);
404    }
405
406    #[tokio::test]
407    async fn shard_methods_delegate_to_a_healthy_primary() {
408        use crate::serve::history::memory::MemoryHistory;
409        use crate::serve::history::{ReclaimReport, ShardProgress};
410        // A healthy (memory) primary → the shard methods delegate via `via!`;
411        // memory's shard methods are inert, so we get the inert results back
412        // (exercising the forwarding path).
413        let fb = FallbackHistory::healthy(
414            Box::new(MemoryHistory::new(Duration::from_secs(60))),
415            Duration::from_secs(60),
416            "test",
417        );
418        assert_eq!(fb.insert_shards("r", &[]).await.unwrap(), 0);
419        assert!(fb.claim_shards(4).await.unwrap().is_empty());
420        assert_eq!(fb.renew_shard_leases().await.unwrap(), 0);
421        assert_eq!(
422            fb.reclaim_shards(3).await.unwrap(),
423            ReclaimReport::default()
424        );
425        assert!(!fb.finalize_shard("r", "0", true).await.unwrap());
426        assert_eq!(
427            fb.shard_progress("r").await.unwrap(),
428            ShardProgress::default()
429        );
430        assert!(!fb.degraded());
431    }
432}