145 lines
4.9 KiB
Rust
145 lines
4.9 KiB
Rust
|
|
//! Indexing-rate estimation for the status displays.
|
||
|
|
//!
|
||
|
|
//! The old tracker sampled the counter every poll tick but pruned to a
|
||
|
|
//! 1-second window, so anything slower than ~1 file/sec measured a
|
||
|
|
//! genuine zero and displayed "0.0 files/sec" despite progress. This one
|
||
|
|
//! records a point only when the counter *changes*, keeps up to 60 s of
|
||
|
|
//! history but never fewer than two points (so slow rates stay
|
||
|
|
//! computable), and measures against `now` so the estimate decays during
|
||
|
|
//! stalls instead of freezing at the last burst.
|
||
|
|
|
||
|
|
use std::time::{Duration, Instant};
|
||
|
|
|
||
|
|
const HISTORY: Duration = Duration::from_secs(60);
|
||
|
|
|
||
|
|
pub struct SpeedTracker {
|
||
|
|
/// (when, counter value) — appended only on counter change.
|
||
|
|
points: Vec<(Instant, usize)>,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl SpeedTracker {
|
||
|
|
pub fn new() -> SpeedTracker {
|
||
|
|
SpeedTracker { points: Vec::new() }
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Reset between phases (each phase restarts its counter).
|
||
|
|
pub fn reset(&mut self) {
|
||
|
|
self.points.clear();
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn record(&mut self, files_processed: usize) {
|
||
|
|
self.record_at(Instant::now(), files_processed);
|
||
|
|
}
|
||
|
|
|
||
|
|
fn record_at(&mut self, now: Instant, files_processed: usize) {
|
||
|
|
match self.points.last() {
|
||
|
|
Some(&(_, last)) if last == files_processed => return,
|
||
|
|
// Counter went backwards — a new phase started without an
|
||
|
|
// explicit reset.
|
||
|
|
Some(&(_, last)) if files_processed < last => self.points.clear(),
|
||
|
|
_ => {}
|
||
|
|
}
|
||
|
|
self.points.push((now, files_processed));
|
||
|
|
// Prune old points, but always keep at least two so a slow but
|
||
|
|
// steady rate never becomes unmeasurable.
|
||
|
|
while self.points.len() > 2
|
||
|
|
&& now.duration_since(self.points[0].0) > HISTORY
|
||
|
|
{
|
||
|
|
self.points.remove(0);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Estimated files/sec, measured from the oldest retained progress
|
||
|
|
/// point to *now*. `None` until two data points exist.
|
||
|
|
pub fn files_per_sec(&self) -> Option<f64> {
|
||
|
|
self.files_per_sec_at(Instant::now())
|
||
|
|
}
|
||
|
|
|
||
|
|
fn files_per_sec_at(&self, now: Instant) -> Option<f64> {
|
||
|
|
let (t0, c0) = *self.points.first()?;
|
||
|
|
let (_, c1) = *self.points.last()?;
|
||
|
|
if self.points.len() < 2 {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
let span = now.duration_since(t0).as_secs_f64();
|
||
|
|
if span <= 0.0 {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
Some((c1 - c0) as f64 / span)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn needs_two_points() {
|
||
|
|
let mut t = SpeedTracker::new();
|
||
|
|
let base = Instant::now();
|
||
|
|
assert!(t.files_per_sec_at(base).is_none());
|
||
|
|
t.record_at(base, 10);
|
||
|
|
assert!(t.files_per_sec_at(base).is_none());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn slow_rate_is_measurable_not_zero() {
|
||
|
|
// One file every 2.5 s — the old 1 s window reported 0.0 here.
|
||
|
|
let mut t = SpeedTracker::new();
|
||
|
|
let base = Instant::now();
|
||
|
|
for i in 0..4 {
|
||
|
|
t.record_at(base + Duration::from_millis(2500 * i), 10 + i as usize);
|
||
|
|
}
|
||
|
|
let rate = t.files_per_sec_at(base + Duration::from_millis(7500)).unwrap();
|
||
|
|
assert!((rate - 0.4).abs() < 0.01, "expected ~0.4/s, got {}", rate);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn unchanged_counter_adds_no_points() {
|
||
|
|
let mut t = SpeedTracker::new();
|
||
|
|
let base = Instant::now();
|
||
|
|
for i in 0..100 {
|
||
|
|
t.record_at(base + Duration::from_millis(50 * i), 42);
|
||
|
|
}
|
||
|
|
assert_eq!(t.points.len(), 1, "only the first observation recorded");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn stall_decays_toward_zero() {
|
||
|
|
let mut t = SpeedTracker::new();
|
||
|
|
let base = Instant::now();
|
||
|
|
t.record_at(base, 0);
|
||
|
|
t.record_at(base + Duration::from_secs(1), 100); // 100/s burst
|
||
|
|
let just_after = t.files_per_sec_at(base + Duration::from_secs(1)).unwrap();
|
||
|
|
let stalled = t.files_per_sec_at(base + Duration::from_secs(20)).unwrap();
|
||
|
|
assert!(just_after > 90.0);
|
||
|
|
assert!(stalled < 6.0, "estimate must decay during a stall: {}", stalled);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn pruning_keeps_at_least_two_points() {
|
||
|
|
let mut t = SpeedTracker::new();
|
||
|
|
let base = Instant::now();
|
||
|
|
t.record_at(base, 1);
|
||
|
|
t.record_at(base + Duration::from_secs(30), 2);
|
||
|
|
// Far beyond the history window; both points are older than 60 s
|
||
|
|
// relative to this record.
|
||
|
|
t.record_at(base + Duration::from_secs(300), 3);
|
||
|
|
assert!(t.points.len() >= 2);
|
||
|
|
assert!(t
|
||
|
|
.files_per_sec_at(base + Duration::from_secs(300))
|
||
|
|
.is_some());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn counter_regression_resets() {
|
||
|
|
let mut t = SpeedTracker::new();
|
||
|
|
let base = Instant::now();
|
||
|
|
t.record_at(base, 500);
|
||
|
|
t.record_at(base + Duration::from_secs(1), 600);
|
||
|
|
// New phase restarts from a small number.
|
||
|
|
t.record_at(base + Duration::from_secs(2), 3);
|
||
|
|
assert_eq!(t.points.len(), 1);
|
||
|
|
}
|
||
|
|
}
|