Skip to main content

alembic_engine/
journal.rs

1//! Keep track of successfully applied ops to enable resume after an error.
2//!
3//! When resuming, the journal must match the previous run's non-delete op sequence (including op hashes).
4
5use crate::{BackendId, Op};
6use alembic_core::{TypeName, Uid};
7use anyhow::{anyhow, Result};
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::fs::{File, OpenOptions};
11use std::hash::{DefaultHasher, Hash, Hasher};
12use std::io::{Read, Seek, Write};
13use std::path::{Path, PathBuf};
14use tempfile::NamedTempFile;
15
16#[derive(Debug, Serialize, Deserialize)]
17pub struct Journal {
18    #[serde(skip)]
19    file: Option<(File, PathBuf)>,
20    ops: Vec<OpWithMeta>,
21}
22
23impl Journal {
24    pub fn stable_file_name(directory: &Path, adapter_name: &str, ops: &[Op]) -> PathBuf {
25        let mut hasher = DefaultHasher::new();
26        ops.hash(&mut hasher);
27        let hash = hasher.finish();
28        let file_name: PathBuf = format!("{}_journal_{}.yaml", adapter_name, hash).into();
29        directory.join(file_name)
30    }
31
32    /// tries to load a Journal from `file_path`, otherwise creates a new one.
33    /// in either case, the new Journal instance will be backed by the file at `file_path`.
34    /// delete ops will not be saved in the journal.
35    pub fn load_or_create(directory: &Path, adapter_name: &str, ops: &[Op]) -> Result<Self> {
36        // apply writes the journal before any state save, so `directory` (e.g. `.alembic/`)
37        // may not exist yet on a fresh checkout.
38        fs::create_dir_all(directory)?;
39        let file_name = Self::stable_file_name(directory, adapter_name, ops);
40        if fs::metadata(&file_name).is_ok() {
41            Self::new_from_existing_file(directory, adapter_name, ops)
42        } else {
43            Self::new_with_file(directory, adapter_name, ops)
44        }
45    }
46
47    /// loads a journal from the file with `file_path` and sets its backing file to that file
48    fn new_from_existing_file(
49        directory: &Path,
50        adapter_name: &str,
51        expected_ops: &[Op],
52    ) -> Result<Self> {
53        let file_name = Self::stable_file_name(directory, adapter_name, expected_ops);
54
55        let mut file = OpenOptions::new()
56            .read(true)
57            .write(true)
58            .create(false)
59            .append(false)
60            .open(&file_name)?;
61        let mut contents = String::new();
62        file.read_to_string(&mut contents)?;
63
64        let mut journal: Journal = serde_yaml::from_str(&contents)?;
65
66        let journal_keys = journal
67            .ops
68            .iter()
69            .map(|op_with_meta| {
70                (
71                    op_with_meta.op_uid,
72                    &op_with_meta.op_typename,
73                    op_with_meta.op_hash,
74                )
75            })
76            .collect::<Vec<(Uid, &TypeName, u64)>>();
77
78        let expected_keys = expected_ops
79            .iter()
80            .filter(|op| !matches!(op, Op::Delete { .. }))
81            .map(|op| (op.uid(), op.type_name(), op.hashed()))
82            .collect::<Vec<(Uid, &TypeName, u64)>>();
83        if journal_keys != expected_keys {
84            return Err(anyhow!(
85                "the ops in the loaded journal file `{}` don't match the expected ops",
86                file_name.display()
87            ));
88        }
89
90        journal.file = Some((file, file_name));
91        Ok(journal)
92    }
93
94    /// creates a journal with a new backing file
95    fn new_with_file(directory: &Path, adapter_name: &str, ops: &[Op]) -> Result<Self> {
96        let file_name = Self::stable_file_name(directory, adapter_name, ops);
97        let mut journal = Self::new_ephemeral(ops);
98
99        // create and write to the file to check that it works before applying any ops
100        let mut file = OpenOptions::new()
101            .read(true)
102            .write(true)
103            .create(true)
104            .truncate(true)
105            .append(false)
106            .open(&file_name)?;
107        file.set_len(0)?;
108        file.rewind()?;
109
110        journal.file = Some((file, file_name));
111        journal.save()?;
112
113        Ok(journal)
114    }
115
116    /// creates a journal without a backing file set
117    pub fn new_ephemeral(ops: &[Op]) -> Self {
118        Self {
119            file: None,
120            ops: ops
121                .iter()
122                .filter(|op| !matches!(op, Op::Delete { .. }))
123                .map(OpWithMeta::new)
124                .collect(),
125        }
126    }
127
128    pub fn done_ops(&self) -> Vec<(Uid, TypeName, u64)> {
129        self.ops
130            .iter()
131            .filter(|owm| owm.done)
132            .map(|owm| (owm.op_uid, owm.op_typename.clone(), owm.op_hash))
133            .collect()
134    }
135
136    pub fn done_ops_count(&self) -> usize {
137        self.ops.iter().filter(|op| op.done).count()
138    }
139
140    pub fn is_completed(&self) -> bool {
141        self.ops.iter().all(|op| op.done)
142    }
143
144    /// will mark the first op that is not done (will fail if there's no such op).
145    /// uses a linear search from the start.
146    pub fn mark_op_as_done(&mut self, op: &Op) -> Result<()> {
147        let op_hash = op.hashed();
148        let op_uid = op.uid();
149        let op_typename = op.type_name();
150
151        let Some(op_index) = self.ops.iter().position(|op| {
152            !op.done
153                && op.op_hash == op_hash
154                && op.op_uid == op_uid
155                && &op.op_typename == op_typename
156        }) else {
157            return Err(anyhow!(
158                "no matching op found in journal, can't mark any as done"
159            ));
160        };
161
162        // index comes from call to `position` above, so it must be in range
163        self.ops[op_index].done = true;
164
165        Ok(())
166    }
167
168    pub fn save(&mut self) -> Result<()> {
169        let str = serde_yaml::to_string(self)?;
170
171        let (_, path) = self
172            .file
173            .as_ref()
174            .ok_or_else(|| anyhow!("can't save journal because it's missing a backing file"))?;
175
176        let path = path.clone();
177        let dir = path
178            .parent()
179            .ok_or_else(|| anyhow!("file path has no parent directory"))?;
180
181        let mut temp_file = NamedTempFile::new_in(dir)?;
182        temp_file.write_all(str.as_bytes())?;
183        temp_file.as_file().sync_all()?; // fsync data + metadata before it can become visible
184        temp_file.persist(&path)?;
185        File::open(dir)?.sync_all()?;
186        let new_file = OpenOptions::new().read(true).write(true).open(&path)?;
187        self.file = Some((new_file, path));
188
189        Ok(())
190    }
191
192    pub fn delete_backing_file(&mut self) -> Result<()> {
193        if let Some((file, file_path)) = self.file.take() {
194            drop(file);
195            fs::remove_file(file_path)?;
196        }
197        Ok(())
198    }
199}
200
201impl Drop for Journal {
202    fn drop(&mut self) {
203        if let Some((file, _)) = self.file.take() {
204            let _ = file.sync_all();
205        }
206    }
207}
208
209// we're only storing the uid and typename for the Op to keep this struct small and readable
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211struct OpWithMeta {
212    op_uid: Uid,
213    op_typename: TypeName,
214    op_hash: u64,
215    done: bool,
216    backend_id: Option<BackendId>, // FIXME: not sure if/when this is needed
217}
218
219impl OpWithMeta {
220    fn new(op: &Op) -> Self {
221        OpWithMeta {
222            op_uid: op.uid(),
223            op_typename: op.type_name().clone(),
224            op_hash: op.hashed(),
225            done: false,
226            backend_id: None,
227        }
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use alembic_core::{Object, TypeName};
235    use tempfile::tempdir;
236
237    fn test_ops() -> Vec<Op> {
238        vec![
239            Op::Create {
240                uid: Uid::from_u128(1),
241                type_name: TypeName::new("dcim.device"),
242                desired: Object {
243                    uid: Uid::from_u128(1),
244                    type_name: TypeName::new("dcim.device"),
245                    key: Default::default(),
246                    attrs: Default::default(),
247                    source: None,
248                },
249            },
250            Op::Create {
251                uid: Uid::from_u128(2),
252                type_name: TypeName::new("dcim.device"),
253                desired: Object {
254                    uid: Uid::from_u128(2),
255                    type_name: TypeName::new("dcim.device"),
256                    key: Default::default(),
257                    attrs: Default::default(),
258                    source: None,
259                },
260            },
261            Op::Update {
262                uid: Uid::from_u128(3),
263                type_name: TypeName::new("dcim.site"),
264                desired: Object {
265                    uid: Uid::from_u128(3),
266                    type_name: TypeName::new("dcim.site"),
267                    key: Default::default(),
268                    attrs: Default::default(),
269                    source: None,
270                },
271                changes: vec![],
272                backend_id: None,
273            },
274        ]
275    }
276
277    #[test]
278    fn save_and_load_journal() {
279        let dir = tempdir().unwrap();
280        let ops = test_ops();
281        {
282            let mut journal = Journal::new_with_file(dir.path(), "test", &ops).unwrap();
283            journal.save().unwrap();
284            drop(journal);
285        }
286        {
287            let journal = Journal::new_from_existing_file(dir.path(), "test", &ops).unwrap();
288            assert_eq!(
289                journal
290                    .ops
291                    .iter()
292                    .map(|owm| (owm.op_uid, &owm.op_typename))
293                    .collect::<Vec<(Uid, &TypeName)>>(),
294                ops.iter()
295                    .map(|op| (op.uid(), op.type_name()))
296                    .collect::<Vec<(Uid, &TypeName)>>()
297            );
298        }
299    }
300
301    #[test]
302    fn load_and_save_existing_journal() {
303        let dir = tempdir().unwrap();
304        let ops = test_ops();
305        {
306            let mut journal = Journal::new_with_file(dir.path(), "test", &ops).unwrap();
307            journal.save().unwrap();
308        }
309        {
310            let mut journal = Journal::new_from_existing_file(dir.path(), "test", &ops).unwrap();
311            journal.save().unwrap();
312            assert_eq!(journal.ops.len(), 3);
313        }
314    }
315
316    #[test]
317    fn mark_ops_as_done() {
318        let ops = test_ops();
319        let mut journal = Journal::new_with_file(tempdir().unwrap().path(), "test", &ops).unwrap();
320        journal.mark_op_as_done(&ops[0]).unwrap();
321        assert!(!journal.is_completed());
322        journal.mark_op_as_done(&ops[1]).unwrap();
323        assert!(!journal.is_completed());
324        journal.mark_op_as_done(&ops[2]).unwrap();
325        assert!(journal.is_completed());
326    }
327
328    #[test]
329    fn mark_ops_as_done_backwards() {
330        let ops = test_ops();
331        let mut journal = Journal::new_with_file(tempdir().unwrap().path(), "test", &ops).unwrap();
332        journal.mark_op_as_done(&ops[2]).unwrap();
333        assert!(!journal.is_completed());
334        journal.mark_op_as_done(&ops[1]).unwrap();
335        assert!(!journal.is_completed());
336        journal.mark_op_as_done(&ops[0]).unwrap();
337        assert!(journal.is_completed());
338    }
339
340    #[test]
341    fn mark_invalid_op_as_done() {
342        let ops = test_ops();
343        let mut journal = Journal::new_with_file(tempdir().unwrap().path(), "test", &ops).unwrap();
344        journal
345            .mark_op_as_done(&Op::Create {
346                uid: Uid::from_u128(999),
347                type_name: TypeName::new("dcim.site"),
348                desired: Object {
349                    uid: Uid::from_u128(999),
350                    type_name: TypeName::new("dcim.site"),
351                    key: Default::default(),
352                    attrs: Default::default(),
353                    source: None,
354                },
355            })
356            .expect_err("should fail");
357        assert!(!journal.is_completed());
358    }
359
360    #[test]
361    fn mark_same_op_as_done_twice() {
362        let ops = test_ops();
363        let mut journal = Journal::new_with_file(tempdir().unwrap().path(), "test", &ops).unwrap();
364        journal.mark_op_as_done(&ops[1]).unwrap();
365        journal.mark_op_as_done(&ops[1]).expect_err("should fail");
366    }
367
368    #[test]
369    fn delete_backing_file() {
370        let dir = tempdir().unwrap();
371        let ops = test_ops();
372        let mut journal = Journal::new_with_file(dir.path(), "test", &ops).unwrap();
373        journal.save().unwrap();
374        let file_path = Journal::stable_file_name(dir.path(), "test", &ops);
375        assert!(file_path.exists());
376        journal.delete_backing_file().unwrap();
377        assert!(!file_path.exists());
378    }
379}