Skip to main content

alembic_engine/
apply_retry.rs

1use crate::journal::Journal;
2use crate::{AdapterApplyError, AppliedOp, Op};
3use alembic_core::Uid;
4use anyhow::{anyhow, Result};
5use async_trait::async_trait;
6use serde_json::Value;
7use std::collections::{BTreeMap, BTreeSet};
8
9#[derive(Debug)]
10pub struct RetryApplyResult {
11    pub applied: Vec<AppliedOp>,
12    pub pending: Vec<Op>,
13}
14
15#[async_trait]
16pub trait RetryApplyDriver {
17    async fn apply_non_delete(&mut self, op: &Op) -> Result<AppliedOp>;
18    fn is_retryable(&self, err: &anyhow::Error) -> bool;
19}
20
21pub async fn apply_non_delete_with_retries(
22    ops: &[Op],
23    mut journal: Option<&mut Journal>,
24    driver: &mut impl RetryApplyDriver,
25) -> Result<RetryApplyResult> {
26    let mut applied = Vec::new();
27    let mut pending: Vec<Op> = ops
28        .iter()
29        .filter(|op| !matches!(op, Op::Delete { .. }))
30        .cloned()
31        .collect();
32
33    if let Some(journal) = journal.as_mut() {
34        let done_ops = journal.done_ops();
35        let done_ops_len = done_ops.len();
36
37        let mut done = done_ops
38            .into_iter()
39            .collect::<std::collections::HashSet<_>>();
40
41        if done.len() != done_ops_len {
42            // the use of a hash set here is an optimization, but it rules out ops with
43            // exactly the same uid, typename and hash.
44            // if there's a need to support such a thing in the future, it can be done by
45            // switching the container for `done` into a type that supports duplicates.
46            return Err(anyhow!("journal contained duplicated ops (same uid, typename and hash) which is not supported"));
47        }
48
49        pending.retain(|op| !done.remove(&(op.uid(), op.type_name().clone(), op.hashed())));
50
51        if !done.is_empty() {
52            return Err(anyhow!(
53                "journal contains done ops that are not present in the provided ops"
54            ));
55        }
56    }
57
58    while !pending.is_empty() {
59        let current = std::mem::take(&mut pending);
60        let applied_before = applied.len();
61
62        for op in current {
63            match driver.apply_non_delete(&op).await {
64                Ok(applied_op) => {
65                    // marked in memory only; the journal is flushed to disk at the exit
66                    // points below, not once per op (per-op saving was a ~100x regression).
67                    if let Some(journal) = journal.as_mut() {
68                        journal.mark_op_as_done(&op)?;
69                    }
70                    applied.push(applied_op);
71                }
72                Err(err) if driver.is_retryable(&err) => pending.push(op),
73                Err(err) => {
74                    // a fatal error is a clean unwind: persist progress before surfacing it
75                    // so the next run can resume from here. don't mask the original error if
76                    // the save itself fails.
77                    if let Some(journal) = journal.as_mut() {
78                        if let Err(save_err) = journal.save() {
79                            tracing::warn!(
80                                error = %save_err,
81                                "failed to persist journal after apply error"
82                            );
83                        }
84                    }
85                    return Err(err);
86                }
87            }
88        }
89
90        // only break if no progress was made (no items applied in this iteration)
91        if applied.len() == applied_before {
92            break;
93        }
94    }
95
96    if let Some(journal) = journal.as_mut() {
97        if journal.is_completed() {
98            journal.delete_backing_file()?;
99        } else {
100            // ops remain pending (stuck with no progress): persist so a re-run can resume
101            journal.save()?;
102        }
103    }
104
105    Ok(RetryApplyResult { applied, pending })
106}
107
108/// journal-wiring shared by the internal apply-adapters: build the journal from `state`,
109/// run the retry loop, and return the result plus the resumed count (`None` when none),
110/// ready for `ApplyReport::previously_applied_count`.
111pub async fn apply_non_delete_journaled(
112    state: &crate::StateStore,
113    adapter_name: &str,
114    creates_updates: &[Op],
115    driver: &mut impl RetryApplyDriver,
116) -> Result<(RetryApplyResult, Option<usize>)> {
117    let mut journal = match state.journal_dir() {
118        Some(dir) => Some(Journal::load_or_create(dir, adapter_name, creates_updates)?),
119        None => None,
120    };
121    let previously_applied = journal.as_ref().map(|j| j.done_ops_count()).unwrap_or(0);
122    let result = apply_non_delete_with_retries(creates_updates, journal.as_mut(), driver).await?;
123    Ok((
124        result,
125        (previously_applied > 0).then_some(previously_applied),
126    ))
127}
128
129/// true when `err` is a retryable missing-ref apply error.
130pub fn is_missing_ref_error(err: &anyhow::Error) -> bool {
131    err.downcast_ref::<AdapterApplyError>()
132        .is_some_and(|e| matches!(e, AdapterApplyError::MissingRef { .. }))
133}
134
135/// comma-joined referenced uids in `ops` that are absent from `resolved`.
136pub fn describe_missing_refs<V>(ops: &[Op], resolved: &BTreeMap<Uid, V>) -> String {
137    let mut missing = BTreeSet::new();
138    for op in ops {
139        if let Op::Create { desired, .. } | Op::Update { desired, .. } = op {
140            for value in desired.attrs.values() {
141                collect_missing_refs(value, resolved, &mut missing);
142            }
143        }
144    }
145    missing
146        .into_iter()
147        .map(|uid| uid.to_string())
148        .collect::<Vec<_>>()
149        .join(", ")
150}
151
152fn collect_missing_refs<V>(
153    value: &Value,
154    resolved: &BTreeMap<Uid, V>,
155    missing: &mut BTreeSet<Uid>,
156) {
157    match value {
158        Value::String(raw) => {
159            if let Ok(uid) = Uid::parse_str(raw) {
160                if !resolved.contains_key(&uid) {
161                    missing.insert(uid);
162                }
163            }
164        }
165        Value::Array(items) => {
166            for item in items {
167                collect_missing_refs(item, resolved, missing);
168            }
169        }
170        Value::Object(map) => {
171            for value in map.values() {
172                collect_missing_refs(value, resolved, missing);
173            }
174        }
175        _ => {}
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::BackendId;
183    use alembic_core::{JsonMap, Key, Object, TypeName, Uid};
184    use anyhow::anyhow;
185    use rand::rng;
186    use rand::seq::SliceRandom;
187    use serde_json::json;
188    use tempfile::tempdir;
189
190    #[test]
191    fn is_missing_ref_error_matches_only_missing_ref() {
192        let err = anyhow::Error::from(AdapterApplyError::MissingRef {
193            uid: Uid::from_u128(1),
194        });
195        assert!(is_missing_ref_error(&err));
196        assert!(!is_missing_ref_error(&anyhow!("some other error")));
197    }
198
199    #[test]
200    fn describe_missing_refs_reports_unresolved_nested_refs() {
201        let present = Uid::from_u128(1);
202        let missing = Uid::from_u128(2);
203
204        let attrs = JsonMap::from(BTreeMap::from([
205            ("resolved_ref".to_string(), json!(present.to_string())),
206            (
207                "nested".to_string(),
208                json!({ "list": [missing.to_string()] }),
209            ),
210        ]));
211        let op = Op::Create {
212            uid: present,
213            type_name: TypeName::new("test.item"),
214            desired: Object {
215                uid: present,
216                type_name: TypeName::new("test.item"),
217                key: Key::default(),
218                attrs,
219                source: None,
220            },
221        };
222
223        let mut resolved = BTreeMap::new();
224        resolved.insert(present, BackendId::Int(1));
225
226        let described = describe_missing_refs(&[op], &resolved);
227        assert!(described.contains(&missing.to_string()));
228        assert!(!described.contains(&present.to_string()));
229    }
230
231    fn create_op(uid: Uid) -> Op {
232        Op::Create {
233            uid,
234            type_name: TypeName::new("test.item"),
235            desired: Object {
236                uid,
237                type_name: TypeName::new("test.item"),
238                key: Key::default(),
239                attrs: JsonMap::default(),
240                source: None,
241            },
242        }
243    }
244
245    #[derive(Clone, Copy)]
246    enum Mode {
247        RetryThenOk,
248        AlwaysRetry,
249        Fatal,
250    }
251
252    struct TestDriver {
253        attempts: usize,
254        mode: Mode,
255    }
256
257    #[async_trait]
258    impl RetryApplyDriver for TestDriver {
259        async fn apply_non_delete(&mut self, op: &Op) -> Result<AppliedOp> {
260            self.attempts += 1;
261            match self.mode {
262                Mode::RetryThenOk if self.attempts == 1 => {
263                    Err(anyhow!("missing referenced uid {}", op.uid()))
264                }
265                Mode::AlwaysRetry => Err(anyhow!("missing referenced uid {}", op.uid())),
266                Mode::Fatal => Err(anyhow!("boom")),
267                Mode::RetryThenOk => Ok(AppliedOp {
268                    uid: op.uid(),
269                    type_name: op.type_name().clone(),
270                    backend_id: Some(BackendId::Int(1)),
271                }),
272            }
273        }
274
275        fn is_retryable(&self, err: &anyhow::Error) -> bool {
276            err.to_string().contains("missing referenced uid")
277        }
278    }
279
280    #[tokio::test]
281    async fn retries_then_applies() {
282        let uid1 = Uid::from_u128(1);
283        let uid2 = Uid::from_u128(2);
284        let ops = vec![create_op(uid1), create_op(uid2)];
285        let mut driver = TestDriver {
286            attempts: 0,
287            mode: Mode::RetryThenOk,
288        };
289
290        let result = apply_non_delete_with_retries(&ops, None, &mut driver)
291            .await
292            .unwrap();
293
294        assert_eq!(driver.attempts, 3);
295        assert_eq!(result.applied.len(), 2);
296        assert!(result.pending.is_empty());
297    }
298
299    #[tokio::test]
300    async fn returns_pending_when_stuck() {
301        let uid = Uid::from_u128(1);
302        let ops = vec![create_op(uid)];
303        let mut driver = TestDriver {
304            attempts: 0,
305            mode: Mode::AlwaysRetry,
306        };
307
308        let result = apply_non_delete_with_retries(&ops, None, &mut driver)
309            .await
310            .unwrap();
311
312        assert!(result.applied.is_empty());
313        assert_eq!(result.pending.len(), 1);
314    }
315
316    #[tokio::test]
317    async fn returns_non_retryable_error() {
318        let uid = Uid::from_u128(1);
319        let ops = vec![create_op(uid)];
320        let mut driver = TestDriver {
321            attempts: 0,
322            mode: Mode::Fatal,
323        };
324
325        let err = apply_non_delete_with_retries(&ops, None, &mut driver)
326            .await
327            .unwrap_err();
328
329        assert!(err.to_string().contains("boom"));
330    }
331
332    #[tokio::test]
333    async fn ignores_delete_ops() {
334        let uid = Uid::from_u128(1);
335        let ops = vec![Op::Delete {
336            uid,
337            type_name: TypeName::new("test.item"),
338            key: Key::default(),
339            backend_id: None,
340        }];
341        let mut driver = TestDriver {
342            attempts: 0,
343            mode: Mode::Fatal,
344        };
345
346        let result = apply_non_delete_with_retries(&ops, None, &mut driver)
347            .await
348            .unwrap();
349
350        assert_eq!(driver.attempts, 0);
351        assert!(result.pending.is_empty());
352        assert!(result.applied.is_empty());
353    }
354
355    struct ErraticDriver {
356        countdown_to_crash: u32,
357        applied_ops: Vec<AppliedOp>,
358    }
359
360    #[async_trait]
361    impl RetryApplyDriver for ErraticDriver {
362        async fn apply_non_delete(&mut self, op: &Op) -> Result<AppliedOp> {
363            self.countdown_to_crash -= 1;
364
365            if self.countdown_to_crash == 0 {
366                return Err(anyhow!("planned error"));
367            }
368
369            let applied_op = AppliedOp {
370                uid: op.uid(),
371                type_name: op.type_name().clone(),
372                backend_id: None,
373            };
374            self.applied_ops.push(applied_op.clone());
375
376            Ok(applied_op)
377        }
378
379        fn is_retryable(&self, _err: &anyhow::Error) -> bool {
380            false
381        }
382    }
383    #[tokio::test]
384    async fn erratic_driver_first_fails_then_succeeds() {
385        let uid1 = Uid::from_u128(1);
386        let uid2 = Uid::from_u128(2);
387        let ops = vec![create_op(uid1), create_op(uid2)];
388        let mut driver = ErraticDriver {
389            countdown_to_crash: 2,
390            applied_ops: vec![],
391        };
392        let dir = tempdir().unwrap();
393        let mut journal = Journal::load_or_create(dir.path(), "erratic_driver", &ops).unwrap();
394
395        apply_non_delete_with_retries(&ops, Some(&mut journal), &mut driver)
396            .await
397            .expect_err("should fail (on second op applied this run)");
398        assert_eq!(driver.applied_ops.len(), 1);
399        assert!(!journal.is_completed());
400
401        // turn off crashing
402        driver.countdown_to_crash = 99999;
403        _ = apply_non_delete_with_retries(&ops, Some(&mut journal), &mut driver)
404            .await
405            .unwrap();
406        assert_eq!(
407            driver.applied_ops.iter().map(|a| a.uid).collect::<Vec<_>>(),
408            vec![uid1, uid2]
409        );
410        assert!(journal.is_completed());
411    }
412
413    #[tokio::test]
414    async fn resumes_from_disk_after_error() {
415        let uid1 = Uid::from_u128(1);
416        let uid2 = Uid::from_u128(2);
417        let uid3 = Uid::from_u128(3);
418        let ops = vec![create_op(uid1), create_op(uid2), create_op(uid3)];
419        let dir = tempdir().unwrap();
420
421        // first run crashes after applying the first op; the journal is dropped to
422        // simulate the process exiting, so resume must rely on what was flushed to disk.
423        {
424            let mut driver = ErraticDriver {
425                countdown_to_crash: 2,
426                applied_ops: vec![],
427            };
428            let mut journal = Journal::load_or_create(dir.path(), "resume_test", &ops).unwrap();
429            apply_non_delete_with_retries(&ops, Some(&mut journal), &mut driver)
430                .await
431                .expect_err("should fail on the second op");
432            assert_eq!(driver.applied_ops.len(), 1);
433        }
434
435        // second run reloads the journal from disk and applies only the remaining ops.
436        {
437            let mut driver = ErraticDriver {
438                countdown_to_crash: 99999,
439                applied_ops: vec![],
440            };
441            let mut journal = Journal::load_or_create(dir.path(), "resume_test", &ops).unwrap();
442            let result = apply_non_delete_with_retries(&ops, Some(&mut journal), &mut driver)
443                .await
444                .unwrap();
445            assert_eq!(
446                driver.applied_ops.iter().map(|a| a.uid).collect::<Vec<_>>(),
447                vec![uid2, uid3]
448            );
449            assert_eq!(result.applied.len(), 2);
450            assert!(result.pending.is_empty());
451            assert!(journal.is_completed());
452        }
453    }
454
455    #[tokio::test]
456    async fn erratic_driver_with_shuffled_ops() {
457        let mut ops = Vec::new();
458        for i in 1..10 {
459            ops.push(create_op(Uid::from_u128(i)));
460        }
461
462        let mut rng = rng();
463        ops.shuffle(&mut rng);
464
465        let mut driver = ErraticDriver {
466            countdown_to_crash: 5,
467            applied_ops: vec![],
468        };
469        let dir = tempdir().unwrap();
470        let mut journal = Journal::load_or_create(dir.path(), "erratic_driver", &ops).unwrap();
471
472        apply_non_delete_with_retries(&ops, Some(&mut journal), &mut driver)
473            .await
474            .expect_err("should fail (on fifth op applied this run)");
475        assert_eq!(driver.applied_ops.len(), 4);
476        assert!(!journal.is_completed());
477
478        ops.shuffle(&mut rng);
479
480        // turn off crashing
481        driver.countdown_to_crash = 99999;
482        _ = apply_non_delete_with_retries(&ops, Some(&mut journal), &mut driver)
483            .await
484            .unwrap();
485
486        let mut applied_uids = driver.applied_ops.iter().map(|a| a.uid).collect::<Vec<_>>();
487        applied_uids.sort();
488        let mut op_uids = ops.iter().map(|op| op.uid()).collect::<Vec<_>>();
489        op_uids.sort();
490        assert_eq!(applied_uids, op_uids,);
491        assert!(journal.is_completed());
492    }
493
494    /// exercises the same `apply_non_delete_journaled` wiring the adapters use:
495    /// filter to non-delete ops, run the journaled retry loop, and surface the
496    /// resumed count on the report.
497    async fn run_journaled_apply(
498        state: &crate::StateStore,
499        ops: &[Op],
500        driver: &mut impl RetryApplyDriver,
501    ) -> Result<crate::ApplyReport> {
502        let creates_updates: Vec<Op> = ops
503            .iter()
504            .filter(|op| !matches!(op, Op::Delete { .. }))
505            .cloned()
506            .collect();
507        let (result, previously_applied_count) =
508            apply_non_delete_journaled(state, "test", &creates_updates, driver).await?;
509        Ok(crate::ApplyReport {
510            applied: result.applied,
511            previously_applied_count,
512            ..Default::default()
513        })
514    }
515
516    #[tokio::test]
517    async fn journaled_apply_via_state_store_resumes_and_reports() {
518        let uid1 = Uid::from_u128(1);
519        let uid2 = Uid::from_u128(2);
520        let uid3 = Uid::from_u128(3);
521        let ops = vec![create_op(uid1), create_op(uid2), create_op(uid3)];
522        let dir = tempdir().unwrap();
523        let state = crate::StateStore::new(None, crate::StateData::default())
524            .with_journal_dir(dir.path().to_path_buf());
525        let journal_path = crate::Journal::stable_file_name(dir.path(), "test", &ops);
526
527        // first run crashes after applying the first op; the journal persists progress.
528        {
529            let mut driver = ErraticDriver {
530                countdown_to_crash: 2,
531                applied_ops: vec![],
532            };
533            run_journaled_apply(&state, &ops, &mut driver)
534                .await
535                .expect_err("should crash on the second op");
536            assert_eq!(driver.applied_ops.len(), 1);
537            assert!(journal_path.exists());
538        }
539
540        // second run resumes: only the remaining ops apply, the report notes the
541        // resumed count, and the completed journal is cleaned up.
542        {
543            let mut driver = ErraticDriver {
544                countdown_to_crash: 99999,
545                applied_ops: vec![],
546            };
547            let report = run_journaled_apply(&state, &ops, &mut driver)
548                .await
549                .unwrap();
550            assert_eq!(
551                driver.applied_ops.iter().map(|a| a.uid).collect::<Vec<_>>(),
552                vec![uid2, uid3]
553            );
554            assert_eq!(report.applied.len(), 2);
555            assert_eq!(report.previously_applied_count, Some(1));
556            assert!(!journal_path.exists());
557        }
558    }
559
560    #[tokio::test]
561    async fn journaled_apply_without_journal_dir_reports_no_resume() {
562        let ops = vec![create_op(Uid::from_u128(1))];
563        let state = crate::StateStore::new(None, crate::StateData::default());
564        let mut driver = ErraticDriver {
565            countdown_to_crash: 99999,
566            applied_ops: vec![],
567        };
568        let report = run_journaled_apply(&state, &ops, &mut driver)
569            .await
570            .unwrap();
571        assert_eq!(report.applied.len(), 1);
572        assert_eq!(report.previously_applied_count, None);
573    }
574}