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}