git_policy_cli/service/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 crate::repo_read_layout::dot_git_target;
16use crate::service_land_start::{IndexWitness, decode, encode};
17use crate::service_protocol::Cleanup;
18use std::collections::HashMap;
19use std::fs::{self, File};
20use std::io::Write;
21use std::path::{Path, PathBuf};
22
23/// Extension of an intent whose batch may be unfinished.
24pub const OPEN_EXTENSION: &str = "intent";
25
26/// Extension of a finished batch, kept so its request identities are remembered.
27pub const DONE_EXTENSION: &str = "done";
28
29/// Extension of a file being written; never read as an intent.
30const TEMPORARY_EXTENSION: &str = "tmp";
31
32/// Extension of the empty file that holds a batch number for the service landing it, from the moment the number is taken until
33/// the landing ends; never read as an intent.
34pub const CLAIM_EXTENSION: &str = "claim";
35
36/// 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
37/// milliseconds; the grace covers a writer stalled on a slow disk or a loaded host. Measured: the longest journal write step of
38/// 1,302 landings in the service journal (`journal_ms`, 2026-11-02T09:01:38Z to 2026-11-03T15:11:58Z) was 40,188 ms, the 99th
39/// percentile 3,792 ms, so ten minutes is fifteen times the longest. Kept as the coordinator's pick under the owner's delegation, open to the owner's veto (`doc/planning/cli-git-shipped-limits-audit.md`, "Sizes and ages of the service's own housekeeping").
40pub const TEMPORARY_GRACE: std::time::Duration = std::time::Duration::from_secs(10 * 60);
41
42/// What:
43/// What the service decided to do for a batch, written before the branch moves.
44/// Why:
45/// Enough to finish the batch after a kill: where the branch was, where it goes, which requests, which index paths.
46///
47/// In TS you'd write (pseudocode):
48/// ```ts
49/// type Intent = { seq; repository; commonDir; reference; old; new; ids; paths };
50/// ```
51#[derive(Clone, Debug, Eq, PartialEq)]
52pub struct Intent {
53 /// Number of the batch, unique and increasing within one journal.
54 pub seq: u64,
55 /// Worktree root whose index the batch updates.
56 pub repository: PathBuf,
57 /// Common git directory.
58 pub common_dir: PathBuf,
59 /// The branch.
60 pub reference: String,
61 /// The branch before the batch.
62 pub old: String,
63 /// The branch after the batch.
64 pub new: String,
65 /// Identities of the requests in the batch.
66 pub ids: Vec<String>,
67 /// The commit each request became, in the same order as the identities.
68 pub commits: Vec<String>,
69 /// Paths whose index entries the batch updates.
70 pub paths: Vec<String>,
71 /// Whether the index entries to update are the paths that differ between `old` and `new`, read from the repository
72 /// when they are needed (a commit Git built has no list of changes to carry, and its names are bytes).
73 pub derived: bool,
74 /// The conclusion state the landing removes after the branch moved; nothing for any other batch.
75 pub cleanup: Option<Cleanup>,
76 /// The real index whose lock the landing held, so a restart can remove its own stale lock and finish the index.
77 pub index: PathBuf,
78 /// The worktree's own Git directory, which names the instance that serves the batch; an intent written before it was
79 /// recorded reads as the Git directory of its repository's worktree.
80 pub git_dir: PathBuf,
81}
82
83/// What a restart does with an open intent.
84#[derive(Clone, Copy, Debug, Eq, PartialEq)]
85pub enum Recovery {
86 /// The batch never landed; forget the intent.
87 Drop,
88 /// The batch landed; make sure the index shows it, then remember the identities.
89 Finish,
90}
91
92/// What:
93/// The decision for an open intent from the branch's current value.
94/// Why:
95/// The branch is the commit point: still at the old value means nothing happened, at the new value or past it
96/// (`new_is_ancestor_of_current`) means the batch landed, anywhere else means it cannot land now.
97///
98/// In TS you'd write (pseudocode):
99/// ```ts
100/// recoveryFor(intent, current, newIsAncestorOfCurrent)
101/// ```
102pub fn recovery_for(intent: &Intent, current: &str, new_is_ancestor_of_current: bool) -> Recovery {
103 if current == intent.new || (current != intent.old && new_is_ancestor_of_current) {
104 return Recovery::Finish;
105 }
106 return Recovery::Drop;
107}
108
109/// The text of an intent: `key=value` lines, tabs between list items.
110pub fn intent_text(intent: &Intent) -> String {
111 let cleanup: String = intent.cleanup.as_ref().map_or_else(String::new, |found| {
112 return format!("{}\t{}\t{}", found.git_dir.display(), found.scratch.display(), u8::from(found.reftable));
113 });
114 return format!(
115 "seq={}\nrepository={}\ncommon={}\nref={}\nold={}\nnew={}\nids={}\ncommits={}\npaths={}\nderived={}\ncleanup={}\nindex={}\ngitdir={}\n",
116 intent.seq,
117 intent.repository.display(),
118 intent.common_dir.display(),
119 intent.reference,
120 intent.old,
121 intent.new,
122 intent.ids.join("\t"),
123 intent.commits.join("\t"),
124 intent.paths.join("\t"),
125 u8::from(intent.derived),
126 cleanup,
127 intent.index.display(),
128 intent.git_dir.display(),
129 );
130}
131
132/// What:
133/// The worktree's own Git directory, read from the worktree's `.git` entry: the entry itself when it is a directory, the
134/// directory a `gitdir:` file names for a linked worktree, and `.git` under the worktree when it cannot be read.
135/// Why:
136/// An intent written before it recorded its Git directory has only the repository to name the worktree by; the worktree's
137/// instance is the hash of this directory, so the recovery that finds the intent must resolve it the way a commit does.
138///
139/// In TS you'd write (pseudocode):
140/// ```ts
141/// gitDirOfWorktree('/work/repo') // '/work/repo/.git', or '/work/main/.git/worktrees/repo' for a linked worktree
142/// ```
143fn git_dir_of_worktree(repository: &Path) -> PathBuf {
144 return match dot_git_target(repository) {
145 Some(Some((git_dir, _))) => git_dir,
146 Some(None) | None => repository.join(".git"),
147 };
148}
149
150/// What:
151/// Reads an intent's text.
152/// Why:
153/// A damaged file is no intent: recovery drops it.
154///
155/// In TS you'd write (pseudocode):
156/// ```ts
157/// parseIntent(text) // undefined when damaged
158/// ```
159pub fn parse_intent(text: &str) -> Option<Intent> {
160 let value = |key: &str| -> Option<&str> { return text.lines().find_map(|line| { return line.strip_prefix(key)?.strip_prefix('='); }); };
161 let list = |key: &str| -> Option<Vec<String>> {
162 return Some(value(key)?.split('\t').filter(|item| { return !item.is_empty(); }).map(str::to_string).collect());
163 };
164 let index: PathBuf = value("index").map_or_else(|| { return PathBuf::from(value("repository").unwrap_or("")).join(".git").join("index"); }, PathBuf::from);
165 let repository: PathBuf = PathBuf::from(value("repository")?);
166 // An intent written before the Git directory was recorded names its worktree by the repository it was made for, never
167 // by the directory its index sits in, which `GIT_INDEX_FILE` can put anywhere.
168 let worktree_git_dir: PathBuf = value("gitdir").map_or_else(|| { return git_dir_of_worktree(repository.as_path()); }, PathBuf::from);
169 return Some(Intent {
170 seq: value("seq")?.parse().ok()?,
171 repository,
172 common_dir: PathBuf::from(value("common")?),
173 reference: value("ref")?.to_string(),
174 old: value("old")?.to_string(),
175 new: value("new")?.to_string(),
176 ids: list("ids")?,
177 commits: list("commits")?,
178 paths: list("paths")?,
179 // Intents written before these fields existed name neither.
180 derived: value("derived") == Some("1"),
181 index,
182 git_dir: worktree_git_dir,
183 cleanup: value("cleanup").and_then(|entry| {
184 let mut parts = entry.split('\t');
185 let (git_dir, scratch, reftable) = (parts.next()?, parts.next()?, parts.next()?);
186 return Some(Cleanup { git_dir: PathBuf::from(git_dir), scratch: PathBuf::from(scratch), reftable: reftable == "1" });
187 }),
188 });
189}
190
191/// The path of a batch's file with an extension.
192pub fn batch_path(dir: &Path, seq: u64, extension: &str) -> PathBuf {
193 return dir.join(format!("{seq}.{extension}"));
194}
195
196/// A batch number held for one landing; the number is free to take again when this goes out of scope.
197#[derive(Debug)]
198pub struct BatchClaim {
199 seq: u64,
200 path: PathBuf,
201}
202
203impl BatchClaim {
204 /// The number this claim holds.
205 pub fn seq(&self) -> u64 {
206 return self.seq;
207 }
208}
209
210/// The claim file goes with the landing, whichever way it ended; the intent (or the finished batch) keeps the number taken.
211impl Drop for BatchClaim {
212 fn drop(&mut self) {
213 let _removed = fs::remove_file(&self.path);
214 }
215}
216
217/// Whether the journal already holds a record under the number: an open intent or a finished batch.
218fn number_has_record(dir: &Path, seq: u64) -> bool {
219 return batch_path(dir, seq, OPEN_EXTENSION).exists() || batch_path(dir, seq, DONE_EXTENSION).exists();
220}
221
222/// What:
223/// Takes the first batch number from `first` up that no landing in the journal holds or has held, and holds it.
224/// Why:
225/// Services that share a journal count from clock readings, which can be equal, and a count that starts a few microseconds
226/// behind another's reaches the other's numbers after a few batches. A batch's files are named by its number, so two landings
227/// with one number would write one intent over the other and one finished record over the other, and a request that landed
228/// would be forgotten. The file is created with `O_EXCL`, so exactly one service owns a number; a number with an intent or a
229/// finished batch is skipped too, as the claim of a landing that has ended is gone while its record stays. The loop ends
230/// because the journal holds finitely many files and each pass moves to a number past one it holds.
231///
232/// In TS you'd write (pseudocode):
233/// ```ts
234/// let seq = first
235/// while (!(await createExclusive(claimPath(dir, seq))) || hasRecord(dir, seq)) seq += 1 // removes its own claim when it skips
236/// return claim(seq)
237/// ```
238///
239/// # Errors
240///
241/// The error of a claim file that cannot be created for a reason other than the number being held.
242pub fn claim_batch(dir: &Path, first: u64) -> std::io::Result<BatchClaim> {
243 let mut seq: u64 = first;
244 loop {
245 let path: PathBuf = batch_path(dir, seq, CLAIM_EXTENSION);
246 match File::options().write(true).create_new(true).open(&path) {
247 Ok(_created) => {
248 let claim: BatchClaim = BatchClaim { seq, path };
249 if !number_has_record(dir, seq) {
250 return Ok(claim);
251 }
252 // A landing that ended left its record under this number and took its claim away: the number is not free.
253 drop(claim);
254 }
255 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
256 Err(error) => return Err(error),
257 }
258 seq = seq.checked_add(1).ok_or_else(|| { return std::io::Error::other("the journal holds every batch number"); })?;
259 }
260}
261
262/// What:
263/// Writes an intent durably: the file is synced, renamed into place, and the directory is synced.
264/// Why:
265/// This is the only synchronous write of a batch, whatever its size.
266///
267/// In TS you'd write (pseudocode):
268/// ```ts
269/// await writeIntent(dir, intent)
270/// ```
271pub fn write_intent(dir: &Path, intent: &Intent) -> std::io::Result<()> {
272 let temporary: PathBuf = batch_path(dir, intent.seq, TEMPORARY_EXTENSION);
273 let mut file: File = File::create(&temporary)?;
274 file.write_all(intent_text(intent).as_bytes())?;
275 crate::service_durable::flush_file(&file, "intent-file")?;
276 fs::rename(&temporary, batch_path(dir, intent.seq, OPEN_EXTENSION))?;
277 return crate::service_durable::flush_directory(dir, "intent-dir");
278}
279
280/// The extension of the record that a batch's index update is installed.
281pub const INDEXED_EXTENSION: &str = "indexed";
282
283/// What:
284/// Records durably that the index update of the open batch `seq`, which landed the commit `new`, is installed.
285/// Why:
286/// The service writes it while it still holds the index lock, so no other program can have changed the installed index when
287/// the record exists, and a restart that finds it does not replay the update over what was staged after the lock was given up.
288/// The record names the commit, so a record left behind by a batch number that is used again does not stop another batch.
289///
290/// In TS you'd write (pseudocode):
291/// ```ts
292/// await markIndexed(dir, seq, newCommit)
293/// ```
294pub fn mark_indexed(dir: &Path, seq: u64, new: &str) -> std::io::Result<()> {
295 let temporary: PathBuf = batch_path(dir, seq, TEMPORARY_EXTENSION);
296 let mut file: File = File::create(&temporary)?;
297 file.write_all(format!("{new}\n").as_bytes())?;
298 crate::service_durable::flush_file(&file, "indexed-file")?;
299 fs::rename(&temporary, batch_path(dir, seq, INDEXED_EXTENSION))?;
300 crate::service_fault::note("indexed-recorded");
301 return crate::service_durable::flush_directory(dir, "indexed-dir");
302}
303
304/// The extension of the record of the live index file a batch started from.
305pub const START_EXTENSION: &str = "start";
306
307/// What:
308/// Records the witness of the live index file as the landing found it under the index lock.
309/// Why:
310/// A replay of the batch is made only over an index that is still this file (`service_land_start.rs`), so what another session
311/// staged since is never overwritten. It is written after the lock is held and before the branch moves. It is not synced: a
312/// power loss that loses it leaves a batch with no record, which no replay is made for, and a record that is lost is the safe
313/// side, so the landing does not wait for the disk for it.
314///
315/// In TS you'd write (pseudocode):
316/// ```ts
317/// await writeStart(dir, seq, witness)
318/// ```
319pub fn write_start(dir: &Path, seq: u64, witness: &IndexWitness) -> std::io::Result<()> {
320 let temporary: PathBuf = batch_path(dir, seq, TEMPORARY_EXTENSION);
321 let mut file: File = File::create(&temporary)?;
322 file.write_all(format!("{}\n", encode(witness)).as_bytes())?;
323 return fs::rename(&temporary, batch_path(dir, seq, START_EXTENSION));
324}
325
326/// What a batch's start record says.
327#[derive(Clone, Debug, Eq, PartialEq)]
328pub enum Started {
329 /// No usable record: an older batch, a landing that could not write one, or a record that is damaged. No replay is made.
330 Absent,
331 /// The index file the landing started from.
332 Recorded(IndexWitness),
333 /// A record exists and cannot be read now, so nothing is decided.
334 Unreadable,
335}
336
337/// The start record of the batch `seq`; a missing file or damaged text is no record, and a file that cannot be read now is
338/// unreadable.
339pub fn read_start(dir: &Path, seq: u64) -> Started {
340 return match fs::read_to_string(batch_path(dir, seq, START_EXTENSION)) {
341 Ok(text) => decode(text.trim_end_matches('\n')).map_or(Started::Absent, Started::Recorded),
342 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Started::Absent,
343 Err(error) if error.kind() == std::io::ErrorKind::InvalidData => Started::Absent,
344 Err(_) => Started::Unreadable,
345 };
346}
347
348/// Drops the start record of a batch that is finished or forgotten.
349pub fn remove_start(dir: &Path, seq: u64) {
350 let _removed = fs::remove_file(batch_path(dir, seq, START_EXTENSION));
351}
352
353/// What a batch's completion record says about its index update.
354#[derive(Clone, Copy, Debug, Eq, PartialEq)]
355pub enum Indexed {
356 /// No record: the update was not recorded as installed.
357 Absent,
358 /// The record names the commit: the update was installed.
359 Installed,
360 /// A record exists and cannot be read, or names another commit: nothing can be said about the index.
361 Unreadable,
362}
363
364/// What:
365/// What the completion record of the open batch `seq` that landed `new` says.
366/// Why:
367/// Only a missing record means the update was not installed. A record that is there and cannot be read says nothing, and a
368/// restart that took it for none would replay the update over whatever was staged since, so the caller stops on it.
369///
370/// In TS you'd write (pseudocode):
371/// ```ts
372/// await indexedState(dir, seq, newCommit) // 'absent' | 'installed' | 'unreadable'
373/// ```
374pub fn indexed_state(dir: &Path, seq: u64, new: &str) -> Indexed {
375 return match fs::read_to_string(batch_path(dir, seq, INDEXED_EXTENSION)) {
376 Ok(text) if text.trim_end() == new => Indexed::Installed,
377 Ok(_) => Indexed::Unreadable,
378 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Indexed::Absent,
379 Err(_) => Indexed::Unreadable,
380 };
381}
382
383/// What:
384/// Marks a batch finished and drops its record of the index update.
385/// Why:
386/// The rename is made durable before the record goes: a lost rename with the record already gone would let a restart replay
387/// an index update that was installed. A sync that fails keeps the record and the error says so.
388///
389/// In TS you'd write (pseudocode):
390/// ```ts
391/// await markDone(dir, seq) // rename intent to done; fsync dir; unlink indexed
392/// ```
393pub fn mark_done(dir: &Path, seq: u64) -> std::io::Result<()> {
394 fs::rename(batch_path(dir, seq, OPEN_EXTENSION), batch_path(dir, seq, DONE_EXTENSION))?;
395 crate::service_durable::flush_directory(dir, "journal-dir")?;
396 crate::service_fault::note("remove-indexed");
397 let _record_removed = fs::remove_file(batch_path(dir, seq, INDEXED_EXTENSION));
398 remove_start(dir, seq);
399 return Ok(());
400}
401
402/// What:
403/// The intents in the journal with an extension, in batch order, and the abandoned temporary files removed on the way.
404/// Why:
405/// Recovery reads the open ones; startup reads the done ones for the request identities. A temporary file younger than the
406/// grace may be another instance's write in progress, so it stays.
407///
408/// In TS you'd write (pseudocode):
409/// ```ts
410/// listIntents(dir, 'intent')
411/// ```
412pub fn list_intents(dir: &Path, extension: &str) -> Vec<(PathBuf, Option<Intent>)> {
413 let Ok(entries) = fs::read_dir(dir) else {
414 return Vec::new();
415 };
416 let mut found: Vec<(PathBuf, Option<Intent>)> = Vec::new();
417 for entry in entries.flatten() {
418 let path: PathBuf = entry.path();
419 match path.extension().and_then(|text| { return text.to_str(); }) {
420 Some(TEMPORARY_EXTENSION | CLAIM_EXTENSION) => {
421 // Only the remains of a write or a landing that was killed: a younger file may be another instance's write in
422 // progress, and removing it would fail that instance's rename or hand its number to a second landing.
423 let abandoned: bool = entry
424 .metadata()
425 .and_then(|meta| { return meta.modified(); })
426 .is_ok_and(|modified| { return modified.elapsed().is_ok_and(|age| { return age > TEMPORARY_GRACE; }); });
427 if abandoned {
428 let _removed = fs::remove_file(&path);
429 }
430 }
431 Some(found_extension) if found_extension == extension => {
432 let parsed: Option<Intent> = fs::read_to_string(&path).ok().and_then(|text| { return parse_intent(text.as_str()); });
433 found.push((path, parsed));
434 }
435 _ => {}
436 }
437 }
438 found.sort_by_key(|(path, intent)| {
439 return intent.as_ref().map_or_else(|| { return path.to_string_lossy().len() as u64; }, |parsed| { return parsed.seq; });
440 });
441 return found;
442}
443
444/// How many finished batches a journal keeps. A request is answered as already landed only when its own client sends it again
445/// after a broken connection: at most `RECONNECTS` times, each after a pause of milliseconds and a wait for a service to
446/// start of at most `START_WAIT` (`service_client.rs`), so the resend of a landed request arrives within seconds of the
447/// landing. A service lands a batch in the time of a few Git processes, so this many batches reach back past any such resend.
448/// Measured: in the service journal (1,085 landings of the `cli-git@` units, 2026-11-02T09:01:38Z to 2026-11-03T15:11:58Z) the busiest
449/// unit landed 4 batches in its busiest 10 seconds, 4 in its busiest minute and 15 in its busiest ten minutes, and 349 in all, so
450/// this many reach back over sixty times the busiest ten minutes. Kept as the coordinator's pick under the owner's delegation,
451/// open to the owner's veto (`doc/planning/cli-git-shipped-limits-audit.md`, "Sizes and ages of the service's own housekeeping").
452pub const DONE_RETAINED: usize = 1024;
453
454/// What:
455/// Removes the files of the oldest finished batches, keeping the newest `retained`, and gives how many it removed.
456/// Why:
457/// A finished batch's file is only memory of the request identities it landed. Nothing else removed them, so a journal grew by
458/// a file per batch for as long as the worktree lived. Batch numbers increase with time, so the oldest are the smallest.
459/// Only files named as a batch number with the finished extension are touched, and a file that cannot be removed is left.
460///
461/// In TS you'd write (pseudocode):
462/// ```ts
463/// await pruneDone(dir, DONE_RETAINED) // 3: removed the three oldest
464/// ```
465pub fn prune_done(dir: &Path, retained: usize) -> usize {
466 let Ok(entries) = fs::read_dir(dir) else {
467 return 0;
468 };
469 let mut finished: Vec<(u64, PathBuf)> = entries
470 .flatten()
471 .filter_map(|entry| {
472 let path: PathBuf = entry.path();
473 if path.extension()? != DONE_EXTENSION || !entry.file_type().ok()?.is_file() {
474 return None;
475 }
476 let seq: u64 = path.file_stem()?.to_str()?.parse().ok()?;
477 return Some((seq, path));
478 })
479 .collect();
480 finished.sort_by_key(|(seq, _)| { return std::cmp::Reverse(*seq); });
481 return finished.into_iter().skip(retained).filter(|(_, path)| { return fs::remove_file(path).is_ok(); }).count();
482}
483
484/// The request identities of every finished batch, each with the commit it became.
485pub fn landed_commits(dir: &Path) -> HashMap<String, String> {
486 let mut landed: HashMap<String, String> = HashMap::new();
487 for (_, intent) in list_intents(dir, DONE_EXTENSION) {
488 if let Some(parsed) = intent {
489 landed.extend(parsed.ids.into_iter().zip(parsed.commits));
490 }
491 }
492 return landed;
493}
494
495/// Journal controls stay out of the release executable.
496#[cfg(test)]
497#[path = "service_journal_tests.rs"]
498mod tests;