git_policy_cli/landing/service_durable.rs
1//! What:
2//! Where the landing's own syncs wait: not on the path the person waits on, unless the landing publishes nothing.
3//! Why:
4//! The owner's rule for the commit protocol, 2026-11-03: "Force at leaving, else background": no forced write while the person
5//! waits, so a power cut loses that attempt exactly as it does with native Git (staged changes and files are intact, the
6//! commit is run again), and whatever the command already promised when it left (an error, a withdrawal, a hand-over) was
7//! forced to disk first. A landing that publishes a commit therefore only queues the syncs of its records (the intent, the
8//! lock marker, the completion record, the done mark and the directory syncs); one background thread makes them in batches,
9//! each batch overlapped. A landing that publishes nothing syncs what it wrote before it returns. Git's own object, reference
10//! and index writes follow `core.fsync` as they do for native Git: the in-process index writer flushes only when
11//! `core.fsync` names the index (`ConfigSnapshot::hardens_index`).
12//! Nothing here changes what a killed process leaves: the page cache keeps every write whether or not it was synced.
13//!
14//! In TS you'd write (pseudocode):
15//! ```ts
16//! const landing = beginDeferral({ hardensIndex });
17//! // ... flushFile(file, 'intent-file') queues; flushDir(dir, 'intent-dir') queues ...
18//! landing.settle(published); // true: hand the queue to the background thread; false: sync it now
19//! ```
20
21use std::cell::{Cell, RefCell};
22use std::fs::File;
23use std::sync::OnceLock;
24use std::sync::mpsc::{Receiver, Sender, channel};
25
26/// One sync waiting for its turn: a handle on the file or directory, and what it is for.
27struct Pending {
28 /// A handle on the same file or directory the record lives in; a flush through it flushes the file itself.
29 handle: File,
30 /// What the sync is for, as the controls and the log name it.
31 what: &'static str,
32}
33
34/// What the background thread is asked to do.
35enum Job {
36 /// Sync every handle of a landing, all at once.
37 Batch(Vec<Pending>),
38 /// Answer on the channel when every earlier job is done.
39 Barrier(Sender<()>),
40}
41
42thread_local! {
43 /// Whether the landing on this thread queues its syncs (`true`) or makes them where it asks (`false`).
44 static DEFERRING: Cell<bool> = const { Cell::new(false) };
45 /// Whether the index the landing writes is flushed before it is installed: `core.fsync` names the index, or nothing said.
46 static INDEX_HARDENED: Cell<bool> = const { Cell::new(true) };
47 /// The syncs this landing has queued so far.
48 static QUEUED: RefCell<Vec<Pending>> = const { RefCell::new(Vec::new()) };
49}
50
51/// The sender of the one background thread, started by the first landing that publishes.
52static BACKGROUND: OnceLock<Sender<Job>> = OnceLock::new();
53
54/// How many syncs the background thread has made, for a control that waits for them.
55pub static BACKGROUND_SYNCED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
56
57/// Sync every handle of a batch at once, one thread each, and log what failed: nobody waits for this, so a failure cannot
58/// fail a commit that was already answered, and the next landing or restart handles the record as it would after a power cut.
59fn sync_batch(batch: Vec<Pending>) {
60 std::thread::scope(|scope| {
61 for pending in &batch {
62 scope.spawn(move || {
63 if let Err(error) = pending.handle.sync_all() {
64 crate::service_warn!("the background sync of the {} failed: {error}", pending.what);
65 }
66 BACKGROUND_SYNCED.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
67 });
68 }
69 });
70}
71
72/// The background thread: takes the jobs in order until the sender is gone.
73fn background(jobs: &Receiver<Job>) {
74 while let Ok(job) = jobs.recv() {
75 match job {
76 Job::Batch(batch) => sync_batch(batch),
77 Job::Barrier(done) => {
78 let _answered = done.send(());
79 }
80 }
81 }
82}
83
84/// The sender of the background thread, started when first asked for.
85fn sender() -> &'static Sender<Job> {
86 return BACKGROUND.get_or_init(|| {
87 let (sender, jobs) = channel::<Job>();
88 let started = std::thread::Builder::new().name(String::from("cli-git-durable")).spawn(move || { background(&jobs); });
89 if let Err(error) = started {
90 crate::service_warn!("the thread that makes the landing's syncs could not start ({error}); they are made where they are asked for");
91 }
92 return sender;
93 });
94}
95
96/// Waits until every sync handed to the background thread so far is done.
97/// A control uses it to see the queue empty; the service does not wait for it on a commit's path.
98pub fn drain() {
99 let (done, answered) = channel::<()>();
100 if sender().send(Job::Barrier(done)).is_ok() {
101 let _waited = answered.recv();
102 }
103}
104
105/// What a landing holds while it runs: its syncs are queued, and it settles them when it ends.
106pub struct Deferral {
107 /// Whether `settle` has run; a deferral dropped without it (a panic) syncs what it queued.
108 settled: bool,
109}
110
111/// Starts queueing the syncs of the landing on this thread.
112/// `hardens_index` says whether the index file the landing installs is flushed before the rename (`core.fsync` names it).
113pub fn begin(hardens_index: bool) -> Deferral {
114 DEFERRING.with(|flag| { flag.set(true); });
115 INDEX_HARDENED.with(|flag| { flag.set(hardens_index); });
116 QUEUED.with(|queue| { queue.borrow_mut().clear(); });
117 return Deferral { settled: false };
118}
119
120/// Ends the deferral on this thread and returns what it queued.
121fn end() -> Vec<Pending> {
122 DEFERRING.with(|flag| { flag.set(false); });
123 INDEX_HARDENED.with(|flag| { flag.set(true); });
124 return QUEUED.with(|queue| { return std::mem::take(&mut *queue.borrow_mut()); });
125}
126
127/// Syncs every handle now, all at once, and names each as waited for.
128fn force(batch: Vec<Pending>) {
129 for pending in &batch {
130 crate::service_fault::note(format!("sync-waited:{}", pending.what).as_str());
131 }
132 std::thread::scope(|scope| {
133 for pending in &batch {
134 scope.spawn(move || {
135 if let Err(error) = pending.handle.sync_all() {
136 crate::service_warn!("the sync of the {} failed: {error}", pending.what);
137 }
138 });
139 }
140 });
141}
142
143impl Deferral {
144 /// Ends the deferral. A landing that published a commit hands its queue to the background thread and returns at once; a
145 /// landing that published nothing (a withdrawal, an error, a commit sent back) syncs its records before it returns.
146 pub fn settle(mut self, published: bool) {
147 self.settled = true;
148 let batch: Vec<Pending> = end();
149 if batch.is_empty() {
150 return;
151 }
152 if published {
153 if let Err(rejected) = sender().send(Job::Batch(batch)) {
154 // The thread is gone, so the queue is made here rather than lost.
155 if let Job::Batch(returned) = rejected.0 {
156 force(returned);
157 }
158 }
159 } else {
160 force(batch);
161 }
162 }
163}
164
165impl Drop for Deferral {
166 fn drop(&mut self) {
167 if !self.settled {
168 force(end());
169 }
170 }
171}
172
173/// Flushes a file that is a landing's record: queued while a landing defers, made now otherwise.
174///
175/// # Errors
176///
177/// The error of the flush when it is made now.
178pub fn flush_file(file: &File, what: &'static str) -> std::io::Result<()> {
179 if DEFERRING.with(Cell::get) {
180 crate::service_fault::note(format!("sync-queued:{what}").as_str());
181 QUEUED.with(|queue| -> std::io::Result<()> {
182 queue.borrow_mut().push(Pending { handle: file.try_clone()?, what });
183 return Ok(());
184 })?;
185 return Ok(());
186 }
187 let synced: std::io::Result<()> = file.sync_all();
188 crate::service_fault::note(format!("sync-waited:{what}").as_str());
189 return synced;
190}
191
192/// Flushes a directory whose entries are a landing's records, as `flush_file` does for a file.
193///
194/// # Errors
195///
196/// The error of opening the directory, or of the flush when it is made now.
197pub fn flush_directory(directory: &std::path::Path, what: &'static str) -> std::io::Result<()> {
198 return flush_file(&File::open(directory)?, what);
199}
200
201/// Flushes the index file the landing writes, when the index is hardened: `core.fsync` names it, as native Git's index write
202/// is hardened then. Otherwise the index is written as native Git writes it, unflushed. It is made now, on the landing's path,
203/// as Git's own write is.
204///
205/// # Errors
206///
207/// The error of the flush.
208pub fn flush_index(file: &File, what: &'static str) -> std::io::Result<()> {
209 if INDEX_HARDENED.with(Cell::get) {
210 let synced: std::io::Result<()> = file.sync_all();
211 crate::service_fault::note(format!("sync-waited:{what}").as_str());
212 return synced;
213 }
214 return Ok(());
215}
216
217/// Whether the index file the landing writes is flushed before it is installed.
218pub fn index_is_hardened() -> bool {
219 return INDEX_HARDENED.with(Cell::get);
220}
221
222/// Controls for the queue stay out of the release executable.
223#[cfg(test)]
224#[path = "service_durable_tests.rs"]
225mod tests;