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;