git_policy_cli/commit_timing.rs
1//! What:
2//! The record of where one wrapped `git commit` spent its time,
3//! appended to a size-capped file in the common Git directory
4//! beside the discard audit:
5//! preparation,
6//! building,
7//! setting up the service,
8//! reaching it,
9//! waiting for its answer,
10//! the work
11//! after landing,
12//! the post-commit gate and the hand-over of the push.
13//! Why:
14//! A commit that took seventeen seconds could not be split afterwards,
15//! because nothing outside verbose mode said which phase
16//! held the time.
17//! The numbers are taken from the monotonic clock as the commit runs (one `Instant` read per phase edge,
18//! no
19//! Git start,
20//! no flush to disk) and written once,
21//! in one append,
22//! when the command ends.
23//! The record goes to the file only:
24//! the person's terminal never shows it.
25//!
26//! In TS you'd write (pseudocode):
27//! ```ts
28//! await appendFile(join(commonDir, 'cli-git-commit-timing.jsonl'), JSON.stringify(timing) + '\n');
29//! ```
30
31use super::diagnostic_log::debug;
32use super::discard_audit::append_line;
33use super::iso_time::now_iso;
34use super::json_record::{quote, string_array};
35use super::service_instance::instance_of;
36use super::service_protocol::RELEASE;
37use std::path::{Path, PathBuf};
38use std::time::{Duration, Instant};
39
40/// The file inside the common Git directory that collects the timing records of every worktree of the repository.
41/// It moves
42/// aside to `cli-git-commit-timing.jsonl.1` at the size limit `discard_audit::append_line` applies.
43pub const TIMING_FILE: &str = "cli-git-commit-timing.jsonl";
44
45/// The version of the record's fields;
46/// it changes only when a field's meaning changes,
47/// never when a field is added.
48pub const TIMING_SCHEMA_VERSION: u32 = 1;
49
50/// Module tag for diagnostics.
51const TAG: &str = "commitTiming";
52
53/// The word written for a release a service did not announce (the services of the earliest releases sent no release).
54const UNANNOUNCED: &str = "unannounced";
55
56/// What:
57/// What the client did to reach a service that speaks its protocol and was started from its release.
58/// Why:
59/// The 17-second commit began with a service of another release being asked to retire;
60/// the tries,
61/// the wait for a start,
62/// the
63/// retires sent and the releases that answered tell that apart from a slow landing.
64///
65/// In TS you'd write (pseudocode):
66/// ```ts
67/// type ReachTrace = { tries: number; refused: number; startWaited: number; retiresSent: number; releasesSeen: string[]; answeredBy?: string };
68/// ```
69#[derive(Clone, Debug, Default, Eq, PartialEq)]
70pub struct ReachTrace {
71 /// Connection attempts made,
72 /// each one a `connect` call.
73 pub tries: usize,
74 /// Attempts that found nothing listening or no greeting,
75 /// which is the service being started by the manager.
76 pub refused: usize,
77 /// Time from the first attempt of a reach to the greeting,
78 /// summed over the reaches that needed more than one attempt.
79 pub start_waited: Duration,
80 /// Retire messages sent to services of another release.
81 pub retires_sent: usize,
82 /// The release of every greeting,
83 /// in the order received,
84 /// without repeats.
85 pub releases_seen: Vec<String>,
86 /// The release of the service that finally took the request.
87 pub answered_by: Option<String>,
88 /// Time spent reaching,
89 /// retire rounds included.
90 pub elapsed: Duration,
91}
92
93impl ReachTrace {
94 /// Notes the release a greeting named.
95 pub fn saw_release(&mut self, release: Option<&str>) {
96 let word: String = release.unwrap_or(UNANNOUNCED).to_string();
97 if !self.releases_seen.contains(&word) {
98 self.releases_seen.push(word);
99 }
100 }
101}
102
103/// What:
104/// What a waiting notice of the service says of the holder of the index lock.
105/// Why:
106/// The notice is the only thing about the holder that reaches the client,
107/// and the record should say whether the wait was for a process proven to hold the lock or for one nobody could prove alive.
108///
109/// In TS you'd write (pseudocode):
110/// ```ts
111/// holderKindOf(text) // 'proven-alive' | 'unproven'
112/// ```
113pub fn holder_kind_of(text: &str) -> &'static str {
114 return if text.contains("cannot be proven alive") { "unproven" } else { "proven-alive" };
115}
116
117/// What:
118/// What the client did to hand one request to the service and get its answer.
119/// Why:
120/// The queue wait,
121/// the wait for a foreign index lock and the landing itself all happen between the send and the answer;
122/// the service's own log line for the request splits that wait,
123/// and this record says how long the whole of it was.
124///
125/// In TS you'd write (pseudocode):
126/// ```ts
127/// type SubmitTrace = { total: number; reach: ReachTrace; answerWait: number; positions: number[]; waitingNotices: number; holderKind?: string };
128/// ```
129#[derive(Clone, Debug, Default, Eq, PartialEq)]
130pub struct SubmitTrace {
131 /// The whole of the sends,
132 /// reaches included.
133 pub total: Duration,
134 /// How the service was reached.
135 pub reach: ReachTrace,
136 /// Time from a request being written to the answer (or the connection breaking),
137 /// summed over the sends.
138 pub answer_wait: Duration,
139 /// The queue positions the service reported,
140 /// in order,
141 /// without repeats.
142 pub positions: Vec<usize>,
143 /// Notices the service sent while it waited for another program's hold on the index.
144 pub waiting_notices: usize,
145 /// What the last of those notices said of the holder:
146 /// `proven-alive` when it named a process proven to hold the lock,
147 /// `unproven` when it said the owner could not be proven alive.
148 pub holder_kind: Option<&'static str>,
149 /// Times the connection broke before an answer and the same request was sent again.
150 pub reconnects: usize,
151 /// Requests written.
152 pub sends: usize,
153}
154
155/// What:
156/// The common Git directory of a worktree's own Git directory:
157/// the directory its `commondir` file names,
158/// else the directory
159/// itself.
160/// Why:
161/// A linked worktree's own Git directory goes away with the worktree,
162/// while the repository's one timing file lives in the
163/// common directory.
164/// The service's review module reads the file the same way (Git strips every trailing carriage return and
165/// line feed and resolves a relative path against the Git directory),
166/// but that module belongs to the service run and the client
167/// path may not name it (`standards_review_boundary_tests.rs`),
168/// so the few lines are repeated here.
169/// A path that cannot be
170/// resolved is used as written.
171///
172/// In TS you'd write (pseudocode):
173/// ```ts
174/// existsSync(join(gitDir, 'commondir')) ? realpath(resolve(gitDir, readFile(...).trimEnd())) : gitDir
175/// ```
176fn common_dir_for(git_dir: &Path) -> PathBuf {
177 let Ok(text) = std::fs::read_to_string(git_dir.join("commondir")) else {
178 return git_dir.to_path_buf();
179 };
180 let named: &str = text.trim_end_matches(['\r', '\n']);
181 if named.is_empty() {
182 return git_dir.to_path_buf();
183 }
184 let joined: PathBuf = git_dir.join(named);
185 return std::fs::canonicalize(&joined).unwrap_or(joined);
186}
187
188/// Where the record goes and which service instance it belongs to.
189#[derive(Clone, Debug, Eq, PartialEq)]
190struct Place {
191 /// The worktree's own Git directory.
192 git_dir: PathBuf,
193 /// The common Git directory,
194 /// which holds the file.
195 common_dir: PathBuf,
196 /// The instance name that `journalctl --user --unit=cli-git@<instance>.service` takes.
197 instance: String,
198}
199
200/// The whole milliseconds of a duration.
201fn whole_milliseconds(elapsed: Duration) -> u128 {
202 return elapsed.as_millis();
203}
204
205/// The JSON word for a field that has no value.
206fn null_word() -> String {
207 return String::from("null");
208}
209
210impl Place {
211 /// The instance as a JSON string.
212 fn instance_json(&self) -> String {
213 return quote(self.instance.as_str());
214 }
215
216 /// The worktree's own Git directory as a JSON string.
217 fn git_dir_json(&self) -> String {
218 return quote(&self.git_dir.to_string_lossy());
219 }
220}
221
222/// What:
223/// The phases of one wrapped commit,
224/// filled as the commit runs and written when it ends.
225/// Why:
226/// Each field is one disjoint stretch of the command,
227/// so the phases add up to no more than the total,
228/// and what they do not
229/// cover is reported as `otherMs` instead of being hidden.
230///
231/// In TS you'd write (pseudocode):
232/// ```ts
233/// type CommitTiming = { began: number; prepare: number; build: number; provision: number; submit: SubmitTrace; /* ... */ };
234/// ```
235#[derive(Clone, Debug)]
236pub struct CommitTiming {
237 /// The monotonic start.
238 began: Instant,
239 /// Milliseconds since the Unix epoch at the start,
240 /// for finding the commit among the service's journal lines.
241 start_millis: u128,
242 /// Known once the worktree's Git directory was found.
243 place: Option<Place>,
244 /// The branch (or `HEAD`) the first preparation found.
245 pub reference: Option<String>,
246 /// The identity the commit's request carries, which the service's `answered` line states as `request` and as the
247 /// journal field `CLI_GIT_REQUEST_ID`; the key that joins this record to the service's.
248 pub request: Option<String>,
249 /// How the command ended:
250 /// `landed`,
251 /// `ended` (blocked or declined,
252 /// nothing landed) or `stopped` (a failure).
253 pub outcome: &'static str,
254 /// Preparations made:
255 /// one,
256 /// and one more for each time the service sent the commit back or Git built it again.
257 pub attempts: usize,
258 /// Policies,
259 /// hooks,
260 /// the private index,
261 /// the tree and the objects of the commit,
262 /// summed over the attempts.
263 pub prepare: Duration,
264 /// The message,
265 /// the identities,
266 /// the tree check and Git's own build of the commit,
267 /// with the scratch hand-over.
268 pub build: Duration,
269 /// Setting up socket activation,
270 /// which is the manager's unit comparison and,
271 /// after an upgrade,
272 /// a rewrite and a reload.
273 pub provision: Duration,
274 /// Sending the request and waiting for the answer.
275 pub submit: SubmitTrace,
276 /// The summary line,
277 /// the names it needs,
278 /// and the files a policy corrected,
279 /// after the branch moved.
280 pub after_landing: Duration,
281 /// The post-commit gate.
282 pub gate: Duration,
283 /// Leaving the push and the upkeep for the service and waking it.
284 pub handoff: Duration,
285}
286
287impl CommitTiming {
288 /// Starts the clocks:
289 /// the monotonic one for the durations and the wall clock for the stamp.
290 pub fn start() -> Self {
291 let start_millis: u128 = std::time::SystemTime::now()
292 .duration_since(std::time::UNIX_EPOCH)
293 .map(whole_milliseconds)
294 .unwrap_or_default();
295 return Self {
296 began: Instant::now(),
297 start_millis,
298 place: None,
299 reference: None,
300 request: None,
301 outcome: "stopped",
302 attempts: 0,
303 prepare: Duration::ZERO,
304 build: Duration::ZERO,
305 provision: Duration::ZERO,
306 submit: SubmitTrace::default(),
307 after_landing: Duration::ZERO,
308 gate: Duration::ZERO,
309 handoff: Duration::ZERO,
310 };
311 }
312
313 /// Names the worktree whose Git directory the command found;
314 /// a command outside a repository writes no record.
315 pub fn place_at(&mut self, git_dir: &Path) {
316 self.place = Some(Place { git_dir: git_dir.to_path_buf(), common_dir: common_dir_for(git_dir), instance: instance_of(git_dir) });
317 }
318
319 /// What the phases leave uncovered out of `total`.
320 fn other(&self, total: Duration) -> Duration {
321 let covered: Duration = self.prepare + self.build + self.provision + self.submit.total + self.after_landing + self.gate + self.handoff;
322 return total.saturating_sub(covered);
323 }
324
325 /// What:
326 /// The record as one line of JSON ending in a newline.
327 /// Why:
328 /// One `write` call per line keeps lines of concurrent commits from interleaving;
329 /// a branch name with a quote or a
330 /// newline is escaped by `quote`,
331 /// so the line stays one line.
332 ///
333 /// In TS you'd write (pseudocode):
334 /// ```ts
335 /// JSON.stringify(record) + '\n'
336 /// ```
337 pub fn line(&self, total: Duration, exit_code: i32) -> String {
338 let place: Option<&Place> = self.place.as_ref();
339 let branch: String = self.reference.as_deref().map_or_else(null_word, quote);
340 let answered_by: String = self.submit.reach.answered_by.as_deref().map_or_else(null_word, quote);
341 let request: String = self.request.as_deref().map_or_else(null_word, quote);
342 let level: &str = if self.outcome == "stopped" { "warn" } else { "info" };
343 let positions: Vec<String> = self.submit.positions.iter().map(usize::to_string).collect();
344 return format!(
345 "{{\"schemaVersion\":{TIMING_SCHEMA_VERSION},\"time\":{},\"level\":\"{level}\",\"startMillis\":{},\"pid\":{},\"release\":{},\"instance\":{},\"gitDir\":{},\
346 \"requestId\":{request},\"branch\":{branch},\"outcome\":{},\"exitCode\":{exit_code},\"totalMs\":{},\
347 \"prepareMs\":{},\"attempts\":{},\"buildMs\":{},\"provisionMs\":{},\
348 \"submitMs\":{},\"sends\":{},\"reachMs\":{},\"connectTries\":{},\"refusedConnects\":{},\"startWaitMs\":{},\
349 \"updateRounds\":{},\"releasesSeen\":{},\"answeredByRelease\":{answered_by},\
350 \"answerWaitMs\":{},\"positions\":[{}],\"waitingNotices\":{},\"lockHolderKind\":{},\"reconnects\":{},\
351 \"afterLandingMs\":{},\"gateMs\":{},\"handoffMs\":{},\"otherMs\":{}}}\n",
352 quote(now_iso().as_str()),
353 self.start_millis,
354 std::process::id(),
355 quote(RELEASE),
356 place.map_or_else(null_word, Place::instance_json),
357 place.map_or_else(null_word, Place::git_dir_json),
358 quote(self.outcome),
359 total.as_millis(),
360 self.prepare.as_millis(),
361 self.attempts,
362 self.build.as_millis(),
363 self.provision.as_millis(),
364 self.submit.total.as_millis(),
365 self.submit.sends,
366 self.submit.reach.elapsed.as_millis(),
367 self.submit.reach.tries,
368 self.submit.reach.refused,
369 self.submit.reach.start_waited.as_millis(),
370 self.submit.reach.retires_sent,
371 string_array(self.submit.reach.releases_seen.as_slice()),
372 self.submit.answer_wait.as_millis(),
373 positions.join(","),
374 self.submit.waiting_notices,
375 self.submit.holder_kind.map_or_else(null_word, quote),
376 self.submit.reconnects,
377 self.after_landing.as_millis(),
378 self.gate.as_millis(),
379 self.handoff.as_millis(),
380 self.other(total).as_millis(),
381 );
382 }
383
384 /// What:
385 /// Appends the record to the repository's timing file,
386 /// best effort.
387 /// Why:
388 /// The command's result never depends on the record:
389 /// a file that cannot be written is only a debug line.
390 /// The directory
391 /// is never created,
392 /// and nothing is flushed to disk,
393 /// so the record costs one `write`.
394 ///
395 /// In TS you'd write (pseudocode):
396 /// ```ts
397 /// recordTiming(timing, exitCode)
398 /// ```
399 pub fn finish(&self, exit_code: i32) {
400 let Some(place) = self.place.as_ref() else { return };
401 let target: PathBuf = place.common_dir.join(TIMING_FILE);
402 let line: String = self.line(self.began.elapsed(), exit_code);
403 if let Err(error) = append_line(target.as_path(), line.as_bytes()) {
404 debug(TAG, &format!("the timing record could not be written to {}: {error}", target.display()));
405 }
406 }
407}
408
409/// The line,
410/// the sum rule,
411/// and the escaping.
412#[cfg(test)]
413#[path = "commit_timing_tests.rs"]
414mod tests;