Skip to main content

tpchgen_cli/
progress.rs

1//! Progress tracking for table generation.
2//!
3//! # Overview
4//!
5//! [`ProgressTracker`] is a small, dyn-compatible trait that receives
6//! generation events from [`crate::runner::PlanRunner`]. The runner calls:
7//!
8//! 1. [`ProgressTracker::register`] once per table, before any worker
9//!    starts, with the total number of output units the table will produce
10//!    (chunks for TBL/CSV, row groups for Parquet).
11//! 2. [`ProgressTracker::increment`] after output units are written.
12//!    Multiple table-generation tasks may call it concurrently, so impls
13//!    must be `Send + Sync` and `increment` itself should be lightweight.
14//! 3. [`ProgressTracker::finish`] once on the success path when the
15//!    runner exits. Implementations should use `finish` for normal
16//!    success cleanup and `Drop` only as an error or panic fallback.
17//!
18//! `register` and `finish` are invoked serially by the runner and may
19//! do bookkeeping or I/O; `increment` may run concurrently while output
20//! is being written.
21//!
22//! Implementations must not panic and must not propagate I/O errors —
23//! progress reporting is best-effort and must never affect the data
24//! path.
25//!
26//! # Default implementation
27//!
28//! When the `indicatif-progress` feature is enabled (on by default), the
29//! crate provides an `IndicatifProgress` implementation, which renders
30//! one progress bar per table on stderr using the `indicatif` crate.
31//! Library users who do not want to pull in `indicatif` can enable the
32//! `progress` feature directly and supply their own [`ProgressTracker`]
33//! implementation.
34//!
35//! # Example: a custom logging tracker
36//!
37//! ```
38//! # #[cfg(feature = "progress")]
39//! # {
40//! use std::sync::atomic::{AtomicU64, Ordering};
41//! use tpchgen_cli::progress::ProgressTracker;
42//! use tpchgen_cli::Table;
43//!
44//! #[derive(Debug)]
45//! struct LoggingTracker {
46//!     written: AtomicU64,
47//! }
48//!
49//! impl ProgressTracker for LoggingTracker {
50//!     fn register(&self, table: Table, total: u64) {
51//!         eprintln!("plan: {table:?} -> {total} output units");
52//!     }
53//!     fn increment(&self, _table: Table, units: u64) {
54//!         self.written.fetch_add(units, Ordering::Relaxed);
55//!     }
56//!     fn finish(&self) {
57//!         eprintln!("done: {} output units", self.written.load(Ordering::Relaxed));
58//!     }
59//! }
60//! # }
61//! ```
62
63use crate::output_plan::OutputPlan;
64use crate::Table;
65#[cfg(feature = "progress")]
66use std::collections::BTreeMap;
67#[cfg(feature = "progress")]
68use std::fmt;
69#[cfg(feature = "progress")]
70use std::sync::Arc;
71
72/// Receives generation-progress events for one
73/// [`PlanRunner`](crate::runner::PlanRunner) invocation.
74///
75/// See the [module-level documentation](self) for the call-order
76/// contract. Trackers are passed to the runner as an
77/// [`std::sync::Arc`] and shared across concurrent generation tasks, so
78/// they must be `Send + Sync`.
79/// They must also be `Debug` so containing types can derive `Debug`.
80#[cfg(feature = "progress")]
81pub trait ProgressTracker: Send + Sync + fmt::Debug {
82    /// Pre-register a table with its total expected output-unit count.
83    ///
84    /// Called once per table before any worker starts. Implementations
85    /// that need to know totals up front (e.g. to render a progress bar
86    /// or compute an ETA) should override this; the default does
87    /// nothing.
88    fn register(&self, _table: Table, _total_units: u64) {}
89
90    /// Advance the counter for `table` by `units` output units.
91    ///
92    /// Called after generated output units are written. Multiple
93    /// table-generation tasks may call this concurrently, so implementations
94    /// should be lightweight and must never panic.
95    fn increment(&self, table: Table, units: u64);
96
97    /// Called once after the last [`Self::increment`] on the success
98    /// path. Implementations should use this for normal success cleanup
99    /// and `Drop` only as an error or panic fallback. The default does
100    /// nothing.
101    fn finish(&self) {}
102}
103
104/// Progress handle for one [`PlanRunner::run`](crate::runner::PlanRunner::run)
105/// invocation.
106///
107/// Owns run-level progress lifecycle: registering totals, accounting for
108/// skipped outputs, and finishing the tracker.
109#[derive(Debug, Clone, Default)]
110#[cfg(feature = "progress")]
111pub(crate) struct RunProgress {
112    tracker: Option<Arc<dyn ProgressTracker>>,
113}
114
115#[cfg(feature = "progress")]
116impl RunProgress {
117    pub(crate) fn with_tracker(tracker: Arc<dyn ProgressTracker>) -> Self {
118        Self {
119            tracker: Some(tracker),
120        }
121    }
122
123    pub(crate) fn register_totals(&self, plans: &[OutputPlan]) {
124        if let Some(tracker) = self.tracker.as_ref() {
125            let mut totals: BTreeMap<Table, u64> = BTreeMap::new();
126            for plan in plans {
127                *totals.entry(plan.table()).or_insert(0) += plan.chunk_count() as u64;
128            }
129            for (table, total) in totals {
130                tracker.register(table, total);
131            }
132        }
133    }
134
135    pub(crate) fn increment_for_existing(&self, plan: &OutputPlan) {
136        if let Some(tracker) = self.tracker.as_ref() {
137            tracker.increment(plan.table(), plan.chunk_count() as u64);
138        }
139    }
140
141    pub(crate) fn for_table(&self, table: Table) -> TableProgress {
142        TableProgress::for_table(self.tracker.clone(), table)
143    }
144
145    pub(crate) fn finish(self) {
146        if let Some(tracker) = self.tracker {
147            tracker.finish();
148        }
149    }
150}
151
152/// No-op run progress handle used when progress support is disabled.
153#[derive(Debug, Clone, Default)]
154#[cfg(not(feature = "progress"))]
155pub(crate) struct RunProgress;
156
157#[cfg(not(feature = "progress"))]
158impl RunProgress {
159    pub(crate) fn register_totals(&self, _plans: &[OutputPlan]) {}
160
161    pub(crate) fn increment_for_existing(&self, _plan: &OutputPlan) {}
162
163    pub(crate) fn for_table(&self, _table: Table) -> TableProgress {
164        TableProgress
165    }
166
167    pub(crate) fn finish(self) {}
168}
169
170/// Progress handle for one table output stream.
171///
172/// Used by format writers to report each successfully written output unit
173/// without knowing whether progress tracking is enabled.
174#[derive(Clone, Default)]
175#[cfg(feature = "progress")]
176pub(crate) struct TableProgress {
177    tracker: Option<(Arc<dyn ProgressTracker>, Table)>,
178}
179
180#[cfg(feature = "progress")]
181impl TableProgress {
182    pub(crate) fn for_table(progress: Option<Arc<dyn ProgressTracker>>, table: Table) -> Self {
183        Self {
184            tracker: progress.map(|progress| (progress, table)),
185        }
186    }
187
188    pub(crate) fn increment_output_unit(&self) {
189        if let Some((progress, table)) = self.tracker.as_ref() {
190            progress.increment(*table, 1);
191        }
192    }
193}
194
195/// No-op table progress handle used when progress support is disabled.
196#[derive(Clone, Default)]
197#[cfg(not(feature = "progress"))]
198pub(crate) struct TableProgress;
199
200#[cfg(not(feature = "progress"))]
201impl TableProgress {
202    pub(crate) fn increment_output_unit(&self) {}
203}
204
205#[cfg(feature = "indicatif-progress")]
206pub use indicatif_impl::IndicatifProgress;
207
208#[cfg(feature = "indicatif-progress")]
209mod indicatif_impl {
210    use super::ProgressTracker;
211    use crate::Table;
212    use indicatif::{MultiProgress, ProgressBar, ProgressFinish, ProgressStyle};
213    use std::collections::BTreeMap;
214    use std::io::{self, Write};
215    use std::sync::{OnceLock, RwLock};
216
217    /// Default [`ProgressTracker`] implementation backed by
218    /// [`indicatif::MultiProgress`].
219    ///
220    /// Renders one bar per table on stderr. Bars are pre-allocated in
221    /// [`ProgressTracker::register`] and are looked up by [`Table`] on
222    /// each [`ProgressTracker::increment`] call. Lookup uses a `RwLock`
223    /// read on the increment path; this is uncontended after the serial
224    /// `register` phase completes.
225    #[derive(Debug)]
226    pub struct IndicatifProgress {
227        multi: MultiProgress,
228        tables: RwLock<BTreeMap<Table, ProgressBar>>,
229    }
230
231    impl IndicatifProgress {
232        /// Construct an empty tracker. Tables are added via
233        /// [`ProgressTracker::register`].
234        pub fn new() -> Self {
235            Self {
236                multi: MultiProgress::new(),
237                tables: RwLock::new(BTreeMap::new()),
238            }
239        }
240
241        /// Return a writer that coordinates stderr log writes with progress
242        /// bar redraws.
243        pub fn log_writer(&self) -> Box<dyn io::Write + Send + 'static> {
244            Box::new(IndicatifLogWriter {
245                multi: self.multi.clone(),
246            })
247        }
248    }
249
250    impl Default for IndicatifProgress {
251        fn default() -> Self {
252            Self::new()
253        }
254    }
255
256    impl ProgressTracker for IndicatifProgress {
257        fn register(&self, table: Table, total_units: u64) {
258            let Ok(mut tables) = self.tables.write() else {
259                return;
260            };
261
262            let pb = self.multi.add(ProgressBar::new(total_units));
263            pb.set_style(bar_style().clone());
264            pb.set_message(table.to_string());
265            let pb = pb.with_finish(ProgressFinish::AndLeave);
266            // Write-lock is only contended during the register phase, which
267            // happens serially before any worker task starts.
268            tables.insert(table, pb);
269        }
270
271        fn increment(&self, table: Table, units: u64) {
272            // Minimize the read-lock scope so concurrent `increment` callers
273            // don't serialize on it. Cloning the bar is a cheap `Arc` bump,
274            // and `ProgressBar::inc` is internally thread-safe.
275            let bar = {
276                let Ok(tables) = self.tables.read() else {
277                    return;
278                };
279                tables.get(&table).cloned()
280            };
281            if let Some(bar) = bar {
282                bar.inc(units);
283            }
284        }
285
286        fn finish(&self) {
287            let bars = {
288                let Ok(tables) = self.tables.read() else {
289                    return;
290                };
291                tables.values().cloned().collect::<Vec<_>>()
292            };
293            for bar in bars {
294                bar.finish_using_style();
295            }
296        }
297    }
298
299    fn bar_style() -> &'static ProgressStyle {
300        static STYLE: OnceLock<ProgressStyle> = OnceLock::new();
301        STYLE.get_or_init(|| {
302            ProgressStyle::default_bar()
303                .template("{msg:10} [{bar:28}]   Progress: {percent:>3}%")
304                .expect("static progress bar template is valid")
305                .progress_chars("█▓░")
306        })
307    }
308
309    struct IndicatifLogWriter {
310        multi: MultiProgress,
311    }
312
313    impl Write for IndicatifLogWriter {
314        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
315            self.multi.suspend(|| {
316                let mut stderr = io::stderr().lock();
317                stderr.write(buf)
318            })
319        }
320
321        fn flush(&mut self) -> io::Result<()> {
322            self.multi.suspend(|| {
323                let mut stderr = io::stderr().lock();
324                stderr.flush()
325            })
326        }
327    }
328
329    #[cfg(test)]
330    mod tests {
331        use super::*;
332
333        #[test]
334        fn registers_and_increments() {
335            let t = IndicatifProgress::new();
336            t.register(Table::Lineitem, 60);
337            t.register(Table::Orders, 15);
338            t.increment(Table::Lineitem, 1);
339            t.increment(Table::Orders, 5);
340
341            let tables = t.tables.read().unwrap();
342            assert_eq!(tables[&Table::Lineitem].position(), 1);
343            assert_eq!(tables[&Table::Orders].position(), 5);
344        }
345
346        #[test]
347        fn reaches_total() {
348            let t = IndicatifProgress::new();
349            t.register(Table::Orders, 5);
350            for _ in 0..5 {
351                t.increment(Table::Orders, 1);
352            }
353            assert_eq!(t.tables.read().unwrap()[&Table::Orders].position(), 5);
354        }
355
356        #[test]
357        fn unknown_table_is_no_op() {
358            // Incrementing a table not registered must not panic.
359            let t = IndicatifProgress::new();
360            t.register(Table::Orders, 1);
361            t.increment(Table::Lineitem, 1);
362            assert_eq!(t.tables.read().unwrap()[&Table::Orders].position(), 0);
363        }
364
365        #[test]
366        fn finish_marks_registered_bars_finished() {
367            let t = IndicatifProgress::new();
368            t.register(Table::Orders, 2);
369            t.increment(Table::Orders, 2);
370            t.finish();
371
372            assert!(t.tables.read().unwrap()[&Table::Orders].is_finished());
373        }
374    }
375}
376
377#[cfg(all(test, feature = "progress"))]
378mod tests {
379    use super::*;
380    use crate::Table;
381    use std::sync::{
382        atomic::{AtomicU64, Ordering},
383        Arc, Mutex,
384    };
385
386    /// Mock implementation that records every event. Demonstrates that
387    /// the trait is dyn-compatible and usable from external code without
388    /// pulling in `indicatif`.
389    #[derive(Debug, Default)]
390    struct MockTracker {
391        registered: Mutex<Vec<(Table, u64)>>,
392        total_increments: AtomicU64,
393        finished: AtomicU64,
394    }
395
396    impl ProgressTracker for MockTracker {
397        fn register(&self, table: Table, total_units: u64) {
398            self.registered.lock().unwrap().push((table, total_units));
399        }
400        fn increment(&self, _table: Table, units: u64) {
401            self.total_increments.fetch_add(units, Ordering::Relaxed);
402        }
403        fn finish(&self) {
404            self.finished.fetch_add(1, Ordering::Relaxed);
405        }
406    }
407
408    #[test]
409    fn mock_tracker_works_through_arc_dyn() {
410        let mock = Arc::new(MockTracker::default());
411        let dynamic: Arc<dyn ProgressTracker> = mock.clone();
412        dynamic.register(Table::Lineitem, 10);
413        dynamic.register(Table::Orders, 4);
414        dynamic.increment(Table::Lineitem, 3);
415        dynamic.increment(Table::Orders, 1);
416        dynamic.finish();
417
418        assert_eq!(
419            *mock.registered.lock().unwrap(),
420            vec![(Table::Lineitem, 10), (Table::Orders, 4)]
421        );
422        assert_eq!(mock.total_increments.load(Ordering::Relaxed), 4);
423        assert_eq!(mock.finished.load(Ordering::Relaxed), 1);
424    }
425
426    #[test]
427    fn default_register_and_finish_are_noops() {
428        // An impl that only overrides `increment` should compile and run.
429        #[derive(Debug)]
430        struct Minimal(AtomicU64);
431        impl ProgressTracker for Minimal {
432            fn increment(&self, _t: Table, c: u64) {
433                self.0.fetch_add(c, Ordering::Relaxed);
434            }
435        }
436        let m = Minimal(AtomicU64::new(0));
437        m.register(Table::Region, 99); // no-op default
438        m.increment(Table::Region, 7);
439        m.finish(); // no-op default
440        assert_eq!(m.0.load(Ordering::Relaxed), 7);
441    }
442}