git_policy_cli/landing/service_round.rs
1//! What:
2//! The drain of several repositories in one round of the service's after-commit work, side by side.
3//! Why:
4//! Repositories share nothing: each has its own work directory, its own claims and its own pushes, so one repository's slow
5//! push or review never needs to hold up another's. The round's thread hands each repository to a worker and takes the answers
6//! back in the order the repositories were named (`parallel_runner.rs`), so the retry list is the one a drain of one after
7//! another gives. No lock is taken (`service_lock_inventory_tests.rs`): the handlers are shared read-only, every claim is made
8//! by an atomic rename as before, and the directories of one repository (a main checkout and its linked worktrees) share a lane
9//! and are drained one after another there, because they share a work directory and its upkeep.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! const retries = await drainDirectories({ directories, handlers, now, host });
14//! ```
15
16use crate::claim_time::ClaimTime;
17use crate::parallel_budget::{BudgetRequest, HostResources, worker_budget};
18use crate::parallel_runner::{Event, OrderedRun, run_ordered};
19use crate::push_retry::{RetryChange, Retrying};
20use crate::service_work::{CLAIM_LEASE, Drained, WorkHandlers, common_directory_of, drain_common_records_planned, drain_planned};
21use std::path::{Path, PathBuf};
22use std::time::Duration;
23
24/// The 90th percentile of `MemoryPeak` over the 28 commit-service units of this host that `systemctl --user show 'cli-git@*.service'
25/// --property=MemoryPeak` reported on 2026-11-03 (the units' peaks since each one's last start; the Git processes a service
26/// starts are inside the unit): 130,482,176 bytes. The median was 33,000,000 bytes, the 75th percentile 49,000,000 bytes, and the
27/// largest 1,073,766,400 bytes, a unit that had run under the one-gibibyte cap this build no longer sets. A unit serves one
28/// worktree for its whole life, so a unit's peak is an upper bound of what one repository's drain holds at once.
29pub const MEASURED_REPOSITORY_PEAK: u64 = 130_482_176;
30
31/// The bytes the drain of one repository holds at its peak: `MEASURED_REPOSITORY_PEAK` rounded up to a power of two, as
32/// `CONVERSION_MEMORY` rounds its measurement. The coordinator's pick under the owner's delegation, open to the owner's veto
33/// (`doc/planning/cli-git-shipped-limits-audit.md`, "Parallelism caps and memory estimates").
34pub const REPOSITORY_MEMORY: u64 = MEASURED_REPOSITORY_PEAK.next_power_of_two();
35
36/// What:
37/// One directory of a round, with the pushes of it that are due again.
38/// Why:
39/// The service loop owns the retry plan; it takes what is due for the directory out of the plan before the round and hands it to
40/// the directory's worker inside the request, as an owned value, so no worker reads the plan.
41///
42/// In TS you'd write (pseudocode):
43/// ```ts
44/// type Assignment = { directory: string; retrying: Retrying };
45/// ```
46#[derive(Clone, Debug)]
47pub struct Assignment {
48 /// The Git directory to drain.
49 pub directory: PathBuf,
50 /// The first pause and the pushes due again for this directory.
51 pub retrying: Retrying,
52}
53
54/// Methods over an assignment.
55impl Assignment {
56 /// A directory with no plan: a push that could pass is reported as failed.
57 pub fn plain(directory: PathBuf) -> Self {
58 return Self { directory, retrying: Retrying::none() };
59 }
60}
61
62/// What:
63/// What a round gives back: when to look again at the directories that have a claim another instance holds, and the changes the
64/// workers ask of the retry plan.
65/// Why:
66/// The waits keep the order the directories were named; the changes are in that order too, so applying them in sequence gives
67/// the plan a drain of one directory after another would.
68///
69/// In TS you'd write (pseudocode):
70/// ```ts
71/// type RoundAnswer = { waits: [string, number][]; changes: RetryChange[] };
72/// ```
73#[derive(Debug, Default)]
74pub struct RoundAnswer {
75 /// For each directory that has such a claim, how long until its lease runs out, in the order the directories were named.
76 pub waits: Vec<(PathBuf, Duration)>,
77 /// What the workers ask of the retry plan.
78 pub changes: Vec<RetryChange>,
79}
80
81/// What:
82/// What a round needs to drain its repositories.
83/// Why:
84/// One value names the inputs, so the call reads as a list of facts.
85///
86/// In TS you'd write (pseudocode):
87/// ```ts
88/// type RoundDrain<H> = { directories: string[]; handlers: H; now: ClaimTime; host: HostResources };
89/// ```
90pub struct RoundDrain<'a, H: WorkHandlers + Sync> {
91 /// The Git directories named by the round's wakes, each once, in the order they were first named, each with what is due again.
92 pub directories: &'a [Assignment],
93 /// What runs each kind of work; shared read-only by every worker.
94 pub handlers: &'a H,
95 /// The reading of the clock the round started at.
96 pub now: &'a ClaimTime,
97 /// The CPUs and memory the budget is derived from.
98 pub host: &'a HostResources,
99}
100
101/// What:
102/// The lane of a directory: the common directory of its repository when it names one, else the directory itself.
103/// Why:
104/// The directories of one repository share a work directory and its upkeep, so they are drained one after another.
105///
106/// In TS you'd write (pseudocode):
107/// ```ts
108/// const lane = commonDirectoryOf(gitDir) ?? gitDir;
109/// ```
110fn lane_key(directory: &Path) -> PathBuf {
111 return common_directory_of(directory).unwrap_or_else(|| { return directory.to_path_buf(); });
112}
113
114/// What:
115/// Drain every directory, repositories side by side, and give, for each directory that has a claim another instance holds, how
116/// long until that claim's lease runs out, in the order the directories were named.
117/// Why:
118/// See the module description.
119///
120/// In TS you'd write (pseudocode):
121/// ```ts
122/// const answer = await drainDirectories({ directories, handlers, now, host });
123/// ```
124pub fn drain_directories<H: WorkHandlers + Sync>(request: &RoundDrain<'_, H>) -> RoundAnswer {
125 let RoundDrain { directories, handlers, now, host } = *request;
126 let mut lanes: Vec<Vec<usize>> = Vec::new();
127 let mut lane_of: std::collections::HashMap<PathBuf, usize> = std::collections::HashMap::new();
128 for (index, assignment) in directories.iter().enumerate() {
129 match lane_of.get(&lane_key(assignment.directory.as_path())) {
130 Some(&lane) => lanes[lane].push(index),
131 None => {
132 lane_of.insert(lane_key(assignment.directory.as_path()), lanes.len());
133 lanes.push(vec![index]);
134 }
135 }
136 }
137 let workers: usize = worker_budget(&BudgetRequest { items: lanes.len(), per_item_memory: REPOSITORY_MEMORY, ceiling: None, cap: None, host });
138 let mut answer: RoundAnswer = RoundAnswer::default();
139 run_ordered(OrderedRun {
140 items: directories,
141 lanes: lanes.as_slice(),
142 workers,
143 work: &|_index: usize, assignment: &Assignment, _say: &mut dyn FnMut(&str)| {
144 let own: Drained = drain_planned(assignment.directory.as_path(), handlers, now, &assignment.retrying);
145 let common: Drained = drain_common_records_planned(assignment.directory.as_path(), handlers, now, CLAIM_LEASE, assignment.retrying.first);
146 let wait: Option<Duration> = [own.wait, common.wait].into_iter().flatten().min();
147 return (wait, own.changes.into_iter().chain(common.changes).collect::<Vec<RetryChange>>());
148 },
149 on_event: &mut |event: Event<'_, (Option<Duration>, Vec<RetryChange>)>| {
150 if let Event::End(index, (wait, changes)) = event {
151 if let Some(lease_wait) = wait {
152 answer.waits.push((directories[index].directory.clone(), lease_wait));
153 }
154 answer.changes.extend(changes);
155 }
156 },
157 });
158 return answer;
159}
160
161/// Round controls stay out of the release executable.
162#[cfg(test)]
163#[path = "service_round_tests.rs"]
164mod tests;