tidaldb/tidal/src/signals/warm.rs
jx12n 9728194f16 fix: M0-M10 code-review pass2 remediation — all 91 findings
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.
2026-06-09 12:21:00 -06:00

1344 lines
61 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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.32x 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 830), 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);
}
}
}