file_engine/eta.rs
1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::time::{Duration, Instant};
4
5use crate::progress::Progress;
6
7/// Minimum wall time a rate sample must cover before it's folded into the
8/// running average. Completion events arrive in bursts (a whole batch's
9/// entries finish near-simultaneously), so a rate computed over the
10/// microseconds between two of them is noise, not throughput.
11const SAMPLE_WINDOW: Duration = Duration::from_millis(500);
12
13/// Weight given to each new sample. Low enough that a single slow batch
14/// doesn't make the estimate lurch, high enough to track a genuine
15/// slowdown (a USB write cache filling up, say) within a few seconds.
16const EWMA_ALPHA: f64 = 0.3;
17
18/// Which cost regime the currently-executing work belongs to. The three
19/// have genuinely different cost drivers, which is the whole reason this
20/// type exists — see `EtaEstimator`.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22enum Regime {
23 Directory,
24 SmallFile,
25 LargeFile,
26}
27
28/// An observed rate of "work units per second" for one regime, where a
29/// work unit is a directory, a file, or a byte depending on the regime.
30///
31/// Time and work are accumulated separately rather than as (work, elapsed)
32/// pairs: work arrives on completion events, elapsed time accrues
33/// continuously, and the two only need to line up at flush boundaries.
34#[derive(Debug, Clone, Default)]
35struct Rate {
36 ewma: Option<f64>,
37 pending_work: f64,
38 pending_secs: f64,
39}
40
41impl Rate {
42 fn add_work(&mut self, work: f64) {
43 self.pending_work += work;
44 }
45
46 fn add_time(&mut self, secs: f64) {
47 self.pending_secs += secs;
48 if self.pending_secs >= SAMPLE_WINDOW.as_secs_f64() {
49 let sample = self.pending_work / self.pending_secs;
50 self.ewma = Some(match self.ewma {
51 Some(previous) => previous * (1.0 - EWMA_ALPHA) + sample * EWMA_ALPHA,
52 None => sample,
53 });
54 self.pending_work = 0.0;
55 self.pending_secs = 0.0;
56 }
57 }
58
59 /// `None` until there's something to divide — a caller with
60 /// outstanding work in this regime and no rate yet genuinely cannot
61 /// estimate, and should say so rather than guess.
62 ///
63 /// Falls back to the un-flushed partial sample so a run that finishes
64 /// in under one `SAMPLE_WINDOW` still reports something.
65 fn per_sec(&self) -> Option<f64> {
66 if let Some(ewma) = self.ewma {
67 if ewma > 0.0 {
68 return Some(ewma);
69 }
70 }
71 if self.pending_secs > 0.0 && self.pending_work > 0.0 {
72 return Some(self.pending_work / self.pending_secs);
73 }
74 None
75 }
76}
77
78/// Predicts how much longer an operation has left, from the `Progress`
79/// events it emits.
80///
81/// # Why not just bytes-done over elapsed
82///
83/// A single bytes-per-second figure is wrong for this crate's pipeline in
84/// three specific ways, and this type exists to correct each:
85///
86/// 1. **Two cost regimes.** Small files are packed into batches and are
87/// syscall-bound — their cost is essentially per-file and barely
88/// depends on size. Large files are streamed and are bandwidth-bound.
89/// A bytes/sec rate learned during the small-file phase overestimates
90/// the large-file phase badly, and vice versa, so the two are measured
91/// separately and recombined.
92/// 2. **The directory pre-pass isn't in `bytes_total`.** It runs before
93/// `Progress::Started` is ever emitted and can dominate a run on a slow
94/// filesystem (a real exFAT-over-USB copy spent about a minute creating
95/// ~7,700 directories). It gets its own per-directory cost term.
96/// 3. **Default batch sort is `SortOrder::Descending`.** The largest
97/// entries complete first, so the mix observed early in a run is not
98/// representative of what's left — extrapolating remaining work from
99/// observed work converges on the wrong answer. `Progress::Planned`
100/// supplies the true split up front instead.
101///
102/// # How wall time is attributed
103///
104/// A second of wall time is charged to *every* regime that had work in
105/// flight during it, not to a single "current" regime. Small and large
106/// files genuinely do run at the same time: the dispatcher enqueues every
107/// batch before any stream, but a workload small enough to fit inside the
108/// concurrency limit starts all of them at once, and then a streaming
109/// large file overlaps the entire small-file phase. Charging that second
110/// to only one of them leaves the other with work recorded but no elapsed
111/// time to divide it by — an infinite rate, or more precisely no usable
112/// rate at all.
113///
114/// The regimes are then recombined the way the pipeline actually runs
115/// them: the directory pre-pass finishes strictly before dispatch begins,
116/// so its cost adds, while small and large files overlap, so theirs is a
117/// maximum rather than a sum.
118///
119/// ```text
120/// estimate = directories + max(small files, large files)
121/// ```
122///
123/// # Where the numbers come from, in order of authority
124///
125/// 1. **`EntryProgress` samples** — bytes observed landing at the
126/// destination while a large file is still in flight. The most direct
127/// measurement available, and the only one that exists during a single
128/// long transfer.
129/// 2. **Completed large files** — an exact byte count over an exact
130/// duration, folded in the same way.
131/// 3. **Overall byte throughput** — used for outstanding large bytes
132/// before either of the above has produced anything. Dominated by
133/// batched small files, which pay per-file overhead that streaming
134/// doesn't, so it reads low and the estimate starts pessimistic.
135///
136/// Bytes credited by (1) are not re-counted by (2); a completing entry
137/// contributes only what sampling hadn't already seen.
138///
139/// A copy the filesystem satisfies by copy-on-write (APFS `clonefile`,
140/// reflinks) finishes before the first sample and produces no rate at all
141/// — correctly, since there is nothing to wait for. Measured here at 2GB
142/// in under a millisecond.
143///
144/// # Usage
145///
146/// ```no_run
147/// # async fn example(engine: &file_engine::FileEngine) -> file_engine::Result<()> {
148/// use file_engine::EtaEstimator;
149/// use tokio_stream::StreamExt;
150///
151/// let mut handle = engine.copy("src", "dst").start()?;
152/// let mut eta = EtaEstimator::new();
153///
154/// while let Some(progress) = handle.progress().next().await {
155/// eta.observe(&progress);
156/// if let Some(remaining) = eta.estimate() {
157/// println!("{}s remaining", remaining.as_secs());
158/// }
159/// }
160/// # Ok(())
161/// # }
162/// ```
163///
164/// Purely observational: it performs no I/O, spawns nothing, and holds no
165/// reference to the running operation. Feeding it events out of order, or
166/// only some of them, degrades the estimate but never panics.
167#[derive(Debug, Clone)]
168pub struct EtaEstimator {
169 small_file_threshold: u64,
170 directories_remaining: usize,
171 small_files_remaining: usize,
172 large_bytes_remaining: u64,
173 directory_rate: Rate,
174 small_file_rate: Rate,
175 large_file_rate: Rate,
176 /// Bytes per second across every completed entry regardless of regime.
177 /// Used only to stand in for `large_file_rate` before any large file
178 /// has finished — see `estimate`.
179 overall_byte_rate: Rate,
180 /// Entries currently between `EntryStarted` and their terminal event,
181 /// per regime — the basis for deciding which regimes a span of wall
182 /// time is charged to. Counts, not booleans, because several entries
183 /// of the same regime are normally in flight at once.
184 small_in_flight: usize,
185 large_in_flight: usize,
186 /// The directory pre-pass reports no per-directory start event, so it
187 /// counts as in flight from `DirectoriesStarted` until the last
188 /// directory is accounted for.
189 directories_in_flight: bool,
190 /// Bytes already counted for entries still in flight, from
191 /// `EntryProgress` samples. Keyed by source path, and cleared when the
192 /// entry reaches a terminal event, so this holds at most one key per
193 /// concurrently streaming file.
194 credited_bytes: HashMap<PathBuf, u64>,
195 last_event: Option<Instant>,
196 /// Set by `Planned`, cleared by the `Started` that follows it. Lets a
197 /// `Started` arriving *without* a preceding `Planned` be recognised as
198 /// a metadata-only phase (the delete sweeps) and modelled as per-entry
199 /// cost, rather than being mistaken for a phase whose plan went
200 /// missing.
201 awaiting_planned_start: bool,
202}
203
204impl Default for EtaEstimator {
205 fn default() -> Self {
206 Self::new()
207 }
208}
209
210impl EtaEstimator {
211 pub fn new() -> Self {
212 Self {
213 small_file_threshold: 0,
214 directories_remaining: 0,
215 small_files_remaining: 0,
216 large_bytes_remaining: 0,
217 directory_rate: Rate::default(),
218 small_file_rate: Rate::default(),
219 large_file_rate: Rate::default(),
220 overall_byte_rate: Rate::default(),
221 small_in_flight: 0,
222 large_in_flight: 0,
223 directories_in_flight: false,
224 credited_bytes: HashMap::new(),
225 last_event: None,
226 awaiting_planned_start: false,
227 }
228 }
229
230 /// Feeds one event in. Call this for every event on the stream: each
231 /// one either supplies work done or marks the boundary of a span of
232 /// wall time, and skipping events costs accuracy in both.
233 pub fn observe(&mut self, progress: &Progress) {
234 self.observe_at(progress, Instant::now());
235 }
236
237 fn observe_at(&mut self, progress: &Progress, now: Instant) {
238 // What was in flight over the interval that just ended — captured
239 // before the match, which may start or retire work.
240 let was_in_flight = self.in_flight_regimes();
241
242 match progress {
243 Progress::Planned {
244 directories,
245 small_files,
246 large_bytes,
247 small_file_threshold,
248 ..
249 } => {
250 self.small_file_threshold = *small_file_threshold;
251 self.directories_remaining = *directories;
252 self.small_files_remaining = *small_files;
253 self.large_bytes_remaining = *large_bytes;
254 self.awaiting_planned_start = true;
255 }
256
257 Progress::DirectoriesStarted { total } => {
258 self.directories_remaining = *total;
259 self.directories_in_flight = *total > 0;
260 }
261
262 Progress::DirectoryCompleted { .. } | Progress::DirectoryFailed { .. } => {
263 self.directory_rate.add_work(1.0);
264 self.directories_remaining = self.directories_remaining.saturating_sub(1);
265 if self.directories_remaining == 0 {
266 self.directories_in_flight = false;
267 }
268 }
269
270 // A `Started` with no `Planned` before it is a metadata-only
271 // phase (delete sweep): no bytes to model, so every entry is
272 // charged as one per-operation unit, which is exactly the
273 // small-file regime's cost shape.
274 Progress::Started { entries_total, .. } => {
275 // `dispatch()` emits this only after the directory
276 // pre-pass has returned, so it is the definitive end of
277 // that phase — without this, an unfinished-looking
278 // directory count keeps absorbing the file phase's wall
279 // time and drags the per-directory rate toward zero.
280 self.directories_in_flight = false;
281
282 if self.awaiting_planned_start {
283 self.awaiting_planned_start = false;
284 } else {
285 self.small_files_remaining = *entries_total;
286 self.large_bytes_remaining = 0;
287 self.small_file_threshold = u64::MAX;
288 }
289 }
290
291 Progress::EntryStarted { entry } => {
292 if self.regime_for(entry.size) == Regime::LargeFile {
293 self.large_in_flight += 1;
294 } else {
295 self.small_in_flight += 1;
296 }
297 }
298
299 Progress::EntryCompleted { entry } | Progress::EntryFailed { entry } => {
300 // A failure still consumed wall time and still retired an
301 // entry, so it counts toward the rate exactly as a success
302 // does — otherwise a run failing every entry would report
303 // a rate of zero and never produce an estimate at all.
304 if self.regime_for(entry.size) == Regime::LargeFile {
305 // Only the bytes not already credited by in-flight
306 // sampling — otherwise a sampled file is counted twice
307 // and reports double its real throughput.
308 let outstanding = entry
309 .size
310 .saturating_sub(self.credited_bytes.remove(&entry.path).unwrap_or(0));
311 self.large_file_rate.add_work(outstanding as f64);
312 self.overall_byte_rate.add_work(outstanding as f64);
313 self.large_bytes_remaining =
314 self.large_bytes_remaining.saturating_sub(outstanding);
315 self.large_in_flight = self.large_in_flight.saturating_sub(1);
316 } else {
317 self.overall_byte_rate.add_work(entry.size as f64);
318 self.small_file_rate.add_work(1.0);
319 self.small_files_remaining = self.small_files_remaining.saturating_sub(1);
320 self.small_in_flight = self.small_in_flight.saturating_sub(1);
321 }
322 }
323
324 // Partial progress for an entry still in flight. `bytes_copied`
325 // is cumulative, so only the increment since the last sample is
326 // new work.
327 Progress::EntryProgress {
328 entry,
329 bytes_copied,
330 } => {
331 let credited = self.credited_bytes.entry(entry.path.clone()).or_insert(0);
332 let delta = bytes_copied.saturating_sub(*credited);
333 if delta > 0 {
334 *credited = *bytes_copied;
335 self.large_file_rate.add_work(delta as f64);
336 self.overall_byte_rate.add_work(delta as f64);
337 self.large_bytes_remaining = self.large_bytes_remaining.saturating_sub(delta);
338 // Bytes that have landed are no longer outstanding
339 // work for the in-flight entry either — without this,
340 // the in-flight pool exceeds what actually remains and
341 // the overlapping term is inflated by everything
342 // already copied.
343 }
344 }
345 }
346
347 // Charged *after* the match, so that work reported by this event
348 // lands in the same sample window as the interval during which it
349 // was performed. Doing it first instead leaves every flushed
350 // sample short by exactly the work of the event that triggered it,
351 // which reads as a systematic underestimate of throughput — and so
352 // a systematic overestimate of time remaining.
353 if let Some(last) = self.last_event {
354 let elapsed = now.saturating_duration_since(last).as_secs_f64();
355 for regime in &was_in_flight {
356 self.rate_mut(*regime).add_time(elapsed);
357 }
358 // Any entry in flight is moving bytes, whichever regime it
359 // belongs to.
360 if was_in_flight
361 .iter()
362 .any(|r| matches!(r, Regime::SmallFile | Regime::LargeFile))
363 {
364 self.overall_byte_rate.add_time(elapsed);
365 }
366 }
367 self.last_event = Some(now);
368 }
369
370 /// Estimated time remaining, or `None` while any regime with
371 /// outstanding work has no measured rate yet — an operation that has
372 /// only just started genuinely has no basis for an estimate, and
373 /// reporting nothing is more useful than reporting a fabricated
374 /// number that collapses by an order of magnitude a second later.
375 ///
376 /// Returns `Duration::ZERO` once no work is outstanding.
377 pub fn estimate(&self) -> Option<Duration> {
378 let seconds_for = |remaining: f64, rate: &Rate| -> Option<f64> {
379 if remaining <= 0.0 {
380 return Some(0.0);
381 }
382 Some(remaining / rate.per_sec()?)
383 };
384
385 let directories = seconds_for(self.directories_remaining as f64, &self.directory_rate)?;
386 let small = seconds_for(self.small_files_remaining as f64, &self.small_file_rate)?;
387
388 // A streamed file reports nothing between `EntryStarted` and
389 // `EntryCompleted`, so its own byte rate stays unmeasurable for as
390 // long as it takes to copy — on a multi-gigabyte file that is the
391 // entire run, i.e. precisely when an ETA is most wanted. Fall back
392 // to the byte rate observed across all completed entries. Small
393 // files carry per-file overhead that streaming doesn't, so that
394 // figure understates streaming throughput: the estimate starts
395 // pessimistic and tightens once a large file actually lands, which
396 // is the right direction for a countdown to move.
397 let large_rate = self
398 .large_file_rate
399 .per_sec()
400 .or_else(|| self.overall_byte_rate.per_sec());
401 let seconds_for_bytes = |bytes: u64| -> Option<f64> {
402 match (bytes, large_rate) {
403 (0, _) => Some(0.0),
404 (bytes, Some(rate)) => Some(bytes as f64 / rate),
405 (_, None) => None,
406 }
407 };
408
409 // All outstanding large bytes are treated as overlapping the
410 // small-file phase, even though the dispatcher only runs one stream
411 // ahead of the batches and queues the rest behind them.
412 //
413 // The structurally-honest alternative — adding the queued portion
414 // rather than maxing it — was implemented and measured, and the
415 // result was inconclusive: run-to-run variance on the mixed fixture
416 // (6.1s to 8.2s wall time for identical work) is larger than the
417 // difference between the two models. `max` is kept as the simpler
418 // of the two, not as a demonstrated winner.
419 //
420 // Note what the additive model would depend on: queued bytes divide
421 // by a rate learned while the calibration stream competes with
422 // thousands of small files, which understates how fast those
423 // streams run once the batches drain and they have the device to
424 // themselves. `max` under-counts queued work; the contended rate
425 // over-counts its duration. Distinguishing them needs a
426 // lower-variance benchmark than this one.
427 //
428 // The cost of keeping `max` is a visible one: while every remaining
429 // stream sits queued, its byte count can't fall and its rate can't
430 // update, so the estimate pins at a constant — measured as a
431 // countdown frozen at exactly 5.9s for over a second. That is a
432 // stale term, not device slowdown; see `dev-docs/design/eta.md` §9.
433 let large = seconds_for_bytes(self.large_bytes_remaining)?;
434
435 // Directories add: the pre-pass completes before dispatch starts.
436 // Small and large overlap, so the longer of the two absorbs the
437 // shorter rather than queueing behind it.
438 Duration::try_from_secs_f64(directories + small.max(large)).ok()
439 }
440
441 /// Observed throughput for large, streamed files, in bytes per second.
442 /// `None` until at least one has completed. Deliberately excludes the
443 /// batched small-file phase, whose cost is per-file rather than
444 /// per-byte — averaging the two together produces a number that
445 /// describes neither.
446 pub fn bytes_per_sec(&self) -> Option<f64> {
447 self.large_file_rate.per_sec()
448 }
449
450 /// Every regime with work in flight right now. A span of wall time is
451 /// charged to all of them, since they were all making progress during
452 /// it — see the type-level note on attribution.
453 fn in_flight_regimes(&self) -> Vec<Regime> {
454 let mut regimes = Vec::with_capacity(3);
455 if self.directories_in_flight {
456 regimes.push(Regime::Directory);
457 }
458 if self.small_in_flight > 0 {
459 regimes.push(Regime::SmallFile);
460 }
461 if self.large_in_flight > 0 {
462 regimes.push(Regime::LargeFile);
463 }
464 regimes
465 }
466
467 fn regime_for(&self, size: u64) -> Regime {
468 if size <= self.small_file_threshold {
469 Regime::SmallFile
470 } else {
471 Regime::LargeFile
472 }
473 }
474
475 fn rate_mut(&mut self, regime: Regime) -> &mut Rate {
476 match regime {
477 Regime::Directory => &mut self.directory_rate,
478 Regime::SmallFile => &mut self.small_file_rate,
479 Regime::LargeFile => &mut self.large_file_rate,
480 }
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use std::path::PathBuf;
487
488 use crate::profiler::Entry;
489
490 use super::*;
491
492 fn entry(size: u64) -> Entry {
493 Entry {
494 path: PathBuf::from("x"),
495 relative_path: PathBuf::from("x"),
496 size,
497 modified: None,
498 }
499 }
500
501 fn planned(directories: usize, small_files: usize, large_bytes: u64) -> Progress {
502 Progress::Planned {
503 directories,
504 small_files,
505 small_bytes: small_files as u64,
506 large_files: usize::from(large_bytes > 0),
507 large_bytes,
508 small_file_threshold: 1024,
509 }
510 }
511
512 fn started() -> Progress {
513 Progress::Started {
514 bytes_total: Some(0),
515 entries_total: 0,
516 }
517 }
518
519 /// Drives a sequence of `(event, seconds_since_previous)` through the
520 /// estimator against a synthetic clock, so tests assert on the model's
521 /// arithmetic rather than on how fast the machine running them is.
522 ///
523 /// The clock is owned by the caller so that a test replaying two
524 /// scripts against one estimator advances a single timeline. Starting
525 /// a fresh `Instant::now()` per call instead would place the second
526 /// script *behind* the first on the real clock, and every elapsed span
527 /// in it would saturate to zero.
528 fn replay(estimator: &mut EtaEstimator, clock: &mut Instant, script: &[(Progress, f64)]) {
529 for (event, delay) in script {
530 *clock += Duration::from_secs_f64(*delay);
531 estimator.observe_at(event, *clock);
532 }
533 }
534
535 #[test]
536 fn no_estimate_before_anything_completes() {
537 let mut eta = EtaEstimator::new();
538 let mut clock = Instant::now();
539 replay(
540 &mut eta,
541 &mut clock,
542 &[(planned(0, 10, 0), 0.0), (started(), 0.0)],
543 );
544
545 assert_eq!(eta.estimate(), None);
546 }
547
548 #[test]
549 fn estimates_zero_when_nothing_is_outstanding() {
550 let mut eta = EtaEstimator::new();
551 let mut clock = Instant::now();
552 replay(
553 &mut eta,
554 &mut clock,
555 &[(planned(0, 0, 0), 0.0), (started(), 0.0)],
556 );
557
558 assert_eq!(eta.estimate(), Some(Duration::ZERO));
559 }
560
561 #[test]
562 fn small_files_are_estimated_per_file_not_per_byte() {
563 let mut eta = EtaEstimator::new();
564 let mut clock = Instant::now();
565 let mut script = vec![(planned(0, 100, 0), 0.0), (started(), 0.0)];
566
567 // 10 files over 1s total => 10 files/sec, so the 90 left ~= 9s.
568 // Sizes vary 100x across them; a per-byte model would not land on
569 // 9s, which is the point of the assertion.
570 for i in 0..10 {
571 let size = if i % 2 == 0 { 10 } else { 1000 };
572 script.push((Progress::EntryStarted { entry: entry(size) }, 0.0));
573 script.push((Progress::EntryCompleted { entry: entry(size) }, 0.1));
574 }
575 replay(&mut eta, &mut clock, &script);
576
577 let estimate = eta.estimate().unwrap().as_secs_f64();
578 assert!(
579 (estimate - 9.0).abs() < 0.5,
580 "expected ~9s for 90 files at 10 files/sec, got {estimate}"
581 );
582 }
583
584 #[test]
585 fn large_files_are_estimated_per_byte() {
586 let mut eta = EtaEstimator::new();
587 let mut clock = Instant::now();
588 let big = 10_000_u64;
589 let mut script = vec![(planned(0, 0, big * 10), 0.0), (started(), 0.0)];
590
591 // 4 files x 10_000 bytes over 4s => 10_000 bytes/sec, leaving
592 // 60_000 bytes => ~6s.
593 for _ in 0..4 {
594 script.push((Progress::EntryStarted { entry: entry(big) }, 0.0));
595 script.push((Progress::EntryCompleted { entry: entry(big) }, 1.0));
596 }
597 replay(&mut eta, &mut clock, &script);
598
599 let estimate = eta.estimate().unwrap().as_secs_f64();
600 assert!(
601 (estimate - 6.0).abs() < 0.5,
602 "expected ~6s for 60_000 bytes at 10_000 B/s, got {estimate}"
603 );
604 }
605
606 #[test]
607 fn directory_pre_pass_is_estimated_before_any_file_work_is_known() {
608 let mut eta = EtaEstimator::new();
609 let mut clock = Instant::now();
610 let mut script = vec![
611 (planned(100, 0, 0), 0.0),
612 (Progress::DirectoriesStarted { total: 100 }, 0.0),
613 ];
614
615 // 20 directories over 2s => 10 dirs/sec, 80 left => ~8s. This is
616 // the window that emits no `Started` at all, so an estimator keyed
617 // only on `Started`/`bytes_total` would report nothing here.
618 for _ in 0..20 {
619 script.push((
620 Progress::DirectoryCompleted {
621 path: PathBuf::from("d"),
622 },
623 0.1,
624 ));
625 }
626 replay(&mut eta, &mut clock, &script);
627
628 let estimate = eta.estimate().unwrap().as_secs_f64();
629 assert!(
630 (estimate - 8.0).abs() < 0.5,
631 "expected ~8s for 80 directories at 10/sec, got {estimate}"
632 );
633 }
634
635 #[test]
636 fn directory_and_file_costs_are_summed_not_conflated() {
637 let mut eta = EtaEstimator::new();
638 let mut clock = Instant::now();
639 let mut script = vec![
640 (planned(30, 30, 0), 0.0),
641 (Progress::DirectoriesStarted { total: 30 }, 0.0),
642 ];
643 // 10 dirs in 1s => 10/sec, 20 dirs left => 2s.
644 for _ in 0..10 {
645 script.push((
646 Progress::DirectoryCompleted {
647 path: PathBuf::from("d"),
648 },
649 0.1,
650 ));
651 }
652 script.push((started(), 0.0));
653 // 10 files in 2s => 5/sec, 20 files left => 4s. Total ~6s.
654 for _ in 0..10 {
655 script.push((Progress::EntryStarted { entry: entry(10) }, 0.0));
656 script.push((Progress::EntryCompleted { entry: entry(10) }, 0.2));
657 }
658 replay(&mut eta, &mut clock, &script);
659
660 let estimate = eta.estimate().unwrap().as_secs_f64();
661 assert!(
662 (estimate - 6.0).abs() < 0.7,
663 "expected ~6s (2s of directories + 4s of files), got {estimate}"
664 );
665 }
666
667 #[test]
668 fn failed_entries_count_as_progress() {
669 let mut eta = EtaEstimator::new();
670 let mut clock = Instant::now();
671 let mut script = vec![(planned(0, 20, 0), 0.0), (started(), 0.0)];
672 for _ in 0..10 {
673 script.push((Progress::EntryStarted { entry: entry(10) }, 0.0));
674 script.push((Progress::EntryFailed { entry: entry(10) }, 0.1));
675 }
676 replay(&mut eta, &mut clock, &script);
677
678 // A failure consumes wall time and retires an entry just as a
679 // success does — 10 done at 10/sec leaves 10 => ~1s.
680 let estimate = eta.estimate().unwrap().as_secs_f64();
681 assert!((estimate - 1.0).abs() < 0.3, "expected ~1s, got {estimate}");
682 }
683
684 #[test]
685 fn started_without_planned_is_modelled_as_a_metadata_only_phase() {
686 let mut eta = EtaEstimator::new();
687 let mut clock = Instant::now();
688 let mut script = vec![(
689 Progress::Started {
690 bytes_total: None,
691 entries_total: 100,
692 },
693 0.0,
694 )];
695 // Deletes carry a real `entry.size` but cost nothing per byte; the
696 // phase must be costed per-operation. 10 in 1s => 90 left => ~9s.
697 for _ in 0..10 {
698 script.push((
699 Progress::EntryStarted {
700 entry: entry(5_000_000),
701 },
702 0.0,
703 ));
704 script.push((
705 Progress::EntryCompleted {
706 entry: entry(5_000_000),
707 },
708 0.1,
709 ));
710 }
711 replay(&mut eta, &mut clock, &script);
712
713 let estimate = eta.estimate().unwrap().as_secs_f64();
714 assert!(
715 (estimate - 9.0).abs() < 0.5,
716 "expected ~9s for 90 deletions at 10/sec, got {estimate}"
717 );
718 }
719
720 #[test]
721 fn a_later_phase_resets_remaining_work_without_discarding_learned_rates() {
722 let mut eta = EtaEstimator::new();
723 let mut clock = Instant::now();
724 let mut script = vec![(planned(0, 10, 0), 0.0), (started(), 0.0)];
725 for _ in 0..10 {
726 script.push((Progress::EntryStarted { entry: entry(10) }, 0.0));
727 script.push((Progress::EntryCompleted { entry: entry(10) }, 0.1));
728 }
729 replay(&mut eta, &mut clock, &script);
730 assert_eq!(eta.estimate(), Some(Duration::ZERO));
731
732 // sync's delete phase: a fresh `Started` announces 5 more entries.
733 // The 10 files/sec learned above still applies, so an estimate is
734 // available immediately rather than starting from `None` again.
735 replay(
736 &mut eta,
737 &mut clock,
738 &[(
739 Progress::Started {
740 bytes_total: None,
741 entries_total: 5,
742 },
743 0.0,
744 )],
745 );
746
747 let estimate = eta.estimate().unwrap().as_secs_f64();
748 assert!(
749 (estimate - 0.5).abs() < 0.2,
750 "expected ~0.5s for 5 entries at 10/sec, got {estimate}"
751 );
752 }
753
754 #[test]
755 fn slowdown_is_tracked_rather_than_averaged_away() {
756 let mut eta = EtaEstimator::new();
757 let mut clock = Instant::now();
758 let mut script = vec![(planned(0, 1000, 0), 0.0), (started(), 0.0)];
759 for _ in 0..100 {
760 script.push((Progress::EntryStarted { entry: entry(10) }, 0.0));
761 script.push((Progress::EntryCompleted { entry: entry(10) }, 0.01));
762 }
763 replay(&mut eta, &mut clock, &script);
764 let fast = eta.estimate().unwrap();
765
766 // Same events, ten times slower. The EWMA must move most of the
767 // way toward the new rate; a cumulative average would barely budge.
768 let mut script = Vec::new();
769 for _ in 0..100 {
770 script.push((Progress::EntryStarted { entry: entry(10) }, 0.0));
771 script.push((Progress::EntryCompleted { entry: entry(10) }, 0.1));
772 }
773 replay(&mut eta, &mut clock, &script);
774 let slow = eta.estimate().unwrap();
775
776 assert!(
777 slow > fast * 3,
778 "estimate should track the slowdown: {fast:?} -> {slow:?}"
779 );
780 }
781
782 /// A streamed large file signals nothing until it completes, so its own
783 /// byte rate is unmeasurable while it runs — which on a large enough
784 /// file is the whole operation. The estimate must still appear, and
785 /// must still account for the outstanding bytes, by falling back to the
786 /// byte rate observed across everything that *has* completed.
787 ///
788 /// Reproduces the shape of a real 2.7GB run that reported no ETA at all
789 /// until 95% of the way through, when the first large file landed.
790 #[test]
791 fn outstanding_large_files_are_costed_from_overall_byte_throughput() {
792 let mut eta = EtaEstimator::new();
793 let mut clock = Instant::now();
794
795 // 100 small files of 100KB, and one 100MB file that never finishes.
796 let plan = Progress::Planned {
797 directories: 0,
798 small_files: 100,
799 small_bytes: 10_000_000,
800 large_files: 1,
801 large_bytes: 100_000_000,
802 small_file_threshold: 1_000_000,
803 };
804 let mut script = vec![
805 (plan, 0.0),
806 (started(), 0.0),
807 (
808 Progress::EntryStarted {
809 entry: entry(100_000_000),
810 },
811 0.0,
812 ),
813 ];
814 // 50 small files x 100KB over 5s => 1MB/s observed overall.
815 for _ in 0..50 {
816 script.push((
817 Progress::EntryStarted {
818 entry: entry(100_000),
819 },
820 0.0,
821 ));
822 script.push((
823 Progress::EntryCompleted {
824 entry: entry(100_000),
825 },
826 0.1,
827 ));
828 }
829 replay(&mut eta, &mut clock, &script);
830
831 // 100MB left at ~1MB/s => ~100s, which must dominate the ~5s of
832 // remaining small files rather than being dropped from the total.
833 let estimate = eta
834 .estimate()
835 .expect("an unfinished large file must not suppress the estimate")
836 .as_secs_f64();
837 assert!(
838 (estimate - 100.0).abs() < 15.0,
839 "expected ~100s dominated by the outstanding large file, got {estimate}"
840 );
841 }
842
843 /// The fallback above is a stand-in only. A real measurement of large
844 /// file throughput must take over as soon as one is available, since
845 /// streaming avoids the per-file overhead that the small-file phase
846 /// pays and is normally faster per byte.
847 #[test]
848 fn a_measured_large_file_rate_supersedes_the_overall_fallback() {
849 let mut eta = EtaEstimator::new();
850 let mut clock = Instant::now();
851
852 let plan = Progress::Planned {
853 directories: 0,
854 small_files: 0,
855 small_bytes: 0,
856 large_files: 3,
857 large_bytes: 300_000_000,
858 small_file_threshold: 1_000_000,
859 };
860 replay(&mut eta, &mut clock, &[(plan, 0.0), (started(), 0.0)]);
861
862 // One 100MB file in 1s => 100MB/s measured directly.
863 replay(
864 &mut eta,
865 &mut clock,
866 &[
867 (
868 Progress::EntryStarted {
869 entry: entry(100_000_000),
870 },
871 0.0,
872 ),
873 (
874 Progress::EntryCompleted {
875 entry: entry(100_000_000),
876 },
877 1.0,
878 ),
879 ],
880 );
881
882 assert_eq!(eta.bytes_per_sec(), Some(100_000_000.0));
883 let estimate = eta.estimate().unwrap().as_secs_f64();
884 assert!(
885 (estimate - 2.0).abs() < 0.3,
886 "expected ~2s for the remaining 200MB at 100MB/s, got {estimate}"
887 );
888 }
889
890 /// The case that motivated in-flight sampling: one large file, nothing
891 /// else. There is no completion to learn from until the very end, so
892 /// without `EntryProgress` this reports nothing for the whole transfer.
893 #[test]
894 fn a_single_large_file_is_estimated_from_in_flight_samples() {
895 let mut eta = EtaEstimator::new();
896 let mut clock = Instant::now();
897
898 let plan = Progress::Planned {
899 directories: 0,
900 small_files: 0,
901 small_bytes: 0,
902 large_files: 1,
903 large_bytes: 1_000_000_000,
904 small_file_threshold: 1_000_000,
905 };
906 let big = entry(1_000_000_000);
907 let mut script = vec![
908 (plan, 0.0),
909 (started(), 0.0),
910 (Progress::EntryStarted { entry: big.clone() }, 0.0),
911 ];
912 // 100MB/s: four 0.25s samples, 25MB each.
913 for i in 1..=4 {
914 script.push((
915 Progress::EntryProgress {
916 entry: big.clone(),
917 bytes_copied: i * 25_000_000,
918 },
919 0.25,
920 ));
921 }
922 replay(&mut eta, &mut clock, &script);
923
924 // 900MB left at 100MB/s => ~9s, while the file is still in flight.
925 let estimate = eta
926 .estimate()
927 .expect("in-flight samples must produce an estimate")
928 .as_secs_f64();
929 assert!(
930 (estimate - 9.0).abs() < 1.0,
931 "expected ~9s for the outstanding 900MB at 100MB/s, got {estimate}"
932 );
933 assert_eq!(eta.bytes_per_sec(), Some(100_000_000.0));
934 }
935
936 /// `bytes_copied` is cumulative, and the terminal event carries the
937 /// entry's full size. Counting both in full would report a file as
938 /// having moved roughly twice its own bytes.
939 #[test]
940 fn sampled_bytes_are_not_counted_again_on_completion() {
941 let mut eta = EtaEstimator::new();
942 let mut clock = Instant::now();
943
944 let plan = Progress::Planned {
945 directories: 0,
946 small_files: 0,
947 small_bytes: 0,
948 large_files: 2,
949 large_bytes: 200_000_000,
950 small_file_threshold: 1_000_000,
951 };
952 let big = entry(100_000_000);
953 replay(
954 &mut eta,
955 &mut clock,
956 &[
957 (plan, 0.0),
958 (started(), 0.0),
959 (Progress::EntryStarted { entry: big.clone() }, 0.0),
960 // 100MB over 1s, reported as four cumulative samples.
961 (
962 Progress::EntryProgress {
963 entry: big.clone(),
964 bytes_copied: 25_000_000,
965 },
966 0.25,
967 ),
968 (
969 Progress::EntryProgress {
970 entry: big.clone(),
971 bytes_copied: 50_000_000,
972 },
973 0.25,
974 ),
975 (
976 Progress::EntryProgress {
977 entry: big.clone(),
978 bytes_copied: 75_000_000,
979 },
980 0.25,
981 ),
982 (
983 Progress::EntryProgress {
984 entry: big.clone(),
985 bytes_copied: 100_000_000,
986 },
987 0.25,
988 ),
989 (Progress::EntryCompleted { entry: big }, 0.0),
990 ],
991 );
992
993 // 100MB in 1s is 100MB/s. Double-counting would report ~200MB/s
994 // and halve the estimate for the remaining file.
995 assert_eq!(eta.bytes_per_sec(), Some(100_000_000.0));
996 let estimate = eta.estimate().unwrap().as_secs_f64();
997 assert!(
998 (estimate - 1.0).abs() < 0.2,
999 "expected ~1s for the remaining 100MB at 100MB/s, got {estimate}"
1000 );
1001 }
1002
1003 #[test]
1004 fn out_of_order_and_surplus_completions_do_not_panic() {
1005 let mut eta = EtaEstimator::new();
1006 let mut clock = Instant::now();
1007 replay(
1008 &mut eta,
1009 &mut clock,
1010 &[
1011 (Progress::EntryCompleted { entry: entry(10) }, 0.1),
1012 (
1013 Progress::DirectoryCompleted {
1014 path: PathBuf::from("d"),
1015 },
1016 0.1,
1017 ),
1018 (planned(0, 1, 0), 0.0),
1019 (started(), 0.0),
1020 (Progress::EntryCompleted { entry: entry(10) }, 0.1),
1021 (Progress::EntryCompleted { entry: entry(10) }, 0.1),
1022 ],
1023 );
1024
1025 assert_eq!(eta.estimate(), Some(Duration::ZERO));
1026 }
1027}