faucet_cli/serve/history/
fallback.rs1use super::memory::MemoryHistory;
11use super::{Claim, DeleteOutcome, HistoryError, ListFilter, ListPage, RunHistory, RunRecord};
12use async_trait::async_trait;
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::time::Duration;
15
16pub struct FallbackHistory {
17 primary: Option<Box<dyn RunHistory>>,
19 fallback: MemoryHistory,
20 degraded: AtomicBool,
21 label: &'static str,
23}
24
25impl FallbackHistory {
26 pub fn healthy(
28 primary: Box<dyn RunHistory>,
29 idem_retention: Duration,
30 label: &'static str,
31 ) -> Self {
32 Self {
33 primary: Some(primary),
34 fallback: MemoryHistory::new(idem_retention),
35 degraded: AtomicBool::new(false),
36 label,
37 }
38 }
39
40 pub fn degraded_at_startup(idem_retention: Duration, label: &'static str) -> Self {
42 crate::serve::metrics::set_history_degraded(true);
43 Self {
44 primary: None,
45 fallback: MemoryHistory::new(idem_retention),
46 degraded: AtomicBool::new(true),
47 label,
48 }
49 }
50
51 fn is_degraded(&self) -> bool {
52 self.primary.is_none() || self.degraded.load(Ordering::Acquire)
53 }
54
55 fn trip(&self, err: &HistoryError) {
57 if !self.degraded.swap(true, Ordering::AcqRel) {
58 tracing::error!(
59 backend = self.label,
60 error = %err,
61 "run-history backend failed; falling back to in-memory store (DEGRADED — \
62 persisted run records are no longer served; /readyz now reports 503)"
63 );
64 crate::serve::metrics::set_history_degraded(true);
65 }
66 }
67}
68
69macro_rules! via {
73 ($self:ident, $primary:ident => $pcall:expr, $fb:ident => $fcall:expr) => {{
74 if !$self.is_degraded()
75 && let Some($primary) = $self.primary.as_ref()
76 {
77 match $pcall.await {
78 Ok(v) => return Ok(v),
79 Err(e) => $self.trip(&e),
80 }
81 }
82 let $fb = &$self.fallback;
83 $fcall.await
84 }};
85}
86
87#[async_trait]
88impl RunHistory for FallbackHistory {
89 async fn claim_idempotency(
90 &self,
91 key: &str,
92 fingerprint: &str,
93 run_id: &str,
94 window: Duration,
95 ) -> Result<Claim, HistoryError> {
96 if !self.is_degraded()
100 && let Some(p) = self.primary.as_ref()
101 {
102 match p.claim_idempotency(key, fingerprint, run_id, window).await {
103 Ok(v) => return Ok(v),
104 Err(e) => self.trip(&e),
105 }
106 }
107 if self.primary.is_some() {
116 return Err(HistoryError::Degraded(
117 "idempotency unavailable: the run-history backend is degraded; retry once it \
118 recovers, or resubmit without an idempotency key"
119 .into(),
120 ));
121 }
122 self.fallback
123 .claim_idempotency(key, fingerprint, run_id, window)
124 .await
125 }
126
127 async fn upsert(&self, rec: &RunRecord) -> Result<(), HistoryError> {
128 via!(self, p => p.upsert(rec), f => f.upsert(rec))
129 }
130
131 async fn get(&self, id: &str) -> Result<Option<RunRecord>, HistoryError> {
132 via!(self, p => p.get(id), f => f.get(id))
133 }
134
135 async fn list(&self, filter: &ListFilter) -> Result<ListPage, HistoryError> {
136 via!(self, p => p.list(filter), f => f.list(filter))
137 }
138
139 async fn delete(&self, id: &str) -> Result<DeleteOutcome, HistoryError> {
140 via!(self, p => p.delete(id), f => f.delete(id))
141 }
142
143 async fn purge_expired(&self, retain_for: Duration) -> Result<usize, HistoryError> {
144 via!(self, p => p.purge_expired(retain_for), f => f.purge_expired(retain_for))
145 }
146
147 async fn recover_orphans(&self) -> Result<usize, HistoryError> {
148 via!(self, p => p.recover_orphans(), f => f.recover_orphans())
149 }
150
151 async fn renew_leases(&self) -> Result<usize, HistoryError> {
152 via!(self, p => p.renew_leases(), f => f.renew_leases())
153 }
154
155 fn degraded(&self) -> bool {
156 self.is_degraded()
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use crate::serve::history::RunStatus;
164
165 struct AlwaysFail;
167
168 #[async_trait]
169 impl RunHistory for AlwaysFail {
170 async fn claim_idempotency(
171 &self,
172 _: &str,
173 _: &str,
174 _: &str,
175 _: Duration,
176 ) -> Result<Claim, HistoryError> {
177 Err(HistoryError::Backend("down".into()))
178 }
179 async fn upsert(&self, _: &RunRecord) -> Result<(), HistoryError> {
180 Err(HistoryError::Backend("down".into()))
181 }
182 async fn get(&self, _: &str) -> Result<Option<RunRecord>, HistoryError> {
183 Err(HistoryError::Backend("down".into()))
184 }
185 async fn list(&self, _: &ListFilter) -> Result<ListPage, HistoryError> {
186 Err(HistoryError::Backend("down".into()))
187 }
188 async fn delete(&self, _: &str) -> Result<DeleteOutcome, HistoryError> {
189 Err(HistoryError::Backend("down".into()))
190 }
191 async fn purge_expired(&self, _: Duration) -> Result<usize, HistoryError> {
192 Err(HistoryError::Backend("down".into()))
193 }
194 async fn recover_orphans(&self) -> Result<usize, HistoryError> {
195 Err(HistoryError::Backend("down".into()))
196 }
197 fn degraded(&self) -> bool {
198 false
199 }
200 }
201
202 #[tokio::test]
203 async fn trips_to_fallback_on_primary_error_and_serves_from_memory() {
204 let fb = FallbackHistory::healthy(Box::new(AlwaysFail), Duration::from_secs(60), "test");
205 assert!(!fb.degraded(), "starts healthy");
206
207 let rec = RunRecord::queued(
209 "r1".into(),
210 None,
211 Default::default(),
212 None,
213 chrono::Utc::now(),
214 );
215 fb.upsert(&rec).await.unwrap();
216 assert!(fb.degraded(), "primary error must flip degraded");
217
218 let got = fb.get("r1").await.unwrap();
220 assert_eq!(got.unwrap().run_id, "r1");
221 }
222
223 #[tokio::test]
224 async fn claim_fails_closed_once_degraded_from_a_healthy_primary() {
225 let fb = FallbackHistory::healthy(Box::new(AlwaysFail), Duration::from_secs(60), "test");
229 let err = fb
230 .claim_idempotency("k", "fp", "r1", Duration::from_secs(60))
231 .await
232 .unwrap_err();
233 assert!(matches!(err, HistoryError::Degraded(_)), "got {err:?}");
234 assert!(fb.degraded(), "the failed primary claim trips degraded");
235 assert!(matches!(
237 fb.claim_idempotency("k", "fp", "r2", Duration::from_secs(60))
238 .await,
239 Err(HistoryError::Degraded(_))
240 ));
241 }
242
243 #[tokio::test]
244 async fn claim_uses_memory_when_started_degraded() {
245 let fb = FallbackHistory::degraded_at_startup(Duration::from_secs(60), "test");
248 assert_eq!(
249 fb.claim_idempotency("k", "fp", "r1", Duration::from_secs(60))
250 .await
251 .unwrap(),
252 Claim::Fresh
253 );
254 assert_eq!(
255 fb.claim_idempotency("k", "fp", "r2", Duration::from_secs(60))
256 .await
257 .unwrap(),
258 Claim::Replay("r1".into())
259 );
260 }
261
262 #[tokio::test]
263 async fn degraded_at_startup_uses_memory_only() {
264 let fb = FallbackHistory::degraded_at_startup(Duration::from_secs(60), "test");
265 assert!(fb.degraded());
266 let mut rec = RunRecord::queued(
267 "r2".into(),
268 None,
269 Default::default(),
270 None,
271 chrono::Utc::now(),
272 );
273 rec.status = RunStatus::Completed;
274 rec.finished_at = Some(chrono::Utc::now());
275 fb.upsert(&rec).await.unwrap();
276 assert_eq!(
277 fb.get("r2").await.unwrap().unwrap().status,
278 RunStatus::Completed
279 );
280 assert_eq!(fb.delete("r2").await.unwrap(), DeleteOutcome::Deleted);
281 }
282}