1use 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};
18use tokio::time::Instant as Clock;
20
21const CSV_TIME_FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ";
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct Schedule {
27 pub every: Duration,
29 pub duration: Option<Duration>,
32}
33
34impl Schedule {
35 pub fn new(every: Duration, duration: Duration) -> Result<Self> {
37 Self::with(every, Some(duration))
38 }
39
40 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 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#[derive(Debug, Clone)]
66pub struct RunOutput<T> {
67 pub sample: Sample,
69 pub snapshot: String,
71 pub value: T,
73 pub view: Option<crate::cli::output::SummaryView>,
75}
76
77pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}