diff --git a/src/ds/expiration_index.rs b/src/ds/expiration_index.rs index 2454aac..d6693cb 100644 --- a/src/ds/expiration_index.rs +++ b/src/ds/expiration_index.rs @@ -6,12 +6,13 @@ //! ┌──────────────────────────────────────────────────────────────┐ //! │ ExpirationIndex │ //! │ │ -//! │ LazyMinHeap (key -> deadline_ms) │ +//! │ LazyMinHeap (key -> deadline tick) │ //! │ ▲ │ //! │ ├── set_deadline(key, expires_at) │ //! │ ├── remove(key) │ -//! │ ├── peek_deadline() -> earliest live (key, deadline) │ -//! │ └── pop_expired(now) -> earliest live if deadline <= now │ +//! │ ├── next_deadline() -> earliest live (key, deadline) │ +//! │ ├── pop_expired(now) -> earliest if deadline <= now │ +//! │ └── drain_expired(now)-> iterator over all expired │ //! └──────────────────────────────────────────────────────────────┘ //! ``` //! @@ -19,21 +20,23 @@ //! //! [`ExpirationIndex`] is a thin wrapper around //! [`LazyMinHeap`]`` with `auto_rebuild` -//! enabled. The wrapper hides the score type (always a `u64` deadline in +//! enabled. The wrapper hides the score type (always a [`Deadline`] in //! the cache's tick unit) and exposes operations specialised for TTL: //! //! - [`set_deadline`](ExpirationIndex::set_deadline) updates an entry's //! deadline, returning the previous deadline if any. -//! - [`peek_deadline`](ExpirationIndex::peek_deadline) returns references -//! to the live entry with the earliest deadline. +//! - [`next_deadline`](ExpirationIndex::next_deadline) returns references +//! to the live entry with the earliest deadline (may prune stale roots). //! - [`pop_expired`](ExpirationIndex::pop_expired) atomically peeks the //! earliest entry and, if its deadline `<= now`, removes and returns it. +//! - [`drain_expired`](ExpirationIndex::drain_expired) yields all entries +//! with deadline `<= now` in ascending order. //! //! ## Performance Trade-offs //! //! - Insertion is `O(log n)` plus one key clone (the heap and the //! authoritative map both retain a copy). -//! - `peek_deadline` discards stale heap roots in place, so it is +//! - `next_deadline` discards stale heap roots in place, so it is //! amortised `O(1)` between updates. //! - Auto-rebuild defaults to factor `2`: stale heap entries are bounded //! at `2 * len()`. Callers that mutate every entry many times per @@ -54,19 +57,26 @@ //! idx.set_deadline("a", 100); //! idx.set_deadline("b", 50); //! -//! assert_eq!(idx.peek_deadline(), Some((&"b", 50))); +//! assert_eq!(idx.next_deadline(), Some((&"b", 50))); //! assert_eq!(idx.pop_expired(40), None); // none yet expired //! assert_eq!(idx.pop_expired(60), Some(("b", 50))); // "b" expired at <=60 -//! assert_eq!(idx.peek_deadline(), Some((&"a", 100))); +//! assert_eq!(idx.next_deadline(), Some((&"a", 100))); //! ``` //! //! [`set_auto_rebuild`]: ExpirationIndex::set_auto_rebuild use std::borrow::Borrow; use std::hash::Hash; +use std::iter::FusedIterator; use crate::ds::lazy_heap::LazyMinHeap; +/// Opaque tick-based deadline (milliseconds by convention in `cachekit`). +/// +/// The concrete unit is determined by the [`Clock`](crate::time::Clock) +/// implementation the TTL decorator uses. +pub type Deadline = u64; + /// Default rebuild factor: bound stale heap growth to `2 * live_len`. /// /// Picked to keep amortised maintenance cheap while preventing heap bloat @@ -75,13 +85,13 @@ const DEFAULT_REBUILD_FACTOR: usize = 2; /// Min-priority deadline index keyed by `K`. /// -/// Wraps a [`LazyMinHeap`]`` with auto-rebuild enabled. Deadlines -/// are opaque `u64` ticks; the unit is determined by the +/// Wraps a [`LazyMinHeap`]`` with auto-rebuild enabled. +/// Deadlines are opaque ticks; the unit is determined by the /// [`Clock`](crate::time::Clock) the TTL decorator uses (conventionally /// milliseconds in `cachekit`). -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct ExpirationIndex { - heap: LazyMinHeap, + heap: LazyMinHeap, } impl ExpirationIndex @@ -89,6 +99,15 @@ where K: Eq + Hash + Clone, { /// Creates an empty index with the default rebuild factor. + /// + /// # Examples + /// + /// ``` + /// use cachekit::ds::expiration_index::ExpirationIndex; + /// + /// let idx: ExpirationIndex = ExpirationIndex::new(); + /// assert!(idx.is_empty()); + /// ``` pub fn new() -> Self { Self { heap: LazyMinHeap::with_auto_rebuild(DEFAULT_REBUILD_FACTOR), @@ -99,6 +118,16 @@ where /// distinct keys. /// /// Useful when the TTL decorator wraps a fixed-capacity cache. + /// Passing `0` is equivalent to [`new`](Self::new). + /// + /// # Examples + /// + /// ``` + /// use cachekit::ds::expiration_index::ExpirationIndex; + /// + /// let idx: ExpirationIndex = ExpirationIndex::with_capacity(1024); + /// assert!(idx.is_empty()); + /// ``` pub fn with_capacity(capacity: usize) -> Self { let mut heap = LazyMinHeap::with_capacity(capacity); heap.set_auto_rebuild(Some(DEFAULT_REBUILD_FACTOR)); @@ -125,12 +154,33 @@ where /// `expires_at` is in the cache's tick unit (typically milliseconds). /// A previous deadline is replaced; no validation against the current /// clock happens here. - pub fn set_deadline(&mut self, key: K, expires_at: u64) -> Option { + /// + /// # Examples + /// + /// ``` + /// use cachekit::ds::expiration_index::ExpirationIndex; + /// + /// let mut idx = ExpirationIndex::new(); + /// assert_eq!(idx.set_deadline("a", 100), None); + /// assert_eq!(idx.set_deadline("a", 200), Some(100)); // replaced + /// ``` + pub fn set_deadline(&mut self, key: K, expires_at: Deadline) -> Option { self.heap.update(key, expires_at) } /// Returns the current deadline for `key`, if any. - pub fn deadline_of(&self, key: &Q) -> Option + /// + /// # Examples + /// + /// ``` + /// use cachekit::ds::expiration_index::ExpirationIndex; + /// + /// let mut idx = ExpirationIndex::new(); + /// idx.set_deadline("a", 100); + /// assert_eq!(idx.deadline_of("a"), Some(100)); + /// assert_eq!(idx.deadline_of("b"), None); + /// ``` + pub fn deadline_of(&self, key: &Q) -> Option where K: Borrow, Q: Hash + Eq + ?Sized, @@ -139,6 +189,17 @@ where } /// Returns `true` if `key` has a deadline tracked here. + /// + /// # Examples + /// + /// ``` + /// use cachekit::ds::expiration_index::ExpirationIndex; + /// + /// let mut idx = ExpirationIndex::new(); + /// idx.set_deadline("a", 100); + /// assert!(idx.contains("a")); + /// assert!(!idx.contains("b")); + /// ``` pub fn contains(&self, key: &Q) -> bool where K: Borrow, @@ -148,7 +209,18 @@ where } /// Removes `key` and returns its deadline, if any. - pub fn remove(&mut self, key: &Q) -> Option + /// + /// # Examples + /// + /// ``` + /// use cachekit::ds::expiration_index::ExpirationIndex; + /// + /// let mut idx = ExpirationIndex::new(); + /// idx.set_deadline("a", 100); + /// assert_eq!(idx.remove("a"), Some(100)); + /// assert_eq!(idx.remove("a"), None); // already gone + /// ``` + pub fn remove(&mut self, key: &Q) -> Option where K: Borrow, Q: Hash + Eq + ?Sized, @@ -158,9 +230,19 @@ where /// Returns the live entry with the earliest deadline without removing it. /// - /// Discards stale heap roots in place; takes `&mut self` for that - /// reason. - pub fn peek_deadline(&mut self) -> Option<(&K, u64)> { + /// May discard stale heap roots in place, hence `&mut self`. + /// + /// # Examples + /// + /// ``` + /// use cachekit::ds::expiration_index::ExpirationIndex; + /// + /// let mut idx = ExpirationIndex::new(); + /// idx.set_deadline("a", 100); + /// idx.set_deadline("b", 50); + /// assert_eq!(idx.next_deadline(), Some((&"b", 50))); + /// ``` + pub fn next_deadline(&mut self) -> Option<(&K, Deadline)> { self.heap.peek_best().map(|(k, s)| (k, *s)) } @@ -169,18 +251,85 @@ where /// The comparison is `<=` (not `<`): a deadline equal to `now` is /// already past in the chosen tick unit, matching the algorithm /// described in `docs/design/ttl.md` §3. - pub fn pop_expired(&mut self, now: u64) -> Option<(K, u64)> { - match self.peek_deadline() { + /// + /// # Examples + /// + /// ``` + /// use cachekit::ds::expiration_index::ExpirationIndex; + /// + /// let mut idx = ExpirationIndex::new(); + /// idx.set_deadline("a", 100); + /// + /// assert_eq!(idx.pop_expired(99), None); // not yet + /// assert_eq!(idx.pop_expired(100), Some(("a", 100))); // expired at =100 + /// assert_eq!(idx.pop_expired(200), None); // already removed + /// ``` + pub fn pop_expired(&mut self, now: Deadline) -> Option<(K, Deadline)> { + match self.next_deadline() { Some((_, deadline)) if deadline <= now => self.heap.pop_best(), _ => None, } } + /// Drains all entries with deadline `<= now` in ascending order. + /// + /// # Examples + /// + /// ``` + /// use cachekit::ds::expiration_index::ExpirationIndex; + /// + /// let mut idx = ExpirationIndex::new(); + /// idx.set_deadline("a", 100); + /// idx.set_deadline("b", 200); + /// idx.set_deadline("c", 300); + /// + /// let expired: Vec<_> = idx.drain_expired(250).collect(); + /// assert_eq!(expired, vec![("a", 100), ("b", 200)]); + /// assert_eq!(idx.len(), 1); // "c" remains + /// ``` + pub fn drain_expired(&mut self, now: Deadline) -> impl Iterator + '_ { + std::iter::from_fn(move || self.pop_expired(now)) + } + + /// Returns a borrowing iterator over `(&K, Deadline)` pairs. + /// + /// Iteration order is arbitrary (hash-map order of the backing store). + /// + /// # Examples + /// + /// ``` + /// use cachekit::ds::expiration_index::ExpirationIndex; + /// + /// let mut idx = ExpirationIndex::new(); + /// idx.set_deadline("a", 100); + /// idx.set_deadline("b", 200); + /// + /// let mut entries: Vec<_> = idx.iter().collect(); + /// entries.sort_by_key(|&(_, d)| d); + /// assert_eq!(entries, vec![(&"a", 100), (&"b", 200)]); + /// ``` + pub fn iter(&self) -> Iter<'_, K> { + Iter { + inner: self.heap.iter(), + } + } + /// Overrides the underlying heap's auto-rebuild factor. /// /// `None` disables auto-rebuild. Values below `1` are clamped to `1`. - pub fn set_auto_rebuild(&mut self, factor: Option) { + /// + /// # Examples + /// + /// ``` + /// use cachekit::ds::expiration_index::ExpirationIndex; + /// + /// let mut idx: ExpirationIndex<&str> = ExpirationIndex::with_capacity(64); + /// idx.set_auto_rebuild(Some(4)) + /// .set_deadline("a", 100); + /// ``` + pub fn set_auto_rebuild(&mut self, factor: Option) -> &mut Self { self.heap.set_auto_rebuild(factor); + self } } @@ -193,6 +342,129 @@ where } } +// --------------------------------------------------------------------------- +// Iterator types +// --------------------------------------------------------------------------- + +/// Borrowing iterator over `(&K, Deadline)` pairs. +/// +/// Created by [`ExpirationIndex::iter`]. +pub struct Iter<'a, K> { + inner: crate::ds::lazy_heap::Iter<'a, K, Deadline>, +} + +impl std::fmt::Debug for Iter<'_, K> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Iter").finish_non_exhaustive() + } +} + +impl<'a, K> Iterator for Iter<'a, K> { + type Item = (&'a K, Deadline); + + fn next(&mut self) -> Option { + self.inner.next().map(|(k, s)| (k, *s)) + } + + fn size_hint(&self) -> (usize, Option) { + self.inner.size_hint() + } +} + +impl ExactSizeIterator for Iter<'_, K> {} +impl FusedIterator for Iter<'_, K> {} + +/// Owning iterator over `(K, Deadline)` pairs. +/// +/// Created by the [`IntoIterator`] implementation on [`ExpirationIndex`]. +pub struct IntoIter { + inner: crate::ds::lazy_heap::IntoIter, +} + +impl std::fmt::Debug for IntoIter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("IntoIter").finish_non_exhaustive() + } +} + +impl Iterator for IntoIter { + type Item = (K, Deadline); + + fn next(&mut self) -> Option { + self.inner.next() + } + + fn size_hint(&self) -> (usize, Option) { + self.inner.size_hint() + } +} + +impl ExactSizeIterator for IntoIter {} +impl FusedIterator for IntoIter {} + +// --------------------------------------------------------------------------- +// IntoIterator +// --------------------------------------------------------------------------- + +impl IntoIterator for ExpirationIndex +where + K: Eq + Hash + Clone, +{ + type Item = (K, Deadline); + type IntoIter = IntoIter; + + fn into_iter(self) -> Self::IntoIter { + IntoIter { + inner: self.heap.into_iter(), + } + } +} + +impl<'a, K> IntoIterator for &'a ExpirationIndex +where + K: Eq + Hash + Clone, +{ + type Item = (&'a K, Deadline); + type IntoIter = Iter<'a, K>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +// --------------------------------------------------------------------------- +// FromIterator / Extend +// --------------------------------------------------------------------------- + +impl FromIterator<(K, Deadline)> for ExpirationIndex +where + K: Eq + Hash + Clone, +{ + fn from_iter>(iter: I) -> Self { + let mut idx = Self::new(); + idx.extend(iter); + idx + } +} + +impl Extend<(K, Deadline)> for ExpirationIndex +where + K: Eq + Hash + Clone, +{ + fn extend>(&mut self, iter: I) { + for (key, deadline) in iter { + self.set_deadline(key, deadline); + } + } +} + +const _: () = { + fn _assert_send_sync() {} + fn _check() { + _assert_send_sync::>(); + } +}; + #[cfg(test)] mod tests { use super::*; @@ -207,21 +479,21 @@ mod tests { } #[test] - fn peek_deadline_returns_earliest_live() { + fn next_deadline_returns_earliest_live() { let mut idx: ExpirationIndex<&str> = ExpirationIndex::new(); idx.set_deadline("a", 100); idx.set_deadline("b", 50); idx.set_deadline("c", 75); - assert_eq!(idx.peek_deadline(), Some((&"b", 50))); + assert_eq!(idx.next_deadline(), Some((&"b", 50))); } #[test] - fn peek_deadline_skips_replaced_entries() { + fn next_deadline_skips_replaced_entries() { let mut idx: ExpirationIndex<&str> = ExpirationIndex::new(); idx.set_deadline("a", 10); idx.set_deadline("a", 100); // earlier entry is stale idx.set_deadline("b", 50); - assert_eq!(idx.peek_deadline(), Some((&"b", 50))); + assert_eq!(idx.next_deadline(), Some((&"b", 50))); } #[test] @@ -279,7 +551,7 @@ mod property_tests { use proptest::prelude::*; proptest! { - /// `peek_deadline` always returns the earliest live deadline. + /// `next_deadline` always returns the earliest live deadline. #[cfg_attr(miri, ignore)] #[test] fn prop_peek_returns_earliest( @@ -295,7 +567,7 @@ mod property_tests { latest.insert(k, s); } let expected_min = latest.values().min().copied(); - let actual_min = idx.peek_deadline().map(|(_, d)| d); + let actual_min = idx.next_deadline().map(|(_, d)| d); prop_assert_eq!(actual_min, expected_min); } diff --git a/src/ds/mod.rs b/src/ds/mod.rs index c6dc813..98ed7de 100644 --- a/src/ds/mod.rs +++ b/src/ds/mod.rs @@ -17,7 +17,9 @@ pub use clock_ring::{ ValuesMut, }; #[cfg(feature = "ttl")] -pub use expiration_index::ExpirationIndex; +pub use expiration_index::{ + Deadline, ExpirationIndex, IntoIter as ExpirationIndexIntoIter, Iter as ExpirationIndexIter, +}; pub use fixed_history::{FixedHistory, MAX_K as FIXED_HISTORY_MAX_K}; pub use frequency_buckets::{ BucketEntries, BucketIds, DEFAULT_BUCKET_PREALLOC, FrequencyBucketEntryDebug, diff --git a/src/policy/expiring.rs b/src/policy/expiring.rs index 4c853ae..694de24 100644 --- a/src/policy/expiring.rs +++ b/src/policy/expiring.rs @@ -1,11 +1,11 @@ -//! `Expiring` — decorator that adds time-based expiration to any +//! `Expiring` — decorator that adds time-based expiration to any //! `Cache`. //! //! ## Architecture //! //! ```text //! ┌─────────────────────────────────────────────────────────────────┐ -//! │ Expiring │ +//! │ Expiring │ //! │ │ //! │ inner: C ◀──── policy / storage │ //! │ index: ExpirationIndex ◀──── deadlines │ @@ -47,7 +47,7 @@ //! //! ## Thread Safety //! -//! `Expiring` is not itself thread-safe. The future +//! `Expiring` is not itself thread-safe. The future //! `ConcurrentExpiring` wrapper (Phase 1.5 / Phase 2) holds the //! decorator behind a `parking_lot::RwLock` and returns owned/`Arc` //! values, per `docs/design/ttl.md` §4(e). @@ -102,6 +102,25 @@ pub struct Expiring { _value: PhantomData V>, } +impl Clone for Expiring +where + C: Clone, + K: Clone, + T: Clone, +{ + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + index: self.index.clone(), + clock: self.clock.clone(), + default_ttl_ticks: self.default_ttl_ticks, + #[cfg(feature = "metrics")] + expirations: self.expirations, + _value: PhantomData, + } + } +} + impl Expiring where C: Cache, @@ -109,6 +128,23 @@ where { /// Creates an expiring wrapper around `inner` using the system clock /// and no default TTL. + /// + /// Entries inserted via [`Cache::insert`] will never expire unless + /// a per-entry TTL is set through [`ExpiringCache::insert_with_ttl`] + /// or [`ExpiringCache::set_ttl`]. + /// + /// # Examples + /// + /// ``` + /// use cachekit::policy::expiring::Expiring; + /// use cachekit::policy::fast_lru::FastLru; + /// use cachekit::traits::Cache; + /// + /// let mut cache = Expiring::new(FastLru::::new(8)); + /// cache.insert(1, "hello".into()); + /// assert_eq!(cache.peek(&1), Some(&"hello".to_string())); + /// ``` + #[must_use] pub fn new(inner: C) -> Self { Self::with_clock_and_default(inner, StdClock::new(), None) } @@ -116,6 +152,21 @@ where /// Creates an expiring wrapper around `inner` using the system clock /// and a default TTL applied to entries inserted without an explicit /// TTL. + /// + /// # Examples + /// + /// ``` + /// use cachekit::policy::expiring::Expiring; + /// use cachekit::policy::fast_lru::FastLru; + /// use std::time::Duration; + /// + /// let cache = Expiring::with_default_ttl_std( + /// FastLru::::new(8), + /// Duration::from_secs(300), + /// ); + /// assert_eq!(cache.default_ttl(), Some(Duration::from_secs(300))); + /// ``` + #[must_use] pub fn with_default_ttl_std(inner: C, default_ttl: Duration) -> Self { Self::with_clock_and_default(inner, StdClock::new(), Some(default_ttl)) } @@ -129,6 +180,31 @@ where { /// Creates an expiring wrapper with an explicit clock and a default /// TTL (or `None` for no default). + /// + /// Pass a [`MockClock`] for deterministic tests, or any other + /// [`Clock`] implementation for custom tick sources. + /// + /// # Examples + /// + /// ``` + /// use cachekit::policy::expiring::Expiring; + /// use cachekit::policy::fast_lru::FastLru; + /// use cachekit::time::{Clock, MockClock}; + /// use cachekit::traits::Cache; + /// use std::time::Duration; + /// + /// let clock = MockClock::new(); + /// let mut cache = Expiring::with_default_ttl( + /// FastLru::::new(8), + /// clock, + /// Some(Duration::from_secs(60)), + /// ); + /// + /// cache.insert(1, "value".into()); + /// cache.clock().advance(Duration::from_secs(61)); + /// assert_eq!(cache.peek(&1), None); // expired + /// ``` + #[must_use] pub fn with_default_ttl(inner: C, clock: T, default_ttl: Option) -> Self { Self::with_clock_and_default(inner, clock, default_ttl) } @@ -151,6 +227,22 @@ where /// /// Updated only when the `metrics` feature is enabled. Returns `0` /// otherwise so call sites compile regardless of the feature gate. + /// + /// # Examples + /// + /// ``` + /// use cachekit::policy::expiring::Expiring; + /// use cachekit::policy::fast_lru::FastLru; + /// use cachekit::time::MockClock; + /// + /// let cache = Expiring::with_default_ttl( + /// FastLru::::new(8), + /// MockClock::new(), + /// None, + /// ); + /// assert_eq!(cache.expirations(), 0); + /// ``` + #[must_use] pub fn expirations(&self) -> u64 { #[cfg(feature = "metrics")] { @@ -163,24 +255,99 @@ where } /// Returns a reference to the inner cache. + /// + /// # Examples + /// + /// ``` + /// use cachekit::policy::expiring::Expiring; + /// use cachekit::policy::fast_lru::FastLru; + /// use cachekit::time::MockClock; + /// use cachekit::traits::Cache; + /// + /// let mut cache = Expiring::with_default_ttl( + /// FastLru::::new(8), + /// MockClock::new(), + /// None, + /// ); + /// cache.insert(1, "a".into()); + /// assert_eq!(cache.inner().len(), 1); + /// ``` + #[must_use] pub fn inner(&self) -> &C { &self.inner } /// Returns a mutable reference to the inner cache. /// - /// Bypassing the decorator with this can desync the expiration index; - /// use only when you understand the ordering invariant. + /// # Warning + /// + /// Mutations through this reference bypass the expiration index and + /// can desync deadlines. Use only when you understand the ordering + /// invariant documented at the module level. + /// + /// # Examples + /// + /// ``` + /// use cachekit::policy::expiring::Expiring; + /// use cachekit::policy::fast_lru::FastLru; + /// use cachekit::time::MockClock; + /// use cachekit::traits::Cache; + /// + /// let mut cache = Expiring::with_default_ttl( + /// FastLru::::new(8), + /// MockClock::new(), + /// None, + /// ); + /// cache.insert(1, "a".into()); + /// // Direct inner access — use with care. + /// let _ = cache.inner_mut().remove(&1); + /// ``` pub fn inner_mut(&mut self) -> &mut C { &mut self.inner } /// Returns a reference to the configured clock. + /// + /// # Examples + /// + /// ``` + /// use cachekit::policy::expiring::Expiring; + /// use cachekit::policy::fast_lru::FastLru; + /// use cachekit::time::{Clock, MockClock}; + /// use std::time::Duration; + /// + /// let cache = Expiring::with_default_ttl( + /// FastLru::::new(8), + /// MockClock::new(), + /// None, + /// ); + /// assert_eq!(cache.clock().now(), 0); + /// cache.clock().advance(Duration::from_millis(100)); + /// assert_eq!(cache.clock().now(), 100); + /// ``` + #[must_use] pub fn clock(&self) -> &T { &self.clock } /// Returns the cache's default TTL as a `Duration`, if any. + /// + /// # Examples + /// + /// ``` + /// use cachekit::policy::expiring::Expiring; + /// use cachekit::policy::fast_lru::FastLru; + /// use cachekit::time::MockClock; + /// use std::time::Duration; + /// + /// let cache = Expiring::with_default_ttl( + /// FastLru::::new(8), + /// MockClock::new(), + /// Some(Duration::from_secs(60)), + /// ); + /// assert_eq!(cache.default_ttl(), Some(Duration::from_secs(60))); + /// ``` + #[must_use] pub fn default_ttl(&self) -> Option { self.default_ttl_ticks.map(Duration::from_millis) } @@ -204,24 +371,38 @@ where /// Number of live (non-expired) entries. /// - /// Takes `&mut self` because draining stale roots from the - /// expiration index requires mutation. Returns an exact count, unlike - /// the conservative [`Cache::len`] which reports physical occupancy. - pub fn live_len(&mut self) -> usize { + /// Returns an exact count, unlike [`Cache::len`] which reports + /// physical occupancy (including expired-but-not-yet-purged entries). + /// Iterates the expiration index once — O(n) with no allocation. + /// + /// # Examples + /// + /// ``` + /// use cachekit::policy::expiring::Expiring; + /// use cachekit::policy::fast_lru::FastLru; + /// use cachekit::time::{Clock, MockClock}; + /// use cachekit::traits::{Cache, ExpiringCache}; + /// use std::time::Duration; + /// + /// let clock = MockClock::new(); + /// let mut cache = Expiring::with_default_ttl( + /// FastLru::::new(8), + /// clock, + /// None, + /// ); + /// + /// cache.insert_with_ttl(1, "short".into(), Duration::from_millis(50)); + /// cache.insert_with_ttl(2, "long".into(), Duration::from_millis(500)); + /// cache.clock().advance(Duration::from_millis(100)); + /// + /// assert_eq!(cache.len(), 2); // physical occupancy + /// assert_eq!(cache.live_len(), 1); // only "long" is still alive + /// ``` + #[must_use] + pub fn live_len(&self) -> usize { let now = self.clock.now(); - let mut expired: Vec<(K, u64)> = Vec::new(); - while let Some(entry) = self.index.pop_expired(now) { - expired.push(entry); - } - let live = self.inner.len().saturating_sub(expired.len()); - // Restore index entries so subsequent operations still see the - // deadlines; physical purge happens through `purge_expired` or a - // mutating access. `pop_expired` only ever yields entries with - // deadline <= now, so re-adding them keeps the state consistent. - for (k, deadline) in expired { - self.index.set_deadline(k, deadline); - } - live + let expired_count = self.index.iter().filter(|&(_, d)| d <= now).count(); + self.inner.len().saturating_sub(expired_count) } /// Removes `key` from inner and index, honouring the ordering @@ -252,19 +433,21 @@ where K: Eq + Hash + Clone, T: Clock, { + /// Returns `true` only if `key` is present **and** not expired. fn contains(&self, key: &K) -> bool { if !self.inner.contains(key) { return false; } - // Logical read: hide expired entries. match self.index.deadline_of(key) { Some(deadline) => deadline > self.clock.now(), None => true, } } + /// Physical entry count (includes expired-but-resident entries). + /// + /// Use [`live_len`](Self::live_len) for the logical count. fn len(&self) -> usize { - // Physical occupancy. See `live_len_value_aware` for the live count. self.inner.len() } @@ -272,6 +455,8 @@ where self.inner.capacity() } + /// Side-effect-free lookup; returns `None` for expired entries + /// without purging them. fn peek(&self, key: &K) -> Option<&V> { let value = self.inner.peek(key)?; match self.index.deadline_of(key) { @@ -280,6 +465,7 @@ where } } + /// Policy-tracked lookup; purges the entry on access if expired. fn get(&mut self, key: &K) -> Option<&V> { let now = self.clock.now(); if self.is_expired_at(key, now) { @@ -290,11 +476,11 @@ where self.inner.get(key) } + /// Inserts with the default TTL (if configured); replaces any + /// stale deadline. Returns `None` if the previous entry was expired. fn insert(&mut self, key: K, value: V) -> Option { let now = self.clock.now(); let was_expired = self.is_expired_at(&key, now); - - // Apply the default TTL (if any) to the new entry. if let Some(ttl_ticks) = self.default_ttl_ticks { let deadline = self.deadline_from(now, ttl_ticks); self.index.set_deadline(key.clone(), deadline); @@ -313,6 +499,7 @@ where } } + /// Removes `key`; returns `None` if it was already expired. fn remove(&mut self, key: &K) -> Option { let was_expired = self.is_expired_at(key, self.clock.now()); let removed = self.purge_one(key); @@ -324,6 +511,7 @@ where } } + /// Clears both the inner cache and the expiration index. fn clear(&mut self) { self.inner.clear(); self.index.clear(); @@ -340,6 +528,26 @@ where K: Eq + Hash + Clone, T: Clock, { + /// # Examples + /// + /// ``` + /// use cachekit::policy::expiring::Expiring; + /// use cachekit::policy::fast_lru::FastLru; + /// use cachekit::time::{Clock, MockClock}; + /// use cachekit::traits::{Cache, ExpiringCache}; + /// use std::time::Duration; + /// + /// let clock = MockClock::new(); + /// let mut cache = Expiring::with_default_ttl( + /// FastLru::::new(8), clock, None, + /// ); + /// + /// cache.insert_with_ttl(1, "a".into(), Duration::from_millis(100)); + /// assert_eq!(cache.peek(&1), Some(&"a".to_string())); + /// + /// cache.clock().advance(Duration::from_millis(101)); + /// assert_eq!(cache.get(&1), None); // expired and purged + /// ``` fn insert_with_ttl(&mut self, key: K, value: V, ttl: Duration) -> Option { let now = self.clock.now(); let was_expired = self.is_expired_at(&key, now); @@ -367,6 +575,28 @@ where } } + /// # Examples + /// + /// ``` + /// use cachekit::policy::expiring::Expiring; + /// use cachekit::policy::fast_lru::FastLru; + /// use cachekit::time::{Clock, MockClock}; + /// use cachekit::traits::{Cache, ExpiringCache, TtlStatus}; + /// use std::time::Duration; + /// + /// let clock = MockClock::new(); + /// let mut cache = Expiring::with_default_ttl( + /// FastLru::::new(8), clock, None, + /// ); + /// + /// assert_eq!(cache.ttl_status(&1), TtlStatus::Missing); + /// + /// cache.insert(1, "immortal".into()); + /// assert_eq!(cache.ttl_status(&1), TtlStatus::Immortal); + /// + /// cache.insert_with_ttl(2, "mortal".into(), Duration::from_millis(100)); + /// assert!(matches!(cache.ttl_status(&2), TtlStatus::Live { .. })); + /// ``` fn ttl_status(&self, key: &K) -> TtlStatus { if !self.inner.contains(key) { return TtlStatus::Missing; @@ -382,6 +612,24 @@ where } } + /// # Examples + /// + /// ``` + /// use cachekit::policy::expiring::Expiring; + /// use cachekit::policy::fast_lru::FastLru; + /// use cachekit::time::{Clock, MockClock}; + /// use cachekit::traits::{Cache, ExpiringCache}; + /// use std::time::Duration; + /// + /// let clock = MockClock::new(); + /// let mut cache = Expiring::with_default_ttl( + /// FastLru::::new(8), clock, None, + /// ); + /// + /// cache.insert_with_ttl(1, "a".into(), Duration::from_millis(100)); + /// assert!(cache.set_ttl(&1, Duration::from_millis(5_000))); // extended + /// assert!(!cache.set_ttl(&99, Duration::from_millis(100))); // missing key + /// ``` fn set_ttl(&mut self, key: &K, ttl: Duration) -> bool { let now = self.clock.now(); if !self.inner.contains(key) { @@ -403,6 +651,28 @@ where true } + /// # Examples + /// + /// ``` + /// use cachekit::policy::expiring::Expiring; + /// use cachekit::policy::fast_lru::FastLru; + /// use cachekit::time::{Clock, MockClock}; + /// use cachekit::traits::{Cache, ExpiringCache}; + /// use std::time::Duration; + /// + /// let clock = MockClock::new(); + /// let mut cache = Expiring::with_default_ttl( + /// FastLru::::new(8), clock, None, + /// ); + /// + /// cache.insert_with_ttl(1, "a".into(), Duration::from_millis(50)); + /// cache.insert_with_ttl(2, "b".into(), Duration::from_millis(50)); + /// cache.insert_with_ttl(3, "c".into(), Duration::from_millis(500)); + /// + /// cache.clock().advance(Duration::from_millis(100)); + /// assert_eq!(cache.purge_expired(), 2); // entries 1 and 2 + /// assert_eq!(cache.len(), 1); // only entry 3 remains + /// ``` fn purge_expired(&mut self) -> usize { let now = self.clock.now(); let mut count = 0; @@ -413,7 +683,7 @@ where // To preserve the documented invariant we don't pop first; instead // we peek, remove inner, then remove from index. loop { - let key_clone = match self.index.peek_deadline() { + let key_clone = match self.index.next_deadline() { Some((k, deadline)) if deadline <= now => k.clone(), _ => break, }; @@ -429,6 +699,16 @@ where } } +const _: () = { + fn _assert_send() {} + fn _check() { + use crate::policy::fast_lru::FastLru; + // Rc<()> for V proves the PhantomData V> choice keeps V + // out of auto-trait bounds. + _assert_send::, String, std::rc::Rc<()>, StdClock>>(); + } +}; + // Public alias: a clock-backed Expiring wrapper used by the builder. #[allow(dead_code)] pub(crate) type ExpiringStdClock = Expiring; diff --git a/src/time.rs b/src/time.rs index 763dee7..f4245ae 100644 --- a/src/time.rs +++ b/src/time.rs @@ -63,12 +63,7 @@ use std::time::{Duration, Instant}; /// practice but the cast keeps the contract explicit. #[inline] pub(crate) fn duration_to_ticks(duration: Duration) -> u64 { - let ms = duration.as_millis(); - if ms > u64::MAX as u128 { - u64::MAX - } else { - ms as u64 - } + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) } /// Monotonic millisecond clock consulted by the TTL layer. @@ -76,7 +71,25 @@ pub(crate) fn duration_to_ticks(duration: Duration) -> u64 { /// Implementations must be monotonic non-decreasing across calls and should /// never return `u64::MAX`, which the TTL layer reserves as the "effectively /// never expires" sentinel. -pub trait Clock: Send + Sync { +/// +/// This trait is **object-safe** and can be used as `dyn Clock`. +/// +/// # Examples +/// +/// ``` +/// use cachekit::time::Clock; +/// +/// #[derive(Debug)] +/// struct FixedClock(u64); +/// +/// impl Clock for FixedClock { +/// fn now(&self) -> u64 { self.0 } +/// } +/// +/// let clock = FixedClock(42); +/// assert_eq!(clock.now(), 42); +/// ``` +pub trait Clock: Send + Sync + std::fmt::Debug { /// Returns the current tick. /// /// The tick unit is implementation-defined but is conventionally @@ -104,13 +117,23 @@ pub trait Clock: Send + Sync { /// let b = clock.now(); /// assert!(b >= a); /// ``` -#[derive(Debug)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct StdClock { anchor: Instant, } impl StdClock { /// Creates a clock anchored at `Instant::now()`. + /// + /// # Examples + /// + /// ``` + /// use cachekit::time::{Clock, StdClock}; + /// + /// let clock = StdClock::new(); + /// let _tick = clock.now(); // ms since construction + /// ``` + #[must_use] pub fn new() -> Self { Self { anchor: Instant::now(), @@ -121,11 +144,31 @@ impl StdClock { /// /// Useful for tests that need to derive several clocks from a shared /// anchor without depending on wall-clock timing. + /// + /// # Examples + /// + /// ``` + /// use cachekit::time::{Clock, StdClock}; + /// use std::time::Instant; + /// + /// let anchor = Instant::now(); + /// let clock_a = StdClock::with_anchor(anchor); + /// let clock_b = StdClock::with_anchor(anchor); + /// // Both clocks share the same epoch. + /// assert!(clock_b.now() >= clock_a.now() || clock_a.now() == clock_b.now()); + /// ``` + #[must_use] pub fn with_anchor(anchor: Instant) -> Self { Self { anchor } } } +impl From for StdClock { + fn from(anchor: Instant) -> Self { + Self::with_anchor(anchor) + } +} + impl Default for StdClock { fn default() -> Self { Self::new() @@ -166,13 +209,41 @@ pub struct MockClock { now: AtomicU64, } +impl Clone for MockClock { + fn clone(&self) -> Self { + Self { + now: AtomicU64::new(self.now.load(Ordering::Relaxed)), + } + } +} + impl MockClock { /// Creates a clock anchored at tick `0`. + /// + /// # Examples + /// + /// ``` + /// use cachekit::time::{Clock, MockClock}; + /// + /// let clock = MockClock::new(); + /// assert_eq!(clock.now(), 0); + /// ``` + #[must_use] pub fn new() -> Self { Self::with_tick(0) } /// Creates a clock anchored at the supplied tick. + /// + /// # Examples + /// + /// ``` + /// use cachekit::time::{Clock, MockClock}; + /// + /// let clock = MockClock::with_tick(500); + /// assert_eq!(clock.now(), 500); + /// ``` + #[must_use] pub fn with_tick(tick: u64) -> Self { Self { now: AtomicU64::new(tick), @@ -183,9 +254,20 @@ impl MockClock { /// /// Saturates one short of `u64::MAX` because the TTL layer reserves /// `u64::MAX` as the "effectively never expires" sentinel. + /// + /// # Examples + /// + /// ``` + /// use cachekit::time::{Clock, MockClock}; + /// use std::time::Duration; + /// + /// let clock = MockClock::new(); + /// clock.advance(Duration::from_millis(100)); + /// clock.advance(Duration::from_secs(1)); + /// assert_eq!(clock.now(), 1_100); + /// ``` pub fn advance(&self, delta: Duration) { let ticks = duration_to_ticks(delta); - // `fetch_add` would wrap; use a CAS loop to saturate explicitly. let mut current = self.now.load(Ordering::Relaxed); loop { let next = current.saturating_add(ticks).min(u64::MAX - 1); @@ -205,11 +287,27 @@ impl MockClock { /// /// Callers are responsible for keeping the clock monotonic; setting a /// value lower than the current tick violates the `Clock` contract. + /// + /// # Examples + /// + /// ``` + /// use cachekit::time::{Clock, MockClock}; + /// + /// let clock = MockClock::new(); + /// clock.set(42); + /// assert_eq!(clock.now(), 42); + /// ``` pub fn set(&self, tick: u64) { self.now.store(tick, Ordering::Relaxed); } } +impl From for MockClock { + fn from(tick: u64) -> Self { + Self::with_tick(tick) + } +} + impl Clock for MockClock { #[inline] fn now(&self) -> u64 { @@ -217,6 +315,14 @@ impl Clock for MockClock { } } +const _: () = { + fn _assert_send_sync() {} + fn _check() { + _assert_send_sync::(); + _assert_send_sync::(); + } +}; + #[cfg(test)] mod tests { use super::*;