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            // No bytes were transferred (the destination already matched),
300            // so unlike `EntryCompleted` this must not feed the byte-rate
301            // estimator — doing so would make a near-instant stat+hash
302            // look like it moved `entry.size` bytes, inflating future
303            // throughput estimates. Still retires the entry from
304            // `*_remaining`/in-flight, the same as a completion, so ETA
305            // doesn't keep waiting on work that will never happen.
306            Progress::EntrySkipped { entry } => {
307                if self.regime_for(entry.size) == Regime::LargeFile {
308                    self.large_bytes_remaining =
309                        self.large_bytes_remaining.saturating_sub(entry.size);
310                    self.large_in_flight = self.large_in_flight.saturating_sub(1);
311                } else {
312                    self.small_files_remaining = self.small_files_remaining.saturating_sub(1);
313                    self.small_in_flight = self.small_in_flight.saturating_sub(1);
314                }
315            }
316
317            Progress::EntryCompleted { entry } | Progress::EntryFailed { entry } => {
318                // A failure still consumed wall time and still retired an
319                // entry, so it counts toward the rate exactly as a success
320                // does — otherwise a run failing every entry would report
321                // a rate of zero and never produce an estimate at all.
322                if self.regime_for(entry.size) == Regime::LargeFile {
323                    // Only the bytes not already credited by in-flight
324                    // sampling — otherwise a sampled file is counted twice
325                    // and reports double its real throughput.
326                    let outstanding = entry
327                        .size
328                        .saturating_sub(self.credited_bytes.remove(&entry.path).unwrap_or(0));
329                    self.large_file_rate.add_work(outstanding as f64);
330                    self.overall_byte_rate.add_work(outstanding as f64);
331                    self.large_bytes_remaining =
332                        self.large_bytes_remaining.saturating_sub(outstanding);
333                    self.large_in_flight = self.large_in_flight.saturating_sub(1);
334                } else {
335                    self.overall_byte_rate.add_work(entry.size as f64);
336                    self.small_file_rate.add_work(1.0);
337                    self.small_files_remaining = self.small_files_remaining.saturating_sub(1);
338                    self.small_in_flight = self.small_in_flight.saturating_sub(1);
339                }
340            }
341
342            // Partial progress for an entry still in flight. `bytes_copied`
343            // is cumulative, so only the increment since the last sample is
344            // new work.
345            Progress::EntryProgress {
346                entry,
347                bytes_copied,
348            } => {
349                let credited = self.credited_bytes.entry(entry.path.clone()).or_insert(0);
350                let delta = bytes_copied.saturating_sub(*credited);
351                if delta > 0 {
352                    *credited = *bytes_copied;
353                    self.large_file_rate.add_work(delta as f64);
354                    self.overall_byte_rate.add_work(delta as f64);
355                    self.large_bytes_remaining = self.large_bytes_remaining.saturating_sub(delta);
356                    // Bytes that have landed are no longer outstanding
357                    // work for the in-flight entry either — without this,
358                    // the in-flight pool exceeds what actually remains and
359                    // the overlapping term is inflated by everything
360                    // already copied.
361                }
362            }
363        }
364
365        // Charged *after* the match, so that work reported by this event
366        // lands in the same sample window as the interval during which it
367        // was performed. Doing it first instead leaves every flushed
368        // sample short by exactly the work of the event that triggered it,
369        // which reads as a systematic underestimate of throughput — and so
370        // a systematic overestimate of time remaining.
371        if let Some(last) = self.last_event {
372            let elapsed = now.saturating_duration_since(last).as_secs_f64();
373            for regime in &was_in_flight {
374                self.rate_mut(*regime).add_time(elapsed);
375            }
376            // Any entry in flight is moving bytes, whichever regime it
377            // belongs to.
378            if was_in_flight
379                .iter()
380                .any(|r| matches!(r, Regime::SmallFile | Regime::LargeFile))
381            {
382                self.overall_byte_rate.add_time(elapsed);
383            }
384        }
385        self.last_event = Some(now);
386    }
387
388    /// Estimated time remaining, or `None` while any regime with
389    /// outstanding work has no measured rate yet — an operation that has
390    /// only just started genuinely has no basis for an estimate, and
391    /// reporting nothing is more useful than reporting a fabricated
392    /// number that collapses by an order of magnitude a second later.
393    ///
394    /// Returns `Duration::ZERO` once no work is outstanding.
395    pub fn estimate(&self) -> Option<Duration> {
396        let seconds_for = |remaining: f64, rate: &Rate| -> Option<f64> {
397            if remaining <= 0.0 {
398                return Some(0.0);
399            }
400            Some(remaining / rate.per_sec()?)
401        };
402
403        let directories = seconds_for(self.directories_remaining as f64, &self.directory_rate)?;
404        let small = seconds_for(self.small_files_remaining as f64, &self.small_file_rate)?;
405
406        // A streamed file reports nothing between `EntryStarted` and
407        // `EntryCompleted`, so its own byte rate stays unmeasurable for as
408        // long as it takes to copy — on a multi-gigabyte file that is the
409        // entire run, i.e. precisely when an ETA is most wanted. Fall back
410        // to the byte rate observed across all completed entries. Small
411        // files carry per-file overhead that streaming doesn't, so that
412        // figure understates streaming throughput: the estimate starts
413        // pessimistic and tightens once a large file actually lands, which
414        // is the right direction for a countdown to move.
415        let large_rate = self
416            .large_file_rate
417            .per_sec()
418            .or_else(|| self.overall_byte_rate.per_sec());
419        let seconds_for_bytes = |bytes: u64| -> Option<f64> {
420            match (bytes, large_rate) {
421                (0, _) => Some(0.0),
422                (bytes, Some(rate)) => Some(bytes as f64 / rate),
423                (_, None) => None,
424            }
425        };
426
427        // All outstanding large bytes are treated as overlapping the
428        // small-file phase, even though the dispatcher only runs one stream
429        // ahead of the batches and queues the rest behind them.
430        //
431        // The structurally-honest alternative — adding the queued portion
432        // rather than maxing it — was implemented and measured, and the
433        // result was inconclusive: run-to-run variance on the mixed fixture
434        // (6.1s to 8.2s wall time for identical work) is larger than the
435        // difference between the two models. `max` is kept as the simpler
436        // of the two, not as a demonstrated winner.
437        //
438        // Note what the additive model would depend on: queued bytes divide
439        // by a rate learned while the calibration stream competes with
440        // thousands of small files, which understates how fast those
441        // streams run once the batches drain and they have the device to
442        // themselves. `max` under-counts queued work; the contended rate
443        // over-counts its duration. Distinguishing them needs a
444        // lower-variance benchmark than this one.
445        //
446        // The cost of keeping `max` is a visible one: while every remaining
447        // stream sits queued, its byte count can't fall and its rate can't
448        // update, so the estimate pins at a constant — measured as a
449        // countdown frozen at exactly 5.9s for over a second. That is a
450        // stale term, not device slowdown; see `dev-docs/design/eta.md` §9.
451        let large = seconds_for_bytes(self.large_bytes_remaining)?;
452
453        // Directories add: the pre-pass completes before dispatch starts.
454        // Small and large overlap, so the longer of the two absorbs the
455        // shorter rather than queueing behind it.
456        Duration::try_from_secs_f64(directories + small.max(large)).ok()
457    }
458
459    /// Observed throughput for large, streamed files, in bytes per second.
460    /// `None` until at least one has completed. Deliberately excludes the
461    /// batched small-file phase, whose cost is per-file rather than
462    /// per-byte — averaging the two together produces a number that
463    /// describes neither.
464    pub fn bytes_per_sec(&self) -> Option<f64> {
465        self.large_file_rate.per_sec()
466    }
467
468    /// Every regime with work in flight right now. A span of wall time is
469    /// charged to all of them, since they were all making progress during
470    /// it — see the type-level note on attribution.
471    fn in_flight_regimes(&self) -> Vec<Regime> {
472        let mut regimes = Vec::with_capacity(3);
473        if self.directories_in_flight {
474            regimes.push(Regime::Directory);
475        }
476        if self.small_in_flight > 0 {
477            regimes.push(Regime::SmallFile);
478        }
479        if self.large_in_flight > 0 {
480            regimes.push(Regime::LargeFile);
481        }
482        regimes
483    }
484
485    fn regime_for(&self, size: u64) -> Regime {
486        if size <= self.small_file_threshold {
487            Regime::SmallFile
488        } else {
489            Regime::LargeFile
490        }
491    }
492
493    fn rate_mut(&mut self, regime: Regime) -> &mut Rate {
494        match regime {
495            Regime::Directory => &mut self.directory_rate,
496            Regime::SmallFile => &mut self.small_file_rate,
497            Regime::LargeFile => &mut self.large_file_rate,
498        }
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use std::path::PathBuf;
505
506    use crate::profiler::Entry;
507
508    use super::*;
509
510    fn entry(size: u64) -> Entry {
511        Entry {
512            path: PathBuf::from("x"),
513            relative_path: PathBuf::from("x"),
514            size,
515            modified: None,
516        }
517    }
518
519    fn planned(directories: usize, small_files: usize, large_bytes: u64) -> Progress {
520        Progress::Planned {
521            directories,
522            small_files,
523            small_bytes: small_files as u64,
524            large_files: usize::from(large_bytes > 0),
525            large_bytes,
526            small_file_threshold: 1024,
527        }
528    }
529
530    fn started() -> Progress {
531        Progress::Started {
532            bytes_total: Some(0),
533            entries_total: 0,
534        }
535    }
536
537    /// Drives a sequence of `(event, seconds_since_previous)` through the
538    /// estimator against a synthetic clock, so tests assert on the model's
539    /// arithmetic rather than on how fast the machine running them is.
540    ///
541    /// The clock is owned by the caller so that a test replaying two
542    /// scripts against one estimator advances a single timeline. Starting
543    /// a fresh `Instant::now()` per call instead would place the second
544    /// script *behind* the first on the real clock, and every elapsed span
545    /// in it would saturate to zero.
546    fn replay(estimator: &mut EtaEstimator, clock: &mut Instant, script: &[(Progress, f64)]) {
547        for (event, delay) in script {
548            *clock += Duration::from_secs_f64(*delay);
549            estimator.observe_at(event, *clock);
550        }
551    }
552
553    #[test]
554    fn no_estimate_before_anything_completes() {
555        let mut eta = EtaEstimator::new();
556        let mut clock = Instant::now();
557        replay(
558            &mut eta,
559            &mut clock,
560            &[(planned(0, 10, 0), 0.0), (started(), 0.0)],
561        );
562
563        assert_eq!(eta.estimate(), None);
564    }
565
566    #[test]
567    fn estimates_zero_when_nothing_is_outstanding() {
568        let mut eta = EtaEstimator::new();
569        let mut clock = Instant::now();
570        replay(
571            &mut eta,
572            &mut clock,
573            &[(planned(0, 0, 0), 0.0), (started(), 0.0)],
574        );
575
576        assert_eq!(eta.estimate(), Some(Duration::ZERO));
577    }
578
579    #[test]
580    fn small_files_are_estimated_per_file_not_per_byte() {
581        let mut eta = EtaEstimator::new();
582        let mut clock = Instant::now();
583        let mut script = vec![(planned(0, 100, 0), 0.0), (started(), 0.0)];
584
585        // 10 files over 1s total => 10 files/sec, so the 90 left ~= 9s.
586        // Sizes vary 100x across them; a per-byte model would not land on
587        // 9s, which is the point of the assertion.
588        for i in 0..10 {
589            let size = if i % 2 == 0 { 10 } else { 1000 };
590            script.push((Progress::EntryStarted { entry: entry(size) }, 0.0));
591            script.push((Progress::EntryCompleted { entry: entry(size) }, 0.1));
592        }
593        replay(&mut eta, &mut clock, &script);
594
595        let estimate = eta.estimate().unwrap().as_secs_f64();
596        assert!(
597            (estimate - 9.0).abs() < 0.5,
598            "expected ~9s for 90 files at 10 files/sec, got {estimate}"
599        );
600    }
601
602    #[test]
603    fn large_files_are_estimated_per_byte() {
604        let mut eta = EtaEstimator::new();
605        let mut clock = Instant::now();
606        let big = 10_000_u64;
607        let mut script = vec![(planned(0, 0, big * 10), 0.0), (started(), 0.0)];
608
609        // 4 files x 10_000 bytes over 4s => 10_000 bytes/sec, leaving
610        // 60_000 bytes => ~6s.
611        for _ in 0..4 {
612            script.push((Progress::EntryStarted { entry: entry(big) }, 0.0));
613            script.push((Progress::EntryCompleted { entry: entry(big) }, 1.0));
614        }
615        replay(&mut eta, &mut clock, &script);
616
617        let estimate = eta.estimate().unwrap().as_secs_f64();
618        assert!(
619            (estimate - 6.0).abs() < 0.5,
620            "expected ~6s for 60_000 bytes at 10_000 B/s, got {estimate}"
621        );
622    }
623
624    #[test]
625    fn directory_pre_pass_is_estimated_before_any_file_work_is_known() {
626        let mut eta = EtaEstimator::new();
627        let mut clock = Instant::now();
628        let mut script = vec![
629            (planned(100, 0, 0), 0.0),
630            (Progress::DirectoriesStarted { total: 100 }, 0.0),
631        ];
632
633        // 20 directories over 2s => 10 dirs/sec, 80 left => ~8s. This is
634        // the window that emits no `Started` at all, so an estimator keyed
635        // only on `Started`/`bytes_total` would report nothing here.
636        for _ in 0..20 {
637            script.push((
638                Progress::DirectoryCompleted {
639                    path: PathBuf::from("d"),
640                },
641                0.1,
642            ));
643        }
644        replay(&mut eta, &mut clock, &script);
645
646        let estimate = eta.estimate().unwrap().as_secs_f64();
647        assert!(
648            (estimate - 8.0).abs() < 0.5,
649            "expected ~8s for 80 directories at 10/sec, got {estimate}"
650        );
651    }
652
653    #[test]
654    fn directory_and_file_costs_are_summed_not_conflated() {
655        let mut eta = EtaEstimator::new();
656        let mut clock = Instant::now();
657        let mut script = vec![
658            (planned(30, 30, 0), 0.0),
659            (Progress::DirectoriesStarted { total: 30 }, 0.0),
660        ];
661        // 10 dirs in 1s => 10/sec, 20 dirs left => 2s.
662        for _ in 0..10 {
663            script.push((
664                Progress::DirectoryCompleted {
665                    path: PathBuf::from("d"),
666                },
667                0.1,
668            ));
669        }
670        script.push((started(), 0.0));
671        // 10 files in 2s => 5/sec, 20 files left => 4s. Total ~6s.
672        for _ in 0..10 {
673            script.push((Progress::EntryStarted { entry: entry(10) }, 0.0));
674            script.push((Progress::EntryCompleted { entry: entry(10) }, 0.2));
675        }
676        replay(&mut eta, &mut clock, &script);
677
678        let estimate = eta.estimate().unwrap().as_secs_f64();
679        assert!(
680            (estimate - 6.0).abs() < 0.7,
681            "expected ~6s (2s of directories + 4s of files), got {estimate}"
682        );
683    }
684
685    #[test]
686    fn failed_entries_count_as_progress() {
687        let mut eta = EtaEstimator::new();
688        let mut clock = Instant::now();
689        let mut script = vec![(planned(0, 20, 0), 0.0), (started(), 0.0)];
690        for _ in 0..10 {
691            script.push((Progress::EntryStarted { entry: entry(10) }, 0.0));
692            script.push((Progress::EntryFailed { entry: entry(10) }, 0.1));
693        }
694        replay(&mut eta, &mut clock, &script);
695
696        // A failure consumes wall time and retires an entry just as a
697        // success does — 10 done at 10/sec leaves 10 => ~1s.
698        let estimate = eta.estimate().unwrap().as_secs_f64();
699        assert!((estimate - 1.0).abs() < 0.3, "expected ~1s, got {estimate}");
700    }
701
702    #[test]
703    fn started_without_planned_is_modelled_as_a_metadata_only_phase() {
704        let mut eta = EtaEstimator::new();
705        let mut clock = Instant::now();
706        let mut script = vec![(
707            Progress::Started {
708                bytes_total: None,
709                entries_total: 100,
710            },
711            0.0,
712        )];
713        // Deletes carry a real `entry.size` but cost nothing per byte; the
714        // phase must be costed per-operation. 10 in 1s => 90 left => ~9s.
715        for _ in 0..10 {
716            script.push((
717                Progress::EntryStarted {
718                    entry: entry(5_000_000),
719                },
720                0.0,
721            ));
722            script.push((
723                Progress::EntryCompleted {
724                    entry: entry(5_000_000),
725                },
726                0.1,
727            ));
728        }
729        replay(&mut eta, &mut clock, &script);
730
731        let estimate = eta.estimate().unwrap().as_secs_f64();
732        assert!(
733            (estimate - 9.0).abs() < 0.5,
734            "expected ~9s for 90 deletions at 10/sec, got {estimate}"
735        );
736    }
737
738    #[test]
739    fn a_later_phase_resets_remaining_work_without_discarding_learned_rates() {
740        let mut eta = EtaEstimator::new();
741        let mut clock = Instant::now();
742        let mut script = vec![(planned(0, 10, 0), 0.0), (started(), 0.0)];
743        for _ in 0..10 {
744            script.push((Progress::EntryStarted { entry: entry(10) }, 0.0));
745            script.push((Progress::EntryCompleted { entry: entry(10) }, 0.1));
746        }
747        replay(&mut eta, &mut clock, &script);
748        assert_eq!(eta.estimate(), Some(Duration::ZERO));
749
750        // sync's delete phase: a fresh `Started` announces 5 more entries.
751        // The 10 files/sec learned above still applies, so an estimate is
752        // available immediately rather than starting from `None` again.
753        replay(
754            &mut eta,
755            &mut clock,
756            &[(
757                Progress::Started {
758                    bytes_total: None,
759                    entries_total: 5,
760                },
761                0.0,
762            )],
763        );
764
765        let estimate = eta.estimate().unwrap().as_secs_f64();
766        assert!(
767            (estimate - 0.5).abs() < 0.2,
768            "expected ~0.5s for 5 entries at 10/sec, got {estimate}"
769        );
770    }
771
772    #[test]
773    fn slowdown_is_tracked_rather_than_averaged_away() {
774        let mut eta = EtaEstimator::new();
775        let mut clock = Instant::now();
776        let mut script = vec![(planned(0, 1000, 0), 0.0), (started(), 0.0)];
777        for _ in 0..100 {
778            script.push((Progress::EntryStarted { entry: entry(10) }, 0.0));
779            script.push((Progress::EntryCompleted { entry: entry(10) }, 0.01));
780        }
781        replay(&mut eta, &mut clock, &script);
782        let fast = eta.estimate().unwrap();
783
784        // Same events, ten times slower. The EWMA must move most of the
785        // way toward the new rate; a cumulative average would barely budge.
786        let mut script = Vec::new();
787        for _ in 0..100 {
788            script.push((Progress::EntryStarted { entry: entry(10) }, 0.0));
789            script.push((Progress::EntryCompleted { entry: entry(10) }, 0.1));
790        }
791        replay(&mut eta, &mut clock, &script);
792        let slow = eta.estimate().unwrap();
793
794        assert!(
795            slow > fast * 3,
796            "estimate should track the slowdown: {fast:?} -> {slow:?}"
797        );
798    }
799
800    /// A streamed large file signals nothing until it completes, so its own
801    /// byte rate is unmeasurable while it runs — which on a large enough
802    /// file is the whole operation. The estimate must still appear, and
803    /// must still account for the outstanding bytes, by falling back to the
804    /// byte rate observed across everything that *has* completed.
805    ///
806    /// Reproduces the shape of a real 2.7GB run that reported no ETA at all
807    /// until 95% of the way through, when the first large file landed.
808    #[test]
809    fn outstanding_large_files_are_costed_from_overall_byte_throughput() {
810        let mut eta = EtaEstimator::new();
811        let mut clock = Instant::now();
812
813        // 100 small files of 100KB, and one 100MB file that never finishes.
814        let plan = Progress::Planned {
815            directories: 0,
816            small_files: 100,
817            small_bytes: 10_000_000,
818            large_files: 1,
819            large_bytes: 100_000_000,
820            small_file_threshold: 1_000_000,
821        };
822        let mut script = vec![
823            (plan, 0.0),
824            (started(), 0.0),
825            (
826                Progress::EntryStarted {
827                    entry: entry(100_000_000),
828                },
829                0.0,
830            ),
831        ];
832        // 50 small files x 100KB over 5s => 1MB/s observed overall.
833        for _ in 0..50 {
834            script.push((
835                Progress::EntryStarted {
836                    entry: entry(100_000),
837                },
838                0.0,
839            ));
840            script.push((
841                Progress::EntryCompleted {
842                    entry: entry(100_000),
843                },
844                0.1,
845            ));
846        }
847        replay(&mut eta, &mut clock, &script);
848
849        // 100MB left at ~1MB/s => ~100s, which must dominate the ~5s of
850        // remaining small files rather than being dropped from the total.
851        let estimate = eta
852            .estimate()
853            .expect("an unfinished large file must not suppress the estimate")
854            .as_secs_f64();
855        assert!(
856            (estimate - 100.0).abs() < 15.0,
857            "expected ~100s dominated by the outstanding large file, got {estimate}"
858        );
859    }
860
861    /// The fallback above is a stand-in only. A real measurement of large
862    /// file throughput must take over as soon as one is available, since
863    /// streaming avoids the per-file overhead that the small-file phase
864    /// pays and is normally faster per byte.
865    #[test]
866    fn a_measured_large_file_rate_supersedes_the_overall_fallback() {
867        let mut eta = EtaEstimator::new();
868        let mut clock = Instant::now();
869
870        let plan = Progress::Planned {
871            directories: 0,
872            small_files: 0,
873            small_bytes: 0,
874            large_files: 3,
875            large_bytes: 300_000_000,
876            small_file_threshold: 1_000_000,
877        };
878        replay(&mut eta, &mut clock, &[(plan, 0.0), (started(), 0.0)]);
879
880        // One 100MB file in 1s => 100MB/s measured directly.
881        replay(
882            &mut eta,
883            &mut clock,
884            &[
885                (
886                    Progress::EntryStarted {
887                        entry: entry(100_000_000),
888                    },
889                    0.0,
890                ),
891                (
892                    Progress::EntryCompleted {
893                        entry: entry(100_000_000),
894                    },
895                    1.0,
896                ),
897            ],
898        );
899
900        assert_eq!(eta.bytes_per_sec(), Some(100_000_000.0));
901        let estimate = eta.estimate().unwrap().as_secs_f64();
902        assert!(
903            (estimate - 2.0).abs() < 0.3,
904            "expected ~2s for the remaining 200MB at 100MB/s, got {estimate}"
905        );
906    }
907
908    /// The case that motivated in-flight sampling: one large file, nothing
909    /// else. There is no completion to learn from until the very end, so
910    /// without `EntryProgress` this reports nothing for the whole transfer.
911    #[test]
912    fn a_single_large_file_is_estimated_from_in_flight_samples() {
913        let mut eta = EtaEstimator::new();
914        let mut clock = Instant::now();
915
916        let plan = Progress::Planned {
917            directories: 0,
918            small_files: 0,
919            small_bytes: 0,
920            large_files: 1,
921            large_bytes: 1_000_000_000,
922            small_file_threshold: 1_000_000,
923        };
924        let big = entry(1_000_000_000);
925        let mut script = vec![
926            (plan, 0.0),
927            (started(), 0.0),
928            (Progress::EntryStarted { entry: big.clone() }, 0.0),
929        ];
930        // 100MB/s: four 0.25s samples, 25MB each.
931        for i in 1..=4 {
932            script.push((
933                Progress::EntryProgress {
934                    entry: big.clone(),
935                    bytes_copied: i * 25_000_000,
936                },
937                0.25,
938            ));
939        }
940        replay(&mut eta, &mut clock, &script);
941
942        // 900MB left at 100MB/s => ~9s, while the file is still in flight.
943        let estimate = eta
944            .estimate()
945            .expect("in-flight samples must produce an estimate")
946            .as_secs_f64();
947        assert!(
948            (estimate - 9.0).abs() < 1.0,
949            "expected ~9s for the outstanding 900MB at 100MB/s, got {estimate}"
950        );
951        assert_eq!(eta.bytes_per_sec(), Some(100_000_000.0));
952    }
953
954    /// `bytes_copied` is cumulative, and the terminal event carries the
955    /// entry's full size. Counting both in full would report a file as
956    /// having moved roughly twice its own bytes.
957    #[test]
958    fn sampled_bytes_are_not_counted_again_on_completion() {
959        let mut eta = EtaEstimator::new();
960        let mut clock = Instant::now();
961
962        let plan = Progress::Planned {
963            directories: 0,
964            small_files: 0,
965            small_bytes: 0,
966            large_files: 2,
967            large_bytes: 200_000_000,
968            small_file_threshold: 1_000_000,
969        };
970        let big = entry(100_000_000);
971        replay(
972            &mut eta,
973            &mut clock,
974            &[
975                (plan, 0.0),
976                (started(), 0.0),
977                (Progress::EntryStarted { entry: big.clone() }, 0.0),
978                // 100MB over 1s, reported as four cumulative samples.
979                (
980                    Progress::EntryProgress {
981                        entry: big.clone(),
982                        bytes_copied: 25_000_000,
983                    },
984                    0.25,
985                ),
986                (
987                    Progress::EntryProgress {
988                        entry: big.clone(),
989                        bytes_copied: 50_000_000,
990                    },
991                    0.25,
992                ),
993                (
994                    Progress::EntryProgress {
995                        entry: big.clone(),
996                        bytes_copied: 75_000_000,
997                    },
998                    0.25,
999                ),
1000                (
1001                    Progress::EntryProgress {
1002                        entry: big.clone(),
1003                        bytes_copied: 100_000_000,
1004                    },
1005                    0.25,
1006                ),
1007                (Progress::EntryCompleted { entry: big }, 0.0),
1008            ],
1009        );
1010
1011        // 100MB in 1s is 100MB/s. Double-counting would report ~200MB/s
1012        // and halve the estimate for the remaining file.
1013        assert_eq!(eta.bytes_per_sec(), Some(100_000_000.0));
1014        let estimate = eta.estimate().unwrap().as_secs_f64();
1015        assert!(
1016            (estimate - 1.0).abs() < 0.2,
1017            "expected ~1s for the remaining 100MB at 100MB/s, got {estimate}"
1018        );
1019    }
1020
1021    #[test]
1022    fn out_of_order_and_surplus_completions_do_not_panic() {
1023        let mut eta = EtaEstimator::new();
1024        let mut clock = Instant::now();
1025        replay(
1026            &mut eta,
1027            &mut clock,
1028            &[
1029                (Progress::EntryCompleted { entry: entry(10) }, 0.1),
1030                (
1031                    Progress::DirectoryCompleted {
1032                        path: PathBuf::from("d"),
1033                    },
1034                    0.1,
1035                ),
1036                (planned(0, 1, 0), 0.0),
1037                (started(), 0.0),
1038                (Progress::EntryCompleted { entry: entry(10) }, 0.1),
1039                (Progress::EntryCompleted { entry: entry(10) }, 0.1),
1040            ],
1041        );
1042
1043        assert_eq!(eta.estimate(), Some(Duration::ZERO));
1044    }
1045}