use std::collections::HashMap;
use crate::model::ContainerMetrics;
use crate::store::{ContainerTrend, Metric};
#[derive(Debug, Default)]
pub struct Flushed {
pub containers: Vec<ContainerTrend>,
}
impl Flushed {
pub fn is_empty(&self) -> bool {
self.containers.is_empty()
}
}
pub struct Bucketer {
bucket_ms: u64,
host: String,
current_start: Option<u64>,
containers: HashMap<(String, Metric), ContainerAcc>,
}
struct ContainerAcc {
name: String,
stack: Option<String>,
values: Vec<f64>,
}
impl Bucketer {
#[must_use]
pub fn new(bucket_secs: u64, host: String) -> Self {
Self {
bucket_ms: bucket_secs.max(1) * 1000,
host,
current_start: None,
containers: HashMap::new(),
}
}
pub fn push(&mut self, ts_ms: u64, containers: &[ContainerMetrics]) -> Flushed {
let start = ts_ms - (ts_ms % self.bucket_ms);
let flushed = match self.current_start {
Some(cur) if start > cur => {
let out = self.close(cur);
self.current_start = Some(start);
out
}
None => {
self.current_start = Some(start);
Flushed::default()
}
_ => Flushed::default(),
};
for c in containers {
if let Some(cpu) = c.cpu_percent {
self.acc(c, Metric::Cpu).push(cpu);
}
if let Some(mem) = c.mem_used {
self.acc(c, Metric::Mem).push(mem as f64);
}
if let Some(rx) = c.net_rx_bps {
self.acc(c, Metric::NetRx).push(rx);
}
if let Some(tx) = c.net_tx_bps {
self.acc(c, Metric::NetTx).push(tx);
}
if let Some(read) = c.disk_read_bps {
self.acc(c, Metric::DiskRead).push(read);
}
if let Some(write) = c.disk_write_bps {
self.acc(c, Metric::DiskWrite).push(write);
}
}
flushed
}
fn acc(&mut self, c: &ContainerMetrics, metric: Metric) -> &mut Vec<f64> {
&mut self
.containers
.entry((c.id.clone(), metric))
.or_insert_with(|| ContainerAcc {
name: c.name.clone(),
stack: c.stack.clone(),
values: Vec::new(),
})
.values
}
fn close(&mut self, start: u64) -> Flushed {
let bucket_secs = self.bucket_ms / 1000;
let mut out = Flushed::default();
for ((id, metric), acc) in self.containers.drain() {
if let Some(stats) = Stats::from(&acc.values) {
out.containers.push(ContainerTrend {
bucket_start_ms: start,
bucket_secs,
host: self.host.clone(),
id,
name: acc.name,
stack: acc.stack,
metric: metric.as_str(),
min: stats.min,
max: stats.max,
median: stats.median,
samples: stats.count,
});
}
}
out
}
}
struct Stats {
min: f64,
max: f64,
median: f64,
count: u32,
}
impl Stats {
fn from(values: &[f64]) -> Option<Self> {
if values.is_empty() {
return None;
}
let mut sorted = values.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
Some(Self {
min: sorted[0],
max: sorted[sorted.len() - 1],
median: median_of_sorted(&sorted),
count: u32::try_from(sorted.len()).unwrap_or(u32::MAX),
})
}
}
fn median_of_sorted(sorted: &[f64]) -> f64 {
let n = sorted.len();
if n % 2 == 1 {
sorted[n / 2]
} else {
f64::midpoint(sorted[n / 2 - 1], sorted[n / 2])
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::float_cmp)]
use super::*;
use crate::model::{ContainerState, HealthState};
fn container(id: &str, cpu: Option<f64>) -> ContainerMetrics {
ContainerMetrics {
id: id.to_string(),
name: format!("c-{id}"),
image: "img".to_string(),
state: ContainerState::Running,
status: "Up".to_string(),
health: HealthState::None,
stack: Some("stack".to_string()),
cpu_percent: cpu,
mem_used: Some(100),
mem_limit: Some(200),
net_rx_bps: None,
net_tx_bps: None,
disk_read_bps: None,
disk_write_bps: None,
ports: Vec::new(),
}
}
#[test]
fn median_odd_and_even() {
assert_eq!(median_of_sorted(&[1.0, 2.0, 3.0]), 2.0);
assert_eq!(median_of_sorted(&[1.0, 2.0, 3.0, 4.0]), 2.5);
assert_eq!(median_of_sorted(&[5.0]), 5.0);
}
#[test]
fn median_is_robust_against_a_spike() {
let stats = Stats::from(&[1.0, 1.0, 1.0, 1.0, 1000.0]).unwrap();
assert_eq!(stats.median, 1.0);
assert_eq!(stats.max, 1000.0);
assert_eq!(stats.min, 1.0);
assert_eq!(stats.count, 5);
}
#[test]
fn no_flush_within_one_bucket() {
let mut b = Bucketer::new(60, "local".into()); assert!(b.push(60_000, &[]).is_empty());
assert!(b.push(90_000, &[]).is_empty());
assert!(b.push(119_000, &[]).is_empty());
}
#[test]
fn flush_on_bucket_boundary_emits_rollups() {
let mut b = Bucketer::new(60, "local".into());
b.push(60_000, &[container("x", Some(2.0))]);
b.push(90_000, &[container("x", Some(8.0))]);
let flushed = b.push(125_000, &[container("x", Some(5.0))]);
assert!(!flushed.is_empty());
let cont_cpu = flushed
.containers
.iter()
.find(|t| t.metric == "cpu")
.expect("container cpu trend");
assert_eq!(cont_cpu.id, "x");
assert_eq!(cont_cpu.name, "c-x");
assert_eq!(cont_cpu.median, 5.0); assert_eq!(cont_cpu.bucket_start_ms, 60_000);
assert_eq!(cont_cpu.bucket_secs, 60);
}
#[test]
fn missing_cpu_samples_are_skipped() {
let mut b = Bucketer::new(60, "local".into());
b.push(60_000, &[container("x", None)]);
b.push(90_000, &[container("x", Some(4.0))]);
let flushed = b.push(130_000, &[]);
let cont_cpu = flushed
.containers
.iter()
.find(|t| t.metric == "cpu")
.unwrap();
assert_eq!(cont_cpu.samples, 1); assert_eq!(cont_cpu.median, 4.0);
}
}