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}