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    async fn catalog_record_config_snapshot(
278        &self,
279        snapshot: &crate::serve::history::catalog::ConfigSnapshot,
280    ) -> Result<(), HistoryError> {
281        via!(self, p => p.catalog_record_config_snapshot(snapshot), f => f.catalog_record_config_snapshot(snapshot))
282    }
283    async fn catalog_last_config_snapshot(
284        &self,
285        pipeline: &str,
286    ) -> Result<Option<crate::serve::history::catalog::ConfigSnapshot>, HistoryError> {
287        via!(self, p => p.catalog_last_config_snapshot(pipeline), f => f.catalog_last_config_snapshot(pipeline))
288    }
289
290    fn degraded(&self) -> bool {
291        self.is_degraded()
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use crate::serve::history::RunStatus;
299
300    /// A primary backend whose every call fails — drives the degrade path.
301    struct AlwaysFail;
302
303    #[async_trait]
304    impl RunHistory for AlwaysFail {
305        async fn claim_idempotency(
306            &self,
307            _: &str,
308            _: &str,
309            _: &str,
310            _: Duration,
311        ) -> Result<Claim, HistoryError> {
312            Err(HistoryError::Backend("down".into()))
313        }
314        async fn upsert(&self, _: &RunRecord) -> Result<(), HistoryError> {
315            Err(HistoryError::Backend("down".into()))
316        }
317        async fn get(&self, _: &str) -> Result<Option<RunRecord>, HistoryError> {
318            Err(HistoryError::Backend("down".into()))
319        }
320        async fn list(&self, _: &ListFilter) -> Result<ListPage, HistoryError> {
321            Err(HistoryError::Backend("down".into()))
322        }
323        async fn delete(&self, _: &str) -> Result<DeleteOutcome, HistoryError> {
324            Err(HistoryError::Backend("down".into()))
325        }
326        async fn purge_expired(&self, _: Duration) -> Result<usize, HistoryError> {
327            Err(HistoryError::Backend("down".into()))
328        }
329        async fn recover_orphans(&self) -> Result<usize, HistoryError> {
330            Err(HistoryError::Backend("down".into()))
331        }
332        fn degraded(&self) -> bool {
333            false
334        }
335    }
336
337    #[tokio::test]
338    async fn trips_to_fallback_on_primary_error_and_serves_from_memory() {
339        let fb = FallbackHistory::healthy(Box::new(AlwaysFail), Duration::from_secs(60), "test");
340        assert!(!fb.degraded(), "starts healthy");
341
342        // First write fails on the primary, trips degraded, lands in memory.
343        let rec = RunRecord::queued(
344            "r1".into(),
345            None,
346            Default::default(),
347            None,
348            chrono::Utc::now(),
349        );
350        fb.upsert(&rec).await.unwrap();
351        assert!(fb.degraded(), "primary error must flip degraded");
352
353        // Subsequent reads are served from the in-memory fallback.
354        let got = fb.get("r1").await.unwrap();
355        assert_eq!(got.unwrap().run_id, "r1");
356    }
357
358    #[tokio::test]
359    async fn claim_fails_closed_once_degraded_from_a_healthy_primary() {
360        // M5 (#146): the primary may hold claims the in-memory fallback can't
361        // see, so once it trips, an idempotency claim must fail CLOSED rather
362        // than return a `Fresh` from the empty memory store and duplicate a run.
363        let fb = FallbackHistory::healthy(Box::new(AlwaysFail), Duration::from_secs(60), "test");
364        let err = fb
365            .claim_idempotency("k", "fp", "r1", Duration::from_secs(60))
366            .await
367            .unwrap_err();
368        assert!(matches!(err, HistoryError::Degraded(_)), "got {err:?}");
369        assert!(fb.degraded(), "the failed primary claim trips degraded");
370        // A second attempt (already degraded) also fails closed — never Fresh.
371        assert!(matches!(
372            fb.claim_idempotency("k", "fp", "r2", Duration::from_secs(60))
373                .await,
374            Err(HistoryError::Degraded(_))
375        ));
376    }
377
378    #[tokio::test]
379    async fn claim_uses_memory_when_started_degraded() {
380        // No primary ever existed → the in-memory store is authoritative, so
381        // idempotency works normally (no split is possible).
382        let fb = FallbackHistory::degraded_at_startup(Duration::from_secs(60), "test");
383        assert_eq!(
384            fb.claim_idempotency("k", "fp", "r1", Duration::from_secs(60))
385                .await
386                .unwrap(),
387            Claim::Fresh
388        );
389        assert_eq!(
390            fb.claim_idempotency("k", "fp", "r2", Duration::from_secs(60))
391                .await
392                .unwrap(),
393            Claim::Replay("r1".into())
394        );
395    }
396
397    #[tokio::test]
398    async fn degraded_at_startup_uses_memory_only() {
399        let fb = FallbackHistory::degraded_at_startup(Duration::from_secs(60), "test");
400        assert!(fb.degraded());
401        let mut rec = RunRecord::queued(
402            "r2".into(),
403            None,
404            Default::default(),
405            None,
406            chrono::Utc::now(),
407        );
408        rec.status = RunStatus::Completed;
409        rec.finished_at = Some(chrono::Utc::now());
410        fb.upsert(&rec).await.unwrap();
411        assert_eq!(
412            fb.get("r2").await.unwrap().unwrap().status,
413            RunStatus::Completed
414        );
415        assert_eq!(fb.delete("r2").await.unwrap(), DeleteOutcome::Deleted);
416    }
417
418    #[tokio::test]
419    async fn shard_methods_delegate_to_a_healthy_primary() {
420        use crate::serve::history::memory::MemoryHistory;
421        use crate::serve::history::{ReclaimReport, ShardProgress};
422        // A healthy (memory) primary → the shard methods delegate via `via!`;
423        // memory's shard methods are inert, so we get the inert results back
424        // (exercising the forwarding path).
425        let fb = FallbackHistory::healthy(
426            Box::new(MemoryHistory::new(Duration::from_secs(60))),
427            Duration::from_secs(60),
428            "test",
429        );
430        assert_eq!(fb.insert_shards("r", &[]).await.unwrap(), 0);
431        assert!(fb.claim_shards(4).await.unwrap().is_empty());
432        assert_eq!(fb.renew_shard_leases().await.unwrap(), 0);
433        assert_eq!(
434            fb.reclaim_shards(3).await.unwrap(),
435            ReclaimReport::default()
436        );
437        assert!(!fb.finalize_shard("r", "0", true).await.unwrap());
438        assert_eq!(
439            fb.shard_progress("r").await.unwrap(),
440            ShardProgress::default()
441        );
442        assert!(!fb.degraded());
443    }
444}