Skip to main content

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}