Skip to main content

git_policy_cli/
service_land.rs

1//! What:
2//!  The service's serialized step: land a batch of prepared commits on a branch, and finish the batches a killed
3//!  service left open.
4//! Why:
5//!  Everything that must happen one commit at a time happens here, in a fixed order, with one durable write: the objects
6//!  of the whole batch are written by one `fast-import`, the intent is journaled, the branch moves by one compare-and-swap,
7//!  the index entries of the named paths are updated, and the intent is marked done
8//!  (`doc/planning/cli-git-resident-service.md`, "The serialized step, group commit, FIFO and backpressure").
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! const outcomes = await landBatch(context, seq, requests);
13//! ```
14
15use super::service_fault::reach;
16use super::service_journal::{Intent, Recovery, list_intents, mark_done, recovery_for, write_intent, OPEN_EXTENSION};
17use super::service_protocol::{CommitRequest, FailureCode};
18use super::transaction_git::{GitContext, GitOutput, GitRequest, run_git};
19use std::collections::{BTreeSet, HashMap};
20use std::path::{Path, PathBuf};
21
22/// How many times a batch is built again when the branch moved under it before the failure is reported.
23pub const LANDING_ATTEMPTS: usize = 3;
24
25/// How many times the index update is tried while another program holds the index.
26const INDEX_ATTEMPTS: usize = 5;
27
28/// The pause between index tries.
29const INDEX_PAUSE: std::time::Duration = std::time::Duration::from_millis(100);
30
31/// What the service needs to land: how to run Git and where the journal lives.
32pub struct LandContext {
33    /// How to run the real Git, with the bounds of `child_class.rs`.
34    pub git: GitContext,
35    /// Directory of the journal.
36    pub journal: PathBuf,
37    /// Test seam: called after the batch's objects exist and before the branch moves.
38    pub on_objects_written: Option<Box<dyn Fn() + Send + Sync>>,
39}
40
41/// What became of one request of a batch.
42#[derive(Clone, Debug, Eq, PartialEq)]
43pub enum Outcome {
44    /// The commit is on the branch.
45    Landed { commit: String },
46    /// A named path changed on the branch since the request's base.
47    RetryPrepare { base: String },
48}
49
50fn run(git: &GitContext, repository: &Path, arguments: &[&str], input: Option<Vec<u8>>) -> Result<GitOutput, FailureCode> {
51    let mut request: GitRequest = GitRequest::new(repository, arguments);
52    request.input = input;
53    let output: GitOutput = run_git(git, &request).map_err(|_| { return FailureCode::Unavailable; })?;
54    return Ok(output);
55}
56
57fn classify(output: &GitOutput) -> FailureCode {
58    let text: String = output.error_text().to_lowercase();
59    if text.contains("no space left") || text.contains("quota exceeded") {
60        return FailureCode::RepositoryFull;
61    }
62    if text.contains("read-only file system") || text.contains("permission denied") {
63        return FailureCode::RepositoryReadOnly;
64    }
65    if text.contains("index.lock") {
66        return FailureCode::IndexInUse;
67    }
68    return FailureCode::Unavailable;
69}
70
71fn checked(git: &GitContext, repository: &Path, arguments: &[&str], input: Option<Vec<u8>>) -> Result<String, FailureCode> {
72    let output: GitOutput = run(git, repository, arguments, input)?;
73    if !output.succeeded() {
74        return Err(classify(&output));
75    }
76    return Ok(String::from_utf8_lossy(output.stdout.as_slice()).trim().to_string());
77}
78
79/// A path in the quoted form `fast-import` reads, so no byte of a name can end the line or the field.
80fn quoted(path: &str) -> String {
81    let mut text: String = String::from("\"");
82    for character in path.chars() {
83        match character {
84            '"' => text.push_str("\\\""),
85            '\\' => text.push_str("\\\\"),
86            other => text.push(other),
87        }
88    }
89    text.push('"');
90    return text;
91}
92
93/// Whether two repository paths touch the same file or one contains the other.
94fn overlaps(path: &str, changed: &str) -> bool {
95    return path == changed || path.starts_with(&format!("{changed}/")) || changed.starts_with(&format!("{path}/"));
96}
97
98/// The stream that makes one commit per request on a scratch branch that starts at `old`.
99fn import_stream(scratch: &str, old: &str, requests: &[&CommitRequest]) -> Vec<u8> {
100    let mut bytes: Vec<u8> = Vec::new();
101    for (index, request) in requests.iter().enumerate() {
102        bytes.extend(format!("commit {scratch}\nmark :{}\n", index + 1).as_bytes());
103        if let Some(author) = &request.author {
104            bytes.extend(format!("author {author}\n").as_bytes());
105        }
106        bytes.extend(format!("committer {}\ndata {}\n", request.committer, request.message.len()).as_bytes());
107        bytes.extend(request.message.as_bytes());
108        bytes.push(b'\n');
109        if index == 0 {
110            bytes.extend(format!("from {old}\n").as_bytes());
111        }
112        for change in &request.changes {
113            match &change.blob {
114                Some(blob) => bytes.extend(format!("M {:o} {blob} {}\n", change.mode, quoted(change.path.as_str())).as_bytes()),
115                None => bytes.extend(format!("D {}\n", quoted(change.path.as_str())).as_bytes()),
116            }
117        }
118        bytes.push(b'\n');
119    }
120    bytes.extend(b"done\n");
121    return bytes;
122}
123
124/// The object names `fast-import` recorded for the marks, in request order.
125fn commits_from_marks(text: &str, count: usize) -> Option<Vec<String>> {
126    let mut commits: Vec<Option<String>> = vec![None; count];
127    for line in text.lines() {
128        let mut parts = line.split(' ');
129        let (Some(mark), Some(name)) = (parts.next(), parts.next()) else { continue };
130        let slot: usize = mark.trim_start_matches(':').parse::<usize>().ok()?;
131        if slot >= 1 && slot <= count {
132            commits[slot - 1] = Some(name.to_string());
133        }
134    }
135    return commits.into_iter().collect();
136}
137
138/// The paths of the repository's index entries a request names, in the form `reset` reads literally.
139fn literal(path: &str) -> String {
140    return format!(":(top,literal){path}");
141}
142
143/// Updates the index entries of `paths` to the commit `new`, trying again while another program holds the index.
144fn update_index(git: &GitContext, repository: &Path, new: &str, paths: &[String]) -> Result<(), FailureCode> {
145    if paths.is_empty() {
146        // `reset` with no path would reset the whole index; an empty commit changes no entry.
147        return Ok(());
148    }
149    let specs: Vec<String> = paths.iter().map(|path| { return literal(path.as_str()); }).collect();
150    let mut arguments: Vec<&str> = vec!["reset", "-q", new, "--"];
151    arguments.extend(specs.iter().map(String::as_str));
152    let mut last: FailureCode = FailureCode::Unavailable;
153    for attempt in 0..INDEX_ATTEMPTS {
154        match checked(git, repository, &arguments, None) {
155            Ok(_) => return Ok(()),
156            Err(code) => last = code,
157        }
158        if attempt + 1 < INDEX_ATTEMPTS {
159            std::thread::sleep(INDEX_PAUSE);
160        }
161    }
162    return Err(last);
163}
164
165/// What:
166///  Lands a batch of requests that share a repository, a common git directory and a branch.
167/// Why:
168///   Requests whose paths changed on the branch since their base are sent back to be prepared again; the others become
169///  one commit each, in order, on the branch, with one journal write and one branch update for all of them.
170///
171/// In TS you'd write (pseudocode):
172/// ```ts
173/// await landBatch(context, seq, requests) // one outcome per request, in order
174/// ```
175pub fn land_batch(context: &LandContext, seq: u64, requests: &[CommitRequest]) -> Result<Vec<Outcome>, FailureCode> {
176    let first: &CommitRequest = requests.first().ok_or(FailureCode::Rejected)?;
177    if requests.iter().any(|request| {
178        return request.repository != first.repository || request.common_dir != first.common_dir || request.reference != first.reference;
179    }) {
180        return Err(FailureCode::Rejected);
181    }
182    let repository: &Path = first.repository.as_path();
183    let git: GitContext = GitContext { common_dir: Some(first.common_dir.clone()), ..context.git.clone() };
184    let scratch: String = format!("refs/cli-git-service/{seq}");
185    let mut outcomes: Vec<Outcome>;
186    let mut attempts_left: usize = LANDING_ATTEMPTS;
187    loop {
188        let old: String = checked(&git, repository, &["rev-parse", "--verify", first.reference.as_str()], None)?;
189        // One tree comparison per distinct base: the paths that changed on the branch since then.
190        let mut changed_since: HashMap<&str, BTreeSet<String>> = HashMap::new();
191        let mut landing: Vec<(usize, &CommitRequest)> = Vec::new();
192        outcomes = vec![Outcome::RetryPrepare { base: old.clone() }; requests.len()];
193        for (index, request) in requests.iter().enumerate() {
194            if request.base != old && !changed_since.contains_key(request.base.as_str()) {
195                let listing: String = checked(&git, repository, &["diff-tree", "-r", "--name-only", "-z", request.base.as_str(), old.as_str()], None)?;
196                changed_since.insert(request.base.as_str(), listing.split('\0').filter(|name| { return !name.is_empty(); }).map(str::to_string).collect());
197            }
198            let conflicts: bool = request.base != old
199                && request.changes.iter().any(|change| {
200                    return changed_since.get(request.base.as_str()).is_some_and(|names| { return names.iter().any(|name| { return overlaps(change.path.as_str(), name.as_str()); }); });
201                });
202            if !conflicts {
203                landing.push((index, request));
204            }
205        }
206        if landing.is_empty() {
207            return Ok(outcomes);
208        }
209        let batch: Vec<&CommitRequest> = landing.iter().map(|(_, request)| { return *request; }).collect();
210        let marks: PathBuf = context.journal.join(format!("marks-{seq}"));
211        let marks_argument: String = format!("--export-marks={}", marks.display());
212        checked(&git, repository, &["fast-import", "--quiet", "--done", marks_argument.as_str()], Some(import_stream(scratch.as_str(), old.as_str(), &batch)))?;
213        let marks_text: String = std::fs::read_to_string(&marks).map_err(|_| { return FailureCode::Unavailable; })?;
214        let _marks_removed = std::fs::remove_file(&marks);
215        let commits: Vec<String> = commits_from_marks(marks_text.as_str(), batch.len()).ok_or(FailureCode::Unavailable)?;
216        let new: String = commits.last().cloned().ok_or(FailureCode::Unavailable)?;
217        reach("after-objects");
218        if let Some(seam) = &context.on_objects_written {
219            seam();
220        }
221        let paths: Vec<String> = batch.iter().flat_map(|request| { return request.changes.iter().map(|change| { return change.path.clone(); }); }).collect();
222        let intent: Intent = Intent {
223            seq,
224            repository: first.repository.clone(),
225            common_dir: first.common_dir.clone(),
226            reference: first.reference.clone(),
227            old: old.clone(),
228            new: new.clone(),
229            ids: batch.iter().map(|request| { return request.id.clone(); }).collect(),
230            commits: commits.clone(),
231            paths: paths.clone(),
232        };
233        write_intent(context.journal.as_path(), &intent).map_err(|error| { return storage_failure(&error); })?;
234        reach("after-intent");
235        let transaction: String = format!("start\nupdate {} {new} {old}\ndelete {scratch}\nprepare\ncommit\n", first.reference);
236        let moved: GitOutput = run(&git, repository, &["update-ref", "--stdin"], Some(transaction.into_bytes()))?;
237        if !moved.succeeded() {
238            // The branch moved under the batch: forget the intent and the scratch branch, and build the batch again.
239            let _intent_removed = std::fs::remove_file(super::service_journal::batch_path(context.journal.as_path(), seq, OPEN_EXTENSION));
240            let _deleted = run(&git, repository, &["update-ref", "-d", scratch.as_str()], None);
241            attempts_left -= 1;
242            if attempts_left == 0 {
243                return Err(FailureCode::BranchKeepsChanging);
244            }
245            continue;
246        }
247        reach("after-ref");
248        // The branch has moved: the commits exist whatever happens to the index, which recovery finishes.
249        if update_index(&git, repository, new.as_str(), &paths).is_ok() {
250            reach("after-index");
251            let _marked = mark_done(context.journal.as_path(), seq);
252        }
253        for (slot, (index, _)) in landing.iter().enumerate() {
254            outcomes[*index] = Outcome::Landed { commit: commits[slot].clone() };
255        }
256        return Ok(outcomes);
257    }
258}
259
260fn storage_failure(error: &std::io::Error) -> FailureCode {
261    return match error.raw_os_error() {
262        Some(code) if code == 28 || code == 122 => FailureCode::RepositoryFull,
263        Some(code) if code == 30 || code == 13 => FailureCode::RepositoryReadOnly,
264        _ => FailureCode::Unavailable,
265    };
266}
267
268/// One thing a restart did, for the service's log.
269#[derive(Clone, Debug, Eq, PartialEq)]
270pub struct RecoveryReport {
271    /// The batch.
272    pub seq: u64,
273    /// What was decided.
274    pub decision: Recovery,
275}
276
277/// What:
278///  Finishes or drops every batch the journal holds open.
279/// Why:
280///   Runs before the service accepts a connection, and again before each batch for an index update that had to wait.
281///
282/// In TS you'd write (pseudocode):
283/// ```ts
284/// await recover(context)
285/// ```
286pub fn recover(context: &LandContext) -> Vec<RecoveryReport> {
287    let mut reports: Vec<RecoveryReport> = Vec::new();
288    for (path, parsed) in list_intents(context.journal.as_path(), OPEN_EXTENSION) {
289        let Some(intent) = parsed else {
290            let _removed = std::fs::remove_file(&path);
291            continue;
292        };
293        let git: GitContext = GitContext { common_dir: Some(intent.common_dir.clone()), ..context.git.clone() };
294        let current: String = checked(&git, intent.repository.as_path(), &["rev-parse", "--verify", intent.reference.as_str()], None).unwrap_or_default();
295        let ancestor: bool = run(&git, intent.repository.as_path(), &["merge-base", "--is-ancestor", intent.new.as_str(), current.as_str()], None)
296            .is_ok_and(|output| { return output.succeeded(); });
297        let decision: Recovery = recovery_for(&intent, current.as_str(), ancestor);
298        match decision {
299            Recovery::Drop => {
300                let _removed = std::fs::remove_file(&path);
301            }
302            Recovery::Finish => {
303                if update_index(&git, intent.repository.as_path(), intent.new.as_str(), &intent.paths).is_ok() {
304                    let _marked = mark_done(context.journal.as_path(), intent.seq);
305                }
306            }
307        }
308        reports.push(RecoveryReport { seq: intent.seq, decision });
309    }
310    return reports;
311}
312
313/// Landing controls stay out of the release executable.
314#[cfg(test)]
315#[path = "service_land_tests.rs"]
316mod tests;