Skip to main content

grit_lib/progress/
estimate.rs

1//! One overall completion estimate across the phases of a fetch or clone.
2//!
3//! A clone runs as a sequence of phases — transfer the pack, index it, resolve
4//! its deltas, check out files — each with its own unit and total.
5//! [`OverallEstimator`] follows the same [`ProgressPhase`] events a sink sees
6//! and turns them into a single fraction and a time-left estimate: phases that
7//! are under way are projected from their own measured pace, and phases that
8//! have not started are sized from what is already known (the pack's object
9//! and delta counts) at typical [`PhaseRates`].
10//!
11//! The estimator never reads the clock; every call takes the time elapsed
12//! since the operation began.
13
14use std::time::Duration;
15
16use super::{ProgressPhase, ProgressUnit, ReceivedObjects};
17
18/// Typical throughput used to size phases that have not started yet.
19///
20/// Phases already under way are projected from their own measured pace, so
21/// these only shape the estimate until each phase begins.
22#[derive(Debug, Clone, Copy, PartialEq)]
23pub struct PhaseRates {
24    /// Objects indexed per second.
25    pub indexing: f64,
26    /// Deltas resolved per second.
27    pub resolving: f64,
28    /// Files checked out per second.
29    pub checkout: f64,
30}
31
32impl Default for PhaseRates {
33    /// Rates measured from `grit clone` of large public repositories on a
34    /// current laptop.
35    fn default() -> Self {
36        Self {
37            indexing: 250_000.0,
38            resolving: 300_000.0,
39            checkout: 10_000.0,
40        }
41    }
42}
43
44/// Share of deltas assumed in a pack whose delta count is not known yet.
45const ASSUMED_DELTA_SHARE: f64 = 0.7;
46/// Work-tree files assumed per pack object before checkout reports its total.
47const ASSUMED_FILES_PER_OBJECT: f64 = 0.015;
48/// Below this share of a phase done, its measured pace is too noisy to trust
49/// over the typical rate.
50const TRUST_MEASURED_AFTER: f64 = 0.1;
51/// The fraction reported while any phase is still ahead.
52const MAX_UNFINISHED_FRACTION: f64 = 0.99;
53
54/// A snapshot of overall progress.
55#[derive(Debug, Clone, Copy, PartialEq)]
56pub struct OverallEstimate {
57    /// Share of the whole operation done, from `0.0` to `1.0`. It never goes
58    /// down between calls, and stays below `1.0` until every phase finished.
59    pub fraction: f64,
60    /// Estimated time until the operation finishes.
61    pub remaining: Duration,
62}
63
64/// The phases of a clone, in the order they run.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
66enum Stage {
67    Transfer,
68    Indexing,
69    Resolving,
70    Checkout,
71}
72
73const STAGES: [Stage; 4] = [
74    Stage::Transfer,
75    Stage::Indexing,
76    Stage::Resolving,
77    Stage::Checkout,
78];
79
80impl Stage {
81    fn of(phase: ProgressPhase) -> Option<Self> {
82        match phase {
83            ProgressPhase::ReceivingObjects | ProgressPhase::CopyingObjects => Some(Self::Transfer),
84            ProgressPhase::IndexingObjects => Some(Self::Indexing),
85            ProgressPhase::ResolvingDeltas => Some(Self::Resolving),
86            ProgressPhase::CheckingOutFiles => Some(Self::Checkout),
87        }
88    }
89}
90
91#[derive(Debug, Clone, Copy)]
92struct Active {
93    stage: Stage,
94    /// Total for the phase in its own unit; `None` for a byte-counted receive.
95    total: Option<u64>,
96    current: u64,
97    started: Duration,
98}
99
100/// Combines per-phase progress into one overall estimate.
101///
102/// Phases under way are projected from their own measured pace; phases not
103/// yet started are sized from the pack's object and delta counts at typical
104/// [`PhaseRates`]. The estimator never reads the clock: every call takes the
105/// time elapsed since the operation began.
106///
107/// ```rust
108/// use std::time::Duration;
109/// use grit_lib::progress::{OverallEstimator, ProgressPhase, ProgressUnit, ReceivedObjects};
110///
111/// let secs = Duration::from_secs;
112/// let mut est = OverallEstimator::new(true);
113/// est.start(ProgressPhase::ReceivingObjects, ProgressUnit::Bytes, None, secs(1));
114/// est.received_objects(ReceivedObjects { received: 500, total: 1_000, deltas: 300 }, secs(3));
115/// let now = est.estimate(secs(3)).unwrap();
116/// assert!(now.fraction > 0.3 && now.fraction < 0.7);
117/// ```
118#[derive(Debug, Clone)]
119pub struct OverallEstimator {
120    rates: PhaseRates,
121    checkout: bool,
122    objects: Option<u64>,
123    deltas: Option<u64>,
124    files: Option<u64>,
125    received: Option<ReceivedObjects>,
126    active: Option<Active>,
127    /// The furthest stage that has finished.
128    finished: Option<Stage>,
129    best: f64,
130}
131
132impl OverallEstimator {
133    /// Create an estimator with the default [`PhaseRates`].
134    ///
135    /// # Parameters
136    ///
137    /// - `checkout`: whether a work-tree checkout follows the transfer (true
138    ///   for a non-bare clone, false for a fetch).
139    #[must_use]
140    pub fn new(checkout: bool) -> Self {
141        Self {
142            rates: PhaseRates::default(),
143            checkout,
144            objects: None,
145            deltas: None,
146            files: None,
147            received: None,
148            active: None,
149            finished: None,
150            best: 0.0,
151        }
152    }
153
154    /// Use `rates` to size phases that have not started.
155    #[must_use]
156    pub fn with_rates(mut self, rates: PhaseRates) -> Self {
157        self.rates = rates;
158        self
159    }
160
161    /// A phase began at `at`, counting `unit`s towards `total` when known.
162    ///
163    /// Starting a phase ends the previous one.
164    pub fn start(
165        &mut self,
166        phase: ProgressPhase,
167        unit: ProgressUnit,
168        total: Option<u64>,
169        at: Duration,
170    ) {
171        self.finish(at);
172        let Some(stage) = Stage::of(phase) else {
173            return;
174        };
175        let total = total.filter(|_| unit != ProgressUnit::Bytes);
176        match stage {
177            Stage::Transfer | Stage::Indexing => {
178                if total.is_some() {
179                    self.objects = total;
180                }
181            }
182            Stage::Resolving => self.deltas = total,
183            Stage::Checkout => self.files = total,
184        }
185        self.active = Some(Active {
186            stage,
187            total,
188            current: 0,
189            started: at,
190        });
191    }
192
193    /// The current phase reached `current` units.
194    pub fn set(&mut self, current: u64) {
195        if let Some(active) = &mut self.active {
196            active.current = current;
197        }
198    }
199
200    /// The current phase advanced by `units`.
201    pub fn inc(&mut self, units: u64) {
202        if let Some(active) = &mut self.active {
203            active.current = active.current.saturating_add(units);
204        }
205    }
206
207    /// The pack being received now holds `counts` objects.
208    ///
209    /// Sizes the later phases: the pack's object total drives indexing, and
210    /// the share of deltas seen so far projects how many will need resolving.
211    pub fn received_objects(&mut self, counts: ReceivedObjects, _at: Duration) {
212        self.objects = Some(counts.total);
213        if counts.received > 0 {
214            let share = counts.deltas as f64 / counts.received as f64;
215            self.deltas = Some((share * counts.total as f64).round() as u64);
216        }
217        self.received = Some(counts);
218    }
219
220    /// The current phase finished at `at`.
221    pub fn finish(&mut self, _at: Duration) {
222        if let Some(active) = self.active.take() {
223            self.finished = self.finished.max(Some(active.stage));
224        }
225    }
226
227    /// Overall progress at `at`, or `None` while there is not enough to go on
228    /// (before the transfer has reported any objects).
229    pub fn estimate(&mut self, at: Duration) -> Option<OverallEstimate> {
230        let remaining = self.remaining_secs(at)?;
231        let elapsed = at.as_secs_f64();
232        let all_done = self.active.is_none() && self.finished == Some(self.last_stage());
233        let raw = if all_done {
234            1.0
235        } else if elapsed + remaining <= 0.0 {
236            0.0
237        } else {
238            (elapsed / (elapsed + remaining)).min(MAX_UNFINISHED_FRACTION)
239        };
240        self.best = self.best.max(raw);
241        Some(OverallEstimate {
242            fraction: self.best,
243            remaining: Duration::from_secs_f64(remaining.max(0.0)),
244        })
245    }
246
247    fn last_stage(&self) -> Stage {
248        if self.checkout {
249            Stage::Checkout
250        } else {
251            Stage::Resolving
252        }
253    }
254
255    fn remaining_secs(&self, at: Duration) -> Option<f64> {
256        let mut total = 0.0;
257        for stage in STAGES {
258            if stage > self.last_stage() || Some(stage) <= self.finished {
259                continue;
260            }
261            match self.active {
262                Some(active) if active.stage == stage => total += self.active_secs(active, at)?,
263                // Started later than this stage, so this one was skipped.
264                Some(active) if active.stage > stage => {}
265                _ => total += self.pending_secs(stage)?,
266            }
267        }
268        Some(total)
269    }
270
271    /// Seconds left in the phase under way.
272    fn active_secs(&self, active: Active, at: Duration) -> Option<f64> {
273        let elapsed = at.saturating_sub(active.started).as_secs_f64();
274        let (done, total) = match (active.stage, active.total) {
275            (_, Some(total)) => (active.current, total),
276            (Stage::Transfer, None) => {
277                let counts = self.received?;
278                (counts.received, counts.total)
279            }
280            (_, None) => return self.pending_secs(active.stage),
281        };
282        let left = total.saturating_sub(done) as f64;
283        if left == 0.0 {
284            return Some(0.0);
285        }
286        let share = if total == 0 {
287            1.0
288        } else {
289            done as f64 / total as f64
290        };
291        let typical = self.rate(active.stage).map(|rate| left / rate);
292        if share >= TRUST_MEASURED_AFTER || typical.is_none() {
293            if done == 0 {
294                return None;
295            }
296            return Some(elapsed * left / done as f64);
297        }
298        typical
299    }
300
301    /// Seconds a phase that has not started is expected to take.
302    fn pending_secs(&self, stage: Stage) -> Option<f64> {
303        let objects = self.objects? as f64;
304        let units = match stage {
305            // Nothing predicts how fast the network will be.
306            Stage::Transfer => return None,
307            Stage::Indexing => objects,
308            Stage::Resolving => self
309                .deltas
310                .map_or(objects * ASSUMED_DELTA_SHARE, |d| d as f64),
311            Stage::Checkout => self
312                .files
313                .map_or(objects * ASSUMED_FILES_PER_OBJECT, |f| f as f64),
314        };
315        Some(units / self.rate(stage)?)
316    }
317
318    fn rate(&self, stage: Stage) -> Option<f64> {
319        let rate = match stage {
320            Stage::Transfer => return None,
321            Stage::Indexing => self.rates.indexing,
322            Stage::Resolving => self.rates.resolving,
323            Stage::Checkout => self.rates.checkout,
324        };
325        (rate > 0.0).then_some(rate)
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    fn secs(s: f64) -> Duration {
334        Duration::from_secs_f64(s)
335    }
336
337    fn rates() -> PhaseRates {
338        PhaseRates {
339            indexing: 1_000.0,
340            resolving: 1_000.0,
341            checkout: 100.0,
342        }
343    }
344
345    fn receiving(est: &mut OverallEstimator, at: f64) {
346        est.start(
347            ProgressPhase::ReceivingObjects,
348            ProgressUnit::Bytes,
349            None,
350            secs(at),
351        );
352    }
353
354    #[test]
355    fn no_estimate_until_objects_are_counted() {
356        let mut est = OverallEstimator::new(true);
357        assert!(est.estimate(secs(1.0)).is_none());
358        receiving(&mut est, 1.0);
359        est.inc(4096);
360        assert!(
361            est.estimate(secs(2.0)).is_none(),
362            "bytes alone are not enough"
363        );
364    }
365
366    #[test]
367    fn receive_projects_its_pace_and_sizes_later_phases() {
368        let mut est = OverallEstimator::new(true).with_rates(rates());
369        receiving(&mut est, 0.0);
370        // Half of 2,000 objects in 4s; 60% deltas so far.
371        est.received_objects(
372            ReceivedObjects {
373                received: 1_000,
374                total: 2_000,
375                deltas: 600,
376            },
377            secs(4.0),
378        );
379        let e = est.estimate(secs(4.0)).unwrap();
380        // 4s more receiving + 2s indexing + 1.2s deltas + 0.3s checkout.
381        assert!(
382            (e.remaining.as_secs_f64() - 7.5).abs() < 1e-6,
383            "{:?}",
384            e.remaining
385        );
386        assert!((e.fraction - 4.0 / 11.5).abs() < 1e-6, "{}", e.fraction);
387    }
388
389    #[test]
390    fn active_phase_uses_measured_pace_once_trustworthy() {
391        let mut est = OverallEstimator::new(false).with_rates(rates());
392        est.start(
393            ProgressPhase::IndexingObjects,
394            ProgressUnit::Objects,
395            Some(1_000),
396            secs(10.0),
397        );
398        // Early on, the typical rate wins: 990 left at 1,000/s.
399        est.set(10);
400        let early = est.estimate(secs(10.5)).unwrap();
401        assert!((early.remaining.as_secs_f64() - (0.99 + 0.7)).abs() < 1e-6);
402        // At 50% after 2s, 2s more indexing at the measured pace.
403        est.set(500);
404        let later = est.estimate(secs(12.0)).unwrap();
405        assert!((later.remaining.as_secs_f64() - (2.0 + 0.7)).abs() < 1e-6);
406    }
407
408    #[test]
409    fn known_totals_replace_assumptions() {
410        let mut est = OverallEstimator::new(true).with_rates(rates());
411        est.start(
412            ProgressPhase::ResolvingDeltas,
413            ProgressUnit::Objects,
414            Some(0),
415            secs(1.0),
416        );
417        est.finish(secs(1.0));
418        est.start(
419            ProgressPhase::CheckingOutFiles,
420            ProgressUnit::Files,
421            Some(200),
422            secs(1.0),
423        );
424        // Earlier stages count as done; 200 files at 100/s.
425        let e = est.estimate(secs(1.0)).unwrap();
426        assert!((e.remaining.as_secs_f64() - 2.0).abs() < 1e-6);
427    }
428
429    #[test]
430    fn fraction_never_goes_backwards_and_reaches_one_when_done() {
431        let mut est = OverallEstimator::new(false).with_rates(rates());
432        receiving(&mut est, 0.0);
433        est.received_objects(
434            ReceivedObjects {
435                received: 900,
436                total: 1_000,
437                deltas: 0,
438            },
439            secs(9.0),
440        );
441        let high = est.estimate(secs(9.0)).unwrap().fraction;
442        // The last tenth stalls: the projection gets worse, the bar holds.
443        let stalled = est.estimate(secs(30.0)).unwrap().fraction;
444        assert!(stalled >= high);
445        est.start(
446            ProgressPhase::IndexingObjects,
447            ProgressUnit::Objects,
448            Some(1_000),
449            secs(30.0),
450        );
451        est.set(1_000);
452        est.start(
453            ProgressPhase::ResolvingDeltas,
454            ProgressUnit::Objects,
455            Some(0),
456            secs(31.0),
457        );
458        let unfinished = est.estimate(secs(31.0)).unwrap().fraction;
459        assert!(unfinished < 1.0);
460        est.finish(secs(31.0));
461        let done = est.estimate(secs(31.0)).unwrap();
462        assert_eq!(done.fraction, 1.0);
463        assert_eq!(done.remaining, Duration::ZERO);
464    }
465
466    #[test]
467    fn local_copy_with_known_total_is_estimated() {
468        let mut est = OverallEstimator::new(false).with_rates(rates());
469        est.start(
470            ProgressPhase::CopyingObjects,
471            ProgressUnit::Objects,
472            Some(100),
473            secs(0.0),
474        );
475        est.set(50);
476        let e = est.estimate(secs(1.0)).unwrap();
477        // 1s more copying + 0.1s indexing + 0.07s deltas.
478        assert!((e.remaining.as_secs_f64() - 1.17).abs() < 1e-6, "{e:?}");
479    }
480}