Skip to main content

git_policy_cli/
service_journal.rs

1//! What:
2//!  The resident service's journal: one file per batch of commits, with the one durable write the batch needs, and the
3//!  decision a restart makes about a batch it finds open.
4//! Why:
5//!  The branch moves only after the intent is on disk, so after any kill the journal and the branch say whether the
6//!  batch landed; a restart finishes the one step that may be missing (the index update) or drops the intent, and a
7//!  request that landed is remembered by its identity so a retry is answered, not landed twice
8//!  (`doc/planning/cli-git-resident-service.md`, "Durability and crash recovery").
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! await writeIntent(dir, intent); // file fsync, rename, directory fsync: the batch's only synchronous write
13//! ```
14
15use std::collections::HashMap;
16use std::fs::{self, File};
17use std::io::Write;
18use std::path::{Path, PathBuf};
19
20/// Extension of an intent whose batch may be unfinished.
21pub const OPEN_EXTENSION: &str = "intent";
22
23/// Extension of a finished batch, kept so its request identities are remembered.
24pub const DONE_EXTENSION: &str = "done";
25
26/// Extension of a file being written; never read as an intent.
27const TEMPORARY_EXTENSION: &str = "tmp";
28
29/// What:
30///  What the service decided to do for a batch, written before the branch moves.
31/// Why:
32///   Enough to finish the batch after a kill: where the branch was, where it goes, which requests, which index paths.
33///
34/// In TS you'd write (pseudocode):
35/// ```ts
36/// type Intent = { seq; repository; commonDir; reference; old; new; ids; paths };
37/// ```
38#[derive(Clone, Debug, Eq, PartialEq)]
39pub struct Intent {
40    /// Number of the batch, unique and increasing within one journal.
41    pub seq: u64,
42    /// Worktree root whose index the batch updates.
43    pub repository: PathBuf,
44    /// Common git directory.
45    pub common_dir: PathBuf,
46    /// The branch.
47    pub reference: String,
48    /// The branch before the batch.
49    pub old: String,
50    /// The branch after the batch.
51    pub new: String,
52    /// Identities of the requests in the batch.
53    pub ids: Vec<String>,
54    /// The commit each request became, in the same order as the identities.
55    pub commits: Vec<String>,
56    /// Paths whose index entries the batch updates.
57    pub paths: Vec<String>,
58}
59
60/// What a restart does with an open intent.
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62pub enum Recovery {
63    /// The batch never landed; forget the intent.
64    Drop,
65    /// The batch landed; make sure the index shows it, then remember the identities.
66    Finish,
67}
68
69/// What:
70///  The decision for an open intent from the branch's current value.
71/// Why:
72///   The branch is the commit point: still at the old value means nothing happened, at the new value or past it
73///  (`new_is_ancestor_of_current`) means the batch landed, anywhere else means it cannot land now.
74///
75/// In TS you'd write (pseudocode):
76/// ```ts
77/// recoveryFor(intent, current, newIsAncestorOfCurrent)
78/// ```
79pub fn recovery_for(intent: &Intent, current: &str, new_is_ancestor_of_current: bool) -> Recovery {
80    if current == intent.new || (current != intent.old && new_is_ancestor_of_current) {
81        return Recovery::Finish;
82    }
83    return Recovery::Drop;
84}
85
86/// The text of an intent: `key=value` lines, tabs between list items.
87pub fn intent_text(intent: &Intent) -> String {
88    return format!(
89        "seq={}\nrepository={}\ncommon={}\nref={}\nold={}\nnew={}\nids={}\ncommits={}\npaths={}\n",
90        intent.seq,
91        intent.repository.display(),
92        intent.common_dir.display(),
93        intent.reference,
94        intent.old,
95        intent.new,
96        intent.ids.join("\t"),
97        intent.commits.join("\t"),
98        intent.paths.join("\t"),
99    );
100}
101
102/// What:
103///  Reads an intent's text.
104/// Why:
105///   A damaged file is no intent: recovery drops it.
106///
107/// In TS you'd write (pseudocode):
108/// ```ts
109/// parseIntent(text) // undefined when damaged
110/// ```
111pub fn parse_intent(text: &str) -> Option<Intent> {
112    let value = |key: &str| -> Option<&str> { return text.lines().find_map(|line| { return line.strip_prefix(key)?.strip_prefix('='); }); };
113    let list = |key: &str| -> Option<Vec<String>> {
114        return Some(value(key)?.split('\t').filter(|item| { return !item.is_empty(); }).map(str::to_string).collect());
115    };
116    return Some(Intent {
117        seq: value("seq")?.parse().ok()?,
118        repository: PathBuf::from(value("repository")?),
119        common_dir: PathBuf::from(value("common")?),
120        reference: value("ref")?.to_string(),
121        old: value("old")?.to_string(),
122        new: value("new")?.to_string(),
123        ids: list("ids")?,
124        commits: list("commits")?,
125        paths: list("paths")?,
126    });
127}
128
129/// The path of a batch's file with an extension.
130pub fn batch_path(dir: &Path, seq: u64, extension: &str) -> PathBuf {
131    return dir.join(format!("{seq}.{extension}"));
132}
133
134/// What:
135///  Writes an intent durably: the file is synced, renamed into place, and the directory is synced.
136/// Why:
137///   This is the only synchronous write of a batch, whatever its size.
138///
139/// In TS you'd write (pseudocode):
140/// ```ts
141/// await writeIntent(dir, intent)
142/// ```
143pub fn write_intent(dir: &Path, intent: &Intent) -> std::io::Result<()> {
144    let temporary: PathBuf = batch_path(dir, intent.seq, TEMPORARY_EXTENSION);
145    let mut file: File = File::create(&temporary)?;
146    file.write_all(intent_text(intent).as_bytes())?;
147    file.sync_all()?;
148    fs::rename(&temporary, batch_path(dir, intent.seq, OPEN_EXTENSION))?;
149    return File::open(dir)?.sync_all();
150}
151
152/// Marks a batch finished. Not synced: if the mark is lost a restart redoes an idempotent step.
153pub fn mark_done(dir: &Path, seq: u64) -> std::io::Result<()> {
154    return fs::rename(batch_path(dir, seq, OPEN_EXTENSION), batch_path(dir, seq, DONE_EXTENSION));
155}
156
157/// What:
158///  The intents in the journal with an extension, in batch order, and the temporary files removed on the way.
159/// Why:
160///   Recovery reads the open ones; startup reads the done ones for the request identities.
161///
162/// In TS you'd write (pseudocode):
163/// ```ts
164/// listIntents(dir, 'intent')
165/// ```
166pub fn list_intents(dir: &Path, extension: &str) -> Vec<(PathBuf, Option<Intent>)> {
167    let Ok(entries) = fs::read_dir(dir) else {
168        return Vec::new();
169    };
170    let mut found: Vec<(PathBuf, Option<Intent>)> = Vec::new();
171    for entry in entries.flatten() {
172        let path: PathBuf = entry.path();
173        match path.extension().and_then(|text| { return text.to_str(); }) {
174            Some(TEMPORARY_EXTENSION) => {
175                let _removed = fs::remove_file(&path);
176            }
177            Some(found_extension) if found_extension == extension => {
178                let parsed: Option<Intent> = fs::read_to_string(&path).ok().and_then(|text| { return parse_intent(text.as_str()); });
179                found.push((path, parsed));
180            }
181            _ => {}
182        }
183    }
184    found.sort_by_key(|(path, intent)| {
185        return intent.as_ref().map_or_else(|| { return path.to_string_lossy().len() as u64; }, |parsed| { return parsed.seq; });
186    });
187    return found;
188}
189
190/// The request identities of every finished batch, each with the commit it became.
191pub fn landed_commits(dir: &Path) -> HashMap<String, String> {
192    let mut landed: HashMap<String, String> = HashMap::new();
193    for (_, intent) in list_intents(dir, DONE_EXTENSION) {
194        if let Some(parsed) = intent {
195            landed.extend(parsed.ids.into_iter().zip(parsed.commits));
196        }
197    }
198    return landed;
199}
200
201/// Journal controls stay out of the release executable.
202#[cfg(test)]
203#[path = "service_journal_tests.rs"]
204mod tests;