Skip to main content

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