Skip to main content

git_policy_cli/subvolume/
subvolume_journal.rs

1//! What:
2//!  The journal of a subvolume migration: a small JSON file beside the checkout that records which steps finished,
3//!  written atomically after each step.
4//! Why:
5//!  A rerun after a kill at any instant resumes from it: a step marked `done` is skipped, a step marked `started` is
6//!  repeated (every step is idempotent), and a step with no entry has not begun. The file lives in the checkout's
7//!  parent directory (`.cli-git-migrate-<name>.journal.json`), outside the tree that is copied and exchanged, so it
8//!  survives the exchange. It is replaced by writing a sibling file, created exclusively under a name no other file has
9//!  (so nothing planted or left at a fixed name is truncated or followed), and renaming it, so a kill never leaves half a
10//!  journal. The plain files a killed run left under the writer's own temporary names are removed by the next write that
11//!  lands. `serde_json` is already a dependency of the crate.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! const journal = await loadJournal(path); await journal.mark('copy', 'started'); ... await journal.mark('copy', 'done');
16//! ```
17
18use crate::iso_time::{format_iso_milliseconds, now_iso};
19use crate::subvolume_error::MigrateError;
20use serde_json::{Map, Value};
21use std::collections::BTreeMap;
22use std::io::Write;
23use std::os::unix::fs::OpenOptionsExt;
24use std::path::{Path, PathBuf};
25
26/// The journal format version this code writes and reads.
27const SCHEMA: u64 = 1;
28
29/// The permissions of the journal: its owner reads and writes it.
30const PRIVATE_FILE_MODE: u32 = 0o600;
31
32/// What follows the journal's file name in the name of a temporary file: releases up to 0.5.20 wrote exactly this suffix, and
33///  now a process number and a clock reading follow it.
34const TEMPORARY_SUFFIX: &str = ".tmp";
35
36/// What:
37///  Whether `candidate` is a name under which a writer of the journal `journal_name` leaves a temporary file: the journal's
38///  name, `.tmp`, and either nothing (the name of releases up to 0.5.20) or a dot, a process number, a dot and a clock reading.
39/// Why:
40///   Any other name beside the journal is not this writer's to remove.
41///
42/// In TS you'd write (pseudocode):
43/// ```ts
44/// const leftover = new RegExp(`^${escape(journalName)}\\.tmp(\\.\\d+\\.\\d+)?$`).test(candidate);
45/// ```
46fn is_leftover_name(candidate: &[u8], journal_name: &[u8]) -> bool {
47    let Some(after_name) = candidate.strip_prefix(journal_name) else { return false };
48    let Some(rest) = after_name.strip_prefix(TEMPORARY_SUFFIX.as_bytes()) else { return false };
49    if rest.is_empty() {
50        return true;
51    }
52    let Some(numbers) = rest.strip_prefix(b".") else { return false };
53    let mut parts = numbers.split(|byte: &u8| { return *byte == b'.'; });
54    let numeric = |part: Option<&[u8]>| -> bool { return part.is_some_and(|digits: &[u8]| { return !digits.is_empty() && digits.iter().all(u8::is_ascii_digit); }); };
55    let process: Option<&[u8]> = parts.next();
56    let clock: Option<&[u8]> = parts.next();
57    return numeric(process) && numeric(clock) && parts.next().is_none();
58}
59
60/// What:
61///  Write `bytes` to a file made for them alone at `path`: created exclusively, so a path that already exists (a plain file,
62///  a link, a dangling link) is refused and never truncated or followed; synchronized; removed again when the write fails.
63/// Why:
64///   A fixed temporary name that an earlier run or another user left or planted must not redirect the write.
65///
66/// In TS you'd write (pseudocode):
67/// ```ts
68/// await writeFile(path, bytes, { flag: 'wx', mode: 0o600 }); // rejects with EEXIST on anything there, a link included
69/// ```
70fn write_exclusively(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
71    let mut file: std::fs::File = std::fs::OpenOptions::new().write(true).create_new(true).mode(PRIVATE_FILE_MODE).open(path)?;
72    let written: std::io::Result<()> = file.write_all(bytes).and_then(|()| { return file.sync_all(); });
73    if let Err(error) = written {
74        let _removed: std::io::Result<()> = std::fs::remove_file(path);
75        return Err(error);
76    }
77    return Ok(());
78}
79
80/// What:
81///  The records of one migration.
82///  `BTreeMap<String, String>` is a text-to-text dictionary kept in key order (sibling `HashMap` has no order).
83/// Why:
84///   A sorted map writes the same file text for the same records.
85///
86/// In TS you'd write (pseudocode):
87/// ```ts
88/// type Journal = { path: string; started: string; steps: Record<string, { state: string; at: string }> };
89/// ```
90#[derive(Clone, Debug, Eq, PartialEq)]
91pub struct Journal {
92    /// Where the file is (or will be).
93    path: PathBuf,
94    /// When the migration began, as `toISOString` text.
95    started: String,
96    /// Step name to state (`started`, `done` or `rolled-back`).
97    states: BTreeMap<String, String>,
98    /// Step name to the time in milliseconds after the epoch at which it got its current state.
99    times: BTreeMap<String, i64>,
100    /// Whether the file exists on disk.
101    persisted: bool,
102}
103
104impl Journal {
105    /// What:
106    ///  Load the journal at `path`, or start an empty one when there is no file.
107    /// Why:
108    ///   A file that exists but is not a journal of this format is an error naming the file: guessing would resume
109    ///  or skip steps on a false record.
110    ///
111    /// In TS you'd write (pseudocode):
112    /// ```ts
113    /// const journal = await Journal.load(path);
114    /// ```
115    pub fn load(path: &Path) -> Result<Journal, MigrateError> {
116        let text: String = match std::fs::read_to_string(path) {
117            Ok(found) => found,
118            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
119                return Ok(Journal { path: path.to_path_buf(), started: now_iso(), states: BTreeMap::new(), times: BTreeMap::new(), persisted: false });
120            }
121            Err(error) => return Err(MigrateError::io("read the journal", path, &error)),
122        };
123        let broken = |why: &str| { return MigrateError::Journal(format!("the journal {} {why}; read it, and delete it only if no migration is in progress", path.display())); };
124        let value: Value = serde_json::from_str(text.as_str()).map_err(|error: serde_json::Error| { return broken(format!("is not valid JSON ({error})").as_str()); })?;
125        if value.get("schema").and_then(Value::as_u64) != Some(SCHEMA) {
126            return Err(broken("has an unknown schema version"));
127        }
128        let started: String = value.get("started").and_then(Value::as_str).ok_or_else(|| { return broken("has no start time"); })?.to_string();
129        let steps: &Map<String, Value> = value.get("steps").and_then(Value::as_object).ok_or_else(|| { return broken("has no steps"); })?;
130        let mut states: BTreeMap<String, String> = BTreeMap::new();
131        let mut times: BTreeMap<String, i64> = BTreeMap::new();
132        for (name, record) in steps {
133            let state: &str = record.get("state").and_then(Value::as_str).ok_or_else(|| { return broken("has a step without a state"); })?;
134            states.insert(name.clone(), state.to_string());
135            if let Some(milliseconds) = record.get("epoch_ms").and_then(Value::as_i64) {
136                times.insert(name.clone(), milliseconds);
137            }
138        }
139        return Ok(Journal { path: path.to_path_buf(), started, states, times, persisted: true });
140    }
141
142    /// When the migration began, as `toISOString` text; unique to the migration and kept by a resumed run.
143    pub fn started(&self) -> &str {
144        return self.started.as_str();
145    }
146
147    /// The journal file's path.
148    pub fn path(&self) -> &Path {
149        return self.path.as_path();
150    }
151
152    /// Whether any step has been recorded on disk.
153    pub fn is_persisted(&self) -> bool {
154        return self.persisted;
155    }
156
157    /// The time, in whole seconds after the epoch, at which the step got its current state; nothing for a step with no
158    ///  record or a record written without a time.
159    pub fn seconds_of(&self, step: &str) -> Option<i64> {
160        return self.times.get(step).map(|milliseconds: &i64| { return milliseconds.div_euclid(1000); });
161    }
162
163    /// The recorded state of a step, if any.
164    pub fn state(&self, step: &str) -> Option<&str> {
165        return self.states.get(step).map(String::as_str);
166    }
167
168    /// Whether the step finished.
169    pub fn is_done(&self, step: &str) -> bool {
170        return self.state(step) == Some("done");
171    }
172
173    /// Whether the step began (finished or not).
174    pub fn has_begun(&self, step: &str) -> bool {
175        return matches!(self.state(step), Some("started" | "done"));
176    }
177
178    /// What:
179    ///  Record a step's state and write the file.
180    /// Why:
181    ///   The file is written to a sibling created exclusively (under a name of its own, never one an earlier run or another
182    ///  user could have planted a link at) and renamed over the journal, with the data synced first, so a kill leaves either
183    ///  the old or the new journal.
184    ///
185    /// In TS you'd write (pseudocode):
186    /// ```ts
187    /// await journal.mark('copy', 'done');
188    /// ```
189    pub fn mark(&mut self, step: &str, state: &str) -> Result<(), MigrateError> {
190        self.states.insert(step.to_string(), state.to_string());
191        let now: i64 = std::time::SystemTime::now()
192            .duration_since(std::time::UNIX_EPOCH)
193            .map_or(0, |elapsed: std::time::Duration| { return i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX); });
194        self.times.insert(step.to_string(), now);
195        let mut steps: Map<String, Value> = Map::new();
196        for (name, recorded) in &self.states {
197            let milliseconds: i64 = self.times.get(name).copied().unwrap_or(now);
198            let mut record: Map<String, Value> = Map::new();
199            record.insert(String::from("state"), Value::String(recorded.clone()));
200            record.insert(String::from("at"), Value::String(format_iso_milliseconds(milliseconds)));
201            record.insert(String::from("epoch_ms"), Value::from(milliseconds));
202            steps.insert(name.clone(), Value::Object(record));
203        }
204        let mut document: Map<String, Value> = Map::new();
205        document.insert(String::from("schema"), Value::from(SCHEMA));
206        document.insert(String::from("started"), Value::String(self.started.clone()));
207        document.insert(String::from("steps"), Value::Object(steps));
208        let text: String = serde_json::to_string_pretty(&Value::Object(document)).unwrap_or_default();
209        let sibling_path: PathBuf = self.temporary_path();
210        let write = || -> std::io::Result<()> {
211            write_exclusively(sibling_path.as_path(), text.as_bytes())?;
212            if let Err(error) = std::fs::rename(&sibling_path, &self.path) {
213                let _removed: std::io::Result<()> = std::fs::remove_file(&sibling_path);
214                return Err(error);
215            }
216            // The rename is made durable with the directory that holds the new name.
217            return match self.path.parent() {
218                Some(directory) => std::fs::File::open(directory).and_then(|held: std::fs::File| { return held.sync_all(); }),
219                None => Ok(()),
220            };
221        };
222        write().map_err(|error: std::io::Error| { return MigrateError::io("write the journal", self.path.as_path(), &error); })?;
223        self.persisted = true;
224        self.remove_leftovers();
225        return Ok(());
226    }
227
228    /// A name for this write's temporary file that no other file has: this process and the clock make it, and the
229    ///  creation is exclusive.
230    fn temporary_path(&self) -> PathBuf {
231        let nanos: u128 = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |elapsed: std::time::Duration| { return elapsed.as_nanos(); });
232        let mut name: std::ffi::OsString = self.path.as_os_str().to_os_string();
233        name.push(format!("{TEMPORARY_SUFFIX}.{}.{nanos}", std::process::id()));
234        return PathBuf::from(name);
235    }
236
237    /// What:
238    ///  Remove the plain files a killed run left beside the journal under the writer's own temporary names.
239    /// Why:
240    ///   Only the writer's own shapes are removed, and only plain files as the directory entry itself says (a link, a
241    ///  directory or any other name is somebody else's and stays), so nothing is followed and nothing foreign is discarded.
242    ///  One command at a time works on a checkout (a numbered claim file), so no live writer's temporary file is among them.
243    ///  A failure to list or remove is not an error: a leftover costs only space.
244    fn remove_leftovers(&self) {
245        let (Some(directory), Some(journal_name)) = (self.path.parent(), self.path.file_name()) else { return };
246        let Ok(listing) = std::fs::read_dir(directory) else { return };
247        for item in listing.flatten() {
248            let name: std::ffi::OsString = item.file_name();
249            if !is_leftover_name(name.as_encoded_bytes(), journal_name.as_encoded_bytes()) {
250                continue;
251            }
252            let is_plain_file: bool = item.file_type().is_ok_and(|kind: std::fs::FileType| { return kind.is_file(); });
253            if is_plain_file {
254                let _removed: std::io::Result<()> = std::fs::remove_file(item.path());
255            }
256        }
257    }
258
259    /// What:
260    ///  Delete the journal file.
261    /// Why:
262    ///   A rolled-back or fully removed migration leaves no record, so the next migration starts clean.
263    ///
264    /// In TS you'd write (pseudocode):
265    /// ```ts
266    /// await rm(journal.path, { force: true });
267    /// ```
268    pub fn delete(&mut self) -> Result<(), MigrateError> {
269        match std::fs::remove_file(&self.path) {
270            Ok(()) => {}
271            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
272            Err(error) => return Err(MigrateError::io("delete the journal", self.path.as_path(), &error)),
273        }
274        self.states.clear();
275        self.times.clear();
276        self.persisted = false;
277        return Ok(());
278    }
279}
280
281/// Journal controls stay out of the release executable.
282#[cfg(test)]
283#[path = "subvolume_journal_tests.rs"]
284mod tests;