//! 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); } } }