Skip to main content

tuwunel_core/server/
progress.rs

1//! Progress of a long startup phase, readable while it runs.
2//!
3//! Whatever is doing the work names the phase it is in and counts items
4//! through it; a ticker elsewhere decides how often to report. Nothing here
5//! writes to a log or to the service manager.
6
7use std::{
8	sync::{
9		Mutex, MutexGuard, PoisonError,
10		atomic::{AtomicU64, Ordering},
11	},
12	time::Instant,
13};
14
15use smallstr::SmallString;
16
17use crate::{format_small_string, implement, utils::time::pretty};
18
19/// Inline budget for one formatted progress line.
20type Line = SmallString<[u8; 128]>;
21
22/// Inline budget for one part of a progress line.
23type Part = SmallString<[u8; 48]>;
24
25/// A named unit of long-running work, reported while it runs.
26///
27/// The publisher names a phase, optionally counts items through it, and ends
28/// it. A reader takes whatever phase is current rather than waiting for one to
29/// finish, so a phase that counts nothing still reports how long it has been
30/// running. Only the position is lock-free, which is what lets a scan count
31/// every row.
32#[derive(Default)]
33pub struct Progress {
34	/// The phase in flight, absent before the first begins and after the last
35	/// ends.
36	phase: Mutex<Option<Phase>>,
37
38	/// Items the phase in flight has finished.
39	///
40	/// The reset happens under the phase lock, so a report never pairs one
41	/// phase's name with another's count. The increment takes no lock, so a
42	/// report may trail the true count by a few items.
43	position: AtomicU64,
44}
45
46/// One phase, from the moment it is named until the next one replaces it.
47#[derive(Clone, Copy)]
48struct Phase {
49	/// What the publisher called this phase.
50	step: &'static str,
51
52	/// A narrower part of the step, when the publisher named one.
53	pass: Option<&'static str>,
54
55	/// When the step was named.
56	began: Instant,
57
58	/// Items the step expects to finish, when it can say exactly.
59	total: Option<u64>,
60}
61
62/// Names the phase now in flight.
63///
64/// Any pass and any count the previous phase left are cleared, so a step that
65/// counts nothing cannot inherit a number from the step before it. A name
66/// reaches a service manager that delimits its own protocol by newline, so it
67/// must not contain one.
68#[implement(Progress)]
69pub fn begin(&self, step: &'static str) {
70	let mut phase = self.lock();
71
72	self.position.store(0, Ordering::Relaxed);
73	*phase = Some(Phase {
74		step,
75		pass: None,
76		began: Instant::now(),
77		total: None,
78	});
79}
80
81/// Names a narrower part of the phase in flight.
82///
83/// The count resets with the pass, because the items a pass walks are its own.
84/// A call arriving before any phase began is ignored.
85#[implement(Progress)]
86pub fn enter(&self, pass: &'static str) {
87	let mut phase = self.lock();
88
89	let Some(phase) = phase.as_mut() else {
90		return;
91	};
92
93	self.position.store(0, Ordering::Relaxed);
94	*phase = Phase { pass: Some(pass), total: None, ..*phase };
95}
96
97/// Records how many items the phase in flight expects to finish.
98///
99/// Only a step that can count its work exactly says so, since a report shows
100/// the position against this total as if it were reached. Every other step
101/// reports a bare position rather than a proportion of an estimate.
102#[implement(Progress)]
103pub fn expect_total(&self, total: u64) {
104	let mut phase = self.lock();
105
106	if let Some(phase) = phase.as_mut() {
107		phase.total = Some(total);
108	}
109}
110
111/// Counts one more item finished by the phase in flight.
112///
113/// The increment takes no lock, so a scan can afford to call it per row. A
114/// phase change resets the count, so no report carries one phase's position
115/// into another.
116#[implement(Progress)]
117#[inline]
118pub fn advance(&self) { self.position.fetch_add(1, Ordering::Relaxed); }
119
120/// Ends the phase in flight, leaving nothing to report.
121///
122/// A reader between the last phase and the next sees no phase at all, rather
123/// than a finished one whose elapsed time keeps climbing.
124#[implement(Progress)]
125pub fn end(&self) {
126	let mut phase = self.lock();
127
128	*phase = None;
129}
130
131/// Formats the phase in flight, or nothing when there is none.
132///
133/// The name, the position and the elapsed time are read under one lock, so
134/// they describe the same phase. A step that named an expected total reports
135/// its position against that total; every other one reports a bare position,
136/// or only its elapsed time when it counts nothing.
137#[implement(Progress)]
138pub fn report(&self) -> Option<Line> {
139	let phase = self.lock();
140
141	let Phase { step, pass, began, total } = (*phase)?;
142	let position = self.position.load(Ordering::Relaxed);
143
144	drop(phase);
145
146	let pass: Part = pass.map_or_else(Part::new, |pass| format_small_string!(" / {pass}"));
147	let counted: Part = match total {
148		| None if position == 0 => Part::new(),
149		| None => format_small_string!("{position} done, "),
150		| Some(total) => format_small_string!("{position} of {total}, "),
151	};
152
153	let elapsed = pretty(began.elapsed());
154
155	Some(format_small_string!("{step}{pass}, {counted}{elapsed}"))
156}
157
158/// Takes the phase lock, adopting a poisoned one.
159///
160/// A poisoned lock means a publisher panicked mid-update, which leaves the
161/// phase merely stale. Refusing to report at all would be the worse failure.
162#[implement(Progress)]
163#[inline]
164fn lock(&self) -> MutexGuard<'_, Option<Phase>> {
165	self.phase
166		.lock()
167		.unwrap_or_else(PoisonError::into_inner)
168}