Skip to main content

spec_driven_docs/transaction/
journal.rs

1//! The record a run leaves before its first replacement.
2//!
3//! Without it, a process that dies partway through a multi-file write
4//! leaves no statement of what it was doing, and the in-memory backup map
5//! that would restore it dies with the process. The journal names every
6//! destination, the digest of what was there, and the digest of what the
7//! run intends to put there, and it is written and synced before the first
8//! rename.
9//!
10//! Recovery goes one way: back to what was there. Both writers use it, the
11//! skill install and the repository apply alike. A run that did not finish
12//! is undone whole, including its record, so the target returns to one
13//! state it was in rather than to a mixture of two. Completing forward
14//! would need a durable commit marker and a second recovery path, and the
15//! cost of rolling back instead is one re-run of a plan that is still
16//! stored under its own id.
17
18use camino::{Utf8Path, Utf8PathBuf};
19use serde::{Deserialize, Serialize};
20
21use crate::domain::ownership::Sha256;
22use crate::error::AppError;
23use crate::transaction::stage::Stage;
24use crate::transaction::sync_parent;
25
26/// The journal schema this binary writes and recovers.
27pub const SCHEMA_VERSION: u32 = 1;
28
29/// How far one destination has got.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "kebab-case")]
32pub enum EntryState {
33    /// The run intends to touch it and has not yet.
34    Planned,
35    /// The run has replaced or removed it.
36    Done,
37}
38
39/// One destination a run touches.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct Entry {
42    /// The absolute destination.
43    pub destination: Utf8PathBuf,
44    /// The digest of what was there, or `None` where nothing was.
45    pub before: Option<Sha256>,
46    /// The digest the run intends to leave, or `None` for a removal.
47    pub after: Option<Sha256>,
48    /// How far this destination has got.
49    pub state: EntryState,
50}
51
52impl Entry {
53    /// A destination the run will write.
54    #[must_use]
55    pub const fn write(destination: Utf8PathBuf, before: Option<Sha256>, after: Sha256) -> Self {
56        Self {
57            destination,
58            before,
59            after: Some(after),
60            state: EntryState::Planned,
61        }
62    }
63
64    /// A destination the run will remove.
65    #[must_use]
66    pub const fn remove(destination: Utf8PathBuf, before: Sha256) -> Self {
67        Self {
68            destination,
69            before: Some(before),
70            after: None,
71            state: EntryState::Planned,
72        }
73    }
74}
75
76/// What one journal file holds.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(deny_unknown_fields)]
79pub struct Record {
80    /// Always [`SCHEMA_VERSION`] once parsed.
81    pub schema_version: u32,
82    /// Where the copies of the previous bytes are.
83    pub backups: Utf8PathBuf,
84    /// Every destination the run touches.
85    pub entries: Vec<Entry>,
86}
87
88/// An open journal. Its file exists until [`Journal::finish`] removes it.
89#[derive(Debug)]
90pub struct Journal {
91    path: Utf8PathBuf,
92    record: Record,
93}
94
95fn write_record(path: &Utf8Path, record: &Record) -> Result<(), AppError> {
96    let text = serde_json::to_string_pretty(record)
97        .map_err(|source| anyhow::anyhow!("the journal did not serialize: {source}"))?;
98    crate::adapters::fs::write_atomic(path, text.as_bytes())?;
99    sync_parent(path)?;
100    Ok(())
101}
102
103impl Journal {
104    /// Write the journal before the first replacement.
105    ///
106    /// # Errors
107    ///
108    /// Any I/O error writing or syncing the journal.
109    pub fn begin(
110        path: &Utf8Path,
111        backups: &Utf8Path,
112        entries: Vec<Entry>,
113    ) -> Result<Self, AppError> {
114        let record = Record {
115            schema_version: SCHEMA_VERSION,
116            backups: backups.to_owned(),
117            entries,
118        };
119        write_record(path, &record)?;
120        Ok(Self {
121            path: path.to_owned(),
122            record,
123        })
124    }
125
126    /// Mark one destination as reached, and rewrite the journal.
127    ///
128    /// # Errors
129    ///
130    /// Any I/O error rewriting the journal.
131    pub fn mark_done(&mut self, destination: &Utf8Path) -> Result<(), AppError> {
132        for entry in &mut self.record.entries {
133            if entry.destination == destination {
134                entry.state = EntryState::Done;
135            }
136        }
137        write_record(&self.path, &self.record)
138    }
139
140    /// Put every reached destination back, then remove the journal.
141    ///
142    /// # Errors
143    ///
144    /// [`AppError::Unrecovered`] when a destination cannot be put back.
145    pub fn roll_back(&self) -> Result<(), AppError> {
146        roll_back_record(&self.path, &self.record)
147    }
148
149    /// Remove the journal, ending the run.
150    ///
151    /// # Errors
152    ///
153    /// Any I/O error removing the journal or syncing its directory.
154    pub fn finish(&mut self) -> Result<(), AppError> {
155        std::fs::remove_file(&self.path)?;
156        // The record is gone from disk, so the run is complete whatever
157        // the sync reports. A failure here means the removal may not
158        // survive a crash, which is worth saying and is not worth undoing
159        // a landing that already holds what the plan described.
160        sync_parent(&self.path).map_err(AppError::Io)
161    }
162
163    /// What this run recorded, for a test that inspects it.
164    #[must_use]
165    pub const fn record(&self) -> &Record {
166        &self.record
167    }
168}
169
170/// Put every destination the record names back where it was.
171fn roll_back_record(path: &Utf8Path, record: &Record) -> Result<(), AppError> {
172    let stage = Stage::new(&record.backups)?;
173    let mut failed: Vec<String> = Vec::new();
174    for entry in &record.entries {
175        // A destination already holding what it held needs no copy, which
176        // is what makes a second recovery cost nothing.
177        let restored = entry.before.as_ref().map_or_else(
178            || remove_if_present(&entry.destination),
179            |digest| {
180                if already_holds(&entry.destination, digest) {
181                    Ok(())
182                } else {
183                    stage.restore(digest, &entry.destination)
184                }
185            },
186        );
187        if let Err(source) = restored {
188            failed.push(format!("{}: {source}", entry.destination));
189        }
190    }
191    if !failed.is_empty() {
192        return Err(AppError::Unrecovered(format!(
193            "{path} names destinations that could not be put back: {}",
194            failed.join("; ")
195        )));
196    }
197    // Every destination is back, so the record has nothing left to say.
198    // Removing it last is what makes a second interruption harmless: the
199    // recovery runs again from the same journal and reaches the same place.
200    std::fs::remove_file(path)?;
201    sync_parent(path)?;
202    Ok(())
203}
204
205fn already_holds(destination: &Utf8Path, digest: &Sha256) -> bool {
206    std::fs::read(destination).is_ok_and(|found| &Sha256::of(&found) == digest)
207}
208
209fn remove_if_present(destination: &Utf8Path) -> Result<(), AppError> {
210    match std::fs::remove_file(destination) {
211        Ok(()) => sync_parent(destination).map_err(AppError::Io),
212        Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
213        Err(source) => Err(AppError::Io(source)),
214    }
215}
216
217/// Roll back an outstanding run, if one is there.
218///
219/// Called before a command plans new work. `Ok(false)` means there was
220/// nothing to recover. A journal this binary cannot parse is a refusal
221/// rather than a fresh start: the destinations it names are in an unknown
222/// state, and writing over them would bury that.
223///
224/// # Errors
225///
226/// [`AppError::Unrecovered`] when a journal exists and cannot be read or
227/// cannot be completed.
228pub fn recover(path: &Utf8Path) -> Result<bool, AppError> {
229    let text = match std::fs::read_to_string(path) {
230        Ok(text) => text,
231        Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(false),
232        Err(source) => return Err(AppError::Io(source)),
233    };
234    let record: Record = serde_json::from_str(&text).map_err(|source| {
235        AppError::Unrecovered(format!(
236            "{path} is a journal this binary cannot read: {source}; move it aside once you have checked the destinations it names"
237        ))
238    })?;
239    if record.schema_version != SCHEMA_VERSION {
240        return Err(AppError::Unrecovered(format!(
241            "{path} is journal schema {}, and this binary recovers schema {SCHEMA_VERSION}",
242            record.schema_version
243        )));
244    }
245    roll_back_record(path, &record)?;
246    Ok(true)
247}
248
249#[cfg(test)]
250mod tests {
251    #![allow(
252        clippy::unwrap_used,
253        reason = "a test panics as its failure signal, not as control flow"
254    )]
255
256    use super::*;
257
258    fn root(dir: &tempfile::TempDir) -> Utf8PathBuf {
259        Utf8PathBuf::from(dir.path().to_str().unwrap())
260    }
261
262    #[test]
263    fn a_recovery_restores_what_was_there_and_removes_what_was_not() {
264        let dir = tempfile::tempdir().unwrap();
265        let backups = root(&dir).join("backups");
266        let stage = Stage::new(&backups).unwrap();
267        let held = root(&dir).join("a/held.md");
268        let fresh = root(&dir).join("a/fresh.md");
269        crate::adapters::fs::write_file(&held, b"before\n").unwrap();
270        let before = stage.back_up(&held).unwrap().unwrap();
271
272        let path = root(&dir).join("run.journal");
273        let mut journal = Journal::begin(
274            &path,
275            &backups,
276            vec![
277                Entry::write(held.clone(), Some(before), Sha256::of(b"after\n")),
278                Entry::write(fresh.clone(), None, Sha256::of(b"after\n")),
279            ],
280        )
281        .unwrap();
282        std::fs::write(&held, b"after\n").unwrap();
283        journal.mark_done(&held).unwrap();
284        crate::adapters::fs::write_file(&fresh, b"after\n").unwrap();
285
286        // The process dies here; the next invocation recovers.
287        drop(journal);
288        assert!(recover(&path).unwrap());
289        assert_eq!(std::fs::read(&held).unwrap(), b"before\n");
290        assert!(!fresh.exists());
291        assert!(!path.exists());
292        assert!(!recover(&path).unwrap());
293    }
294
295    #[test]
296    fn recovery_is_idempotent_across_a_second_interruption() {
297        let dir = tempfile::tempdir().unwrap();
298        let backups = root(&dir).join("backups");
299        let stage = Stage::new(&backups).unwrap();
300        let first = root(&dir).join("first.md");
301        let second = root(&dir).join("second.md");
302        crate::adapters::fs::write_file(&first, b"one\n").unwrap();
303        crate::adapters::fs::write_file(&second, b"two\n").unwrap();
304        let one = stage.back_up(&first).unwrap().unwrap();
305        let two = stage.back_up(&second).unwrap().unwrap();
306
307        let path = root(&dir).join("run.journal");
308        Journal::begin(
309            &path,
310            &backups,
311            vec![
312                Entry::write(first.clone(), Some(one), Sha256::of(b"new\n")),
313                Entry::write(second.clone(), Some(two), Sha256::of(b"new\n")),
314            ],
315        )
316        .unwrap();
317        std::fs::write(&first, b"new\n").unwrap();
318        std::fs::write(&second, b"new\n").unwrap();
319
320        // A recovery that itself dies leaves the journal, so the next one
321        // does the same work and reaches the same place.
322        recover(&path).unwrap();
323        assert_eq!(std::fs::read(&first).unwrap(), b"one\n");
324        assert_eq!(std::fs::read(&second).unwrap(), b"two\n");
325        assert!(!recover(&path).unwrap());
326    }
327
328    #[test]
329    fn a_finished_run_leaves_no_journal() {
330        let dir = tempfile::tempdir().unwrap();
331        let path = root(&dir).join("run.journal");
332        let mut journal = Journal::begin(&path, &root(&dir).join("backups"), Vec::new()).unwrap();
333        assert!(path.is_file());
334        journal.finish().unwrap();
335        assert!(!path.exists());
336    }
337
338    #[test]
339    fn an_unreadable_journal_refuses_rather_than_starting_over() {
340        let dir = tempfile::tempdir().unwrap();
341        let path = root(&dir).join("run.journal");
342        std::fs::write(&path, "{not json").unwrap();
343        let error = recover(&path).unwrap_err();
344        assert_eq!(error.kind(), "Unrecovered");
345        assert!(path.is_file(), "the unreadable journal was deleted");
346    }
347}