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 super::repo_read_layout::dot_git_target;
16use super::service_protocol::Cleanup;
17use std::collections::HashMap;
18use std::fs::{self, File};
19use std::io::Write;
20use std::path::{Path, PathBuf};
21
22/// Extension of an intent whose batch may be unfinished.
23pub const OPEN_EXTENSION: &str = "intent";
24
25/// Extension of a finished batch, kept so its request identities are remembered.
26pub const DONE_EXTENSION: &str = "done";
27
28/// Extension of a file being written; never read as an intent.
29const TEMPORARY_EXTENSION: &str = "tmp";
30
31/// How old a temporary file must be before a listing treats it as the remains of a killed write and removes it. A write takes
32/// milliseconds; the grace covers a writer stalled on a slow disk or a loaded host.
33pub const TEMPORARY_GRACE: std::time::Duration = std::time::Duration::from_secs(10 * 60);
34
35/// What:
36/// What the service decided to do for a batch, written before the branch moves.
37/// Why:
38/// Enough to finish the batch after a kill: where the branch was, where it goes, which requests, which index paths.
39///
40/// In TS you'd write (pseudocode):
41/// ```ts
42/// type Intent = { seq; repository; commonDir; reference; old; new; ids; paths };
43/// ```
44#[derive(Clone, Debug, Eq, PartialEq)]
45pub struct Intent {
46 /// Number of the batch, unique and increasing within one journal.
47 pub seq: u64,
48 /// Worktree root whose index the batch updates.
49 pub repository: PathBuf,
50 /// Common git directory.
51 pub common_dir: PathBuf,
52 /// The branch.
53 pub reference: String,
54 /// The branch before the batch.
55 pub old: String,
56 /// The branch after the batch.
57 pub new: String,
58 /// Identities of the requests in the batch.
59 pub ids: Vec<String>,
60 /// The commit each request became, in the same order as the identities.
61 pub commits: Vec<String>,
62 /// Paths whose index entries the batch updates.
63 pub paths: Vec<String>,
64 /// Whether the index entries to update are the paths that differ between `old` and `new`, read from the repository
65 /// when they are needed (a commit Git built has no list of changes to carry, and its names are bytes).
66 pub derived: bool,
67 /// The conclusion state the landing removes after the branch moved; nothing for any other batch.
68 pub cleanup: Option<Cleanup>,
69 /// The real index whose lock the landing held, so a restart can remove its own stale lock and finish the index.
70 pub index: PathBuf,
71 /// The worktree's own Git directory, which names the instance that serves the batch; an intent written before it was
72 /// recorded reads as the Git directory of its repository's worktree.
73 pub git_dir: PathBuf,
74}
75
76/// What a restart does with an open intent.
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78pub enum Recovery {
79 /// The batch never landed; forget the intent.
80 Drop,
81 /// The batch landed; make sure the index shows it, then remember the identities.
82 Finish,
83}
84
85/// What:
86/// The decision for an open intent from the branch's current value.
87/// Why:
88/// The branch is the commit point: still at the old value means nothing happened, at the new value or past it
89/// (`new_is_ancestor_of_current`) means the batch landed, anywhere else means it cannot land now.
90///
91/// In TS you'd write (pseudocode):
92/// ```ts
93/// recoveryFor(intent, current, newIsAncestorOfCurrent)
94/// ```
95pub fn recovery_for(intent: &Intent, current: &str, new_is_ancestor_of_current: bool) -> Recovery {
96 if current == intent.new || (current != intent.old && new_is_ancestor_of_current) {
97 return Recovery::Finish;
98 }
99 return Recovery::Drop;
100}
101
102/// The text of an intent: `key=value` lines, tabs between list items.
103pub fn intent_text(intent: &Intent) -> String {
104 let cleanup: String = intent.cleanup.as_ref().map_or_else(String::new, |found| {
105 return format!("{}\t{}\t{}", found.git_dir.display(), found.scratch.display(), u8::from(found.reftable));
106 });
107 return format!(
108 "seq={}\nrepository={}\ncommon={}\nref={}\nold={}\nnew={}\nids={}\ncommits={}\npaths={}\nderived={}\ncleanup={}\nindex={}\ngitdir={}\n",
109 intent.seq,
110 intent.repository.display(),
111 intent.common_dir.display(),
112 intent.reference,
113 intent.old,
114 intent.new,
115 intent.ids.join("\t"),
116 intent.commits.join("\t"),
117 intent.paths.join("\t"),
118 u8::from(intent.derived),
119 cleanup,
120 intent.index.display(),
121 intent.git_dir.display(),
122 );
123}
124
125/// What:
126/// The worktree's own Git directory, read from the worktree's `.git` entry: the entry itself when it is a directory, the
127/// directory a `gitdir:` file names for a linked worktree, and `.git` under the worktree when it cannot be read.
128/// Why:
129/// An intent written before it recorded its Git directory has only the repository to name the worktree by; the worktree's
130/// instance is the hash of this directory, so the recovery that finds the intent must resolve it the way a commit does.
131///
132/// In TS you'd write (pseudocode):
133/// ```ts
134/// gitDirOfWorktree('/work/repo') // '/work/repo/.git', or '/work/main/.git/worktrees/repo' for a linked worktree
135/// ```
136fn git_dir_of_worktree(repository: &Path) -> PathBuf {
137 return match dot_git_target(repository) {
138 Some(Some((git_dir, _))) => git_dir,
139 Some(None) | None => repository.join(".git"),
140 };
141}
142
143/// What:
144/// Reads an intent's text.
145/// Why:
146/// A damaged file is no intent: recovery drops it.
147///
148/// In TS you'd write (pseudocode):
149/// ```ts
150/// parseIntent(text) // undefined when damaged
151/// ```
152pub fn parse_intent(text: &str) -> Option<Intent> {
153 let value = |key: &str| -> Option<&str> { return text.lines().find_map(|line| { return line.strip_prefix(key)?.strip_prefix('='); }); };
154 let list = |key: &str| -> Option<Vec<String>> {
155 return Some(value(key)?.split('\t').filter(|item| { return !item.is_empty(); }).map(str::to_string).collect());
156 };
157 let index: PathBuf = value("index").map_or_else(|| { return PathBuf::from(value("repository").unwrap_or("")).join(".git").join("index"); }, PathBuf::from);
158 let repository: PathBuf = PathBuf::from(value("repository")?);
159 // An intent written before the Git directory was recorded names its worktree by the repository it was made for, never
160 // by the directory its index sits in, which `GIT_INDEX_FILE` can put anywhere.
161 let worktree_git_dir: PathBuf = value("gitdir").map_or_else(|| { return git_dir_of_worktree(repository.as_path()); }, PathBuf::from);
162 return Some(Intent {
163 seq: value("seq")?.parse().ok()?,
164 repository,
165 common_dir: PathBuf::from(value("common")?),
166 reference: value("ref")?.to_string(),
167 old: value("old")?.to_string(),
168 new: value("new")?.to_string(),
169 ids: list("ids")?,
170 commits: list("commits")?,
171 paths: list("paths")?,
172 // Intents written before these fields existed name neither.
173 derived: value("derived") == Some("1"),
174 index,
175 git_dir: worktree_git_dir,
176 cleanup: value("cleanup").and_then(|entry| {
177 let mut parts = entry.split('\t');
178 let (git_dir, scratch, reftable) = (parts.next()?, parts.next()?, parts.next()?);
179 return Some(Cleanup { git_dir: PathBuf::from(git_dir), scratch: PathBuf::from(scratch), reftable: reftable == "1" });
180 }),
181 });
182}
183
184/// The path of a batch's file with an extension.
185pub fn batch_path(dir: &Path, seq: u64, extension: &str) -> PathBuf {
186 return dir.join(format!("{seq}.{extension}"));
187}
188
189/// What:
190/// Writes an intent durably: the file is synced, renamed into place, and the directory is synced.
191/// Why:
192/// This is the only synchronous write of a batch, whatever its size.
193///
194/// In TS you'd write (pseudocode):
195/// ```ts
196/// await writeIntent(dir, intent)
197/// ```
198pub fn write_intent(dir: &Path, intent: &Intent) -> std::io::Result<()> {
199 let temporary: PathBuf = batch_path(dir, intent.seq, TEMPORARY_EXTENSION);
200 let mut file: File = File::create(&temporary)?;
201 file.write_all(intent_text(intent).as_bytes())?;
202 file.sync_all()?;
203 fs::rename(&temporary, batch_path(dir, intent.seq, OPEN_EXTENSION))?;
204 return File::open(dir)?.sync_all();
205}
206
207/// Marks a batch finished. Not synced: if the mark is lost a restart redoes an idempotent step.
208pub fn mark_done(dir: &Path, seq: u64) -> std::io::Result<()> {
209 return fs::rename(batch_path(dir, seq, OPEN_EXTENSION), batch_path(dir, seq, DONE_EXTENSION));
210}
211
212/// What:
213/// The intents in the journal with an extension, in batch order, and the abandoned temporary files removed on the way.
214/// Why:
215/// Recovery reads the open ones; startup reads the done ones for the request identities. A temporary file younger than the
216/// grace may be another instance's write in progress, so it stays.
217///
218/// In TS you'd write (pseudocode):
219/// ```ts
220/// listIntents(dir, 'intent')
221/// ```
222pub fn list_intents(dir: &Path, extension: &str) -> Vec<(PathBuf, Option<Intent>)> {
223 let Ok(entries) = fs::read_dir(dir) else {
224 return Vec::new();
225 };
226 let mut found: Vec<(PathBuf, Option<Intent>)> = Vec::new();
227 for entry in entries.flatten() {
228 let path: PathBuf = entry.path();
229 match path.extension().and_then(|text| { return text.to_str(); }) {
230 Some(TEMPORARY_EXTENSION) => {
231 // Only the remains of a write that was killed: a younger file may be another instance's write in progress,
232 // and removing it would fail that instance's rename.
233 let abandoned: bool = entry
234 .metadata()
235 .and_then(|meta| { return meta.modified(); })
236 .is_ok_and(|modified| { return modified.elapsed().is_ok_and(|age| { return age > TEMPORARY_GRACE; }); });
237 if abandoned {
238 let _removed = fs::remove_file(&path);
239 }
240 }
241 Some(found_extension) if found_extension == extension => {
242 let parsed: Option<Intent> = fs::read_to_string(&path).ok().and_then(|text| { return parse_intent(text.as_str()); });
243 found.push((path, parsed));
244 }
245 _ => {}
246 }
247 }
248 found.sort_by_key(|(path, intent)| {
249 return intent.as_ref().map_or_else(|| { return path.to_string_lossy().len() as u64; }, |parsed| { return parsed.seq; });
250 });
251 return found;
252}
253
254/// How many finished batches a journal keeps. A request is answered as already landed only when its own client sends it again
255/// after a broken connection: at most `RECONNECTS` times, each after a pause of milliseconds and a wait for a service to
256/// start of at most `START_WAIT` (`service_client.rs`), so the resend of a landed request arrives within seconds of the
257/// landing. A service lands a batch in the time of a few Git processes, so this many batches reach back past any such resend.
258pub const DONE_RETAINED: usize = 1024;
259
260/// What:
261/// Removes the files of the oldest finished batches, keeping the newest `retained`, and gives how many it removed.
262/// Why:
263/// A finished batch's file is only memory of the request identities it landed. Nothing else removed them, so a journal grew by
264/// a file per batch for as long as the worktree lived. Batch numbers increase with time, so the oldest are the smallest.
265/// Only files named as a batch number with the finished extension are touched, and a file that cannot be removed is left.
266///
267/// In TS you'd write (pseudocode):
268/// ```ts
269/// await pruneDone(dir, DONE_RETAINED) // 3: removed the three oldest
270/// ```
271pub fn prune_done(dir: &Path, retained: usize) -> usize {
272 let Ok(entries) = fs::read_dir(dir) else {
273 return 0;
274 };
275 let mut finished: Vec<(u64, PathBuf)> = entries
276 .flatten()
277 .filter_map(|entry| {
278 let path: PathBuf = entry.path();
279 if path.extension()? != DONE_EXTENSION || !entry.file_type().ok()?.is_file() {
280 return None;
281 }
282 let seq: u64 = path.file_stem()?.to_str()?.parse().ok()?;
283 return Some((seq, path));
284 })
285 .collect();
286 finished.sort_by_key(|(seq, _)| { return std::cmp::Reverse(*seq); });
287 return finished.into_iter().skip(retained).filter(|(_, path)| { return fs::remove_file(path).is_ok(); }).count();
288}
289
290/// The request identities of every finished batch, each with the commit it became.
291pub fn landed_commits(dir: &Path) -> HashMap<String, String> {
292 let mut landed: HashMap<String, String> = HashMap::new();
293 for (_, intent) in list_intents(dir, DONE_EXTENSION) {
294 if let Some(parsed) = intent {
295 landed.extend(parsed.ids.into_iter().zip(parsed.commits));
296 }
297 }
298 return landed;
299}
300
301/// Journal controls stay out of the release executable.
302#[cfg(test)]
303#[path = "service_journal_tests.rs"]
304mod tests;