Skip to main content

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;