Skip to main content

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;