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