Resolves every finding in docs/reviews/M0-M10-code-review-2026-06-08-pass2.md across the engine, network, server, and CLI crates: session restore, replication/CRDT, WAL format and recovery, storage indexes, query/ranking executors, cohort/community governance, and scatter-gather routing. Adds regression tests: - review_pass2_creator_search_filter - review_pass2_d_replication - review_pass2_query_for_session - review_pass2_storage_indexes_bitmap_cache - review_pass2_zone_a_sessions Verified: cargo clippy -D warnings and full test suite green across all crates.
1344 lines
61 KiB
Rust
1344 lines
61 KiB
Rust
//! Warm-tier bucketed event counter for windowed signal aggregation.
|
||
//!
|
||
//! `BucketedCounter` maintains per-minute, per-hour, and per-day bucketed event
|
||
//! counts for efficient windowed aggregation queries (1h, 24h, 7d, 30d). The
|
||
//! design uses circular buffers with trigger-based rotation — rotation is
|
||
//! performed inline on signal writes when enough time has elapsed, requiring no
|
||
//! background thread.
|
||
//!
|
||
//! # Window query costs
|
||
//!
|
||
//! | Window | Operation | Atomics |
|
||
//! |--------|---------------------------------------------|---------------------|
|
||
//! | 1h | sum 60 minute buckets | 60 relaxed loads |
|
||
//! | 24h | bounded in-progress hour + 23 hour buckets | ≤ 83 relaxed loads |
|
||
//! | 7d | bounded in-progress hour + 167 hour buckets | ≤ 227 relaxed loads |
|
||
//! | 30d | 7d expression + 23 older day buckets | ≤ 250 relaxed loads |
|
||
//! | all | single counter read | 1 relaxed load |
|
||
//!
|
||
//! # Bucket-tier cascade
|
||
//!
|
||
//! Each coarser tier is fed by the one below it on rotation: minute buckets
|
||
//! aggregate into an hour bucket every hour, and hour buckets aggregate into a
|
||
//! day bucket every day. Rotation cascades minute → hour → day in a single
|
||
//! `maybe_rotate` call, so a long gap between writes settles every tier at once.
|
||
//!
|
||
//! # Ordering rationale
|
||
//!
|
||
//! Bucket reads/writes use `Ordering::Relaxed` because windowed counts are
|
||
//! inherently approximate — a query at time T may see data from T ± 60s due
|
||
//! to scheduling. The ranking system does not require exact counts.
|
||
//!
|
||
//! `current_minute`, `current_hour`, and `current_day` use `Acquire`/`Release`
|
||
//! so the pointer update is visible before/after bucket data modifications.
|
||
|
||
use std::{
|
||
fmt,
|
||
sync::atomic::{AtomicU8, AtomicU32, AtomicU64, Ordering},
|
||
};
|
||
|
||
use crate::schema::Window;
|
||
|
||
/// Number of per-minute bucket slots (covers 1 hour).
|
||
pub const MINUTE_BUCKETS: usize = 60;
|
||
/// Number of per-hour bucket slots (covers 7 days).
|
||
pub const HOUR_BUCKETS: usize = 168;
|
||
/// Number of per-day bucket slots (covers 31 days).
|
||
///
|
||
/// The extra slot beyond 30 gives the 30d window's older-than-7-days tail
|
||
/// (offsets 7..=29) a full 23 day buckets to read without wrapping onto the slot
|
||
/// the current day's rotation just wrote.
|
||
pub const DAY_BUCKETS: usize = 31;
|
||
|
||
/// Nanoseconds per minute.
|
||
const NS_PER_MIN: u64 = 60_000_000_000;
|
||
/// Nanoseconds per hour.
|
||
const NS_PER_HOUR: u64 = 3_600_000_000_000;
|
||
/// Nanoseconds per day.
|
||
const NS_PER_DAY: u64 = 86_400_000_000_000;
|
||
|
||
/// Warm-tier bucketed event counter for a single signal type on a single entity.
|
||
///
|
||
/// Supports simultaneous windowed count queries across 1h, 24h, 7d, and
|
||
/// all-time windows by summing appropriate time-bucketed counters.
|
||
///
|
||
/// # Design
|
||
///
|
||
/// Per-minute buckets cover the last 60 minutes. Per-hour buckets cover the
|
||
/// last 168 hours (7 days). Per-day buckets cover the last 31 days. The
|
||
/// all-time counter is unbounded.
|
||
///
|
||
/// Bucket rotation is trigger-based, checked on each `increment()` call.
|
||
/// No background thread is required.
|
||
pub struct BucketedCounter {
|
||
/// Per-minute event count buckets. Circular buffer of 60 slots.
|
||
minute_buckets: [AtomicU32; MINUTE_BUCKETS],
|
||
/// Per-hour event count buckets. Circular buffer of 168 slots.
|
||
hour_buckets: [AtomicU32; HOUR_BUCKETS],
|
||
/// Per-day event count buckets. Circular buffer of 31 slots.
|
||
day_buckets: [AtomicU32; DAY_BUCKETS],
|
||
/// Index of the current minute bucket (0..59).
|
||
current_minute: AtomicU8,
|
||
/// Index of the current hour bucket (0..167).
|
||
current_hour: AtomicU8,
|
||
/// Index of the current day bucket (0..30).
|
||
current_day: AtomicU8,
|
||
/// All-time total event count.
|
||
all_time_count: AtomicU64,
|
||
/// Nanosecond timestamp of the last minute rotation.
|
||
last_minute_rotation_ns: AtomicU64,
|
||
/// Nanosecond timestamp of the last hour rotation.
|
||
last_hour_rotation_ns: AtomicU64,
|
||
/// Nanosecond timestamp of the last day rotation.
|
||
last_day_rotation_ns: AtomicU64,
|
||
}
|
||
|
||
impl BucketedCounter {
|
||
/// Construct a new counter with all buckets zeroed and rotation timestamps at 0.
|
||
#[must_use]
|
||
pub fn new() -> Self {
|
||
Self {
|
||
minute_buckets: std::array::from_fn(|_| AtomicU32::new(0)),
|
||
hour_buckets: std::array::from_fn(|_| AtomicU32::new(0)),
|
||
day_buckets: std::array::from_fn(|_| AtomicU32::new(0)),
|
||
current_minute: AtomicU8::new(0),
|
||
current_hour: AtomicU8::new(0),
|
||
current_day: AtomicU8::new(0),
|
||
all_time_count: AtomicU64::new(0),
|
||
last_minute_rotation_ns: AtomicU64::new(0),
|
||
last_hour_rotation_ns: AtomicU64::new(0),
|
||
last_day_rotation_ns: AtomicU64::new(0),
|
||
}
|
||
}
|
||
|
||
/// Construct with initial rotation timestamps set to `now_ns`.
|
||
///
|
||
/// Use this when the start time is known — it prevents spurious rotations
|
||
/// on the first increment.
|
||
#[must_use]
|
||
pub fn with_start_time(now_ns: u64) -> Self {
|
||
let counter = Self::new();
|
||
counter
|
||
.last_minute_rotation_ns
|
||
.store(now_ns, Ordering::Relaxed);
|
||
counter
|
||
.last_hour_rotation_ns
|
||
.store(now_ns, Ordering::Relaxed);
|
||
counter
|
||
.last_day_rotation_ns
|
||
.store(now_ns, Ordering::Relaxed);
|
||
counter
|
||
}
|
||
|
||
/// Increment the current minute bucket and all-time counter by 1.
|
||
///
|
||
/// Triggers minute and/or hour rotation if enough time has elapsed
|
||
/// since the last rotation (trigger-based, no background thread).
|
||
pub fn increment(&self, now_ns: u64) {
|
||
self.maybe_rotate(now_ns);
|
||
let idx = self.current_minute.load(Ordering::Acquire) as usize;
|
||
self.minute_buckets[idx].fetch_add(1, Ordering::Relaxed);
|
||
self.all_time_count.fetch_add(1, Ordering::Relaxed);
|
||
}
|
||
|
||
/// Increment by a count other than 1 (for batch replay).
|
||
pub fn increment_by(&self, count: u32, now_ns: u64) {
|
||
self.maybe_rotate(now_ns);
|
||
let idx = self.current_minute.load(Ordering::Acquire) as usize;
|
||
self.minute_buckets[idx].fetch_add(count, Ordering::Relaxed);
|
||
self.all_time_count
|
||
.fetch_add(u64::from(count), Ordering::Relaxed);
|
||
}
|
||
|
||
/// Query the windowed event count for a given window, as of `now_ns`.
|
||
///
|
||
/// | Window | Source |
|
||
/// |-----------------|--------------------------------------------------------------|
|
||
/// | `OneHour` | sum 60 minute buckets |
|
||
/// | `TwentyFourHours` | in-progress hour (bounded minutes) + 23 completed hour buckets |
|
||
/// | `SevenDays` | in-progress hour (bounded minutes) + 167 completed hour buckets |
|
||
/// | `ThirtyDays` | last 7 days (hour/minute tier) + day buckets older than 7 days |
|
||
/// | `AllTime` | single atomic read |
|
||
///
|
||
/// # Read-time rotation
|
||
///
|
||
/// Rotation is normally trigger-based on the *write* path
|
||
/// (`increment`/`increment_by`/`reconcile_to_count`). An entity that received
|
||
/// events and then went quiet would, without a read-time rotation, keep
|
||
/// reporting those events in its windowed count forever — the minute/hour/day
|
||
/// buckets that should have aged out are never zeroed because no write fires
|
||
/// the rotation. That silently corrupts trending/velocity ranking for exactly
|
||
/// the bursty-then-quiet items those surfaces are meant to demote, and
|
||
/// violates the "windowed aggregates equal the sum of events within the
|
||
/// window" invariant at read time.
|
||
///
|
||
/// To close that gap we call [`maybe_rotate`](Self::maybe_rotate) with the
|
||
/// read's `now_ns` *before* summing, mirroring how the hot tier lazily decays
|
||
/// its score forward to the read time. `maybe_rotate` early-returns when
|
||
/// `now_ns < last_minute_rotation_ns + 1 minute` and is CAS-guarded on
|
||
/// atomics taking `&self`, so under read-heavy shared access (e.g. a
|
||
/// `DashMap` ref) it adds only a single relaxed load and a comparison except
|
||
/// when a minute boundary has actually been crossed — keeping the hot read
|
||
/// path allocation-free and lock-free.
|
||
///
|
||
/// # Why the in-progress hour is folded in — and why it is BOUNDED
|
||
///
|
||
/// The bucket cascade only rolls minute data up into an hour bucket *when an
|
||
/// hour rotation fires* (and an hour bucket up into a day bucket on a day
|
||
/// rotation). Between rotations, the events of the current in-progress hour
|
||
/// live solely in the minute buckets. Summing only the coarse buckets would
|
||
/// therefore omit the most recent (up to) one full bucket of data — making
|
||
/// 24h/7d/30d counts systematically one bucket short. We fold the in-progress
|
||
/// hour in explicitly so the windows are complete and never lag by a bucket.
|
||
///
|
||
/// But the minute tier is a *rolling* 60-minute window (it must be, because
|
||
/// `OneHour` reads it directly), aged only by the elapsed minutes on each
|
||
/// rotation — never fully drained. So right after an hour rotation it still
|
||
/// holds the previous hour's tail, which the rollup already copied into the
|
||
/// newest completed hour bucket. Folding in *all 60* minute buckets and then
|
||
/// adding the completed hour buckets would double-count that overlap for any
|
||
/// continuously-active entity. The fold-in is therefore bounded to the minutes
|
||
/// since the last hour rotation (see [`sum_current_hour`](Self::sum_current_hour)),
|
||
/// disjoint from every completed hour bucket. The symmetric bound is applied on
|
||
/// the *write* side (`maybe_rotate`'s `hour_agg`/`day_agg`) so the durable hour
|
||
/// and day buckets never absorb the overlap in the first place.
|
||
#[must_use]
|
||
pub fn windowed_count(&self, window: Window, now_ns: u64) -> u64 {
|
||
// Age out expired buckets as of the read time so a quiet entity's
|
||
// windowed count decays correctly even with no intervening write.
|
||
self.maybe_rotate(now_ns);
|
||
match window {
|
||
Window::OneHour => self.sum_last_n_minutes(MINUTE_BUCKETS),
|
||
// 24h = the in-progress hour (minute buckets written since the last
|
||
// hour rotation, bounded by `now_ns`) + the 23 most recent completed
|
||
// hour buckets. The in-progress portion is disjoint from the completed
|
||
// buckets, so no event is counted twice (see `sum_current_hour`).
|
||
Window::TwentyFourHours => self.sum_current_hour(now_ns) + self.sum_last_n_hours(23),
|
||
// 7d = in-progress hour + the 167 most recent completed hour buckets
|
||
// (the full 168-slot ring covers exactly 7 days).
|
||
Window::SevenDays => {
|
||
self.sum_current_hour(now_ns) + self.sum_last_n_hours(HOUR_BUCKETS - 1)
|
||
}
|
||
// 30d = the last 7 days (hour/minute tier, the same expression as 7d) +
|
||
// the day buckets strictly OLDER than the hour tier's 7-day reach. The
|
||
// day tier is not re-read for the recent week, so there is no hour/day
|
||
// overlap (see `sum_days_older_than_7`); the in-progress day is covered
|
||
// entirely by the hour/minute tier.
|
||
Window::ThirtyDays => {
|
||
self.sum_current_hour(now_ns)
|
||
+ self.sum_last_n_hours(HOUR_BUCKETS - 1)
|
||
+ self.sum_days_older_than_7()
|
||
}
|
||
Window::AllTime => self.all_time_count.load(Ordering::Relaxed),
|
||
}
|
||
}
|
||
|
||
/// Read the all-time total event count.
|
||
#[must_use]
|
||
pub fn all_time_count(&self) -> u64 {
|
||
self.all_time_count.load(Ordering::Relaxed)
|
||
}
|
||
|
||
/// Reconcile the warm tier to a merged total event count from a CRDT merge.
|
||
///
|
||
/// Used by reconciliation (`SignalLedger::apply_crdt_state`) to keep the warm
|
||
/// windowed counts in agreement with the hot decay score after a CRDT merge.
|
||
/// Without this, reconciliation would force the hot score to the merged truth
|
||
/// while the warm counts kept diverged local-only state — so `read_decay_score`
|
||
/// and `read_windowed_count` would disagree about the same merged signal.
|
||
///
|
||
/// Semantics: the merged total is authoritative. If it exceeds the locally
|
||
/// observed all-time count, the **delta** is credited into the current minute
|
||
/// bucket as of `now_ns` (mirroring how the hot tier force-sets its score as
|
||
/// current at `now_ns`), so the freshly merged events fall inside every active
|
||
/// window. The all-time counter is then set to exactly `merged_total`. If the
|
||
/// merged total is *not* greater than the local count (the local node already
|
||
/// observed at least as many events — e.g. an idempotent re-merge), the warm
|
||
/// tier is left untouched: we never retroactively shrink a count, which would
|
||
/// erase locally-durable events the CRDT snapshot simply had not yet seen.
|
||
///
|
||
/// Rotation is applied first (`maybe_rotate`) so the delta lands in the
|
||
/// correct current bucket for `now_ns`.
|
||
pub fn reconcile_to_count(&self, merged_total: u64, now_ns: u64) {
|
||
self.maybe_rotate(now_ns);
|
||
let local_total = self.all_time_count.load(Ordering::Relaxed);
|
||
let delta = merged_total.saturating_sub(local_total);
|
||
if delta == 0 {
|
||
// Merged total does not exceed what we already counted locally;
|
||
// never shrink — that would drop locally-durable events.
|
||
return;
|
||
}
|
||
// Credit the delta into the current minute bucket so it is visible in
|
||
// every windowed query. u32 is the bucket width; saturate on the rare
|
||
// pathological delta rather than wrap.
|
||
let idx = self.current_minute.load(Ordering::Acquire) as usize;
|
||
let bucket_delta = u32::try_from(delta).unwrap_or(u32::MAX);
|
||
self.minute_buckets[idx].fetch_add(bucket_delta, Ordering::Relaxed);
|
||
// Set the all-time counter to exactly the merged truth.
|
||
self.all_time_count.store(merged_total, Ordering::Relaxed);
|
||
}
|
||
|
||
/// Read the count in the current minute bucket only.
|
||
///
|
||
/// Used for fine-grained velocity computation within the current minute.
|
||
#[must_use]
|
||
pub fn current_minute_count(&self) -> u32 {
|
||
let idx = self.current_minute.load(Ordering::Acquire) as usize;
|
||
self.minute_buckets[idx].load(Ordering::Relaxed)
|
||
}
|
||
|
||
/// Rotate the minute pointer: zero the next slot and advance `current_minute`.
|
||
///
|
||
/// Returns the count from the expired bucket (the slot that was zeroed).
|
||
/// Used internally and by the checkpoint restore path.
|
||
pub fn rotate_minute(&self) -> u32 {
|
||
let current = self.current_minute.load(Ordering::Acquire) as usize;
|
||
let next = (current + 1) % MINUTE_BUCKETS;
|
||
// Atomically zero the next slot and retrieve its old value.
|
||
let expired = self.minute_buckets[next].swap(0, Ordering::Relaxed);
|
||
// next is (0..59) % 60, always fits in u8.
|
||
self.current_minute.store(next as u8, Ordering::Release);
|
||
expired
|
||
}
|
||
|
||
/// Rotate the hour pointer: store `minute_aggregate` in the next hour slot
|
||
/// and advance `current_hour`.
|
||
///
|
||
/// `minute_aggregate` is the completing hour's event count (the minutes since
|
||
/// the last hour rotation, bounded by the caller — see `maybe_rotate`'s
|
||
/// `hour_agg`), NOT the full rolling 60-minute window. The next slot is
|
||
/// overwritten (no need to zero first).
|
||
pub fn rotate_hour(&self, minute_aggregate: u32) {
|
||
let current = self.current_hour.load(Ordering::Acquire) as usize;
|
||
let next = (current + 1) % HOUR_BUCKETS;
|
||
self.hour_buckets[next].store(minute_aggregate, Ordering::Relaxed);
|
||
// next is (0..167) % 168, always fits in u8.
|
||
self.current_hour.store(next as u8, Ordering::Release);
|
||
}
|
||
|
||
/// Rotate the day pointer: store `hour_aggregate` in the next day slot
|
||
/// and advance `current_day`.
|
||
///
|
||
/// `hour_aggregate` is the completing day's event count (the in-progress hour
|
||
/// plus only the completed hours inside the completing day, bounded by the
|
||
/// caller — see `maybe_rotate`'s `day_agg`), NOT the rolling last 24 hour
|
||
/// buckets. The next slot is overwritten (no need to zero first).
|
||
pub fn rotate_day(&self, hour_aggregate: u32) {
|
||
let current = self.current_day.load(Ordering::Acquire) as usize;
|
||
let next = (current + 1) % DAY_BUCKETS;
|
||
self.day_buckets[next].store(hour_aggregate, Ordering::Relaxed);
|
||
// next is (0..30) % 31, always fits in u8.
|
||
self.current_day.store(next as u8, Ordering::Release);
|
||
}
|
||
|
||
/// Snapshot all state for checkpoint serialization.
|
||
#[must_use]
|
||
pub fn snapshot(&self) -> BucketedCounterSnapshot {
|
||
BucketedCounterSnapshot {
|
||
minute_buckets: std::array::from_fn(|i| self.minute_buckets[i].load(Ordering::Relaxed)),
|
||
hour_buckets: std::array::from_fn(|i| self.hour_buckets[i].load(Ordering::Relaxed)),
|
||
day_buckets: std::array::from_fn(|i| self.day_buckets[i].load(Ordering::Relaxed)),
|
||
current_minute: self.current_minute.load(Ordering::Acquire),
|
||
current_hour: self.current_hour.load(Ordering::Acquire),
|
||
current_day: self.current_day.load(Ordering::Acquire),
|
||
all_time_count: self.all_time_count.load(Ordering::Relaxed),
|
||
last_minute_rotation_ns: self.last_minute_rotation_ns.load(Ordering::Relaxed),
|
||
last_hour_rotation_ns: self.last_hour_rotation_ns.load(Ordering::Relaxed),
|
||
last_day_rotation_ns: self.last_day_rotation_ns.load(Ordering::Relaxed),
|
||
}
|
||
}
|
||
|
||
/// Restore from a checkpoint snapshot.
|
||
///
|
||
/// # Bounds clamping
|
||
///
|
||
/// `current_minute`, `current_hour`, and `current_day` are clamped to valid
|
||
/// array bounds before being stored. A corrupted or maliciously crafted
|
||
/// snapshot with out-of-range indices (≥ 60, ≥ 168, or ≥ 31) would otherwise
|
||
/// cause `rotate_minute`/`rotate_hour`/`rotate_day` to index out of bounds on
|
||
/// the next rotation. Clamping makes restoration safe regardless of snapshot
|
||
/// origin.
|
||
///
|
||
/// # Rotation-anchor ordering
|
||
///
|
||
/// The three rotation anchors carry a monotone invariant set by `maybe_rotate`:
|
||
/// `last_day_rotation_ns ≤ last_hour_rotation_ns ≤ last_minute_rotation_ns`
|
||
/// (a coarser anchor is only ever advanced *inside* a finer rotation that has
|
||
/// already advanced its own anchor). The read path relies on it: `sum_current_hour`
|
||
/// folds `k = (now_ns − last_hour_rotation_ns)/60s` minute buckets, and the
|
||
/// write-side `hour_agg`/`day_agg` bounds derive `(last_min − last_hour)` and
|
||
/// `(last_hour − last_day)`. A corrupt snapshot with, e.g.,
|
||
/// `last_hour_rotation_ns > last_minute_rotation_ns` would make those
|
||
/// `saturating_sub`s clamp to 0 and silently under-count the in-progress
|
||
/// window until the next real rotation re-anchors the timestamps. We therefore
|
||
/// repair the ordering on restore (pulling each coarser anchor down to its
|
||
/// finer neighbor when it is ahead), so a hostile or torn checkpoint cannot
|
||
/// produce a wrong windowed count. Pulling *down* (never up) is conservative:
|
||
/// it can only widen the in-progress fold by at most one tier, never invent
|
||
/// events, and the next `maybe_rotate` re-anchors precisely.
|
||
pub fn restore(&self, snapshot: &BucketedCounterSnapshot) {
|
||
for (i, &v) in snapshot.minute_buckets.iter().enumerate() {
|
||
self.minute_buckets[i].store(v, Ordering::Relaxed);
|
||
}
|
||
for (i, &v) in snapshot.hour_buckets.iter().enumerate() {
|
||
self.hour_buckets[i].store(v, Ordering::Relaxed);
|
||
}
|
||
for (i, &v) in snapshot.day_buckets.iter().enumerate() {
|
||
self.day_buckets[i].store(v, Ordering::Relaxed);
|
||
}
|
||
// Clamp to valid bounds: out-of-range indices from a corrupt snapshot
|
||
// would panic on the next rotate_minute/rotate_hour/rotate_day call.
|
||
// MINUTE_BUCKETS - 1 = 59, HOUR_BUCKETS - 1 = 167, DAY_BUCKETS - 1 = 30;
|
||
// all fit in u8.
|
||
let current_minute = snapshot.current_minute.min((MINUTE_BUCKETS - 1) as u8);
|
||
let current_hour = snapshot.current_hour.min((HOUR_BUCKETS - 1) as u8);
|
||
let current_day = snapshot.current_day.min((DAY_BUCKETS - 1) as u8);
|
||
self.current_minute.store(current_minute, Ordering::Release);
|
||
self.current_hour.store(current_hour, Ordering::Release);
|
||
self.current_day.store(current_day, Ordering::Release);
|
||
self.all_time_count
|
||
.store(snapshot.all_time_count, Ordering::Relaxed);
|
||
|
||
// Repair the monotone anchor ordering (day ≤ hour ≤ minute) before
|
||
// storing, so a corrupt/hostile snapshot cannot drive a coarser anchor
|
||
// ahead of a finer one and silently under-count the in-progress window
|
||
// (see "Rotation-anchor ordering" above). The minute anchor is the
|
||
// authoritative high-water mark; pull each coarser anchor down to it
|
||
// when it is ahead.
|
||
let last_minute = snapshot.last_minute_rotation_ns;
|
||
let last_hour = snapshot.last_hour_rotation_ns.min(last_minute);
|
||
let last_day = snapshot.last_day_rotation_ns.min(last_hour);
|
||
self.last_minute_rotation_ns
|
||
.store(last_minute, Ordering::Relaxed);
|
||
self.last_hour_rotation_ns
|
||
.store(last_hour, Ordering::Relaxed);
|
||
self.last_day_rotation_ns.store(last_day, Ordering::Relaxed);
|
||
}
|
||
|
||
// ── Internal helpers ────────────────────────────────────────────────────────
|
||
|
||
/// Check whether minute (and hour) rotation is needed based on `now_ns`,
|
||
/// and perform it inline if so.
|
||
///
|
||
/// Uses CAS on `last_minute_rotation_ns` so exactly one concurrent caller
|
||
/// performs the rotation.
|
||
fn maybe_rotate(&self, now_ns: u64) {
|
||
let last_min = self.last_minute_rotation_ns.load(Ordering::Relaxed);
|
||
if now_ns < last_min.saturating_add(NS_PER_MIN) {
|
||
return;
|
||
}
|
||
|
||
let elapsed = now_ns.saturating_sub(last_min);
|
||
// Max value: u64::MAX / NS_PER_MIN ≈ 307M, fits in u32 — safe even on 32-bit.
|
||
let minutes_elapsed = (elapsed / NS_PER_MIN) as usize;
|
||
let new_last_min =
|
||
last_min.saturating_add((minutes_elapsed as u64).saturating_mul(NS_PER_MIN));
|
||
|
||
// CAS to claim the rotation; only one thread proceeds.
|
||
if self
|
||
.last_minute_rotation_ns
|
||
.compare_exchange(last_min, new_last_min, Ordering::AcqRel, Ordering::Relaxed)
|
||
.is_err()
|
||
{
|
||
return;
|
||
}
|
||
|
||
// If this minute rotation also crosses an hour boundary, the minute
|
||
// buckets must be rolled up into an hour bucket BEFORE the rotations
|
||
// below clear them. Snapshot the hour aggregate now (covering the
|
||
// last 60 minutes of data) so a sparse-event gap does not lose the
|
||
// count — the rotations clear buckets, and reading them afterward
|
||
// would sum zeros.
|
||
let last_hour = self.last_hour_rotation_ns.load(Ordering::Relaxed);
|
||
let hour_due = now_ns >= last_hour.saturating_add(NS_PER_HOUR);
|
||
let hour_agg: u32 = if hour_due {
|
||
// Roll up ONLY the in-progress hour [last_hour, last_hour+1h): the
|
||
// minute buckets written since the last hour rotation — NOT all 60.
|
||
// The minute tier is a rolling 60-minute window aged only by `steps`
|
||
// per rotation (never fully drained, so `OneHour` stays exact), so
|
||
// after a gap it still holds the PREVIOUS hour's tail that an earlier
|
||
// hour bucket already captured. Summing all 60 would re-roll that
|
||
// overlap into this hour bucket a second time, permanently inflating
|
||
// the 24h/7d/30d windows for a continuously-active entity (a verified
|
||
// ~1.3–2x over-count baked into the durable hour tier). The completing
|
||
// hour's data lives in the buckets from `current_minute` (still at the
|
||
// `last_min` anchor — the minute rotations below run after this) back to
|
||
// the minute aligned with `last_hour`: that count is
|
||
// (last_min - last_hour)/min + 1, capped at the ring. The invariant
|
||
// last_min < last_hour + 1h holds (any write at/after the hour boundary
|
||
// fires this rotation first), so k is in 1..=MINUTE_BUCKETS.
|
||
let k = (last_min.saturating_sub(last_hour) / NS_PER_MIN)
|
||
.saturating_add(1)
|
||
.min(MINUTE_BUCKETS as u64) as usize;
|
||
let cur_min = self.current_minute.load(Ordering::Acquire) as usize;
|
||
u32::try_from(sum_last_n_buckets(&self.minute_buckets, cur_min, k)).unwrap_or(u32::MAX)
|
||
} else {
|
||
0
|
||
};
|
||
|
||
// Perform minute rotations, capped to avoid clearing the entire buffer.
|
||
let steps = minutes_elapsed.min(MINUTE_BUCKETS);
|
||
for _ in 0..steps {
|
||
self.rotate_minute();
|
||
}
|
||
|
||
// Check for hour rotation.
|
||
if !hour_due {
|
||
return;
|
||
}
|
||
|
||
let h_elapsed = now_ns.saturating_sub(last_hour);
|
||
// Max value: u64::MAX / NS_PER_HOUR ≈ 5.1M, fits in u32 — safe even on 32-bit.
|
||
let hours_elapsed = (h_elapsed / NS_PER_HOUR) as usize;
|
||
let new_last_hour =
|
||
last_hour.saturating_add((hours_elapsed as u64).saturating_mul(NS_PER_HOUR));
|
||
|
||
if self
|
||
.last_hour_rotation_ns
|
||
.compare_exchange(
|
||
last_hour,
|
||
new_last_hour,
|
||
Ordering::AcqRel,
|
||
Ordering::Relaxed,
|
||
)
|
||
.is_err()
|
||
{
|
||
return;
|
||
}
|
||
|
||
// If this hour rotation also crosses a day boundary, snapshot the day
|
||
// aggregate BEFORE the hour rotations below advance `current_hour`.
|
||
let last_day = self.last_day_rotation_ns.load(Ordering::Relaxed);
|
||
let day_due = now_ns >= last_day.saturating_add(NS_PER_DAY);
|
||
let day_agg: u32 = if day_due {
|
||
// Roll up ONLY the completing day [last_day, last_day+24h): the hour
|
||
// aggregate we are about to write (the in-progress hour, already bounded
|
||
// above) plus ONLY the completed hour buckets that fall inside the
|
||
// completing day. The hour tier is itself a rolling window, so after a
|
||
// gap "the 23 most recent hour buckets" would reach into the PREVIOUS
|
||
// day that an earlier day bucket already captured — the same overlap as
|
||
// the minute->hour case, one tier up. The completing day's completed
|
||
// hours number (last_hour - last_day)/hour (the 24th hour is `hour_agg`),
|
||
// capped at 23; the invariant last_hour < last_day + 24h keeps it <= 23.
|
||
// `current_hour` still points at the most-recent completed hour (the
|
||
// rotate_hour loop below runs after this), so walking back that many
|
||
// buckets is exactly [last_day, last_hour).
|
||
let j = (last_hour.saturating_sub(last_day) / NS_PER_HOUR).min(23) as usize;
|
||
let cur_hour = self.current_hour.load(Ordering::Acquire) as usize;
|
||
let completed = sum_last_n_buckets(&self.hour_buckets, cur_hour, j);
|
||
u32::try_from(u64::from(hour_agg).saturating_add(completed)).unwrap_or(u32::MAX)
|
||
} else {
|
||
0
|
||
};
|
||
|
||
let h_steps = hours_elapsed.min(HOUR_BUCKETS);
|
||
for i in 0..h_steps {
|
||
// Only the first rotation carries real data; the rest are empty hours.
|
||
let bucket_val = if i == 0 { hour_agg } else { 0 };
|
||
self.rotate_hour(bucket_val);
|
||
}
|
||
|
||
if day_due {
|
||
self.rotate_days(now_ns, last_day, day_agg);
|
||
}
|
||
}
|
||
|
||
/// Perform day-bucket rotation, rolling `day_agg` (the pre-snapshotted last
|
||
/// 24 hours of data) into the first new day bucket. Called from
|
||
/// `maybe_rotate` only when a day boundary has been crossed.
|
||
///
|
||
/// `last_day` is the value read from `last_day_rotation_ns` before the hour
|
||
/// rotations ran; `day_agg` was captured before those rotations cleared the
|
||
/// hour buckets. Uses CAS on `last_day_rotation_ns` so exactly one
|
||
/// concurrent caller performs the rotation.
|
||
fn rotate_days(&self, now_ns: u64, last_day: u64, day_agg: u32) {
|
||
let d_elapsed = now_ns.saturating_sub(last_day);
|
||
// Max value: u64::MAX / NS_PER_DAY ≈ 213K, fits in u32 — safe even on 32-bit.
|
||
let days_elapsed = (d_elapsed / NS_PER_DAY) as usize;
|
||
let new_last_day =
|
||
last_day.saturating_add((days_elapsed as u64).saturating_mul(NS_PER_DAY));
|
||
|
||
if self
|
||
.last_day_rotation_ns
|
||
.compare_exchange(last_day, new_last_day, Ordering::AcqRel, Ordering::Relaxed)
|
||
.is_err()
|
||
{
|
||
return;
|
||
}
|
||
|
||
let d_steps = days_elapsed.min(DAY_BUCKETS);
|
||
for i in 0..d_steps {
|
||
// Only the first rotation carries real data; the rest are empty days.
|
||
let bucket_val = if i == 0 { day_agg } else { 0 };
|
||
self.rotate_day(bucket_val);
|
||
}
|
||
}
|
||
|
||
/// Sum the events of the current in-progress hour — the minute buckets written
|
||
/// *since the last hour rotation*, NOT the full rolling 60-minute window.
|
||
///
|
||
/// The minute tier is a rolling 60-minute window (it must be, to answer
|
||
/// `OneHour` exactly), so right after an hour rotation it still holds up to ~60
|
||
/// minutes of the *previous* hour — the data the rollup already copied into the
|
||
/// most-recent completed hour bucket. Folding all 60 minute buckets into 24h/7d
|
||
/// and then adding the completed hour buckets would double-count that overlap
|
||
/// for a continuously-active entity. Instead we sum only the last `k` minute
|
||
/// buckets, `k = (now_ns - last_hour_rotation_ns)/60s + 1` (capped at the ring):
|
||
/// exactly the minutes elapsed since the last hour rotation, disjoint from every
|
||
/// completed hour bucket. `windowed_count` calls `maybe_rotate(now_ns)` first,
|
||
/// so `now_ns >= last_hour_rotation_ns` and `k` lands in `1..=MINUTE_BUCKETS`.
|
||
/// This mirrors the write-side bound in `maybe_rotate`'s `hour_agg`, which uses
|
||
/// the same shape anchored at the rollup-time `last_min`.
|
||
fn sum_current_hour(&self, now_ns: u64) -> u64 {
|
||
let last_hour = self.last_hour_rotation_ns.load(Ordering::Relaxed);
|
||
let k = (now_ns.saturating_sub(last_hour) / NS_PER_MIN)
|
||
.saturating_add(1)
|
||
.min(MINUTE_BUCKETS as u64) as usize;
|
||
self.sum_last_n_minutes(k)
|
||
}
|
||
|
||
/// Sum only the day buckets *older* than the hour tier's 7-day reach, for the
|
||
/// 30d window's tail beyond what the hour/minute tier already covers.
|
||
///
|
||
/// The hour tier (168 buckets = 7 days) is the authoritative source for the
|
||
/// most recent week, so the 7 most recent completed-day buckets (offsets 0..=6
|
||
/// from `current_day`) duplicate data the hour tier already holds; summing them
|
||
/// on top of the 7-day expression would double-count. We therefore skip those 7
|
||
/// offsets and sum offsets 7..=29 (≈ days 8–30), which lie outside the hour
|
||
/// tier's window. Day and hour ring boundaries advance in whole units from a
|
||
/// common start, so the only imprecision is a bounded ≤1-day slack at the 7-day
|
||
/// seam — far from the recent data that drives ranking, and the correct
|
||
/// trade-off versus the ~2x over-count of summing the overlapping tiers.
|
||
fn sum_days_older_than_7(&self) -> u64 {
|
||
let current = self.current_day.load(Ordering::Acquire) as usize;
|
||
let len = self.day_buckets.len();
|
||
// Offsets 7..=29: the 23 day buckets beyond the hour tier's 7-day reach.
|
||
(7..len.saturating_sub(1))
|
||
.map(|i| {
|
||
let idx = (current + len - i) % len;
|
||
u64::from(self.day_buckets[idx].load(Ordering::Relaxed))
|
||
})
|
||
.sum()
|
||
}
|
||
|
||
fn sum_last_n_minutes(&self, n: usize) -> u64 {
|
||
let current = self.current_minute.load(Ordering::Acquire) as usize;
|
||
sum_last_n_buckets(&self.minute_buckets, current, n)
|
||
}
|
||
|
||
fn sum_last_n_hours(&self, n: usize) -> u64 {
|
||
let current = self.current_hour.load(Ordering::Acquire) as usize;
|
||
sum_last_n_buckets(&self.hour_buckets, current, n)
|
||
}
|
||
}
|
||
|
||
/// Sum the `n` most recent slots of a circular `u32` bucket ring, walking
|
||
/// backward from `current` with wraparound (`current`, `current - 1`, …).
|
||
///
|
||
/// This is the one reverse-sum idiom shared by the minute, hour, and day tiers:
|
||
/// each summed only its own ring with the identical `(current + LEN - i) % LEN`
|
||
/// modular index. Taking the ring slice as a parameter (its `len()` supplies
|
||
/// `LEN`) collapses the three copies into a single read-path helper, so a change
|
||
/// to the wraparound or ordering is made once. Bucket reads use `Relaxed` for
|
||
/// the same reason as everywhere else in this module — windowed counts are
|
||
/// inherently approximate.
|
||
fn sum_last_n_buckets(buckets: &[AtomicU32], current: usize, n: usize) -> u64 {
|
||
let len = buckets.len();
|
||
(0..n)
|
||
.map(|i| {
|
||
let idx = (current + len - i) % len;
|
||
u64::from(buckets[idx].load(Ordering::Relaxed))
|
||
})
|
||
.sum()
|
||
}
|
||
|
||
impl Default for BucketedCounter {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
impl fmt::Debug for BucketedCounter {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
f.debug_struct("BucketedCounter")
|
||
.field("all_time_count", &self.all_time_count())
|
||
.field(
|
||
"current_minute",
|
||
&self.current_minute.load(Ordering::Relaxed),
|
||
)
|
||
.field("current_hour", &self.current_hour.load(Ordering::Relaxed))
|
||
.field(
|
||
"windowed_1h",
|
||
// Debug is a pure diagnostic with no clock; read as of the last
|
||
// rotation so `maybe_rotate` is a no-op (it early-returns when
|
||
// now_ns < last_minute_rotation_ns + 1 minute) and the formatter
|
||
// never mutates bucket state as a side effect of printing.
|
||
&self.windowed_count(
|
||
Window::OneHour,
|
||
self.last_minute_rotation_ns.load(Ordering::Relaxed),
|
||
),
|
||
)
|
||
.finish_non_exhaustive()
|
||
}
|
||
}
|
||
|
||
/// Serializable snapshot of a `BucketedCounter`.
|
||
///
|
||
/// Used for checkpoint/restore. All fields are plain integers.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct BucketedCounterSnapshot {
|
||
pub minute_buckets: [u32; MINUTE_BUCKETS],
|
||
pub hour_buckets: [u32; HOUR_BUCKETS],
|
||
pub day_buckets: [u32; DAY_BUCKETS],
|
||
pub current_minute: u8,
|
||
pub current_hour: u8,
|
||
pub current_day: u8,
|
||
pub all_time_count: u64,
|
||
pub last_minute_rotation_ns: u64,
|
||
pub last_hour_rotation_ns: u64,
|
||
pub last_day_rotation_ns: u64,
|
||
}
|
||
|
||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn new_counter_is_zeroed() {
|
||
let counter = BucketedCounter::new();
|
||
assert_eq!(counter.all_time_count(), 0);
|
||
assert_eq!(counter.windowed_count(Window::OneHour, 0), 0);
|
||
assert_eq!(counter.windowed_count(Window::TwentyFourHours, 0), 0);
|
||
assert_eq!(counter.windowed_count(Window::SevenDays, 0), 0);
|
||
assert_eq!(counter.windowed_count(Window::AllTime, 0), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn single_increment() {
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
counter.increment(1_000_000_000); // 1 second
|
||
assert_eq!(counter.all_time_count(), 1);
|
||
assert_eq!(counter.windowed_count(Window::OneHour, 1_000_000_000), 1);
|
||
assert_eq!(counter.windowed_count(Window::AllTime, 1_000_000_000), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn multiple_increments_same_minute() {
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
for i in 0..100 {
|
||
counter.increment(i * 100_000_000); // every 100ms for 10 seconds
|
||
}
|
||
assert_eq!(counter.all_time_count(), 100);
|
||
// Read as of the last event (~9.9s in); still well inside the 1h window.
|
||
assert_eq!(
|
||
counter.windowed_count(Window::OneHour, 99 * 100_000_000),
|
||
100
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn minute_rotation_zeros_next_bucket() {
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
|
||
// Fill minute 0 with 10 events
|
||
for i in 0..10 {
|
||
counter.increment(i * 1_000_000_000);
|
||
}
|
||
assert_eq!(
|
||
counter.windowed_count(Window::OneHour, 9 * 1_000_000_000),
|
||
10
|
||
);
|
||
|
||
// Advance past minute boundary (61 seconds)
|
||
counter.increment(61_000_000_000);
|
||
assert_eq!(counter.all_time_count(), 11);
|
||
|
||
// The 1h window should include both minutes
|
||
let count_1h = counter.windowed_count(Window::OneHour, 61_000_000_000);
|
||
assert_eq!(count_1h, 11);
|
||
}
|
||
|
||
#[test]
|
||
fn events_outside_1h_window_not_counted() {
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
|
||
// Add an event at t=0 (will rotate out)
|
||
counter.increment(0);
|
||
|
||
// Advance time past 1 hour with many rotations
|
||
for minute in 1..=70 {
|
||
let t_ns = minute * 60_000_000_000_u64;
|
||
counter.increment(t_ns);
|
||
}
|
||
|
||
// The 1h window should contain the last 60 events, not all 71
|
||
let count_1h = counter.windowed_count(Window::OneHour, 70 * 60_000_000_000_u64);
|
||
assert!(count_1h <= 61, "1h count was {count_1h}, expected <= 61");
|
||
assert_eq!(counter.all_time_count(), 71);
|
||
}
|
||
|
||
#[test]
|
||
fn hour_rotation_aggregates_minutes() {
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
|
||
// Simulate 2 hours of events: 5 per minute
|
||
for minute in 0..120_u64 {
|
||
let base_ns = minute * 60_000_000_000;
|
||
for j in 0..5_u64 {
|
||
counter.increment(base_ns + j * 1_000_000_000);
|
||
}
|
||
}
|
||
|
||
assert_eq!(counter.all_time_count(), 600);
|
||
|
||
// 24h window should include events (only 2 hours elapsed). Read as of the
|
||
// last event (~2h in).
|
||
let count_24h = counter.windowed_count(
|
||
Window::TwentyFourHours,
|
||
119 * 60_000_000_000 + 4_000_000_000,
|
||
);
|
||
assert!(count_24h > 0, "24h window should have events");
|
||
}
|
||
|
||
#[test]
|
||
fn all_time_window_reads_atomic_counter() {
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
for i in 0..1000_u64 {
|
||
counter.increment(i * 1_000_000);
|
||
}
|
||
assert_eq!(
|
||
counter.windowed_count(Window::AllTime, 999 * 1_000_000),
|
||
1000
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn thirty_day_window_counts_rolled_up_events() {
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
// Record an event in day 0. It lives in the minute/hour tiers but has
|
||
// not yet cascaded into a day bucket — day rotation has not fired.
|
||
counter.increment(1_000_000_000);
|
||
// Cross the first day boundary; the day-0 events roll up into a day
|
||
// bucket, so the 30d window now sees the day-0 event.
|
||
counter.increment(NS_PER_DAY + 1_000_000_000);
|
||
assert!(
|
||
counter.windowed_count(Window::ThirtyDays, NS_PER_DAY + 1_000_000_000) >= 1,
|
||
"30d window should include rolled-up events"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn thirty_day_window_aggregates_across_days() {
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
|
||
// Record one event per day for 10 days. Each event must cross the next
|
||
// day's boundary to roll up into a day bucket, so step by a full day.
|
||
for day in 0..10u64 {
|
||
counter.increment(day * NS_PER_DAY + 1_000_000_000);
|
||
}
|
||
// The increment at day=10 triggers the day-9 rollup. Trigger a final
|
||
// rotation so the last day's event aggregates into a day bucket.
|
||
counter.increment(10 * NS_PER_DAY + 1_000_000_000);
|
||
|
||
assert_eq!(counter.all_time_count(), 11);
|
||
// All 11 events fall within the last 30 days, so the 30d window should
|
||
// count all events that have rolled up into day buckets (events still in
|
||
// the current day's minute/hour buckets have not yet rolled up).
|
||
let count_30d = counter.windowed_count(Window::ThirtyDays, 10 * NS_PER_DAY + 1_000_000_000);
|
||
assert!(
|
||
count_30d >= 9,
|
||
"30d window should aggregate rolled-up days, got {count_30d}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn events_outside_30d_window_not_counted() {
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
|
||
// Add an event at t=0, then advance well past the 31-day day-bucket
|
||
// capacity so the original event rotates out of the day ring.
|
||
counter.increment(1_000_000_000);
|
||
for day in 1..=40u64 {
|
||
counter.increment(day * NS_PER_DAY + 1_000_000_000);
|
||
}
|
||
|
||
// The 30d window must not include the day-0 event (rotated out), and the
|
||
// window can hold at most 30 days of rolled-up data.
|
||
let count_30d = counter.windowed_count(Window::ThirtyDays, 40 * NS_PER_DAY + 1_000_000_000);
|
||
assert!(
|
||
count_30d <= 31,
|
||
"30d count was {count_30d}, expected <= 31 (one per day for the window)"
|
||
);
|
||
assert_eq!(counter.all_time_count(), 41);
|
||
}
|
||
|
||
#[test]
|
||
fn twenty_four_hour_window_includes_in_progress_hour() {
|
||
// Regression: the 24h window summed only completed hour buckets and
|
||
// omitted the current in-progress hour (still in the minute tier),
|
||
// making the count systematically one bucket short.
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
|
||
// 5 events in the very first (in-progress) hour. No hour rotation has
|
||
// fired yet, so these live only in the minute buckets.
|
||
for i in 0..5u64 {
|
||
counter.increment(i * 1_000_000_000);
|
||
}
|
||
|
||
// The 24h window must see all 5 in-progress-hour events.
|
||
assert_eq!(
|
||
counter.windowed_count(Window::TwentyFourHours, 4 * 1_000_000_000),
|
||
5,
|
||
"24h window must include the in-progress hour, not lag it by a bucket"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn twenty_four_hour_window_no_double_count_across_hour_boundary() {
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
|
||
// 3 events in hour 0.
|
||
for i in 0..3u64 {
|
||
counter.increment(i * 1_000_000_000);
|
||
}
|
||
// Cross into hour 1 (rolls hour-0 minutes into an hour bucket), add 2.
|
||
counter.increment(NS_PER_HOUR);
|
||
counter.increment(NS_PER_HOUR + 1_000_000_000);
|
||
|
||
// All 5 events fall in the last 24h, counted exactly once: 3 completed
|
||
// (hour-0 bucket) + 2 in-progress (minute buckets). No double-count.
|
||
assert_eq!(
|
||
counter.windowed_count(Window::TwentyFourHours, NS_PER_HOUR + 1_000_000_000),
|
||
5
|
||
);
|
||
assert_eq!(counter.all_time_count(), 5);
|
||
}
|
||
|
||
#[test]
|
||
fn seven_day_window_includes_in_progress_hour() {
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
for i in 0..4u64 {
|
||
counter.increment(i * 1_000_000_000);
|
||
}
|
||
assert_eq!(
|
||
counter.windowed_count(Window::SevenDays, 3 * 1_000_000_000),
|
||
4,
|
||
"7d window must include the in-progress hour"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn thirty_day_window_includes_in_progress_day() {
|
||
// Regression: the 30d window summed only completed day buckets and
|
||
// omitted the current in-progress day (still in the hour/minute tiers).
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
for i in 0..7u64 {
|
||
counter.increment(i * 1_000_000_000);
|
||
}
|
||
assert_eq!(
|
||
counter.windowed_count(Window::ThirtyDays, 6 * 1_000_000_000),
|
||
7,
|
||
"30d window must include the in-progress day, not lag it by a bucket"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn snapshot_and_restore_roundtrip() {
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
for i in 0..50_u64 {
|
||
counter.increment(i * 2_000_000_000); // every 2 seconds
|
||
}
|
||
let snapshot = counter.snapshot();
|
||
|
||
let restored = BucketedCounter::new();
|
||
restored.restore(&snapshot);
|
||
|
||
// Read both as of the last event (98s in) so neither side rotates.
|
||
let read_ns = 49 * 2_000_000_000;
|
||
assert_eq!(restored.all_time_count(), counter.all_time_count());
|
||
assert_eq!(
|
||
restored.windowed_count(Window::OneHour, read_ns),
|
||
counter.windowed_count(Window::OneHour, read_ns)
|
||
);
|
||
assert_eq!(
|
||
restored.windowed_count(Window::AllTime, read_ns),
|
||
counter.windowed_count(Window::AllTime, read_ns)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn restore_repairs_anchors_with_coarser_ahead_of_finer() {
|
||
// Review pass2 (signals): restore() must repair the monotone anchor
|
||
// ordering (last_day ≤ last_hour ≤ last_minute). A corrupt snapshot with
|
||
// the hour anchor AHEAD of the minute anchor would make sum_current_hour's
|
||
// `now_ns.saturating_sub(last_hour)` clamp to 0 and silently under-count
|
||
// the in-progress window. We feed exactly that hostile shape and assert
|
||
// the stored anchors come out monotone (day ≤ hour ≤ minute), and that a
|
||
// windowed read does not panic and is internally consistent.
|
||
let hostile = BucketedCounterSnapshot {
|
||
minute_buckets: [0; MINUTE_BUCKETS],
|
||
hour_buckets: [0; HOUR_BUCKETS],
|
||
day_buckets: [0; DAY_BUCKETS],
|
||
current_minute: 0,
|
||
current_hour: 0,
|
||
current_day: 0,
|
||
all_time_count: 0,
|
||
// Deliberately inverted: hour and day anchors are AHEAD of the minute
|
||
// anchor, violating the day ≤ hour ≤ minute invariant maybe_rotate sets.
|
||
last_minute_rotation_ns: 10 * NS_PER_MIN,
|
||
last_hour_rotation_ns: 100 * NS_PER_HOUR,
|
||
last_day_rotation_ns: 50 * NS_PER_DAY,
|
||
};
|
||
|
||
let counter = BucketedCounter::new();
|
||
counter.restore(&hostile);
|
||
|
||
let restored_minute = counter.last_minute_rotation_ns.load(Ordering::Relaxed);
|
||
let restored_hour = counter.last_hour_rotation_ns.load(Ordering::Relaxed);
|
||
let restored_day = counter.last_day_rotation_ns.load(Ordering::Relaxed);
|
||
|
||
// Minute is the authoritative high-water mark — preserved verbatim.
|
||
assert_eq!(restored_minute, 10 * NS_PER_MIN);
|
||
// Coarser anchors are pulled DOWN to satisfy day ≤ hour ≤ minute.
|
||
assert!(
|
||
restored_day <= restored_hour && restored_hour <= restored_minute,
|
||
"anchors must be monotone after restore: day={restored_day} \
|
||
hour={restored_hour} minute={restored_minute}"
|
||
);
|
||
|
||
// A windowed read at a clock just past the (repaired) minute anchor must
|
||
// not under-flow or panic; with empty buckets it is simply 0.
|
||
let read_ns = 11 * NS_PER_MIN;
|
||
assert_eq!(counter.windowed_count(Window::OneHour, read_ns), 0);
|
||
assert_eq!(counter.windowed_count(Window::TwentyFourHours, read_ns), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn restore_preserves_already_monotone_anchors() {
|
||
// A well-formed snapshot (day ≤ hour ≤ minute) must round-trip unchanged —
|
||
// the repair only ever pulls a coarser anchor DOWN, never perturbs a valid
|
||
// ordering.
|
||
let mut snapshot = BucketedCounter::with_start_time(0).snapshot();
|
||
snapshot.last_day_rotation_ns = 2 * NS_PER_DAY;
|
||
snapshot.last_hour_rotation_ns = 50 * NS_PER_HOUR;
|
||
snapshot.last_minute_rotation_ns = 3001 * NS_PER_MIN; // > 50h, > 2d
|
||
|
||
let counter = BucketedCounter::new();
|
||
counter.restore(&snapshot);
|
||
|
||
assert_eq!(
|
||
counter.last_minute_rotation_ns.load(Ordering::Relaxed),
|
||
3001 * NS_PER_MIN
|
||
);
|
||
assert_eq!(
|
||
counter.last_hour_rotation_ns.load(Ordering::Relaxed),
|
||
50 * NS_PER_HOUR
|
||
);
|
||
assert_eq!(
|
||
counter.last_day_rotation_ns.load(Ordering::Relaxed),
|
||
2 * NS_PER_DAY
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn quiet_entity_windowed_count_decays_to_zero_on_read() {
|
||
// CRITICAL #6 regression: a read with an advanced clock and NO intervening
|
||
// write must age expired buckets out. Previously rotation only fired on the
|
||
// write path, so a bursty-then-quiet entity reported its peak count forever.
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
|
||
// 50 events in the first minute.
|
||
for i in 0..50u64 {
|
||
counter.increment(i * 1_000_000); // every 1ms
|
||
}
|
||
|
||
// A read still inside the 1h window (1 minute in) sees all 50 events.
|
||
let within = counter.windowed_count(Window::OneHour, NS_PER_MIN);
|
||
assert_eq!(within, 50, "within-window read must still count the events");
|
||
|
||
// Advance the clock past the full 1h window with NO further writes. The
|
||
// read itself must rotate every minute bucket out — the count drops to 0.
|
||
let past_window = 2 * NS_PER_HOUR;
|
||
let after = counter.windowed_count(Window::OneHour, past_window);
|
||
assert_eq!(
|
||
after, 0,
|
||
"a quiet entity's 1h window must decay to 0 once the clock passes the window, \
|
||
even with no intervening write"
|
||
);
|
||
|
||
// all_time is unbounded and must be untouched by read-time rotation.
|
||
assert_eq!(counter.all_time_count(), 50);
|
||
}
|
||
|
||
#[test]
|
||
fn quiet_entity_velocity_inputs_decay_via_read() {
|
||
// The 24h window (which drives velocity ranking) must likewise age out via
|
||
// a pure read once the clock advances a full 24h past the last event.
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
for i in 0..10u64 {
|
||
counter.increment(i * 1_000_000_000); // 10 events in the first 10s
|
||
}
|
||
// Inside 24h: all 10 counted.
|
||
assert_eq!(
|
||
counter.windowed_count(Window::TwentyFourHours, 9_000_000_000),
|
||
10
|
||
);
|
||
// 25h later with no writes: the 24h window must read 0 on the read path.
|
||
let past_24h = 25 * NS_PER_HOUR;
|
||
assert_eq!(counter.windowed_count(Window::TwentyFourHours, past_24h), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn increment_by_adds_multiple() {
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
counter.increment_by(42, 1_000_000_000);
|
||
assert_eq!(counter.all_time_count(), 42);
|
||
assert_eq!(counter.windowed_count(Window::OneHour, 1_000_000_000), 42);
|
||
}
|
||
|
||
/// Drive a counter with an event every `step_ns` over `[0, end_ns]` inclusive,
|
||
/// returning the total number of events written. Dense enough (sub-minute step)
|
||
/// that the minute tier never fully drains on an hour rotation, so the rolling
|
||
/// window keeps the previous hour's tail — the minute/hour tier-overlap
|
||
/// condition the double-count bugs need.
|
||
fn drive_continuous(counter: &BucketedCounter, step_ns: u64, end_ns: u64) -> u64 {
|
||
let mut total = 0u64;
|
||
let mut t = 0u64;
|
||
while t <= end_ns {
|
||
counter.increment(t);
|
||
total += 1;
|
||
t += step_ns;
|
||
}
|
||
total
|
||
}
|
||
|
||
#[test]
|
||
fn twenty_four_hour_no_double_count_continuous_read_at_end() {
|
||
// Read-side bound: a continuously-active entity crossing an hour boundary,
|
||
// read at the last write. The whole 75-min history fits in the 24h window,
|
||
// so the count must equal the all-time total (was 477 before the read-side
|
||
// bound; the real total is 301).
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
let total = drive_continuous(&counter, 15 * 1_000_000_000, 4500 * 1_000_000_000);
|
||
assert_eq!(counter.all_time_count(), total);
|
||
assert_eq!(
|
||
counter.windowed_count(Window::TwentyFourHours, 4500 * 1_000_000_000),
|
||
total,
|
||
"24h at the last write must count each event once across an hour boundary"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn twenty_four_hour_no_double_count_continuous_read_after_gap() {
|
||
// WRITE-side bound (the half-fix's blind spot): the same dense history, but
|
||
// read 30 min AFTER the last write so the read-time rotation rolls the
|
||
// in-progress hour into a DURABLE hour bucket. If that rollup summed all 60
|
||
// minute buckets it would re-roll the previous hour's tail (verified 477 for
|
||
// 361 real events). With the bounded hour_agg every event is counted once.
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
let total = drive_continuous(&counter, 15 * 1_000_000_000, 90 * NS_PER_MIN);
|
||
assert_eq!(counter.all_time_count(), total);
|
||
let read_ns = 120 * NS_PER_MIN; // 30 min past the last write, into a 3rd hour
|
||
let c24 = counter.windowed_count(Window::TwentyFourHours, read_ns);
|
||
assert_eq!(
|
||
c24, total,
|
||
"24h read after a gap must not double-count the rolled-up hour bucket \
|
||
(got {c24} for {total} real events)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn twenty_four_hour_no_double_count_pure_write_path() {
|
||
// The overlap is committed to the durable hour tier on the WRITE path, with
|
||
// no read-time rotation involved: dense activity in two bursts straddling an
|
||
// hour boundary, then one more write a full hour later that fires the rollup.
|
||
// Reading AT that write (so maybe_rotate ran only on the write path) must
|
||
// still equal the all-time total (was ~441 for ~343 real events).
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
let mut total = 0u64;
|
||
let mut t = 0u64;
|
||
while t <= 45 * NS_PER_MIN {
|
||
counter.increment(t);
|
||
total += 1;
|
||
t += 15 * 1_000_000_000;
|
||
}
|
||
t = 50 * NS_PER_MIN;
|
||
while t <= 90 * NS_PER_MIN {
|
||
counter.increment(t);
|
||
total += 1;
|
||
t += 15 * 1_000_000_000;
|
||
}
|
||
// One event a full hour after the last burst fires the second-hour rollup
|
||
// on the write path itself.
|
||
let read_ns = 120 * NS_PER_MIN;
|
||
counter.increment(read_ns);
|
||
total += 1;
|
||
assert_eq!(counter.all_time_count(), total);
|
||
let c24 = counter.windowed_count(Window::TwentyFourHours, read_ns);
|
||
assert_eq!(
|
||
c24, total,
|
||
"24h must not double-count on the pure write path (got {c24} for {total})"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn seven_day_no_double_count_continuous_read_after_gap() {
|
||
// 7d folds in the in-progress hour the same way; the write-side overlap
|
||
// inflates it identically. Dense history under 7 days, read after a gap.
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
let total = drive_continuous(&counter, 30 * 1_000_000_000, 90 * NS_PER_MIN);
|
||
assert_eq!(counter.all_time_count(), total);
|
||
let c7 = counter.windowed_count(Window::SevenDays, 120 * NS_PER_MIN);
|
||
assert_eq!(c7, total, "7d after a gap must count each event once");
|
||
}
|
||
|
||
#[test]
|
||
fn thirty_day_no_double_count_continuous_read_after_gap() {
|
||
// 30d's recent-7-day prefix is the same hour-tier expression, so the
|
||
// write-side overlap inflates it too. Whole history under 7 days (no day
|
||
// tier involved), read after a gap.
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
let total = drive_continuous(&counter, 30 * 1_000_000_000, 90 * NS_PER_MIN);
|
||
assert_eq!(counter.all_time_count(), total);
|
||
let c30 = counter.windowed_count(Window::ThirtyDays, 120 * NS_PER_MIN);
|
||
assert_eq!(c30, total, "30d after a gap must count each event once");
|
||
}
|
||
|
||
#[test]
|
||
fn thirty_day_no_double_count_day_tier_overlap_after_gap() {
|
||
// WRITE-side DAY rollup (day_agg): the hour tier is itself a rolling window,
|
||
// so a day rotation firing after a gap must not fold the PREVIOUS day's tail
|
||
// (already in an earlier day bucket) into the new day bucket. Day 0 and day 1
|
||
// are both active; day 1 goes quiet well before the day-2 boundary, so when
|
||
// the rollup fires the naive "23 most recent hour buckets" reaches back into
|
||
// day 0. We then age day 1's bucket into the 30d window's day-tier tail.
|
||
//
|
||
// The 30d window is a subset of all-time, so its count must NEVER exceed the
|
||
// all-time total — a day_agg overlap violates exactly that invariant
|
||
// (verified: ~+150 inflation with the unbounded rollup).
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
let mut total = 0u64;
|
||
|
||
// Day 0: dense every 5 min for the full 24h.
|
||
let mut t = 0u64;
|
||
while t < NS_PER_DAY {
|
||
counter.increment(t);
|
||
total += 1;
|
||
t += 5 * NS_PER_MIN;
|
||
}
|
||
// Day 1: dense every 5 min for only the first 10 hours, then quiet.
|
||
t = NS_PER_DAY;
|
||
while t < NS_PER_DAY + 10 * NS_PER_HOUR {
|
||
counter.increment(t);
|
||
total += 1;
|
||
t += 5 * NS_PER_MIN;
|
||
}
|
||
// One event just past the day-2 boundary fires day 1's rollup after the gap.
|
||
counter.increment(2 * NS_PER_DAY + NS_PER_MIN);
|
||
total += 1;
|
||
// One event per day for days 3..=12 ages day 1's bucket into the 30d tail.
|
||
for day in 3..=12u64 {
|
||
counter.increment(day * NS_PER_DAY + NS_PER_MIN);
|
||
total += 1;
|
||
}
|
||
|
||
let read_ns = 12 * NS_PER_DAY + 2 * NS_PER_MIN;
|
||
assert_eq!(counter.all_time_count(), total);
|
||
let c30 = counter.windowed_count(Window::ThirtyDays, read_ns);
|
||
assert!(
|
||
c30 <= total,
|
||
"30d must never exceed all-time; a day-tier rollup overlap inflates it \
|
||
(got {c30} for {total} real events, all within 30 days)"
|
||
);
|
||
// Sanity floor: the bulk of the (in-window) events are still counted; a clean
|
||
// run lands at total minus at most a day's worth of 7-day-seam slack.
|
||
assert!(
|
||
c30 + 50 >= total,
|
||
"30d under-counted far more than the bounded 7-day seam slack: {c30} vs {total}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod proptests {
|
||
use proptest::prelude::*;
|
||
|
||
use super::*;
|
||
|
||
// P3: Windowed count equals event count in window (1h window).
|
||
//
|
||
// Events are constrained to a 3000s (50 minute) span to avoid the design
|
||
// limitation where a gap of ≥ 3600s triggers a full 60-bucket rotation cycle
|
||
// that clears all minute-bucket data. With a 3000s max span, at most 50
|
||
// rotation cycles fire from the initial timestamp, so all event data survives.
|
||
// All events thus fall within the 1h window and expected = total event count.
|
||
proptest! {
|
||
#[test]
|
||
fn windowed_count_1h_matches_events(
|
||
event_times_secs in prop::collection::vec(0u64..3000, 1..200),
|
||
) {
|
||
// Sort events — trigger-based rotation requires time-ordered insertion.
|
||
let mut sorted_times = event_times_secs;
|
||
sorted_times.sort_unstable();
|
||
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
|
||
for &t_secs in &sorted_times {
|
||
counter.increment(t_secs * 1_000_000_000);
|
||
}
|
||
|
||
// All events are within 3000s (< 50 minute-bucket rotation cycles),
|
||
// so all must appear in the 1h windowed count. Read as of the last
|
||
// event so the read-time rotation does not age any of them out.
|
||
let read_ns = sorted_times.last().copied().unwrap_or(0) * 1_000_000_000;
|
||
let expected = sorted_times.len() as u64;
|
||
let actual = counter.windowed_count(Window::OneHour, read_ns);
|
||
|
||
// Allow ±2 for bucket-boundary effects.
|
||
prop_assert!(
|
||
actual.abs_diff(expected) <= 2,
|
||
"actual={actual}, expected={expected}"
|
||
);
|
||
}
|
||
}
|
||
|
||
// All-time count equals total event count.
|
||
proptest! {
|
||
#[test]
|
||
fn all_time_count_matches_total(
|
||
event_count in 0u64..10_000,
|
||
) {
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
for i in 0..event_count {
|
||
let t_ns = i * 1_000_000;
|
||
counter.increment(t_ns);
|
||
}
|
||
prop_assert_eq!(counter.all_time_count(), event_count);
|
||
}
|
||
}
|
||
|
||
// Circular buffer wrapping: all-time count survives full rotation.
|
||
proptest! {
|
||
#[test]
|
||
fn minute_rotation_preserves_total(
|
||
events_per_minute in prop::collection::vec(0u32..100, 60..120),
|
||
) {
|
||
let counter = BucketedCounter::with_start_time(0);
|
||
let mut total = 0u64;
|
||
|
||
for (minute_idx, &count) in events_per_minute.iter().enumerate() {
|
||
let base_ns = (minute_idx as u64) * 60_000_000_000;
|
||
for j in 0..count {
|
||
let t_ns = base_ns + u64::from(j) * 1_000_000;
|
||
counter.increment(t_ns);
|
||
total += 1;
|
||
}
|
||
}
|
||
|
||
prop_assert_eq!(counter.all_time_count(), total);
|
||
}
|
||
}
|
||
}
|