1use super::memory::MemoryHistory;
11use super::{
12 Claim, DeleteOutcome, HistoryError, InstanceHeartbeat, InstanceRecord, ListFilter, ListPage,
13 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 primary: Option<Box<dyn RunHistory>>,
23 fallback: MemoryHistory,
24 degraded: AtomicBool,
25 label: &'static str,
27}
28
29impl FallbackHistory {
30 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 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 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
73macro_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 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 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 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 fn degraded(&self) -> bool {
244 self.is_degraded()
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use crate::serve::history::RunStatus;
252
253 struct AlwaysFail;
255
256 #[async_trait]
257 impl RunHistory for AlwaysFail {
258 async fn claim_idempotency(
259 &self,
260 _: &str,
261 _: &str,
262 _: &str,
263 _: Duration,
264 ) -> Result<Claim, HistoryError> {
265 Err(HistoryError::Backend("down".into()))
266 }
267 async fn upsert(&self, _: &RunRecord) -> Result<(), HistoryError> {
268 Err(HistoryError::Backend("down".into()))
269 }
270 async fn get(&self, _: &str) -> Result<Option<RunRecord>, HistoryError> {
271 Err(HistoryError::Backend("down".into()))
272 }
273 async fn list(&self, _: &ListFilter) -> Result<ListPage, HistoryError> {
274 Err(HistoryError::Backend("down".into()))
275 }
276 async fn delete(&self, _: &str) -> Result<DeleteOutcome, HistoryError> {
277 Err(HistoryError::Backend("down".into()))
278 }
279 async fn purge_expired(&self, _: Duration) -> Result<usize, HistoryError> {
280 Err(HistoryError::Backend("down".into()))
281 }
282 async fn recover_orphans(&self) -> Result<usize, HistoryError> {
283 Err(HistoryError::Backend("down".into()))
284 }
285 fn degraded(&self) -> bool {
286 false
287 }
288 }
289
290 #[tokio::test]
291 async fn trips_to_fallback_on_primary_error_and_serves_from_memory() {
292 let fb = FallbackHistory::healthy(Box::new(AlwaysFail), Duration::from_secs(60), "test");
293 assert!(!fb.degraded(), "starts healthy");
294
295 let rec = RunRecord::queued(
297 "r1".into(),
298 None,
299 Default::default(),
300 None,
301 chrono::Utc::now(),
302 );
303 fb.upsert(&rec).await.unwrap();
304 assert!(fb.degraded(), "primary error must flip degraded");
305
306 let got = fb.get("r1").await.unwrap();
308 assert_eq!(got.unwrap().run_id, "r1");
309 }
310
311 #[tokio::test]
312 async fn claim_fails_closed_once_degraded_from_a_healthy_primary() {
313 let fb = FallbackHistory::healthy(Box::new(AlwaysFail), Duration::from_secs(60), "test");
317 let err = fb
318 .claim_idempotency("k", "fp", "r1", Duration::from_secs(60))
319 .await
320 .unwrap_err();
321 assert!(matches!(err, HistoryError::Degraded(_)), "got {err:?}");
322 assert!(fb.degraded(), "the failed primary claim trips degraded");
323 assert!(matches!(
325 fb.claim_idempotency("k", "fp", "r2", Duration::from_secs(60))
326 .await,
327 Err(HistoryError::Degraded(_))
328 ));
329 }
330
331 #[tokio::test]
332 async fn claim_uses_memory_when_started_degraded() {
333 let fb = FallbackHistory::degraded_at_startup(Duration::from_secs(60), "test");
336 assert_eq!(
337 fb.claim_idempotency("k", "fp", "r1", Duration::from_secs(60))
338 .await
339 .unwrap(),
340 Claim::Fresh
341 );
342 assert_eq!(
343 fb.claim_idempotency("k", "fp", "r2", Duration::from_secs(60))
344 .await
345 .unwrap(),
346 Claim::Replay("r1".into())
347 );
348 }
349
350 #[tokio::test]
351 async fn degraded_at_startup_uses_memory_only() {
352 let fb = FallbackHistory::degraded_at_startup(Duration::from_secs(60), "test");
353 assert!(fb.degraded());
354 let mut rec = RunRecord::queued(
355 "r2".into(),
356 None,
357 Default::default(),
358 None,
359 chrono::Utc::now(),
360 );
361 rec.status = RunStatus::Completed;
362 rec.finished_at = Some(chrono::Utc::now());
363 fb.upsert(&rec).await.unwrap();
364 assert_eq!(
365 fb.get("r2").await.unwrap().unwrap().status,
366 RunStatus::Completed
367 );
368 assert_eq!(fb.delete("r2").await.unwrap(), DeleteOutcome::Deleted);
369 }
370
371 #[tokio::test]
372 async fn shard_methods_delegate_to_a_healthy_primary() {
373 use crate::serve::history::memory::MemoryHistory;
374 use crate::serve::history::{ReclaimReport, ShardProgress};
375 let fb = FallbackHistory::healthy(
379 Box::new(MemoryHistory::new(Duration::from_secs(60))),
380 Duration::from_secs(60),
381 "test",
382 );
383 assert_eq!(fb.insert_shards("r", &[]).await.unwrap(), 0);
384 assert!(fb.claim_shards(4).await.unwrap().is_empty());
385 assert_eq!(fb.renew_shard_leases().await.unwrap(), 0);
386 assert_eq!(
387 fb.reclaim_shards(3).await.unwrap(),
388 ReclaimReport::default()
389 );
390 assert!(!fb.finalize_shard("r", "0", true).await.unwrap());
391 assert_eq!(
392 fb.shard_progress("r").await.unwrap(),
393 ShardProgress::default()
394 );
395 assert!(!fb.degraded());
396 }
397}