use std::collections::{HashMap, VecDeque};
use std::hash::Hash;
use std::time::Duration;
use ahash::RandomState;
use flowscope::Timestamp;
pub struct TimeBucketedCounter<K> {
window: Duration,
bucket_width: Duration,
buckets: VecDeque<(Timestamp, HashMap<K, u64, RandomState>)>,
}
impl<K> std::fmt::Debug for TimeBucketedCounter<K> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TimeBucketedCounter")
.field("window", &self.window)
.field("bucket_width", &self.bucket_width)
.field("buckets", &self.buckets.len())
.finish()
}
}
impl<K: Hash + Eq + Clone> TimeBucketedCounter<K> {
pub fn new(window: Duration, bucket_width: Duration) -> Self {
assert!(
!bucket_width.is_zero(),
"TimeBucketedCounter: bucket_width must be non-zero"
);
assert!(
bucket_width <= window,
"TimeBucketedCounter: bucket_width ({bucket_width:?}) must be ≤ window ({window:?})"
);
Self {
window,
bucket_width,
buckets: VecDeque::new(),
}
}
pub fn bump(&mut self, k: K, now: Timestamp) {
let bucket_start = self.bucket_anchor(now);
self.evict_older_than(now);
match self.buckets.back_mut() {
Some((start, map)) if *start == bucket_start => {
*map.entry(k).or_insert(0) += 1;
}
_ => {
let mut map = HashMap::with_hasher(RandomState::new());
map.insert(k, 1);
self.buckets.push_back((bucket_start, map));
}
}
}
pub fn count(&self, k: &K, now: Timestamp) -> u64 {
let cutoff = self.cutoff(now);
self.buckets
.iter()
.filter(|(start, _)| *start >= cutoff)
.map(|(_, map)| map.get(k).copied().unwrap_or(0))
.sum()
}
pub fn count_unbounded(&self, k: &K) -> u64 {
self.buckets
.iter()
.map(|(_, map)| map.get(k).copied().unwrap_or(0))
.sum()
}
pub fn len(&self) -> usize {
self.buckets.len()
}
pub fn is_empty(&self) -> bool {
self.buckets.is_empty()
}
pub fn evict_older_than(&mut self, now: Timestamp) {
let cutoff = self.cutoff(now);
while let Some((start, _)) = self.buckets.front() {
if *start < cutoff {
self.buckets.pop_front();
} else {
break;
}
}
}
pub fn entries_above(&self, threshold: u64, now: Timestamp) -> Vec<(K, u64)> {
let cutoff = self.cutoff(now);
let mut totals: HashMap<K, u64, RandomState> = HashMap::with_hasher(RandomState::new());
for (start, map) in &self.buckets {
if *start < cutoff {
continue;
}
for (k, v) in map {
*totals.entry(k.clone()).or_insert(0) += *v;
}
}
totals.into_iter().filter(|(_, v)| *v > threshold).collect()
}
fn cutoff(&self, now: Timestamp) -> Timestamp {
let now_dur = now.to_duration();
let cutoff_dur = now_dur.saturating_sub(self.window);
Timestamp::new(cutoff_dur.as_secs() as u32, cutoff_dur.subsec_nanos())
}
fn bucket_anchor(&self, ts: Timestamp) -> Timestamp {
let ts_ns = ts.to_duration().as_nanos();
let width_ns = self.bucket_width.as_nanos();
let anchor_ns = (ts_ns / width_ns) * width_ns;
let anchor_dur = Duration::from_nanos(anchor_ns as u64);
Timestamp::new(anchor_dur.as_secs() as u32, anchor_dur.subsec_nanos())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bump_increments_single_bucket() {
let mut c = TimeBucketedCounter::<&'static str>::new(
Duration::from_secs(10),
Duration::from_secs(1),
);
let now = Timestamp::new(100, 0);
for _ in 0..7 {
c.bump("a", now);
}
assert_eq!(c.count(&"a", now), 7);
assert_eq!(c.count(&"b", now), 0);
}
#[test]
fn buckets_age_out_after_window() {
let mut c = TimeBucketedCounter::<&'static str>::new(
Duration::from_secs(10),
Duration::from_secs(1),
);
c.bump("a", Timestamp::new(100, 0));
c.bump("a", Timestamp::new(100, 0));
assert_eq!(c.count(&"a", Timestamp::new(109, 999_999_999)), 2);
assert_eq!(c.count(&"a", Timestamp::new(110, 0)), 2);
assert_eq!(c.count(&"a", Timestamp::new(111, 0)), 0);
}
#[test]
fn multi_bucket_sum_across_window() {
let mut c = TimeBucketedCounter::<&'static str>::new(
Duration::from_secs(10),
Duration::from_secs(1),
);
for sec in 100..105 {
c.bump("a", Timestamp::new(sec, 0));
}
assert_eq!(c.len(), 5);
assert_eq!(c.count(&"a", Timestamp::new(109, 0)), 5);
assert_eq!(c.count(&"a", Timestamp::new(112, 0)), 3);
}
#[test]
fn bump_evicts_old_buckets() {
let mut c = TimeBucketedCounter::<&'static str>::new(
Duration::from_secs(10),
Duration::from_secs(1),
);
c.bump("a", Timestamp::new(100, 0));
assert_eq!(c.len(), 1);
c.bump("a", Timestamp::new(120, 0)); assert_eq!(c.len(), 1);
assert_eq!(c.count(&"a", Timestamp::new(120, 0)), 1);
}
#[test]
fn entries_above_threshold() {
let mut c = TimeBucketedCounter::<&'static str>::new(
Duration::from_secs(10),
Duration::from_secs(1),
);
let now = Timestamp::new(100, 0);
for _ in 0..5 {
c.bump("a", now);
}
for _ in 0..50 {
c.bump("b", now);
}
let mut hot = c.entries_above(10, now);
hot.sort_by_key(|(k, _)| *k);
assert_eq!(hot, vec![("b", 50)]);
}
#[test]
fn explicit_evict_clears_old_buckets() {
let mut c = TimeBucketedCounter::<&'static str>::new(
Duration::from_secs(10),
Duration::from_secs(1),
);
c.bump("a", Timestamp::new(100, 0));
assert_eq!(c.len(), 1);
c.evict_older_than(Timestamp::new(200, 0));
assert_eq!(c.len(), 0);
assert!(c.is_empty());
}
#[test]
fn bucket_anchor_truncates_correctly() {
let c = TimeBucketedCounter::<&'static str>::new(
Duration::from_secs(10),
Duration::from_secs(1),
);
let anchored = c.bucket_anchor(Timestamp::new(100, 700_000_000));
assert_eq!(anchored, Timestamp::new(100, 0));
let anchored = c.bucket_anchor(Timestamp::new(100, 0));
assert_eq!(anchored, Timestamp::new(100, 0));
}
#[test]
#[should_panic(expected = "bucket_width must be non-zero")]
fn zero_bucket_width_panics() {
let _ = TimeBucketedCounter::<&'static str>::new(Duration::from_secs(10), Duration::ZERO);
}
#[test]
#[should_panic(expected = "must be ≤ window")]
fn bucket_wider_than_window_panics() {
let _ = TimeBucketedCounter::<&'static str>::new(
Duration::from_secs(1),
Duration::from_secs(10),
);
}
}