{
SealedEpoch::from_mutable(self)
}
+ /// Drop every entry whose window-END is at or before `cutoff_end`,
+ /// returning the number of distinct windows evicted.
+ ///
+ /// This is the in-memory retention primitive for the WARM sketch
+ /// store: it bounds `current_epoch` to a recent time horizon so its
+ /// memory is `O(active_series × horizon)` rather than
+ /// `O(active_series × total_elapsed_time)`. It does NOT require
+ /// sealing — unsealed recent windows stay queryable (the read path
+ /// scans `current_epoch` directly), which is the explicit design
+ /// intent. Older data lives in the cold/raw tier.
+ ///
+ /// Uses window-END (`w.1 <= cutoff_end`) rather than window-START so
+ /// a half-open pane that straddles the cutoff is RETAINED until it is
+ /// fully behind the horizon — the read path's overlap scan and the
+ /// delta-stitching carry-in (`collect_ending_at_or_before`) can still
+ /// see it. Callers pick `cutoff_end = newest_end - horizon`, so
+ /// anything kept is within `horizon` of the freshest window.
+ ///
+ /// O(N) — rebuilds the three columns in one pass, same shape as
+ /// [`Self::remove_windows`].
+ pub fn evict_window_ends_before(&mut self, cutoff_end: u64) -> usize {
+ // O(1) skip: nothing is old enough to evict.
+ match self.min_start {
+ // Cheapest guard: if the earliest window-START is already
+ // past the cutoff, no window can END at/before it either.
+ Some(min_s) if min_s > cutoff_end => return 0,
+ None => return 0,
+ _ => {}
+ }
+ let old_windows = std::mem::take(&mut self.windows_col);
+ let old_ids = std::mem::take(&mut self.label_ids_col);
+ let old_payloads = std::mem::take(&mut self.payloads_col);
+ let prev_distinct = self.windows_set.len();
+ self.windows_set.clear();
+ for ((w, id), p) in old_windows.into_iter().zip(old_ids).zip(old_payloads) {
+ if w.1 <= cutoff_end {
+ continue;
+ }
+ self.windows_set.insert(w);
+ self.windows_col.push(w);
+ self.label_ids_col.push(id);
+ self.payloads_col.push(p);
+ }
+ let dropped = prev_distinct.saturating_sub(self.windows_set.len());
+ if dropped > 0 {
+ self.window_to_ids = None;
+ self.last_window = None;
+ self.min_start = self.windows_col.iter().map(|w| w.0).min();
+ self.max_end = self.windows_col.iter().map(|w| w.1).max();
+ }
+ dropped
+ }
+
/// Remove all entries whose window is in `windows`.
/// Mirrors the legacy `SketchStore` CircularBuffer
/// cleanup contract. O(N) — rebuilds columns in one pass.
@@ -670,6 +723,47 @@ pub struct SidStoreData {
pub current_epoch_id: EpochId,
pub epoch_capacity: Option,
pub max_epochs: usize,
+ /// In-memory WARM retention horizon, in milliseconds. On each
+ /// insert, windows whose END is older than `newest_end - horizon`
+ /// are evicted from `current_epoch` (and from any `sealed_epochs`),
+ /// bounding per-sid memory to `O(horizon)` instead of growing with
+ /// total elapsed time. `None` disables retention (legacy/unbounded
+ /// behavior — used by tests that want full history).
+ ///
+ /// Defaults from [`default_retention_horizon_ms`] which reads the
+ /// `ASAP_SKETCH_RETENTION_MS` env once. The horizon is deliberately
+ /// larger than the max query window (~30m) plus the delta-stitching
+ /// carry-in reach, so recent range/instant queries never lose a
+ /// window or its Full base. See `evict_window_ends_before`.
+ pub retention_horizon_ms: Option,
+}
+
+/// Default in-memory retention horizon (ms) for the WARM sketch store.
+/// 2 hours — comfortably exceeds the ~30m max range-query window plus
+/// the delta-stitching carry-in's Full-base reach, so bounding memory
+/// to this horizon cannot regress the recent-window read path. Older
+/// data is served from the cold/raw tier.
+pub const DEFAULT_SKETCH_RETENTION_MS: u64 = 2 * 60 * 60 * 1000;
+
+/// Resolve the WARM retention horizon once from the
+/// `ASAP_SKETCH_RETENTION_MS` env var, caching the result for the life
+/// of the process (read off the per-insert hot path). Falls back to
+/// [`DEFAULT_SKETCH_RETENTION_MS`] when unset or unparseable. A value of
+/// `0` explicitly DISABLES retention (returns `None`) for operators who
+/// need full in-memory history (and accept the unbounded growth).
+pub fn default_retention_horizon_ms() -> Option {
+ use std::sync::OnceLock;
+ static HORIZON: OnceLock