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