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