Skip to main content

git_policy_cli/
service_timing.rs

1//! What:
2//!  The lines the resident commit service writes to its log (standard error,
3//! which the service manager keeps in the journal)
4//!  about its own life and about the time each request took:
5//! when it started,
6//! one line for each request it answered with the
7//!  phases of the wait,
8//! a retire request,
9//! and its exit with the reason and the uptime.
10//! Why:
11//!  After a slow commit the journal held `ready` and nothing else,
12//! so nothing said whether the time went to the queue,
13//! to
14//!  another program's hold on the index,
15//! to the landing or to a service being replaced.
16//! These lines cost one clock read per
17//!  phase edge and one line per request;
18//! they name no person (the commit's message and identities never appear) and are not
19//!  shown to the person at the terminal.
20//!
21//! In TS you'd write (pseudocode):
22//! ```ts
23//! console.error(`cli-git-service: answered request=${id} queued_ms=${queued} lock_wait_ms=${lockWait} total_ms=${total}`);
24//! ```
25
26use super::service_index_found::WaitNotes;
27use super::journald_native::{Delivery, MESSAGE_LIMIT, Priority, Sink, StreamWriter, one_line};
28use super::lfs_output_mask::mask_userinfo_text;
29use std::sync::OnceLock;
30use std::time::{Duration, Instant};
31
32/// The prefix of every line the service writes,
33/// which `journalctl` shows after the unit name.
34pub const LOG_PREFIX: &str = "cli-git-service: ";
35
36/// When a connection was accepted and how long its request line took to arrive.
37#[derive(Clone, Copy, Debug)]
38pub struct Arrival {
39    /// The moment the connection was accepted.
40    pub accepted: Instant,
41    /// Time from the acceptance to the whole request line being read;
42    /// a client that is slow to send shows up here and not as a
43    ///  wait in the queue.
44    pub read: Duration,
45}
46
47/// What:
48///  The phases of one landing the service itself spent time in,
49/// summed over the attempts a moving branch made it repeat.
50/// Why:
51///   The wait for another program's hold on the index,
52/// the objects,
53/// the journal write,
54/// the branch move and the index update are
55///  the stretches a slow landing can lose time in;
56/// whatever else a landing did is reported as the reads that remain.
57///
58/// In TS you'd write (pseudocode):
59/// ```ts
60/// type LandTiming = { lockWait: number; lockHolder?: string; objects: number; journal: number; refUpdate: number; sync: number; attempts: number };
61/// ```
62#[derive(Clone, Debug, Default, Eq, PartialEq)]
63pub struct LandTiming {
64    /// Time to take the index lock,
65    /// which is the whole wait for a foreign holder.
66    pub lock_wait: Duration,
67    /// The holder the evidence named while the lock was waited for,
68    /// when it named one.
69    pub lock_holder: Option<String>,
70    /// What the acquisition noted about its wait:
71    /// what the first and the last look at the held lock found,
72    /// how many looks there were,
73    /// and how the time split between flushing the marker and waiting for the holder.
74    pub lock_notes: WaitNotes,
75    /// Time to make the commits:
76    /// one import for the batch,
77    /// or the checks of a commit Git built.
78    pub objects: Duration,
79    /// Time to write the intent to the journal.
80    pub journal: Duration,
81    /// Time of the compare-and-swap that moves the branch.
82    pub ref_update: Duration,
83    /// Time to remove the conclusion state and to build and install the new index.
84    pub sync: Duration,
85    /// Passes over the batch:
86    /// one,
87    /// and one more each time the branch moved under it.
88    pub attempts: usize,
89}
90
91impl LandTiming {
92    /// What `landing` spent outside the named phases:
93    /// the branch reads,
94    /// the comparisons with the branch,
95    /// the check for removed
96    ///  content.
97    pub fn reads(&self, landing: Duration) -> Duration {
98        return landing.saturating_sub(self.lock_wait + self.objects + self.journal + self.ref_update + self.sync);
99    }
100}
101
102/// Everything the line for one answered request states.
103pub struct Answered<'record> {
104    /// The request's identity.
105    pub request: &'record str,
106    /// The reference the request names.
107    pub reference: &'record str,
108    /// What became of it:
109    /// a word such as `landed`,
110    /// `already`,
111    /// `retry-prepare`,
112    /// `branch-moved` or `failed:<code>`.
113    pub outcome: &'record str,
114    /// The batch number,
115    /// shared by every request landed together.
116    pub batch: u64,
117    /// How many requests the batch of this branch held.
118    pub size: usize,
119    /// This request's place among them,
120    /// from one.
121    pub position: usize,
122    /// How the connection and its request line arrived.
123    pub arrival: Arrival,
124    /// Time from the request being read to its batch starting to land.
125    pub queued: Duration,
126    /// Time to settle the batches a killed service left open,
127    /// which runs before each batch.
128    pub settle: Duration,
129    /// The phases of the landing.
130    pub landing: &'record LandTiming,
131    /// Time the landing took,
132    /// from its start to its return.
133    pub land_total: Duration,
134    /// Time from the landing's end to this request being answered.
135    pub answer: Duration,
136    /// Time from the acceptance of the connection to the answer.
137    pub total: Duration,
138}
139
140/// The word for a named holder,
141/// in the quoted form.
142fn holder_field(who: &String) -> String {
143    return format!(" lock_holder={who:?}");
144}
145
146/// The words for what the wait for the lock found,
147/// empty when the lock was free at the first placement:
148/// the placements that found it held,
149/// the time spent flushing the marker and the time spent waiting,
150/// and the first and the last description of the held lock.
151pub fn lock_notes_fields(notes: &WaitNotes) -> String {
152    if notes.looks == 0 {
153        return format!(" lock_stage_ms={} lock_stage_kind={} lock_place_ms={}", notes.staging.as_millis(), notes.stage_kind, notes.placing.as_millis());
154    }
155    let first: &str = notes.found.as_deref().unwrap_or("none");
156    let last: &str = notes.last.as_deref().unwrap_or("none");
157    return format!(
158        " lock_stage_ms={} lock_stage_kind={} lock_place_ms={} lock_looks={} lock_found={first:?} lock_last={last:?}",
159        notes.staging.as_millis(),
160        notes.stage_kind,
161        notes.placing.as_millis(),
162        notes.looks
163    );
164}
165
166/// The wait for the index lock that is written down even when no holder was met:
167/// a flush of the marker or a wait that long is a slow landing's first suspect.
168pub const SLOW_LOCK_STEP: Duration = Duration::from_secs(1);
169
170/// What:
171///  The line for the end of a wait for the index lock,
172/// or nothing when the lock was free at the first placement and the acquisition was quick.
173/// Why:
174///   Written when the wait ends,
175/// not with the answer,
176/// so a landing that fails afterwards still leaves what it waited for.
177/// It says the total,
178/// how it split between flushing the marker and waiting for a holder,
179/// how many times the lock was found held,
180/// and what the first and the last look found (`lock_found`,
181/// `lock_last`).
182///
183/// In TS you'd write (pseudocode):
184/// ```ts
185/// waitLine(seq, total, notes, taken) // 'waited batch=7 result=taken lock_wait_ms=5629 ...' | undefined
186/// ```
187pub fn wait_line(seq: u64, total: Duration, notes: &WaitNotes, taken: bool) -> Option<String> {
188    if notes.looks == 0 && notes.staging < SLOW_LOCK_STEP && total < SLOW_LOCK_STEP {
189        return None;
190    }
191    return Some(format!(
192        "waited for the index lock batch={seq} result={} lock_wait_ms={}{}",
193        if taken { "taken" } else { "refused" },
194        total.as_millis(),
195        lock_notes_fields(notes)
196    ));
197}
198
199/// What:
200///  One answered request as a line of `key=value` words,
201/// without the prefix.
202/// Why:
203///   Text values are written in Rust's quoted form,
204/// which has no raw newline or unescaped quote,
205/// so the line stays one line
206///  whatever a client sent;
207/// the numbers are whole milliseconds.
208///
209/// In TS you'd write (pseudocode):
210/// ```ts
211/// `answered request=${JSON.stringify(id)} outcome=${outcome} queued_ms=${queued} ...`
212/// ```
213pub fn answered_line(answered: &Answered<'_>) -> String {
214    let mut holder: String = answered.landing.lock_holder.as_ref().map_or_else(String::new, holder_field);
215    holder.push_str(lock_notes_fields(&answered.landing.lock_notes).as_str());
216    return format!(
217        "answered request={:?} branch={:?} outcome={} batch={} size={} position={} read_ms={} queued_ms={} settle_ms={} \
218         lock_wait_ms={}{holder} reads_ms={} objects_ms={} journal_ms={} ref_ms={} sync_ms={} attempts={} landing_ms={} \
219         answer_ms={} total_ms={}",
220        answered.request,
221        answered.reference,
222        answered.outcome,
223        answered.batch,
224        answered.size,
225        answered.position,
226        answered.arrival.read.as_millis(),
227        answered.queued.as_millis(),
228        answered.settle.as_millis(),
229        answered.landing.lock_wait.as_millis(),
230        answered.landing.reads(answered.land_total).as_millis(),
231        answered.landing.objects.as_millis(),
232        answered.landing.journal.as_millis(),
233        answered.landing.ref_update.as_millis(),
234        answered.landing.sync.as_millis(),
235        answered.landing.attempts,
236        answered.land_total.as_millis(),
237        answered.answer.as_millis(),
238        answered.total.as_millis(),
239    );
240}
241
242/// The line a service writes as it starts,
243/// before `ready`:
244/// the release it announces,
245/// the protocol,
246/// its process and the worktree
247///  instance it serves.
248pub fn started_line(release: &str, protocol: u32, instance: Option<&str>) -> String {
249    return format!("started release={release:?} protocol={protocol} pid={} instance={}", std::process::id(), instance.unwrap_or("any"));
250}
251
252/// The line for a retire message:
253/// a client of another release found this service and asked it to finish its batch and exit.
254pub fn retire_line(release: &str) -> String {
255    return format!("retire requested by a client; this service is release {release:?} and exits after its batch");
256}
257
258/// Why a service ended.
259#[derive(Clone, Copy, Debug, Eq, PartialEq)]
260pub enum ExitReason {
261    /// Nothing to do for the whole idle period.
262    Idle,
263    /// A client asked it to retire.
264    Retire,
265    /// The queue its connections feed closed,
266    /// which only happens when the acceptor thread died.
267    Error,
268}
269
270impl ExitReason {
271    /// The word written in the line.
272    pub fn word(self) -> &'static str {
273        return match self {
274            ExitReason::Idle => "idle",
275            ExitReason::Retire => "retire",
276            ExitReason::Error => "error",
277        };
278    }
279}
280
281/// The line for the service's own exit,
282/// with the time it ran since it began serving.
283pub fn exit_line(reason: ExitReason, uptime: Duration) -> String {
284    return format!("exiting reason={} uptime_ms={}", reason.word(), uptime.as_millis());
285}
286
287/// The wall clock in whole milliseconds since the Unix epoch,
288/// the time a log line states for its own event.
289pub fn wall_millis() -> u128 {
290    return std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |elapsed| { return elapsed.as_millis(); });
291}
292
293/// What:
294///  The text of one log line:
295/// the prefix,
296/// the line,
297/// the time of the event as `at_ms=<epoch milliseconds>` at the end,
298/// and the newline.
299/// Why:
300///   The journal stamps a line when it receives it,
301/// and under load that can be seconds after the event;
302/// the line states its own time,
303/// at the end so that the prefix and the first word stay where readers look for them.
304///
305/// In TS you'd write (pseudocode):
306/// ```ts
307/// `${LOG_PREFIX}${line} at_ms=${Date.now()}\n`
308/// ```
309pub fn stamped_line(line: &str, at_ms: u128) -> String {
310    return format!("{LOG_PREFIX}{line} at_ms={at_ms}\n");
311}
312
313/// What:
314///  Writes one line to the service's log.
315/// Why:
316///   Standard error is the service manager's journal;
317/// one `write` carries the prefix,
318/// the line,
319/// its own time and the newline together,
320/// and a
321///  closed or broken stream is not the service's failure.
322///
323/// In TS you'd write (pseudocode):
324/// ```ts
325/// console.error(LOG_PREFIX + line + ` at_ms=${Date.now()}`)
326/// ```
327pub fn log(line: &str) {
328    log_entry(Priority::Info, line, &[]);
329}
330
331/// What the service says of itself in every entry: its release and, when it serves one worktree, its instance.
332static CONTEXT: OnceLock<Vec<(&'static str, String)>> = OnceLock::new();
333
334/// Where entries go, decided at the first one.
335static SINK: OnceLock<Sink> = OnceLock::new();
336
337/// The sink of this process.
338pub fn sink() -> &'static Sink {
339    return SINK.get_or_init(Sink::from_environment);
340}
341
342/// Names the release and instance every later entry carries as `CLI_GIT_RELEASE` and `CLI_GIT_INSTANCE`; the first call wins.
343pub fn set_context(release: &str, instance: Option<&str>) {
344    let mut fields: Vec<(&'static str, String)> = vec![("CLI_GIT_RELEASE", release.to_string())];
345    if let Some(name) = instance {
346        fields.push(("CLI_GIT_INSTANCE", name.to_string()));
347    }
348    let _first = CONTEXT.set(fields);
349}
350
351/// What:
352///  Writes one entry of a priority, with fields a reader can filter the journal on, and gives where it went.
353/// Why:
354///   The text is one line with the userinfo of every URL masked (a push or a review reports what Git and a server said),
355///  then it states its own time (`stamped_line`); a native entry also carries the time as `CLI_GIT_AT_MS`, the priority
356///  and the context. Nothing here waits: see `journald_native`.
357///
358/// In TS you'd write (pseudocode):
359/// ```ts
360/// logEntry('warning', 'cannot listen', [['CLI_GIT_REQUEST_ID', id]])
361/// ```
362pub fn log_entry(priority: Priority, line: &str, fields: &[(&str, String)]) -> Delivery {
363    let at_ms: u128 = wall_millis();
364    // Masked before it is cut: a cut that falls just before the `@` of `scheme://user:secret@host` would leave a password
365    //  that no longer looks like userinfo.
366    let clean: String = one_line(mask_userinfo_text(line).as_str(), MESSAGE_LIMIT);
367    let mut all: Vec<(&str, String)> = CONTEXT.get().map_or_else(Vec::new, |context| { return context.iter().map(|(name, value)| { return (*name, value.clone()); }).collect(); });
368    all.push(("CLI_GIT_AT_MS", at_ms.to_string()));
369    all.extend(fields.iter().map(|(name, value)| { return (*name, mask_userinfo_text(value.as_str())); }));
370    #[cfg(test)]
371    if capture::record(priority, format!("{clean} at_ms={at_ms}").as_str(), all.as_slice()) {
372        return Delivery::Stderr;
373    }
374    let message: String = format!("{clean} at_ms={at_ms}");
375    let text: String = stamped_line(clean.as_str(), at_ms);
376    // On the journal's stream a line that finds no room is dropped, never waited for.
377    if sink().on_stream() {
378        return sink().deliver(priority, message.as_str(), all.as_slice(), text.as_str(), &mut StreamWriter::stderr());
379    }
380    return sink().deliver(priority, message.as_str(), all.as_slice(), text.as_str(), &mut std::io::stderr());
381}
382
383/// What:
384///  Writes one formatted line at the `warning` priority, with its time.
385/// Why:
386///   Something did not go as intended and the service went on; `journalctl --priority=warning` shows it.
387///
388/// In TS you'd write (pseudocode):
389/// ```ts
390/// serviceWarn(`the review of ${oid} was skipped: ${reason}`)
391/// ```
392#[macro_export]
393macro_rules! service_warn {
394    ($($argument:tt)*) => {
395        {
396            let _delivered = $crate::service_timing::log_entry($crate::journald_native::Priority::Warning, format!($($argument)*).as_str(), &[]);
397        }
398    };
399}
400
401/// What:
402///  Writes one formatted line at the `err` priority, with its time.
403/// Why:
404///   Something the service needed failed; `journalctl --priority=err` shows it.
405///
406/// In TS you'd write (pseudocode):
407/// ```ts
408/// serviceError(`cannot listen: ${error}`)
409/// ```
410#[macro_export]
411macro_rules! service_error {
412    ($($argument:tt)*) => {
413        {
414            let _delivered = $crate::service_timing::log_entry($crate::journald_native::Priority::Error, format!($($argument)*).as_str(), &[]);
415        }
416    };
417}
418
419/// What:
420///  Writes one formatted line to the service's log,
421/// with its time.
422/// Why:
423///   Every line the service writes states its own time (`stamped_line`);
424/// the macro is the one way to write a line with arguments.
425///
426/// In TS you'd write (pseudocode):
427/// ```ts
428/// serviceLog(`cannot take the index lock ${path}: ${error}`)
429/// ```
430#[macro_export]
431macro_rules! service_log {
432    ($($argument:tt)*) => {
433        $crate::service_timing::log(format!($($argument)*).as_str())
434    };
435}
436
437/// What a control sees of the entries the code under test writes, on the thread that runs it.
438#[cfg(test)]
439pub mod capture {
440    use super::Priority;
441    use std::cell::RefCell;
442
443    /// One entry as written: its priority, its message with the time, and its fields.
444    #[derive(Clone, Debug, Eq, PartialEq)]
445    pub struct Captured {
446        /// How serious the entry is.
447        pub priority: Priority,
448        /// The message as the journal would show it.
449        pub message: String,
450        /// The fields beside the message.
451        pub fields: Vec<(String, String)>,
452    }
453
454    thread_local! {
455        static ENTRIES: RefCell<Option<Vec<Captured>>> = const { RefCell::new(None) };
456    }
457
458    /// Notes an entry when a control is capturing on this thread; says whether it did.
459    pub fn record(priority: Priority, message: &str, fields: &[(&str, String)]) -> bool {
460        return ENTRIES.with(|entries| {
461            return entries.borrow_mut().as_mut().is_some_and(|taken| {
462                taken.push(Captured { priority, message: message.to_string(), fields: fields.iter().map(|(name, value)| { return ((*name).to_string(), value.clone()); }).collect() });
463                return true;
464            });
465        });
466    }
467
468    /// Runs `body` and gives its entries, which are not written anywhere.
469    pub fn entries_of(body: impl FnOnce()) -> Vec<Captured> {
470        ENTRIES.with(|entries| { *entries.borrow_mut() = Some(Vec::new()); });
471        body();
472        return ENTRIES.with(|entries| { return entries.borrow_mut().take().unwrap_or_default(); });
473    }
474}
475
476/// The lines,
477/// their fields,
478/// and the escaping.
479#[cfg(test)]
480#[path = "service_timing_tests.rs"]
481mod tests;