use std::collections::HashMap;
use std::time::{Duration, Instant};
const WINDOW: Duration = Duration::from_secs(600);
pub const MIN_OBSERVATION: Duration = Duration::from_secs(60);
const SAMPLE_INTERVAL: Duration = Duration::from_millis(950);
#[derive(Debug, Clone, Copy)]
pub struct Growth {
pub bytes_per_day: f64,
pub days_until_full: Option<f64>,
}
#[derive(Debug)]
struct Series {
samples: Vec<(Instant, u64)>,
last_push: Instant,
}
#[derive(Default)]
pub struct GrowthTracker {
by_mount: HashMap<String, Series>,
}
impl GrowthTracker {
pub fn new() -> Self {
Self::default()
}
pub fn observe(&mut self, filesystems: &[crate::collect::FsTick]) {
let now = Instant::now();
for fs in filesystems {
if fs.size_bytes == 0 {
continue;
}
let series = self
.by_mount
.entry(fs.mount.clone())
.or_insert_with(|| Series {
samples: Vec::new(),
last_push: now - SAMPLE_INTERVAL,
});
if now.duration_since(series.last_push) < SAMPLE_INTERVAL {
continue;
}
series.last_push = now;
series.samples.push((now, fs.used_bytes));
series
.samples
.retain(|(t, _)| now.duration_since(*t) <= WINDOW);
}
let live: std::collections::HashSet<&str> =
filesystems.iter().map(|f| f.mount.as_str()).collect();
self.by_mount
.retain(|mount, _| live.contains(mount.as_str()));
}
pub fn growth(&self, mount: &str, used_bytes: u64, size_bytes: u64) -> Option<Growth> {
let series = self.by_mount.get(mount)?;
let first = series.samples.first()?;
let last = series.samples.last()?;
let span = last.0.duration_since(first.0);
if span < MIN_OBSERVATION {
return None;
}
let delta = last.1 as f64 - first.1 as f64;
let bytes_per_sec = delta / span.as_secs_f64();
let bytes_per_day = bytes_per_sec * 86_400.0;
let days_until_full = if bytes_per_sec > 1024.0 {
let free = size_bytes.saturating_sub(used_bytes) as f64;
Some(free / (bytes_per_sec * 86_400.0))
} else {
None
};
Some(Growth {
bytes_per_day,
days_until_full,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fs(mount: &str, used: u64, size: u64) -> crate::collect::FsTick {
crate::collect::FsTick {
mount: mount.to_string(),
device: "test".into(),
fs_type: "test".into(),
size_bytes: size,
used_bytes: used,
avail_bytes: size - used,
inode_pct: None,
is_removable: false,
is_system: false,
}
}
fn seeded(mount: &str, points: &[(u64, u64)]) -> GrowthTracker {
let now = Instant::now();
let mut t = GrowthTracker::new();
t.by_mount.insert(
mount.to_string(),
Series {
samples: points
.iter()
.map(|(secs_ago, used)| (now - Duration::from_secs(*secs_ago), *used))
.collect(),
last_push: now,
},
);
t
}
#[test]
fn reports_nothing_before_the_minimum_observation() {
let t = seeded("/", &[(10, 0), (0, 1_000_000_000)]);
assert!(t.growth("/", 1_000_000_000, 2_000_000_000).is_none());
}
#[test]
fn projects_days_until_full_from_the_window_slope() {
let t = seeded("/", &[(100, 0), (0, 100_000_000)]);
let g = t
.growth("/", 100_000_000, 600_000_000)
.expect("window is long enough");
assert!((g.bytes_per_day - 86_400_000_000.0).abs() < 1e6);
let days = g.days_until_full.expect("growing, so there is a deadline");
assert!((days - 500.0 / 86_400.0).abs() < 1e-4, "got {days}");
}
#[test]
fn a_shrinking_filesystem_has_no_deadline() {
let t = seeded("/", &[(100, 500_000_000), (0, 400_000_000)]);
let g = t
.growth("/", 400_000_000, 1_000_000_000)
.expect("has window");
assert!(g.bytes_per_day < 0.0);
assert!(g.days_until_full.is_none());
}
#[test]
fn flat_usage_has_no_deadline() {
let t = seeded("/", &[(100, 500_000_000), (0, 500_000_100)]);
let g = t
.growth("/", 500_000_100, 1_000_000_000)
.expect("has window");
assert!(g.days_until_full.is_none());
}
#[test]
fn unmounted_volumes_are_forgotten() {
let mut t = GrowthTracker::new();
t.observe(&[fs("/", 1, 100), fs("/mnt/usb", 1, 100)]);
assert_eq!(t.by_mount.len(), 2);
t.observe(&[fs("/", 1, 100)]);
assert_eq!(t.by_mount.len(), 1);
assert!(!t.by_mount.contains_key("/mnt/usb"));
}
#[test]
fn zero_sized_mounts_are_ignored() {
let mut t = GrowthTracker::new();
t.observe(&[fs("/dev", 0, 0)]);
assert!(t.by_mount.is_empty());
}
}