fdu_core/progress.rs
1//! A polled view of how much work a route has done so far.
2//!
3//! A [`Progress`] handle is created by the caller and passed to a route that does long
4//! work: [`prepare_report_with_progress`](crate::prepare_report_with_progress), a
5//! [`ScanConfig`](crate::ScanConfig) with its `progress` field set, and, with the `watch`
6//! build feature, `Session::start_with_progress`. The route adds to the handle as it
7//! goes; the caller polls [`Progress::snapshot`] from any thread, typically a ticker that
8//! redraws a wait indicator. Nothing calls back into the caller, so there is no
9//! re-entrancy and no callback cost on worker threads.
10//!
11//! **It counts work done, never index state.** The counters say how much the walk has
12//! read, not what the answer is: an in-progress cold build stays unobservable, and a
13//! directory a retry rereads is counted each time it is read. The display should call
14//! these entries walked, not found.
15//!
16//! **What holds at completion.** When a walking route returns, `files`, `bytes`, and
17//! `allocated` equal the walked totals its own report exposes, and `directories`
18//! equals the directories it read, except where a documented retry reread part of the
19//! tree, in which case the handle is larger by exactly the rereads. Content analysis
20//! leaves `analysis` at `Some((candidates, candidates))`.
21//!
22//! **Cost.** Walker workers already keep local counts; they add the difference since
23//! their last addition to the shared cells once per chunk of directories they hand over,
24//! never per entry. Without a handle attached, a walk pays one `Option` check per chunk
25//! (per directory on the revalidation and reconcile walks, which fill no batch for an
26//! unchanged tree).
27
28use std::fmt;
29use std::sync::Arc;
30use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
31
32/// Which kind of work a route is doing.
33///
34/// A snapshot carries the phase most recently entered. Routes enter phases in this
35/// order, skipping the ones they do not do: a cold report over a full index goes
36/// `Scanning`, `Indexing` once the walk is over, then `Analyzing` if content was
37/// requested, `Saving` if a snapshot is written, and `Summarizing` while the answer is
38/// built (it enters `Loading` first when it looks for a snapshot and finds none); a
39/// warm one goes `Loading`, `Revalidating`, then the same without `Indexing`;
40/// a cache-only one walks nothing and goes `Loading`, then `Summarizing`.
41///
42/// A watch start builds no answer through these phases and ends at its save, if it
43/// writes one. It then
44/// runs a second pass: it verifies the tree once more while it binds observation, and
45/// that pass begins again at `Revalidating` with the walk counters restarted, so the
46/// line shows the second walk's own progress rather than a sum.
47#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default)]
48pub enum ProgressPhase {
49 /// No route has begun work on this handle.
50 ///
51 /// What a fresh handle reports until the route enters its first phase. A caller
52 /// polling before then shows a wait, not a phase it has not been told about.
53 #[default]
54 Starting,
55 /// A metadata snapshot is being read from the cache.
56 Loading,
57 /// The tree is being walked cold.
58 Scanning,
59 /// A loaded snapshot is being verified against the tree.
60 Revalidating,
61 /// The walk is over and the index is being assembled from the listings it read.
62 ///
63 /// The walkers count as they read, and the thread that assembles the index can
64 /// still be working through their listings when the last one finishes, so the
65 /// counters stop moving before the route returns. This phase is what tells a person
66 /// the walk has finished rather than stalled.
67 Indexing,
68 /// File contents are being read and analyzed.
69 Analyzing,
70 /// A snapshot or content sidecar is being written.
71 Saving,
72 /// The answer is being built from the index.
73 ///
74 /// The last phase of a one-shot report over a full index. A save entered before it
75 /// continues in the background, so the phase names the work in the foreground:
76 /// building a heavy view (`full`, or a deep tree with no limit) over a large index
77 /// takes seconds, which `Saving` would misdescribe.
78 Summarizing,
79}
80
81impl ProgressPhase {
82 /// Every phase, indexed by the code a cell stores.
83 const ALL: [Self; 8] = [
84 Self::Starting,
85 Self::Loading,
86 Self::Scanning,
87 Self::Revalidating,
88 Self::Indexing,
89 Self::Analyzing,
90 Self::Saving,
91 Self::Summarizing,
92 ];
93
94 const fn code(self) -> u8 {
95 match self {
96 Self::Starting => 0,
97 Self::Loading => 1,
98 Self::Scanning => 2,
99 Self::Revalidating => 3,
100 Self::Indexing => 4,
101 Self::Analyzing => 5,
102 Self::Saving => 6,
103 Self::Summarizing => 7,
104 }
105 }
106
107 /// The cell only ever stores a value [`Self::code`] produced, so a code outside the
108 /// table is unreachable; reading it as `Starting` keeps a poller from panicking on
109 /// a state no route can have written.
110 fn from_code(code: u8) -> Self {
111 Self::ALL.get(usize::from(code)).copied().unwrap_or_default()
112 }
113}
114
115/// What a route has done so far, as read at one moment.
116///
117/// A view for display, not a consistent cut: each counter is monotonic within a pass
118/// (every route is one pass, except a watch start, whose closing verification begins a
119/// second), but the counters are read one at a time, so a snapshot taken while workers
120/// run can pair a `files` value with a `bytes` value from a moment later. The phase is
121/// the one most recently entered.
122#[derive(Clone, Copy, PartialEq, Eq, Debug)]
123pub struct ProgressSnapshot {
124 /// The kind of work the route is doing.
125 pub phase: ProgressPhase,
126 /// Directories whose listing was read.
127 pub directories: u64,
128 /// Regular files whose metadata was read.
129 pub files: u64,
130 /// Apparent bytes of those files.
131 pub bytes: u64,
132 /// Allocated bytes of those files. A display should show whichever of the two its
133 /// answer is measured in: a sparse disk image can be terabytes apparent and megabytes
134 /// allocated, so apparent bytes beside an allocated answer can exceed the disk.
135 pub allocated: u64,
136 /// Content files analyzed so far and the candidates known when analysis began,
137 /// or `None` until a route has begun content analysis.
138 ///
139 /// The one counter with an exact denominator: the candidate set is fixed before the
140 /// first file is read, and every candidate produces one result, so the pair reaches
141 /// `(n, n)` when analysis ends. A result the index discards as stale still counts as
142 /// analyzed, because the file was read.
143 pub analysis: Option<(u64, u64)>,
144}
145
146/// One cache line, wide enough for the 128-byte lines of the two shipped architectures
147/// (Apple Silicon's L2, and the adjacent-line prefetch pairing on x86-64).
148///
149/// The counters a walker adds to together share one line and share it with nothing
150/// else, so a worker's addition costs one line transfer rather than four, and a poller
151/// reading the walk counters never invalidates the line the analysis loop writes.
152#[repr(align(128))]
153#[derive(Default)]
154struct WalkCells {
155 directories: AtomicU64,
156 files: AtomicU64,
157 bytes: AtomicU64,
158 allocated: AtomicU64,
159}
160
161/// Written by the content-analysis result loop, on the caller's thread.
162#[repr(align(128))]
163#[derive(Default)]
164struct AnalysisCells {
165 /// Whether `total` has been set.
166 ///
167 /// The one place ordering matters: `total` is stored before this is released, and a
168 /// poller acquires this before reading `total`, so a snapshot never pairs a known
169 /// analysis with a denominator it has not seen. Once per run, so it costs nothing.
170 known: AtomicBool,
171 done: AtomicU64,
172 total: AtomicU64,
173}
174
175/// Written rarely, at phase boundaries.
176#[repr(align(128))]
177#[derive(Default)]
178struct PhaseCell(AtomicU8);
179
180#[derive(Default)]
181struct Cells {
182 walk: WalkCells,
183 analysis: AnalysisCells,
184 phase: PhaseCell,
185}
186
187/// A handle a route reports its progress through.
188///
189/// Cheap to clone: clones share one set of counters, so the caller keeps one clone to
190/// poll and hands another to the route. A handle is for one run; within a pass the
191/// counters only grow, and a second run on the same handle would start from the first
192/// run's totals. A watch start is the one run with two passes: its closing verification
193/// restarts the walk counters.
194///
195/// Relaxed atomics, except the once-per-run flag that publishes the analysis denominator
196/// (Release and Acquire) and the phase, which a new pass stores with Release after
197/// zeroing the counters and a snapshot reads with Acquire before them. Each counter is
198/// read on its own, so no other ordering between them is promised; see
199/// [`ProgressSnapshot`].
200#[derive(Clone, Default)]
201pub struct Progress {
202 cells: Arc<Cells>,
203}
204
205impl Progress {
206 /// A fresh handle: phase [`ProgressPhase::Starting`], every counter zero, no analysis.
207 #[must_use]
208 pub fn new() -> Self {
209 Self::default()
210 }
211
212 /// Read every counter and the current phase.
213 ///
214 /// Safe to call from any thread at any rate; each call is a handful of atomic loads.
215 #[must_use]
216 pub fn snapshot(&self) -> ProgressSnapshot {
217 let cells = &*self.cells;
218 let analysis = cells.analysis.known.load(Ordering::Acquire).then(|| {
219 (
220 cells.analysis.done.load(Ordering::Relaxed),
221 cells.analysis.total.load(Ordering::Relaxed),
222 )
223 });
224 ProgressSnapshot {
225 // Acquire, read before the counters: a new pass stores its phase after zeroing them.
226 phase: ProgressPhase::from_code(cells.phase.0.load(Ordering::Acquire)),
227 directories: cells.walk.directories.load(Ordering::Relaxed),
228 files: cells.walk.files.load(Ordering::Relaxed),
229 bytes: cells.walk.bytes.load(Ordering::Relaxed),
230 allocated: cells.walk.allocated.load(Ordering::Relaxed),
231 analysis,
232 }
233 }
234
235 /// Record that the route has begun `phase`.
236 pub(crate) fn enter(&self, phase: ProgressPhase) {
237 self.cells.phase.0.store(phase.code(), Ordering::Relaxed);
238 }
239
240 /// Begin a second pass at `phase`, with the walk counters back at zero.
241 ///
242 /// Only a watch start runs two passes: its closing verification walks the tree again,
243 /// and counting that walk on top of the first would show about twice the tree. The
244 /// phase is stored after the counters, so a poller that sees the new phase never
245 /// pairs it with the first pass's totals.
246 #[cfg(feature = "watch")]
247 pub(crate) fn begin_pass(&self, phase: ProgressPhase) {
248 let walk = &self.cells.walk;
249 walk.directories.store(0, Ordering::Relaxed);
250 walk.files.store(0, Ordering::Relaxed);
251 walk.bytes.store(0, Ordering::Relaxed);
252 walk.allocated.store(0, Ordering::Relaxed);
253 self.cells.phase.0.store(phase.code(), Ordering::Release);
254 }
255
256 /// Add one worker's share of the walk since it last added.
257 pub(crate) fn add_walked(&self, directories: u64, files: u64, bytes: u64, allocated: u64) {
258 let walk = &self.cells.walk;
259 walk.directories.fetch_add(directories, Ordering::Relaxed);
260 walk.files.fetch_add(files, Ordering::Relaxed);
261 walk.bytes.fetch_add(bytes, Ordering::Relaxed);
262 walk.allocated.fetch_add(allocated, Ordering::Relaxed);
263 }
264
265 /// Record the candidate total content analysis will work through.
266 pub(crate) fn begin_analysis(&self, total: u64) {
267 let analysis = &self.cells.analysis;
268 analysis.total.store(total, Ordering::Relaxed);
269 analysis.known.store(true, Ordering::Release);
270 }
271
272 /// Record one analyzed candidate.
273 pub(crate) fn add_analyzed(&self, files: u64) {
274 self.cells.analysis.done.fetch_add(files, Ordering::Relaxed);
275 }
276}
277
278impl fmt::Debug for Progress {
279 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
280 let ProgressSnapshot { phase, directories, files, bytes, allocated, analysis } =
281 self.snapshot();
282 f.debug_struct("Progress")
283 .field("phase", &phase)
284 .field("directories", &directories)
285 .field("files", &files)
286 .field("bytes", &bytes)
287 .field("allocated", &allocated)
288 .field("analysis", &analysis)
289 .finish()
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn a_fresh_handle_is_starting_with_nothing_counted() {
299 assert_eq!(
300 Progress::new().snapshot(),
301 ProgressSnapshot {
302 phase: ProgressPhase::Starting,
303 directories: 0,
304 files: 0,
305 bytes: 0,
306 allocated: 0,
307 analysis: None,
308 }
309 );
310 }
311
312 #[test]
313 fn clones_share_one_set_of_counters() {
314 let polled = Progress::new();
315 let handed_to_route = polled.clone();
316 handed_to_route.enter(ProgressPhase::Scanning);
317 handed_to_route.add_walked(2, 5, 700, 8_192);
318 handed_to_route.add_walked(1, 0, 0, 0);
319 assert_eq!(
320 polled.snapshot(),
321 ProgressSnapshot {
322 phase: ProgressPhase::Scanning,
323 directories: 3,
324 files: 5,
325 bytes: 700,
326 allocated: 8_192,
327 analysis: None,
328 }
329 );
330 }
331
332 #[test]
333 fn analysis_is_unknown_until_a_total_is_recorded() {
334 let progress = Progress::new();
335 progress.add_analyzed(1);
336 assert_eq!(progress.snapshot().analysis, None, "a count without a denominator");
337 progress.begin_analysis(4);
338 assert_eq!(progress.snapshot().analysis, Some((1, 4)));
339 progress.add_analyzed(3);
340 assert_eq!(progress.snapshot().analysis, Some((4, 4)));
341 }
342
343 #[test]
344 fn every_phase_survives_the_cell_round_trip() {
345 let progress = Progress::new();
346 for phase in ProgressPhase::ALL {
347 progress.enter(phase);
348 assert_eq!(progress.snapshot().phase, phase);
349 assert_eq!(ProgressPhase::from_code(phase.code()), phase);
350 }
351 assert_eq!(ProgressPhase::from_code(u8::MAX), ProgressPhase::Starting);
352 }
353
354 #[test]
355 fn debug_shows_the_snapshot_rather_than_the_cells() {
356 let progress = Progress::new();
357 progress.enter(ProgressPhase::Analyzing);
358 progress.begin_analysis(2);
359 assert_eq!(
360 format!("{progress:?}"),
361 "Progress { phase: Analyzing, directories: 0, files: 0, bytes: 0, allocated: 0, analysis: Some((0, 2)) }"
362 );
363 }
364
365 #[test]
366 fn the_shared_cells_keep_each_writer_on_its_own_line() {
367 assert_eq!(std::mem::align_of::<WalkCells>(), 128);
368 assert_eq!(std::mem::align_of::<AnalysisCells>(), 128);
369 assert_eq!(std::mem::align_of::<PhaseCell>(), 128);
370 assert!(std::mem::size_of::<WalkCells>() <= 128, "the four walk counters fit one line");
371 }
372}