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;