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    // ── Pipeline-template registry (#444) ────────────────────────────────────
291    //
292    // Forwarded like every other method: while the SQL backend is reachable
293    // templates persist; once degraded they land in the in-memory fallback, so
294    // the control plane keeps serving (a registration made while degraded is
295    // process-lifetime only — the same trade-off as a degraded run record).
296    async fn template_register(
297        &self,
298        draft: &crate::serve::history::templates::TemplateDraft,
299    ) -> Result<crate::serve::history::templates::TemplateRecord, HistoryError> {
300        via!(self, p => p.template_register(draft), f => f.template_register(draft))
301    }
302    async fn template_get(
303        &self,
304        id: &str,
305        version: Option<u32>,
306    ) -> Result<Option<crate::serve::history::templates::TemplateRecord>, HistoryError> {
307        via!(self, p => p.template_get(id, version), f => f.template_get(id, version))
308    }
309    async fn template_list(
310        &self,
311    ) -> Result<Vec<crate::serve::history::templates::TemplateSummary>, HistoryError> {
312        via!(self, p => p.template_list(), f => f.template_list())
313    }
314    async fn template_versions(&self, id: &str) -> Result<Vec<u32>, HistoryError> {
315        via!(self, p => p.template_versions(id), f => f.template_versions(id))
316    }
317    async fn template_delete(&self, id: &str, version: Option<u32>) -> Result<usize, HistoryError> {
318        via!(self, p => p.template_delete(id, version), f => f.template_delete(id, version))
319    }
320    async fn template_set_tag(
321        &self,
322        id: &str,
323        tag: &str,
324        version: u32,
325    ) -> Result<(), HistoryError> {
326        via!(self, p => p.template_set_tag(id, tag, version), f => f.template_set_tag(id, tag, version))
327    }
328    async fn template_tags(
329        &self,
330        id: &str,
331    ) -> Result<std::collections::BTreeMap<String, u32>, HistoryError> {
332        via!(self, p => p.template_tags(id), f => f.template_tags(id))
333    }
334    async fn template_delete_tag(&self, id: &str, tag: &str) -> Result<bool, HistoryError> {
335        via!(self, p => p.template_delete_tag(id, tag), f => f.template_delete_tag(id, tag))
336    }
337    async fn template_launch(
338        &self,
339        id: &str,
340        version: u32,
341        launched_by: Option<&str>,
342    ) -> Result<Option<u32>, HistoryError> {
343        via!(
344            self,
345            p => p.template_launch(id, version, launched_by),
346            f => f.template_launch(id, version, launched_by)
347        )
348    }
349    async fn template_launches(
350        &self,
351        id: &str,
352    ) -> Result<Vec<crate::serve::history::templates::LaunchRecord>, HistoryError> {
353        via!(self, p => p.template_launches(id), f => f.template_launches(id))
354    }
355    async fn template_set_deprecation(
356        &self,
357        id: &str,
358        record: Option<&crate::serve::history::templates::DeprecationRecord>,
359    ) -> Result<(), HistoryError> {
360        via!(
361            self,
362            p => p.template_set_deprecation(id, record),
363            f => f.template_set_deprecation(id, record)
364        )
365    }
366    async fn template_deprecation(
367        &self,
368        id: &str,
369    ) -> Result<Option<crate::serve::history::templates::DeprecationRecord>, HistoryError> {
370        via!(self, p => p.template_deprecation(id), f => f.template_deprecation(id))
371    }
372
373    fn degraded(&self) -> bool {
374        self.is_degraded()
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use crate::serve::history::RunStatus;
382
383    /// A primary backend whose every call fails — drives the degrade path.
384    struct AlwaysFail;
385
386    #[async_trait]
387    impl RunHistory for AlwaysFail {
388        async fn claim_idempotency(
389            &self,
390            _: &str,
391            _: &str,
392            _: &str,
393            _: Duration,
394        ) -> Result<Claim, HistoryError> {
395            Err(HistoryError::Backend("down".into()))
396        }
397        async fn upsert(&self, _: &RunRecord) -> Result<(), HistoryError> {
398            Err(HistoryError::Backend("down".into()))
399        }
400        async fn get(&self, _: &str) -> Result<Option<RunRecord>, HistoryError> {
401            Err(HistoryError::Backend("down".into()))
402        }
403        async fn list(&self, _: &ListFilter) -> Result<ListPage, HistoryError> {
404            Err(HistoryError::Backend("down".into()))
405        }
406        async fn delete(&self, _: &str) -> Result<DeleteOutcome, HistoryError> {
407            Err(HistoryError::Backend("down".into()))
408        }
409        async fn purge_expired(&self, _: Duration) -> Result<usize, HistoryError> {
410            Err(HistoryError::Backend("down".into()))
411        }
412        async fn recover_orphans(&self) -> Result<usize, HistoryError> {
413            Err(HistoryError::Backend("down".into()))
414        }
415        fn degraded(&self) -> bool {
416            false
417        }
418    }
419
420    #[tokio::test]
421    async fn trips_to_fallback_on_primary_error_and_serves_from_memory() {
422        let fb = FallbackHistory::healthy(Box::new(AlwaysFail), Duration::from_secs(60), "test");
423        assert!(!fb.degraded(), "starts healthy");
424
425        // First write fails on the primary, trips degraded, lands in memory.
426        let rec = RunRecord::queued(
427            "r1".into(),
428            None,
429            Default::default(),
430            None,
431            chrono::Utc::now(),
432        );
433        fb.upsert(&rec).await.unwrap();
434        assert!(fb.degraded(), "primary error must flip degraded");
435
436        // Subsequent reads are served from the in-memory fallback.
437        let got = fb.get("r1").await.unwrap();
438        assert_eq!(got.unwrap().run_id, "r1");
439    }
440
441    #[tokio::test]
442    async fn claim_fails_closed_once_degraded_from_a_healthy_primary() {
443        // M5 (#146): the primary may hold claims the in-memory fallback can't
444        // see, so once it trips, an idempotency claim must fail CLOSED rather
445        // than return a `Fresh` from the empty memory store and duplicate a run.
446        let fb = FallbackHistory::healthy(Box::new(AlwaysFail), Duration::from_secs(60), "test");
447        let err = fb
448            .claim_idempotency("k", "fp", "r1", Duration::from_secs(60))
449            .await
450            .unwrap_err();
451        assert!(matches!(err, HistoryError::Degraded(_)), "got {err:?}");
452        assert!(fb.degraded(), "the failed primary claim trips degraded");
453        // A second attempt (already degraded) also fails closed — never Fresh.
454        assert!(matches!(
455            fb.claim_idempotency("k", "fp", "r2", Duration::from_secs(60))
456                .await,
457            Err(HistoryError::Degraded(_))
458        ));
459    }
460
461    #[tokio::test]
462    async fn claim_uses_memory_when_started_degraded() {
463        // No primary ever existed → the in-memory store is authoritative, so
464        // idempotency works normally (no split is possible).
465        let fb = FallbackHistory::degraded_at_startup(Duration::from_secs(60), "test");
466        assert_eq!(
467            fb.claim_idempotency("k", "fp", "r1", Duration::from_secs(60))
468                .await
469                .unwrap(),
470            Claim::Fresh
471        );
472        assert_eq!(
473            fb.claim_idempotency("k", "fp", "r2", Duration::from_secs(60))
474                .await
475                .unwrap(),
476            Claim::Replay("r1".into())
477        );
478    }
479
480    #[tokio::test]
481    async fn degraded_at_startup_uses_memory_only() {
482        let fb = FallbackHistory::degraded_at_startup(Duration::from_secs(60), "test");
483        assert!(fb.degraded());
484        let mut rec = RunRecord::queued(
485            "r2".into(),
486            None,
487            Default::default(),
488            None,
489            chrono::Utc::now(),
490        );
491        rec.status = RunStatus::Completed;
492        rec.finished_at = Some(chrono::Utc::now());
493        fb.upsert(&rec).await.unwrap();
494        assert_eq!(
495            fb.get("r2").await.unwrap().unwrap().status,
496            RunStatus::Completed
497        );
498        assert_eq!(fb.delete("r2").await.unwrap(), DeleteOutcome::Deleted);
499    }
500
501    #[tokio::test]
502    async fn shard_methods_delegate_to_a_healthy_primary() {
503        use crate::serve::history::memory::MemoryHistory;
504        use crate::serve::history::{ReclaimReport, ShardProgress};
505        // A healthy (memory) primary → the shard methods delegate via `via!`;
506        // memory's shard methods are inert, so we get the inert results back
507        // (exercising the forwarding path).
508        let fb = FallbackHistory::healthy(
509            Box::new(MemoryHistory::new(Duration::from_secs(60))),
510            Duration::from_secs(60),
511            "test",
512        );
513        assert_eq!(fb.insert_shards("r", &[]).await.unwrap(), 0);
514        assert!(fb.claim_shards(4).await.unwrap().is_empty());
515        assert_eq!(fb.renew_shard_leases().await.unwrap(), 0);
516        assert_eq!(
517            fb.reclaim_shards(3).await.unwrap(),
518            ReclaimReport::default()
519        );
520        assert!(!fb.finalize_shard("r", "0", true).await.unwrap());
521        assert_eq!(
522            fb.shard_progress("r").await.unwrap(),
523            ShardProgress::default()
524        );
525        assert!(!fb.degraded());
526    }
527}