From 127a69b8366645f50c6c461da1b95df46deedd17 Mon Sep 17 00:00:00 2001 From: Thomas Korrison Date: Thu, 14 May 2026 12:30:11 +0100 Subject: [PATCH] refactor: enhance time management and documentation in cache library - Simplified the `duration_to_ticks` function to use `try_from` for better clarity and error handling. - Improved the `Clock` trait documentation, adding examples for better usability and understanding. - Updated `StdClock` and `MockClock` implementations with additional examples to demonstrate usage. - Refined the `ExpirationIndex` to utilize a `Deadline` type instead of `u64`, enhancing type safety and clarity in deadline management. - Expanded documentation across various components to improve clarity and provide practical examples for users. These changes enhance the overall usability and maintainability of the cache library, particularly in the context of time-based operations and expiration management. --- src/ds/expiration_index.rs | 328 ++++++++++++++++++++++++++++++++---- src/ds/mod.rs | 4 +- src/policy/expiring.rs | 334 ++++++++++++++++++++++++++++++++++--- src/time.rs | 124 +++++++++++++- 4 files changed, 725 insertions(+), 65 deletions(-) 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::*;