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;