use std::collections::{HashMap, VecDeque};
use std::time::{Duration, Instant};
use crate::snapshot::FilesystemUsage;
const SAMPLE_EVERY: Duration = Duration::from_secs(30);
const WINDOW: Duration = Duration::from_secs(30 * 60);
const MIN_SPAN: Duration = Duration::from_secs(10 * 60);
#[derive(Debug, Default)]
pub struct FillTracker {
samples: HashMap<String, VecDeque<(Instant, u64)>>,
last_sample: Option<Instant>,
}
impl FillTracker {
pub fn new() -> Self {
Self::default()
}
pub fn annotate(&mut self, filesystems: &mut [FilesystemUsage], now: Instant) {
let due = self
.last_sample
.is_none_or(|at| now.saturating_duration_since(at) >= SAMPLE_EVERY);
if due {
self.last_sample = Some(now);
self.samples
.retain(|key, _| filesystems.iter().any(|fs| fs_key(fs) == *key));
for fs in filesystems.iter() {
let samples = self.samples.entry(fs_key(fs)).or_default();
samples.push_back((now, used_bytes(fs)));
while samples
.front()
.is_some_and(|(at, _)| now.saturating_duration_since(*at) > WINDOW)
{
samples.pop_front();
}
}
}
for fs in filesystems.iter_mut() {
fs.full_in_secs = self
.samples
.get(&fs_key(fs))
.and_then(|samples| full_in_secs(samples, fs.available_bytes));
}
}
}
fn fs_key(fs: &FilesystemUsage) -> String {
fs.mount_points
.first()
.cloned()
.unwrap_or_else(|| fs.device.clone())
}
fn used_bytes(fs: &FilesystemUsage) -> u64 {
fs.total_bytes.saturating_sub(fs.available_bytes)
}
fn full_in_secs(samples: &VecDeque<(Instant, u64)>, available: u64) -> Option<u64> {
let (first, _) = *samples.front()?;
let (last, _) = *samples.back()?;
if last.saturating_duration_since(first) < MIN_SPAN || available == 0 {
return None;
}
let n = samples.len() as f64;
let points = samples.iter().map(|(at, used)| {
(
at.saturating_duration_since(first).as_secs_f64(),
*used as f64,
)
});
let (sum_x, sum_y) = points
.clone()
.fold((0.0, 0.0), |(sx, sy), (x, y)| (sx + x, sy + y));
let (mean_x, mean_y) = (sum_x / n, sum_y / n);
let (cov, var) = points.fold((0.0, 0.0), |(c, v), (x, y)| {
(
c + (x - mean_x) * (y - mean_y),
v + (x - mean_x) * (x - mean_x),
)
});
let bytes_per_sec = cov / var;
(bytes_per_sec > 0.0).then(|| (available as f64 / bytes_per_sec).round() as u64)
}
#[cfg(test)]
mod tests {
use super::*;
const GIB: u64 = 1 << 30;
fn fs(available: u64) -> FilesystemUsage {
FilesystemUsage {
device: "/dev/md0".to_owned(),
mount_points: vec!["/srv".to_owned()],
total_bytes: 100 * GIB,
available_bytes: available,
..Default::default()
}
}
#[test]
fn predicts_from_steady_growth_only() {
let start = Instant::now();
let mut tracker = FillTracker::new();
let mut list = Vec::new();
for step in 0..=20u64 {
list = vec![fs(60 * GIB - step * GIB / 2)];
tracker.annotate(&mut list, start + SAMPLE_EVERY * step as u32);
if step < 20 {
assert_eq!(list[0].full_in_secs, None, "too short at step {step}");
}
}
assert_eq!(list[0].full_in_secs, Some(50 * 60));
let mut later = vec![fs(50 * GIB)];
tracker.annotate(
&mut later,
start + SAMPLE_EVERY * 20 + Duration::from_secs(1),
);
assert_eq!(later[0].full_in_secs, Some(50 * 60));
let mut tracker = FillTracker::new();
for step in 0..=40u64 {
list = vec![fs(10 * GIB + step * GIB / 4)];
tracker.annotate(&mut list, start + SAMPLE_EVERY * step as u32);
}
assert_eq!(list[0].full_in_secs, None);
}
#[test]
fn forgets_old_samples_and_unmounted_filesystems() {
let start = Instant::now();
let mut tracker = FillTracker::new();
for step in 0..=120u64 {
let used = if step < 20 { step } else { 20 };
let mut list = vec![fs(80 * GIB - used * GIB)];
tracker.annotate(&mut list, start + SAMPLE_EVERY * step as u32);
if step == 120 {
assert_eq!(list[0].full_in_secs, None);
}
}
assert!(tracker.samples["/srv"].len() <= 61);
tracker.annotate(&mut [], start + SAMPLE_EVERY * 121);
assert!(tracker.samples.is_empty());
}
}