Skip to main content

cli/data/
progress.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Minimal internal progress abstraction for Export/Import V2.
16//!
17//! This is intentionally small and log/internal oriented. It does not touch
18//! stdout and is safe for non-interactive runs. [`LogProgress`] backs the
19//! import-v2 `--progress` flag for non-interactive runs by routing events to
20//! stderr, while [`IndicatifProgress`] renders an interactive bar on a TTY.
21//! Both implement [`ProgressReporter`], so call sites stay agnostic.
22
23use std::io::{self, Write};
24use std::sync::Mutex;
25
26use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
27
28/// Receives progress events from long-running Export/Import V2 work.
29///
30/// The trait is object-safe so callers can take `&dyn ProgressReporter` and stay
31/// agnostic about the concrete implementation (no-op in production today, a
32/// recording fake in tests).
33pub(crate) trait ProgressReporter: Send + Sync {
34    /// Begins a phase with an optional known total number of units.
35    fn start_phase(&self, name: &str, total: Option<u64>);
36
37    /// Advances the current phase by `delta` units.
38    fn inc(&self, delta: u64);
39
40    /// Marks the current phase as finished.
41    fn finish_phase(&self);
42}
43
44/// A reporter that discards every event. Used as the production default and in
45/// tests that do not care about progress.
46pub(crate) struct NoopProgress;
47
48impl ProgressReporter for NoopProgress {
49    fn start_phase(&self, _name: &str, _total: Option<u64>) {}
50    fn inc(&self, _delta: u64) {}
51    fn finish_phase(&self) {}
52}
53
54/// A lightweight reporter that logs phase lifecycle and progress through the
55/// stderr. It never touches stdout, so it is safe for non-interactive runs and
56/// keeps dry-run output clean.
57pub(crate) struct LogProgress {
58    phase: Mutex<Option<PhaseState>>,
59}
60
61struct PhaseState {
62    name: String,
63    total: Option<u64>,
64    done: u64,
65}
66
67impl LogProgress {
68    pub(crate) fn new() -> Self {
69        Self {
70            phase: Mutex::new(None),
71        }
72    }
73}
74
75fn write_progress_line(line: String) {
76    let _ = writeln!(io::stderr().lock(), "{line}");
77}
78
79impl ProgressReporter for LogProgress {
80    fn start_phase(&self, name: &str, total: Option<u64>) {
81        let Ok(mut phase) = self.phase.lock() else {
82            return;
83        };
84        *phase = Some(PhaseState {
85            name: name.to_string(),
86            total,
87            done: 0,
88        });
89        match total {
90            Some(total) => write_progress_line(format!("Starting phase '{name}' ({total} units)")),
91            None => write_progress_line(format!("Starting phase '{name}'")),
92        }
93    }
94
95    fn inc(&self, delta: u64) {
96        let Ok(mut guard) = self.phase.lock() else {
97            return;
98        };
99        if let Some(phase) = guard.as_mut() {
100            phase.done += delta;
101            match phase.total {
102                Some(total) => {
103                    write_progress_line(format!("Phase '{}': {}/{}", phase.name, phase.done, total))
104                }
105                None => write_progress_line(format!("Phase '{}': {}", phase.name, phase.done)),
106            }
107        }
108    }
109
110    fn finish_phase(&self) {
111        let Ok(mut guard) = self.phase.lock() else {
112            return;
113        };
114        if let Some(phase) = guard.take() {
115            write_progress_line(format!(
116                "Finished phase '{}' ({} units)",
117                phase.name, phase.done
118            ));
119        }
120    }
121}
122
123/// A reporter that renders an interactive progress bar via `indicatif`.
124///
125/// It draws to stderr through an explicit [`ProgressDrawTarget::stderr`] so it
126/// never collides with stdout (e.g. dry-run SQL). Phases with a known total get
127/// a determinate bar; unknown totals fall back to an animated spinner. Each
128/// phase clears itself on finish via [`ProgressBar::finish_and_clear`].
129pub(crate) struct IndicatifProgress {
130    bar: Mutex<Option<ProgressBar>>,
131}
132
133impl IndicatifProgress {
134    pub(crate) fn new() -> Self {
135        Self {
136            bar: Mutex::new(None),
137        }
138    }
139}
140
141impl ProgressReporter for IndicatifProgress {
142    fn start_phase(&self, name: &str, total: Option<u64>) {
143        let Ok(mut guard) = self.bar.lock() else {
144            return;
145        };
146
147        // Replacing any prior phase: clear it before starting the next.
148        if let Some(prev) = guard.take() {
149            prev.finish_and_clear();
150        }
151
152        let bar = ProgressBar::with_draw_target(total, ProgressDrawTarget::stderr());
153        match total {
154            Some(_) => {
155                let style =
156                    ProgressStyle::with_template("{msg} [{bar:40}] {pos}/{len} ({percent}%)")
157                        .unwrap_or_else(|_| ProgressStyle::default_bar())
158                        .progress_chars("=>-");
159                bar.set_style(style);
160            }
161            None => {
162                let style = ProgressStyle::with_template("{spinner} {msg} {pos}")
163                    .unwrap_or_else(|_| ProgressStyle::default_spinner());
164                bar.set_style(style);
165            }
166        }
167        bar.set_message(name.to_string());
168        *guard = Some(bar);
169    }
170
171    fn inc(&self, delta: u64) {
172        let Ok(guard) = self.bar.lock() else {
173            return;
174        };
175        if let Some(bar) = guard.as_ref() {
176            bar.inc(delta);
177        }
178    }
179
180    fn finish_phase(&self) {
181        let Ok(mut guard) = self.bar.lock() else {
182            return;
183        };
184        if let Some(bar) = guard.take() {
185            bar.finish_and_clear();
186        }
187    }
188}
189
190/// RAII guard for a started progress phase.
191///
192/// This keeps future stateful reporters safe on every early-return path after a
193/// phase starts. Call [`Self::finish`] to end the phase at a deliberate point;
194/// otherwise the guard finishes it when dropped.
195#[must_use = "dropping the guard immediately finishes the phase"]
196pub(crate) struct ProgressPhase<'a> {
197    reporter: &'a dyn ProgressReporter,
198    finished: bool,
199}
200
201impl<'a> ProgressPhase<'a> {
202    pub(crate) fn start(
203        reporter: &'a dyn ProgressReporter,
204        name: &str,
205        total: Option<u64>,
206    ) -> Self {
207        reporter.start_phase(name, total);
208        Self {
209            reporter,
210            finished: false,
211        }
212    }
213
214    pub(crate) fn finish(mut self) {
215        self.finish_once();
216    }
217
218    fn finish_once(&mut self) {
219        if !self.finished {
220            self.reporter.finish_phase();
221            self.finished = true;
222        }
223    }
224}
225
226impl Drop for ProgressPhase<'_> {
227    fn drop(&mut self) {
228        self.finish_once();
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn test_log_progress_is_safe_across_phase_lifecycle() {
238        // LogProgress takes only `&self`, so it must drive a full phase
239        // lifecycle (including an out-of-phase `inc`) without panicking.
240        let progress = LogProgress::new();
241        let reporter: &dyn ProgressReporter = &progress;
242
243        reporter.inc(1); // No active phase yet: must be a no-op, not a panic.
244        reporter.start_phase("Import data tasks", Some(2));
245        reporter.inc(1);
246        reporter.inc(1);
247        reporter.finish_phase();
248        reporter.finish_phase(); // Idempotent: finishing twice is harmless.
249    }
250
251    #[test]
252    fn test_indicatif_progress_is_safe_across_phase_lifecycle() {
253        // IndicatifProgress takes only `&self`, so it must survive a full
254        // lifecycle (including determinate and spinner phases, an out-of-phase
255        // `inc`, and double finish) without panicking. The draw target is
256        // stderr, which is non-interactive under the test harness, so nothing
257        // actually renders.
258        let progress = IndicatifProgress::new();
259        let reporter: &dyn ProgressReporter = &progress;
260
261        reporter.inc(1); // No active phase yet: must be a no-op, not a panic.
262        reporter.start_phase("Import data tasks", Some(2));
263        reporter.inc(1);
264        reporter.inc(1);
265        reporter.start_phase("Streaming", None); // Spinner phase replaces the bar.
266        reporter.inc(5);
267        reporter.finish_phase();
268        reporter.finish_phase(); // Idempotent: finishing twice is harmless.
269    }
270}
271
272#[cfg(test)]
273pub(crate) mod test_util {
274    use std::sync::Mutex;
275
276    use super::ProgressReporter;
277
278    /// A single recorded progress event, used to assert progress behavior in
279    /// unit tests.
280    #[derive(Debug, Clone, PartialEq, Eq)]
281    pub(crate) enum ProgressEvent {
282        StartPhase { name: String, total: Option<u64> },
283        Inc { delta: u64 },
284        FinishPhase,
285    }
286
287    /// A reporter that records every event in order for later assertions.
288    #[derive(Default)]
289    pub(crate) struct RecordingProgress {
290        events: Mutex<Vec<ProgressEvent>>,
291    }
292
293    impl RecordingProgress {
294        pub(crate) fn events(&self) -> Vec<ProgressEvent> {
295            self.events.lock().unwrap().clone()
296        }
297
298        /// Sum of all `inc` deltas recorded so far.
299        pub(crate) fn total_inc(&self) -> u64 {
300            self.events
301                .lock()
302                .unwrap()
303                .iter()
304                .filter_map(|event| match event {
305                    ProgressEvent::Inc { delta } => Some(*delta),
306                    _ => None,
307                })
308                .sum()
309        }
310
311        fn push(&self, event: ProgressEvent) {
312            self.events.lock().unwrap().push(event);
313        }
314    }
315
316    impl ProgressReporter for RecordingProgress {
317        fn start_phase(&self, name: &str, total: Option<u64>) {
318            self.push(ProgressEvent::StartPhase {
319                name: name.to_string(),
320                total,
321            });
322        }
323
324        fn inc(&self, delta: u64) {
325            self.push(ProgressEvent::Inc { delta });
326        }
327
328        fn finish_phase(&self) {
329            self.push(ProgressEvent::FinishPhase);
330        }
331    }
332}