use std::collections::{HashMap, VecDeque};
use std::hash::Hash;
use std::ops::AddAssign;
use std::time::Duration;
use crate::Timestamp;
pub trait RateValue: Default + Copy + AddAssign + 'static {
fn as_f64(self) -> f64;
}
macro_rules! impl_rate_value {
($($t:ty),+) => {
$(
impl RateValue for $t {
#[inline]
fn as_f64(self) -> f64 { self as f64 }
}
)+
};
}
impl_rate_value!(u64, u32, i64, i32, f64, f32);
#[derive(Debug)]
struct Bucket<K, V> {
start: Timestamp,
sums: HashMap<K, V>,
other: Option<V>,
}
impl<K: Hash + Eq, V: RateValue> Bucket<K, V> {
fn new(start: Timestamp) -> Self {
Self {
start,
sums: HashMap::new(),
other: None,
}
}
fn add(&mut self, k: K, v: V, max_keys: usize) -> bool {
if let Some(sum) = self.sums.get_mut(&k) {
*sum += v;
return false;
}
if self.sums.len() < max_keys {
self.sums.insert(k, v);
return false;
}
*self.other.get_or_insert_with(V::default) += v;
true
}
}
#[derive(Debug)]
pub struct RollingRate<K, V>
where
K: Hash + Eq,
V: RateValue,
{
window: Duration,
bucket_width: Duration,
buckets: VecDeque<Bucket<K, V>>,
max_keys: usize,
overflowed: u64,
}
impl<K, V> RollingRate<K, V>
where
K: Hash + Eq + Clone,
V: RateValue,
{
pub fn new(window: Duration, bucket_width: Duration, max_keys: usize) -> Self {
assert!(!bucket_width.is_zero(), "bucket_width must be > 0");
assert!(window >= bucket_width, "window must be >= bucket_width");
Self {
window,
bucket_width,
buckets: VecDeque::new(),
max_keys,
overflowed: 0,
}
}
pub fn new_unbounded(window: Duration, bucket_width: Duration) -> Self {
Self::new(window, bucket_width, usize::MAX)
}
pub fn record(&mut self, k: K, v: V, now: Timestamp) {
self.evict_expired(now);
let future = now.to_duration().saturating_add(self.window);
while self
.buckets
.back()
.is_some_and(|b| b.start.to_duration() > future)
{
self.buckets.pop_back();
}
let bucket_start = self.bucket_start_for(now);
let pos = match self.buckets.back() {
Some(b) if b.start == bucket_start => self.buckets.len() - 1,
Some(b) if b.start < bucket_start => {
self.buckets.push_back(Bucket::new(bucket_start));
self.buckets.len() - 1
}
None => {
self.buckets.push_back(Bucket::new(bucket_start));
0
}
Some(_) => match self.buckets.iter().rposition(|b| b.start <= bucket_start) {
Some(i) if self.buckets[i].start == bucket_start => i,
Some(i) => {
self.buckets.insert(i + 1, Bucket::new(bucket_start));
i + 1
}
None => {
self.buckets.push_front(Bucket::new(bucket_start));
0
}
},
};
if self.buckets[pos].add(k, v, self.max_keys) {
self.overflowed = self.overflowed.saturating_add(1);
}
}
pub fn rate(&self, k: &K, now: Timestamp) -> f64 {
let cutoff = self.cutoff_for(now);
let total: f64 = self
.buckets
.iter()
.filter(|b| b.start >= cutoff)
.filter_map(|b| b.sums.get(k))
.map(|v| v.as_f64())
.sum();
total / self.window.as_secs_f64()
}
pub fn other_rate(&self, now: Timestamp) -> f64 {
let cutoff = self.cutoff_for(now);
let total: f64 = self
.buckets
.iter()
.filter(|b| b.start >= cutoff)
.filter_map(|b| b.other)
.map(|v| v.as_f64())
.sum();
total / self.window.as_secs_f64()
}
pub fn other_sum(&self, now: Timestamp) -> V {
let cutoff = self.cutoff_for(now);
let mut total = V::default();
for b in self.buckets.iter().filter(|b| b.start >= cutoff) {
if let Some(v) = b.other {
total += v;
}
}
total
}
pub fn overflowed(&self) -> u64 {
self.overflowed
}
pub fn max_keys(&self) -> usize {
self.max_keys
}
pub fn snapshot(&self, now: Timestamp) -> impl Iterator<Item = (K, f64)> + '_ {
let cutoff = self.cutoff_for(now);
let window_secs = self.window.as_secs_f64();
let mut totals: HashMap<K, V> = HashMap::new();
for b in self.buckets.iter().filter(|b| b.start >= cutoff) {
for (k, v) in &b.sums {
let entry = totals.entry(k.clone()).or_default();
*entry += *v;
}
}
totals
.into_iter()
.map(move |(k, sum)| (k, sum.as_f64() / window_secs))
}
pub fn for_each_bucket<F>(&self, now: Timestamp, mut f: F)
where
F: FnMut(&K, V),
{
let cutoff = self.cutoff_for(now);
for b in self.buckets.iter().filter(|b| b.start >= cutoff) {
for (k, v) in &b.sums {
f(k, *v);
}
}
}
pub fn evict_expired(&mut self, now: Timestamp) {
let cutoff = self.cutoff_for(now);
while self.buckets.front().is_some_and(|b| b.start < cutoff) {
self.buckets.pop_front();
}
}
pub fn sum(&self, k: &K, now: Timestamp) -> V {
let cutoff = self.cutoff_for(now);
let mut total = V::default();
for b in self.buckets.iter().filter(|b| b.start >= cutoff) {
if let Some(v) = b.sums.get(k) {
total += *v;
}
}
total
}
pub fn top_k(&self, n: usize, now: Timestamp) -> Vec<(K, f64)> {
top_n(self.snapshot(now).collect(), n)
}
pub fn clear(&mut self) {
self.buckets.clear();
self.overflowed = 0;
}
pub fn is_empty(&self) -> bool {
self.buckets
.iter()
.all(|b| b.sums.is_empty() && b.other.is_none())
}
pub fn len(&self, now: Timestamp) -> usize {
let cutoff = self.cutoff_for(now);
let mut seen: std::collections::HashSet<&K> = std::collections::HashSet::new();
for b in self.buckets.iter().filter(|b| b.start >= cutoff) {
for k in b.sums.keys() {
seen.insert(k);
}
}
seen.len()
}
pub fn bucket_count(&self) -> usize {
self.buckets.len()
}
pub fn stored_entries(&self) -> usize {
self.buckets.iter().map(|b| b.sums.len()).sum()
}
fn bucket_start_for(&self, ts: Timestamp) -> Timestamp {
let nanos = ts.to_duration().as_nanos();
let bw = self.bucket_width.as_nanos();
let start_nanos = (nanos / bw) * bw;
let start_dur = Duration::from_nanos(start_nanos as u64);
Timestamp::new(start_dur.as_secs() as u32, start_dur.subsec_nanos())
}
fn cutoff_for(&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())
}
}
pub(crate) fn top_n<K>(mut v: Vec<(K, f64)>, n: usize) -> Vec<(K, f64)> {
if n == 0 {
return Vec::new();
}
let desc = |a: &(K, f64), b: &(K, f64)| b.1.total_cmp(&a.1);
if n < v.len() {
v.select_nth_unstable_by(n - 1, desc);
v.truncate(n);
}
v.sort_unstable_by(desc);
v
}
impl<K, V> crate::correlate::Mergeable for RollingRate<K, V>
where
K: Hash + Eq + Clone,
V: RateValue,
{
fn merge(&mut self, other: Self) {
assert_eq!(
self.window, other.window,
"RollingRate::merge requires matching window",
);
assert_eq!(
self.bucket_width, other.bucket_width,
"RollingRate::merge requires matching bucket_width",
);
self.overflowed = self.overflowed.saturating_add(other.overflowed);
let max_keys = self.max_keys;
for b in other.buckets {
let pos = match self.buckets.iter().position(|mine| mine.start >= b.start) {
Some(i) if self.buckets[i].start == b.start => i,
Some(i) => {
self.buckets.insert(i, Bucket::new(b.start));
i
}
None => {
self.buckets.push_back(Bucket::new(b.start));
self.buckets.len() - 1
}
};
let mine = &mut self.buckets[pos];
for (k, v) in b.sums {
if mine.add(k, v, max_keys) {
self.overflowed = self.overflowed.saturating_add(1);
}
}
if let Some(v) = b.other {
*mine.other.get_or_insert_with(V::default) += v;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rr_bytes() -> RollingRate<&'static str, u64> {
RollingRate::new_unbounded(Duration::from_secs(60), Duration::from_secs(1))
}
#[test]
#[should_panic(expected = "bucket_width must be > 0")]
fn new_unbounded_panics_on_zero_bucket_width() {
let _: RollingRate<&'static str, u64> =
RollingRate::new_unbounded(Duration::from_secs(60), Duration::ZERO);
}
#[test]
#[should_panic(expected = "window must be >= bucket_width")]
fn new_unbounded_panics_when_window_lt_bucket_width() {
let _: RollingRate<&'static str, u64> =
RollingRate::new_unbounded(Duration::from_secs(1), Duration::from_secs(5));
}
#[test]
fn single_record_then_rate_returns_v_over_window_secs() {
let mut r = rr_bytes();
r.record("http", 600, Timestamp::new(0, 0));
let rate = r.rate(&"http", Timestamp::new(5, 0));
assert!((rate - 10.0).abs() < 1e-9);
}
#[test]
fn multiple_records_same_bucket_sum() {
let mut r = rr_bytes();
r.record("http", 100, Timestamp::new(0, 0));
r.record("http", 200, Timestamp::new(0, 100_000_000));
r.record("http", 300, Timestamp::new(0, 500_000_000));
let rate = r.rate(&"http", Timestamp::new(0, 600_000_000));
assert!((rate - 10.0).abs() < 1e-9);
assert_eq!(r.bucket_count(), 1);
}
#[test]
fn records_across_buckets_aggregate_in_window() {
let mut r = rr_bytes();
r.record("http", 100, Timestamp::new(0, 0));
r.record("http", 200, Timestamp::new(10, 0));
r.record("http", 300, Timestamp::new(30, 0));
let rate = r.rate(&"http", Timestamp::new(35, 0));
assert!((rate - 10.0).abs() < 1e-9);
}
#[test]
fn rate_zero_when_window_elapsed() {
let mut r = rr_bytes();
r.record("http", 600, Timestamp::new(0, 0));
let rate = r.rate(&"http", Timestamp::new(200, 0));
assert_eq!(rate, 0.0);
}
#[test]
fn rate_zero_for_unknown_key() {
let mut r = rr_bytes();
r.record("http", 600, Timestamp::new(0, 0));
assert_eq!(r.rate(&"never_seen", Timestamp::new(5, 0)), 0.0);
}
#[test]
fn snapshot_yields_every_in_window_key() {
let mut r = rr_bytes();
r.record("http", 600, Timestamp::new(0, 0));
r.record("tls", 1200, Timestamp::new(10, 0));
let snap: Vec<_> = r.snapshot(Timestamp::new(30, 0)).collect();
assert_eq!(snap.len(), 2);
assert!(snap.iter().any(|(k, _)| *k == "http"));
assert!(snap.iter().any(|(k, _)| *k == "tls"));
}
#[test]
fn snapshot_excludes_keys_past_window() {
let mut r = rr_bytes();
r.record("http", 600, Timestamp::new(0, 0));
let snap: Vec<_> = r.snapshot(Timestamp::new(120, 0)).collect();
assert!(snap.is_empty());
}
#[test]
fn evict_expired_drops_old_buckets_idempotent() {
let mut r = rr_bytes();
r.record("http", 600, Timestamp::new(0, 0));
r.record("http", 100, Timestamp::new(120, 0));
let before = r.bucket_count();
r.evict_expired(Timestamp::new(125, 0));
let after_first = r.bucket_count();
r.evict_expired(Timestamp::new(125, 0));
let after_second = r.bucket_count();
assert!(after_first <= before);
assert_eq!(
after_first, after_second,
"evict_expired idempotent on second call"
);
}
#[test]
fn record_in_same_bucket_does_not_grow_bucket_count() {
let mut r = rr_bytes();
for nanos in [0, 100_000_000u32, 200_000_000, 300_000_000] {
r.record("http", 50, Timestamp::new(0, nanos));
}
assert_eq!(r.bucket_count(), 1, "same-bucket records share storage");
}
#[test]
fn for_each_bucket_visits_every_in_window_entry() {
let mut r = rr_bytes();
r.record("http", 100, Timestamp::new(0, 0));
r.record("tls", 200, Timestamp::new(10, 0));
let mut visited: Vec<(String, u64)> = Vec::new();
r.for_each_bucket(Timestamp::new(30, 0), |k, v| {
visited.push((k.to_string(), v));
});
assert_eq!(visited.len(), 2);
}
#[test]
fn rate_works_for_request_count_use_case() {
let mut r: RollingRate<&'static str, u64> =
RollingRate::new_unbounded(Duration::from_secs(60), Duration::from_secs(1));
for sec in 0..60 {
r.record("api", 1, Timestamp::new(sec, 0));
}
let rate = r.rate(&"api", Timestamp::new(60, 0));
assert!((rate - 1.0).abs() < 1e-9);
}
#[test]
fn rate_works_for_f64_value_type() {
let mut r: RollingRate<&'static str, f64> =
RollingRate::new_unbounded(Duration::from_secs(60), Duration::from_secs(1));
r.record("/users", 0.5, Timestamp::new(0, 0));
r.record("/users", 1.0, Timestamp::new(10, 0));
let rate = r.rate(&"/users", Timestamp::new(30, 0));
assert!((rate - (1.5 / 60.0)).abs() < 1e-9);
}
#[test]
fn sum_returns_raw_v_without_window_divide() {
let mut r = rr_bytes();
r.record("http", 100, Timestamp::new(0, 0));
r.record("http", 200, Timestamp::new(10, 0));
r.record("http", 300, Timestamp::new(30, 0));
let s = r.sum(&"http", Timestamp::new(35, 0));
assert_eq!(s, 600);
let rate = r.rate(&"http", Timestamp::new(35, 0));
assert!((rate - (s as f64 / 60.0)).abs() < 1e-9);
}
#[test]
fn sum_zero_for_absent_key() {
let r = rr_bytes();
assert_eq!(r.sum(&"http", Timestamp::new(0, 0)), 0);
}
#[test]
fn sum_zero_for_outside_window_samples() {
let mut r = rr_bytes();
r.record("http", 600, Timestamp::new(0, 0));
assert_eq!(r.sum(&"http", Timestamp::new(200, 0)), 0);
}
#[test]
fn top_k_returns_sorted_descending() {
let mut r = rr_bytes();
r.record("a", 100, Timestamp::new(0, 0));
r.record("b", 500, Timestamp::new(0, 0));
r.record("c", 200, Timestamp::new(0, 0));
r.record("d", 50, Timestamp::new(0, 0));
r.record("e", 350, Timestamp::new(0, 0));
let top3 = r.top_k(3, Timestamp::new(0, 100_000_000));
assert_eq!(top3.len(), 3);
assert_eq!(top3[0].0, "b"); assert_eq!(top3[1].0, "e"); assert_eq!(top3[2].0, "c"); assert!(top3[0].1 >= top3[1].1);
assert!(top3[1].1 >= top3[2].1);
}
#[test]
fn top_k_zero_returns_empty_vec_no_panic() {
let mut r = rr_bytes();
r.record("a", 100, Timestamp::new(0, 0));
assert!(r.top_k(0, Timestamp::new(0, 100_000_000)).is_empty());
}
#[test]
fn top_k_larger_than_keyset_returns_all() {
let mut r = rr_bytes();
r.record("a", 100, Timestamp::new(0, 0));
r.record("b", 200, Timestamp::new(0, 0));
let top = r.top_k(100, Timestamp::new(0, 100_000_000));
assert_eq!(top.len(), 2);
}
#[test]
fn clear_resets_everything() {
let mut r = rr_bytes();
r.record("a", 100, Timestamp::new(0, 0));
r.record("b", 200, Timestamp::new(10, 0));
assert!(!r.is_empty());
r.clear();
assert!(r.is_empty());
assert_eq!(r.bucket_count(), 0);
assert_eq!(r.rate(&"a", Timestamp::new(15, 0)), 0.0);
assert_eq!(r.sum(&"a", Timestamp::new(15, 0)), 0);
assert_eq!(r.len(Timestamp::new(15, 0)), 0);
}
#[test]
fn len_counts_unique_keys_in_window() {
let mut r = rr_bytes();
r.record("a", 100, Timestamp::new(0, 0));
r.record("b", 200, Timestamp::new(0, 0));
r.record("a", 50, Timestamp::new(10, 0)); r.record("c", 30, Timestamp::new(30, 0));
assert_eq!(r.len(Timestamp::new(35, 0)), 3);
}
#[test]
fn len_excludes_outside_window_keys() {
let mut r = rr_bytes();
r.record("old", 100, Timestamp::new(0, 0));
r.record("new", 200, Timestamp::new(120, 0));
assert_eq!(r.len(Timestamp::new(130, 0)), 1);
}
#[test]
fn len_empty_on_fresh_instance() {
let r = rr_bytes();
assert_eq!(r.len(Timestamp::new(0, 0)), 0);
}
#[test]
fn key_cap_bounds_the_table_and_counts_overflow() {
let mut r: RollingRate<u32, u64> =
RollingRate::new(Duration::from_secs(10), Duration::from_secs(1), 100);
for k in 0..10_000u32 {
r.record(k, 10, Timestamp::new(0, k));
}
let now = Timestamp::new(0, 999_999_999);
assert_eq!(r.bucket_count(), 1);
assert!(r.stored_entries() <= 100 * r.bucket_count());
assert_eq!(r.overflowed(), 9_900);
assert_eq!(r.other_sum(now), 99_000);
assert!(r.other_rate(now) > 0.0);
assert_eq!(r.len(now), 100);
r.record(5, 1, now);
assert_eq!(r.sum(&5, now), 11);
r.record(9_999, 1, now);
assert_eq!(r.sum(&9_999, now), 0);
assert_eq!(r.overflowed(), 9_901);
assert_eq!(r.max_keys(), 100);
}
#[test]
fn key_cap_applies_per_bucket_and_bounds_every_bucket() {
let mut r: RollingRate<u32, u64> =
RollingRate::new(Duration::from_secs(5), Duration::from_secs(1), 10);
for sec in 0..20u32 {
for k in 0..1_000u32 {
r.record(sec * 1_000 + k, 1, Timestamp::new(sec, k));
}
assert!(r.bucket_count() <= 6);
assert!(r.stored_entries() <= 10 * r.bucket_count());
}
assert_eq!(r.overflowed(), 20 * 990);
assert_eq!(r.other_sum(Timestamp::new(100, 0)), 0);
r.clear();
assert!(r.is_empty());
assert_eq!(r.overflowed(), 0);
}
#[test]
fn unbounded_never_overflows() {
let mut r = rr_bytes();
assert_eq!(r.max_keys(), usize::MAX);
r.record("a", 1, Timestamp::new(0, 0));
assert_eq!((r.overflowed(), r.other_sum(Timestamp::new(0, 1))), (0, 0));
}
#[test]
fn zero_cap_sends_everything_to_other() {
let mut r: RollingRate<&'static str, u64> =
RollingRate::new(Duration::from_secs(60), Duration::from_secs(1), 0);
r.record("a", 7, Timestamp::new(0, 0));
assert!(!r.is_empty(), "the overflow total is stored");
assert_eq!(r.other_sum(Timestamp::new(0, 1)), 7);
assert_eq!(r.len(Timestamp::new(0, 1)), 0);
}
#[test]
fn merge_respects_the_cap() {
use crate::correlate::Mergeable;
let mut a: RollingRate<u32, u64> =
RollingRate::new(Duration::from_secs(10), Duration::from_secs(1), 3);
let mut b: RollingRate<u32, u64> =
RollingRate::new(Duration::from_secs(10), Duration::from_secs(1), 3);
let t = Timestamp::new(0, 0);
for k in 0..3 {
a.record(k, 1, t);
}
for k in 2..6 {
b.record(k, 1, t); }
a.merge(b);
let now = Timestamp::new(0, 1);
assert_eq!(a.sum(&2, now), 2, "shared key adds");
assert_eq!(a.stored_entries(), 3);
assert_eq!(a.other_sum(now), 3);
assert_eq!(a.overflowed(), 3);
}
proptest::proptest! {
#[test]
fn top_k_matches_a_full_sort(
values in proptest::collection::vec((0u32..64, 1u64..1_000), 0..300),
n in 0usize..80,
) {
let mut r: RollingRate<u32, u64> =
RollingRate::new_unbounded(Duration::from_secs(60), Duration::from_secs(1));
for (i, (k, v)) in values.iter().enumerate() {
r.record(*k, *v, Timestamp::new((i / 50) as u32, 0));
}
let now = Timestamp::new(10, 0);
let got = r.top_k(n, now);
let mut all: Vec<(u32, f64)> = r.snapshot(now).collect();
all.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
proptest::prop_assert_eq!(got.len(), n.min(all.len()));
let got_rates: Vec<f64> = got.iter().map(|e| e.1).collect();
let want_rates: Vec<f64> = all.iter().take(n).map(|e| e.1).collect();
proptest::prop_assert_eq!(got_rates, want_rates);
for (k, rate) in &got {
proptest::prop_assert_eq!(r.rate(k, now), *rate);
}
}
}
#[test]
fn out_of_order_timestamps_reuse_their_bucket() {
let mut r: RollingRate<u32, u64> =
RollingRate::new(Duration::from_secs(60), Duration::from_secs(1), 10);
for i in 0..1_000u32 {
let now = if i % 2 == 0 {
Timestamp::new(10, 0)
} else {
Timestamp::new(9, 999_000_000)
};
r.record(i % 20, 1, now);
}
assert_eq!(r.bucket_count(), 2, "one bucket per start");
assert!(r.stored_entries() <= 2 * 10);
let now = Timestamp::new(10, 1);
assert_eq!(r.sum(&0, now) + r.sum(&1, now), 100);
}
#[test]
fn backwards_timestamps_stay_within_the_window() {
let mut r: RollingRate<u32, u64> =
RollingRate::new(Duration::from_secs(10), Duration::from_secs(1), 10);
r.record(1, 1, Timestamp::new(1_000, 0));
for back in 1..500u32 {
r.record(1, 1, Timestamp::new(1_000 - back, 0));
assert!(r.bucket_count() <= 21, "{} buckets", r.bucket_count());
}
assert_eq!(r.sum(&1, Timestamp::new(511, 0)), 11);
}
#[test]
fn top_k_with_nan_rates_does_not_panic() {
let mut r: RollingRate<u32, f64> =
RollingRate::new_unbounded(Duration::from_secs(1), Duration::from_secs(1));
for k in 0..200u32 {
let v = if k % 7 == 0 { f64::NAN } else { f64::from(k) };
r.record(k, v, Timestamp::new(0, k));
}
let top = r.top_k(10, Timestamp::new(0, 999));
assert_eq!(top.len(), 10);
}
#[test]
fn rates_do_not_overflow_the_value_type() {
let mut r: RollingRate<&'static str, u32> =
RollingRate::new(Duration::from_secs(10), Duration::from_secs(1), 1);
for s in 0..2 {
r.record("a", 3_000_000_000, Timestamp::new(s, 0));
r.record("b", 3_000_000_000, Timestamp::new(s, 0)); }
let now = Timestamp::new(1, 0);
assert!((r.rate(&"a", now) - 6e8).abs() < 1e-3);
assert!((r.other_rate(now) - 6e8).abs() < 1e-3);
}
#[test]
fn a_far_future_record_does_not_drop_later_ones() {
let mut r: RollingRate<u32, u64> =
RollingRate::new(Duration::from_secs(10), Duration::from_secs(1), 10);
r.record(1, 1, Timestamp::new(1_000_000, 0));
for s in 0..20u32 {
r.record(1, 1, Timestamp::new(s, 0));
}
let now = Timestamp::new(19, 0);
assert_eq!(r.sum(&1, now), 11, "records at 9 ..= 19");
assert!(r.bucket_count() <= 21);
}
}