Skip to main content

scope_cli/cli/
repeat.rs

1//! The one repeat runner for commands that take `--every` and `--duration`
2//! (#58).
3//!
4//! It owns the interval, the duration, the run count, the progress record,
5//! the final-run rule and the rule that an error ends the repeat. A command
6//! supplies one run (`step`). Each run's key values go to the CSV file and
7//! to the screen's series (TUI); in the CLI the run's report prints as
8//! before.
9
10use crate::cli::output::{Output, RunRecord};
11use crate::{note, out};
12use scope::error::{Result, ScopeError};
13use scope::metrics::{self as series, Metric, Sample};
14use std::future::Future;
15use std::io::Write as _;
16use std::path::Path;
17use std::time::{Duration, Instant};
18// The schedule runs on tokio's clock, the one its sleep uses.
19use tokio::time::Instant as Clock;
20
21/// The timestamp format of a CSV row.
22const CSV_TIME_FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ";
23
24/// When a repeating command runs.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct Schedule {
27    /// The time from one run's start to the next.
28    pub every: Duration,
29    /// How long the repeat lasts; a last run starts when it ends. `None`
30    /// runs until the user cancels it (#96).
31    pub duration: Option<Duration>,
32}
33
34impl Schedule {
35    /// A schedule; the interval must be positive.
36    pub fn new(every: Duration, duration: Duration) -> Result<Self> {
37        Self::with(every, Some(duration))
38    }
39
40    /// A schedule that runs until the user cancels it.
41    pub fn until_cancelled(every: Duration) -> Result<Self> {
42        Self::with(every, None)
43    }
44
45    fn with(every: Duration, duration: Option<Duration>) -> Result<Self> {
46        if every.is_zero() {
47            return Err(ScopeError::InvalidInput(
48                "Interval must be positive".to_string(),
49            ));
50        }
51        Ok(Self { every, duration })
52    }
53
54    /// How many runs it makes: one every `every` over `duration`, and a last
55    /// one at the end when the span is not a whole number of intervals (the
56    /// final sleep is cut to the time left). `None` until cancelled.
57    pub fn total(&self) -> Option<u64> {
58        let every = self.every.as_millis().max(1);
59        let total = self.duration?.as_millis().div_ceil(every).saturating_add(1);
60        Some(u64::try_from(total).unwrap_or(u64::MAX))
61    }
62}
63
64/// What one run gives the runner.
65#[derive(Debug, Clone)]
66pub struct RunOutput<T> {
67    /// The run's key values, in the order of the runner's metric table.
68    pub sample: Sample,
69    /// The run's report, as the CLI prints it.
70    pub snapshot: String,
71    /// The command's own result of the run (for example for `--report`).
72    pub value: T,
73    /// The run's data, for a screen that draws its own view of it.
74    pub view: Option<crate::cli::output::SummaryView>,
75}
76
77/// Runs `step` on `schedule` and returns the last run's value.
78///
79/// Before each run in the CLI, it notes `--- Run #N at HH:MM:SS ---`; after
80/// it, the CLI prints the snapshot. With a screen series (TUI), the record
81/// goes to the series instead, so the pane does not stack one report per
82/// run. With `csv`, the file gets the header, then one row per run. An
83/// error from `step` ends the repeat and is returned.
84pub async fn run<T, F, Fut>(
85    schedule: &Schedule,
86    metrics: &[Metric],
87    csv: Option<&Path>,
88    out: &Output,
89    mut step: F,
90) -> Result<Option<T>>
91where
92    F: FnMut(u64) -> Fut,
93    Fut: Future<Output = Result<RunOutput<T>>>,
94{
95    // The CSV columns, only for a CSV: a caller without one may pass an
96    // empty table (a watchlist writes its own trail).
97    let columns = match csv {
98        Some(_) => series::csv_columns(metrics).map_err(|e| ScopeError::Other(e.to_string()))?,
99        None => Vec::new(),
100    };
101    if let Some(path) = csv {
102        std::fs::write(path, series::csv_header(&columns))
103            .map_err(|e| ScopeError::io_context(e, format!("cannot write {}", path.display())))?;
104    }
105    out.update_progress(|p| {
106        p.run = 0;
107        p.total = schedule.total();
108        p.next_at = None;
109    });
110
111    let tui = out.series_hook().is_some();
112    let start = Clock::now();
113    let end = schedule.duration.map(|d| start + d);
114    let mut slot = start;
115    let mut run: u64 = 1;
116    loop {
117        let began = Clock::now();
118        out.update_progress(|p| {
119            p.run = run;
120            p.next_at = None;
121        });
122        if !tui {
123            let ts = chrono::Utc::now().format("%H:%M:%S");
124            note!(out, "--- Run #{} at {} ---\n", run, ts);
125        }
126        // An error ends the repeat: `?` returns it and no later run starts.
127        let output = step(run).await?;
128        let at = chrono::Utc::now();
129        if let Some(path) = csv {
130            let time = at.format(CSV_TIME_FORMAT).to_string();
131            let row = series::csv_row(&columns, metrics, &time, run, &output.sample);
132            append(path, &row)?;
133        }
134        let record = RunRecord {
135            run,
136            at,
137            sample: output.sample,
138            snapshot: output.snapshot.clone(),
139            view: output.view.map(std::sync::Arc::new),
140        };
141        if !out.record_run(metrics, record) {
142            out!(out, "{}", output.snapshot)?;
143        }
144        let last = output.value;
145
146        // The repeat ends after the run that starts at the end, not after
147        // any run that finishes past it: a run that crosses the end is
148        // followed at once by the last run, so the count meets `total()`.
149        if let (Some(duration), Some(end)) = (schedule.duration, end)
150            && began >= end
151        {
152            note!(
153                out,
154                "\nCompleted {} run(s) over {}s.",
155                run,
156                duration.as_secs()
157            );
158            return Ok(Some(last));
159        }
160        run += 1;
161        // Runs start on a fixed grid, so the time a run takes does not push
162        // the later ones back. A run that overruns its slot moves the grid
163        // instead of starting a burst of catch-up runs.
164        slot = (slot + schedule.every).max(Clock::now());
165        if let Some(end) = end {
166            slot = slot.min(end);
167        }
168        let pause = slot.saturating_duration_since(Clock::now());
169        out.update_progress(|p| p.next_at = Some(Instant::now() + pause));
170        tokio::time::sleep_until(slot).await;
171    }
172}
173
174fn append(path: &Path, row: &str) -> Result<()> {
175    let context = || format!("cannot append to {}", path.display());
176    let mut file = std::fs::OpenOptions::new()
177        .append(true)
178        .open(path)
179        .map_err(|e| ScopeError::io_context(e, context()))?;
180    file.write_all(row.as_bytes())
181        .map_err(|e| ScopeError::io_context(e, context()))
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use scope::market::HealthThresholds;
188    use scope::metrics::summary_metrics;
189
190    fn metrics() -> Vec<Metric> {
191        summary_metrics(&HealthThresholds::default())
192    }
193
194    fn output(run: u64, metrics: &[Metric]) -> RunOutput<u64> {
195        RunOutput {
196            sample: Sample {
197                values: vec![Some(run as f64); metrics.len()],
198                healthy: true,
199                failed_checks: vec![],
200            },
201            snapshot: format!("report {run}\n"),
202            view: None,
203            value: run,
204        }
205    }
206
207    #[test]
208    fn test_total_counts_the_final_run_at_the_boundary() {
209        // 5s every 2s runs at 0, 2, 4 and then at 5 (the sleep is cut to
210        // the time left): four runs, not 5/2+1.
211        let s = |every, duration| {
212            Schedule::new(Duration::from_secs(every), Duration::from_secs(duration)).unwrap()
213        };
214        assert_eq!(s(2, 5).total(), Some(4));
215        assert_eq!(s(1, 2).total(), Some(3));
216        assert_eq!(s(2, 1).total(), Some(2));
217        // Until cancelled: there is no total to show.
218        let open = Schedule::until_cancelled(Duration::from_secs(1)).unwrap();
219        assert_eq!(open.total(), None);
220    }
221
222    #[tokio::test(start_paused = true)]
223    async fn test_until_cancelled_runs_past_any_duration_with_flat_memory() {
224        // #96: a run with no end must go on until the user stops it (here an
225        // error at run 1001, as Esc would), past the 1 h default, and keep
226        // only the newest MAX_SERIES_RUNS runs in memory.
227        let m = metrics();
228        let (out, _cap) = Output::capture_merged();
229        let schedule = Schedule::until_cancelled(Duration::from_secs(30)).unwrap();
230        let stop = 1_001;
231        let result: Result<Option<u64>> = run(&schedule, &m, None, &out, |n| {
232            let o = output(n, &m);
233            async move {
234                if n == stop {
235                    Err(ScopeError::Other("cancelled".into()))
236                } else {
237                    Ok(o)
238                }
239            }
240        })
241        .await;
242        assert!(result.is_err());
243        let progress = out.progress_hook().unwrap().lock().unwrap().clone();
244        assert_eq!(progress.run, stop, "it ran past 1 h (121 runs at 30 s)");
245        assert_eq!(progress.total, None);
246        let series = out.series_hook().unwrap();
247        let series = series.lock().unwrap();
248        assert_eq!(series.runs.len(), crate::cli::output::MAX_SERIES_RUNS);
249        assert_eq!(
250            series.dropped,
251            stop - 1 - crate::cli::output::MAX_SERIES_RUNS as u64
252        );
253    }
254
255    #[test]
256    fn test_a_zero_interval_is_the_users_error() {
257        let err = Schedule::new(Duration::ZERO, Duration::from_secs(1)).unwrap_err();
258        assert!(matches!(err, ScopeError::InvalidInput(_)), "{err:?}");
259    }
260
261    #[tokio::test(start_paused = true)]
262    async fn test_cli_prints_each_report_and_returns_the_last_value() {
263        let m = metrics();
264        let (out, cap) = Output::capture();
265        let schedule = Schedule::new(Duration::from_secs(1), Duration::from_secs(2)).unwrap();
266        let last = run(&schedule, &m, None, &out, |n| {
267            let o = output(n, &m);
268            async move { Ok(o) }
269        })
270        .await
271        .unwrap();
272        assert_eq!(last, Some(3));
273        assert_eq!(cap.out(), "report 1\nreport 2\nreport 3\n");
274        assert!(cap.err().contains("--- Run #3 at"), "{}", cap.err());
275        assert!(
276            cap.err().contains("Completed 3 run(s) over 2s."),
277            "{}",
278            cap.err()
279        );
280    }
281
282    #[tokio::test(start_paused = true)]
283    async fn test_tui_gets_records_and_no_repeated_reports() {
284        let m = metrics();
285        let (out, cap) = Output::capture_merged();
286        let schedule = Schedule::new(Duration::from_secs(1), Duration::from_secs(1)).unwrap();
287        run(&schedule, &m, None, &out, |n| {
288            let o = output(n, &m);
289            async move { Ok(o) }
290        })
291        .await
292        .unwrap();
293        assert!(!cap.out().contains("report 1"), "{}", cap.out());
294        assert!(!cap.out().contains("--- Run #"), "{}", cap.out());
295        let series = out.series_hook().unwrap();
296        let series = series.lock().unwrap();
297        assert_eq!(series.metrics, m);
298        let runs: Vec<u64> = series.runs.iter().map(|r| r.run).collect();
299        assert_eq!(runs, vec![1, 2]);
300        assert_eq!(series.runs[1].snapshot, "report 2\n");
301    }
302
303    #[tokio::test(start_paused = true)]
304    async fn test_an_error_ends_the_repeat() {
305        let m = metrics();
306        let (out, _cap) = Output::capture_merged();
307        let schedule = Schedule::new(Duration::from_secs(1), Duration::from_secs(30)).unwrap();
308        let mut calls = 0;
309        let result: Result<Option<u64>> = run(&schedule, &m, None, &out, |n| {
310            calls += 1;
311            let o = output(n, &m);
312            async move {
313                if n == 2 {
314                    Err(ScopeError::Network("venue down".into()))
315                } else {
316                    Ok(o)
317                }
318            }
319        })
320        .await;
321        assert!(result.is_err());
322        assert_eq!(calls, 2, "no run after the error");
323        let progress = out.progress_hook().unwrap().lock().unwrap().clone();
324        assert_eq!(progress.run, 2);
325        // The run before the error stays in the series.
326        assert_eq!(out.series_hook().unwrap().lock().unwrap().runs.len(), 1);
327    }
328
329    #[tokio::test(start_paused = true)]
330    async fn test_csv_gets_the_header_then_one_row_per_run() {
331        let m = metrics();
332        let dir = tempfile::tempdir().unwrap();
333        let path = dir.path().join("s.csv");
334        let (out, _cap) = Output::capture();
335        let schedule = Schedule::new(Duration::from_secs(1), Duration::from_secs(1)).unwrap();
336        run(&schedule, &m, Some(&path), &out, |n| {
337            let o = output(n, &m);
338            async move { Ok(o) }
339        })
340        .await
341        .unwrap();
342        let body = std::fs::read_to_string(&path).unwrap();
343        let lines: Vec<&str> = body.lines().collect();
344        let columns = series::csv_columns(&m).unwrap();
345        assert_eq!(format!("{}\n", lines[0]), series::csv_header(&columns));
346        assert_eq!(lines.len(), 3, "{body}");
347        assert_eq!(lines[2].split(',').nth(1), Some("2"));
348    }
349
350    /// Runs `schedule` with a step whose run `n` takes `cost(n)`, and
351    /// returns each run's start time in milliseconds from the start.
352    async fn start_times(schedule: Schedule, cost: impl Fn(u64) -> Duration) -> Vec<u128> {
353        let m = metrics();
354        let (out, _cap) = Output::capture_merged();
355        let start = Clock::now();
356        let starts = std::sync::Mutex::new(Vec::new());
357        run(&schedule, &m, None, &out, |n| {
358            starts.lock().unwrap().push(start.elapsed().as_millis());
359            let o = output(n, &m);
360            let cost = cost(n);
361            async move {
362                tokio::time::sleep(cost).await;
363                Ok(o)
364            }
365        })
366        .await
367        .unwrap();
368        starts.into_inner().unwrap()
369    }
370
371    #[tokio::test(start_paused = true)]
372    async fn test_run_time_does_not_push_later_runs_back() {
373        // A 0.75 s fetch every 30 s made "run 18 of 121" start at 523 s, and
374        // the hour ended near run 118. Runs must stay on the 30 s grid.
375        let schedule = Schedule::new(Duration::from_secs(30), Duration::from_secs(90)).unwrap();
376        let starts = start_times(schedule, |_| Duration::from_millis(750)).await;
377        assert_eq!(starts, vec![0, 30_000, 60_000, 90_000]);
378        assert_eq!(Some(starts.len() as u64), schedule.total());
379    }
380
381    #[tokio::test(start_paused = true)]
382    async fn test_an_overrun_moves_the_grid_without_a_burst() {
383        // A slow first run (3.5 s, every 1 s) starts the next one at once,
384        // and later runs keep the interval from there. A fixed grid would
385        // fire the missed slots back to back at 3.5 s, 3.6 s and 3.7 s.
386        let schedule = Schedule::new(Duration::from_secs(1), Duration::from_secs(6)).unwrap();
387        let cost = |n| Duration::from_millis(if n == 1 { 3_500 } else { 100 });
388        let starts = start_times(schedule, cost).await;
389        assert_eq!(starts, vec![0, 3_500, 4_500, 5_500, 6_000]);
390    }
391
392    #[tokio::test(start_paused = true)]
393    async fn test_a_run_that_reaches_the_end_still_gets_the_last_run() {
394        // Every 1 s for 1 s: a first run of 1 s or more used to end the
395        // repeat after one run while `total()` said 2. The last run starts
396        // at the end, or at once when run 1 finishes past it.
397        let schedule = Schedule::new(Duration::from_secs(1), Duration::from_secs(1)).unwrap();
398        for (cost, second) in [(5, 1_000), (999, 1_000), (1_000, 1_000), (2_500, 2_500)] {
399            let starts = start_times(schedule, |n| {
400                Duration::from_millis(if n == 1 { cost } else { 10 })
401            })
402            .await;
403            assert_eq!(starts, vec![0, second], "first run of {cost} ms");
404            assert_eq!(Some(starts.len() as u64), schedule.total());
405        }
406    }
407
408    #[tokio::test(start_paused = true)]
409    async fn test_a_run_that_crosses_the_end_is_followed_by_one_last_run() {
410        // Every 2 s for 5 s: runs at 0, 2 and 4; run 3 takes 1.5 s and ends
411        // past 5 s, so the last run starts at once and nothing follows it.
412        let schedule = Schedule::new(Duration::from_secs(2), Duration::from_secs(5)).unwrap();
413        let cost = |n| Duration::from_millis(if n == 3 { 1_500 } else { 100 });
414        let starts = start_times(schedule, cost).await;
415        assert_eq!(starts, vec![0, 2_000, 4_000, 5_500]);
416        assert_eq!(Some(starts.len() as u64), schedule.total());
417    }
418
419    #[tokio::test(start_paused = true)]
420    async fn test_progress_reaches_the_total_when_a_run_crosses_the_end() {
421        // `run N of M` must end at M = N, not stop one short.
422        let m = metrics();
423        let (out, _cap) = Output::capture_merged();
424        let schedule = Schedule::new(Duration::from_secs(1), Duration::from_secs(1)).unwrap();
425        run(&schedule, &m, None, &out, |n| {
426            let o = output(n, &m);
427            async move {
428                tokio::time::sleep(Duration::from_millis(1_200)).await;
429                Ok(o)
430            }
431        })
432        .await
433        .unwrap();
434        let progress = out.progress_hook().unwrap().lock().unwrap().clone();
435        assert_eq!(progress.total, Some(2));
436        assert_eq!(progress.run, 2);
437    }
438
439    #[test]
440    fn test_the_series_ring_keeps_the_newest_runs() {
441        let m = metrics();
442        let (out, _cap) = Output::capture_merged();
443        let extra = 3;
444        for n in 1..=(crate::cli::output::MAX_SERIES_RUNS as u64 + extra) {
445            let o = output(n, &m);
446            out.record_run(
447                &m,
448                RunRecord {
449                    run: n,
450                    at: chrono::Utc::now(),
451                    sample: o.sample,
452                    snapshot: o.snapshot,
453                    view: None,
454                },
455            );
456        }
457        let series = out.series_hook().unwrap();
458        let series = series.lock().unwrap();
459        assert_eq!(series.runs.len(), crate::cli::output::MAX_SERIES_RUNS);
460        assert_eq!(series.dropped, extra);
461        assert_eq!(series.runs.front().unwrap().run, extra + 1);
462    }
463}