Skip to main content

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}