diff --git a/Cargo.toml b/Cargo.toml index f12e9fa..6e54af8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,9 @@ default = ["policy-s3-fifo", "policy-lru", "policy-fast-lru", "policy-lru-k", "p metrics = [] serde = ["dep:serde"] concurrency = ["parking_lot"] +# Time-based expiration (`Expiring` decorator, `Clock` trait, `DynExpiringCache`). +# See `docs/design/ttl.md`. +ttl = [] # Eviction policy feature flags. Enable only the policies you need for smaller builds. # Use `default-features = false` and select specific policies, or use `policy-all` for every policy. @@ -150,6 +153,13 @@ name = "policy_s3_fifo" path = "benches/policy/s3_fifo.rs" harness = false +# TTL decorator overhead (requires the `ttl` feature) +[[bench]] +name = "ttl_overhead" +path = "benches/ttl_overhead.rs" +harness = false +required-features = ["ttl"] + # Development profile - optimized for fast compilation and good debugging [profile.dev] opt-level = 0 diff --git a/benches/ttl_overhead.rs b/benches/ttl_overhead.rs new file mode 100644 index 0000000..d3c637f --- /dev/null +++ b/benches/ttl_overhead.rs @@ -0,0 +1,185 @@ +//! Overhead of the TTL decorator versus a bare LRU cache. +//! +//! Three workload groups (zipfian-read, scan + point lookup, mixed +//! read/write) compare: +//! +//! - `LruCore` — the policy as it exists today. +//! - `Expiring, u64, Arc, MockClock>` with a +//! 60-second default TTL — long enough that the bench never fires an +//! expiry but the decorator still does its bookkeeping on every op. +//! +//! Run with: `cargo bench --bench ttl_overhead --features ttl`. + +#![cfg(feature = "ttl")] + +use std::sync::Arc; +use std::time::Duration; + +use bench_support::workload::{Workload, WorkloadSpec}; +use cachekit::policy::expiring::Expiring; +use cachekit::policy::lru::LruCore; +use cachekit::time::MockClock; +use cachekit::traits::Cache; +use criterion::{BatchSize, Criterion, Throughput, criterion_group, criterion_main}; + +const CAPACITY: usize = 8_192; +const OPS: u64 = 4_096; +const UNIVERSE: u64 = 16_384; +const SEED: u64 = 0xc0fee; +const DEFAULT_TTL: Duration = Duration::from_secs(60); + +fn fresh_lru() -> LruCore { + let mut cache = LruCore::new(CAPACITY); + for i in 0..CAPACITY as u64 { + cache.insert(i, Arc::new(i)); + } + cache +} + +fn fresh_expiring() -> Expiring, u64, Arc, MockClock> { + let inner = fresh_lru(); + Expiring::with_default_ttl(inner, MockClock::new(), Some(DEFAULT_TTL)) +} + +fn make_generator(workload: Workload) -> bench_support::workload::WorkloadGenerator { + WorkloadSpec { + universe: UNIVERSE, + workload, + seed: SEED, + } + .generator() +} + +// --------------------------------------------------------------------------- +// Group: zipfian-read (skewed get-only workload) +// --------------------------------------------------------------------------- + +fn bench_zipfian_read(c: &mut Criterion) { + let mut group = c.benchmark_group("ttl_overhead/zipfian_read"); + group.throughput(Throughput::Elements(OPS)); + + group.bench_function("plain_lru", |b| { + b.iter_batched( + fresh_lru, + |mut cache| { + let mut wl = make_generator(Workload::Zipfian { exponent: 1.1 }); + for _ in 0..OPS { + let _ = std::hint::black_box(cache.get(&wl.next_key())); + } + }, + BatchSize::SmallInput, + ) + }); + + group.bench_function("expiring_lru", |b| { + b.iter_batched( + fresh_expiring, + |mut cache| { + let mut wl = make_generator(Workload::Zipfian { exponent: 1.1 }); + for _ in 0..OPS { + let _ = std::hint::black_box(cache.get(&wl.next_key())); + } + }, + BatchSize::SmallInput, + ) + }); + + group.finish(); +} + +// --------------------------------------------------------------------------- +// Group: scan + point lookup (mixed sequential scan and skewed lookups) +// --------------------------------------------------------------------------- + +fn bench_scan_plus_point(c: &mut Criterion) { + let workload = Workload::ScanResistance { + scan_start_prob: 0.05, + scan_length: 32, + point_exponent: 1.0, + }; + let mut group = c.benchmark_group("ttl_overhead/scan_plus_point"); + group.throughput(Throughput::Elements(OPS)); + + group.bench_function("plain_lru", |b| { + b.iter_batched( + fresh_lru, + |mut cache| { + let mut wl = make_generator(workload); + for _ in 0..OPS { + let _ = std::hint::black_box(cache.get(&wl.next_key())); + } + }, + BatchSize::SmallInput, + ) + }); + + group.bench_function("expiring_lru", |b| { + b.iter_batched( + fresh_expiring, + |mut cache| { + let mut wl = make_generator(workload); + for _ in 0..OPS { + let _ = std::hint::black_box(cache.get(&wl.next_key())); + } + }, + BatchSize::SmallInput, + ) + }); + + group.finish(); +} + +// --------------------------------------------------------------------------- +// Group: mixed read/write (90% read, 10% insert, both zipfian) +// --------------------------------------------------------------------------- + +fn bench_mixed_rw(c: &mut Criterion) { + let mut group = c.benchmark_group("ttl_overhead/mixed_rw"); + group.throughput(Throughput::Elements(OPS)); + + group.bench_function("plain_lru", |b| { + b.iter_batched( + fresh_lru, + |mut cache| { + let mut wl = make_generator(Workload::Zipfian { exponent: 1.0 }); + for i in 0..OPS { + let key = wl.next_key(); + if i % 10 == 0 { + cache.insert(key, Arc::new(key)); + } else { + let _ = std::hint::black_box(cache.get(&key)); + } + } + }, + BatchSize::SmallInput, + ) + }); + + group.bench_function("expiring_lru", |b| { + b.iter_batched( + fresh_expiring, + |mut cache| { + let mut wl = make_generator(Workload::Zipfian { exponent: 1.0 }); + for i in 0..OPS { + let key = wl.next_key(); + if i % 10 == 0 { + cache.insert(key, Arc::new(key)); + } else { + let _ = std::hint::black_box(cache.get(&key)); + } + } + }, + BatchSize::SmallInput, + ) + }); + + group.finish(); +} + +criterion_group!( + benches, + bench_zipfian_read, + bench_scan_plus_point, + bench_mixed_rw +); +criterion_main!(benches); diff --git a/docs/design/ttl.md b/docs/design/ttl.md new file mode 100644 index 0000000..5a8d5b4 --- /dev/null +++ b/docs/design/ttl.md @@ -0,0 +1,683 @@ +# TTL / Time-Based Expiration — Design Exploration + +> Status: design exploration. Companion to the high-level stub at +> [`docs/policies/roadmap/ttl.md`](../policies/roadmap/ttl.md). + +TTL is **not** a replacement policy; it is an expiration rule that coexists +with an eviction policy. This document explores how TTL can be introduced into +`cachekit` while preserving the project's invariants: + +- policy ↔ storage separation (see [`src/store/traits.rs`](../../src/store/traits.rs)) +- allocation-free hot paths +- O(1) eviction with index/handle indirection +- explicit, opt-in concurrency and metrics + +## Current State + +- No TTL exists in source today (`rg ttl|expir|Instant` finds only docs and + benchmark labels). +- A high-level stub already exists at + [`docs/policies/roadmap/ttl.md`](../policies/roadmap/ttl.md). +- The `ds::LazyMinHeap` primitive at [`src/ds/lazy_heap.rs`](../../src/ds/lazy_heap.rs) + explicitly lists "TTL expiry heaps" as a use case. +- The capability-trait pattern at [`src/traits.rs`](../../src/traits.rs) + (`RecencyTracking`, `FrequencyTracking`, `HistoryTracking`) gives a clean + injection point for an `ExpiringCache` trait. +- The runtime-policy enum at [`src/builder.rs`](../../src/builder.rs) + (`DynCache` / `CacheInner`) makes a TTL wrapper composable in one variant + rather than 18 per-policy edits. Note: `CacheInner` currently wires + 17 of the 18 policy modules under `src/policy/`; `policy::car` is not + yet a variant. Closing that gap is a prerequisite for "TTL works for + every policy via `DynCache`". + +--- + +## 1. Design Patterns + +Five viable patterns, ordered roughly by invasiveness. + +### a) Decorator / wrapper cache + +A new struct `Expiring` owns an inner `C: Cache` plus a per-key +expiry index, intercepting `get` / `peek` / `insert` / `remove` to consult +the index. + +```rust +pub struct Expiring { + inner: C, + index: ExpirationIndex, + clock: T, + default_ttl: Option, +} + +impl Cache for Expiring +where + C: Cache, + K: Eq + Hash + Clone, + T: Clock, +{ /* … */ } +``` + +`K` must appear as a type parameter on the wrapper itself because the index +is keyed by `K`; threading it only through the `Cache` impl is not +enough. + +- **Pros:** zero churn on the 18 existing policies; opt-in; composes with + `DynCache`; matches the policy/storage separation rule in `.cursorrules`. +- **Cons:** when the index is hash-keyed (e.g., `LazyMinHeap`), + reads pay one extra hash probe; when the index is intrusive over a + shared `SlotArena` (option E in §3) reads pay only a pointer compare. + Two sources of truth that must stay consistent; cannot piggy-back on + intrusive list slots inside `LruCore` / `S3FifoCache` without crossing + the wrapper boundary. +- **Ordering invariant:** the wrapper must always remove from the inner + cache *before* removing from the expiration index. A panic in the inner + removal leaves the index pointing at a key the cache still holds (next + `pop_expired` will be a no-op or remove the now-stale entry), which is + recoverable. The reverse order leaves the index missing a key the cache + still holds, which silently loses the deadline. Document and test this + ordering — it is the single non-obvious correctness rule of the wrapper. +- **Important semantic constraint:** today's [`Cache`](../../src/traits.rs) + trait has `peek`, `contains`, and `len` as `&self` methods. A decorator + cannot physically remove expired entries from those methods unless it adds + interior mutability. The first slice should define them as *logical* reads: + expired entries are invisible to `peek` / `contains`, while physical cleanup + happens on `get`, `insert`, `remove`, `clear`, or explicit + `purge_expired`. **Decision:** `Cache::len` returns physical occupancy + — it is cheaper, matches the underlying cache trait, and is the only + thing implementable through `&self`. Surprise after time advances is + mitigated by exposing `Expiring::live_len(&mut self) -> usize` as an + inherent method on the wrapper, which can amortize an internal sweep. + Document the distinction in both rustdocs. +- **Mutation semantics:** expired-but-resident entries should behave as + logically missing. `get` / `remove` should purge and return `None`; + `insert` / `insert_with_ttl` should purge the stale value before inserting + and return `None` rather than exposing a value that was already expired. + The only time insertion/removal returns the previous value is when the prior + entry was still live at the operation's `now`. + +### b) Capability trait + per-policy implementation + +Add an extension trait alongside `RecencyTracking` / `FrequencyTracking`: + +```rust +pub trait ExpiringCache: Cache { + fn insert_with_ttl(&mut self, key: K, value: V, ttl: Duration) -> Option; + fn ttl_status(&self, key: &K) -> TtlStatus; + fn set_ttl(&mut self, key: &K, ttl: Duration) -> bool; + fn purge_expired(&mut self) -> usize; +} + +pub enum TtlStatus { + Missing, + Immortal, + Expired, + Live { remaining: Duration, deadline: Tick }, +} +``` + +See §4(a) for the `Tick` newtype rationale. Each policy embeds an +`expires_at: u64` field in its node struct (`Node` in +[`src/policy/lru.rs`](../../src/policy/lru.rs), +[`src/policy/s3_fifo.rs`](../../src/policy/s3_fifo.rs), etc.) and shares an +`ExpirationIndex` data structure. The embedded `u64` is the in-memory +representation; the public `TtlStatus::Live` surface still hands callers a +`Tick`, not a raw `u64`. + +- **Pros:** optimal layout — expiration co-located with the existing + slot/node; `purge_expired` can interact with policy ordering (e.g., update + the LRU list in place). +- **Cons:** 18 policies × N integration points; touches every + `Cache::insert` / `get`; harder to gate cleanly behind a feature flag. + +### c) Storage-level TTL + +Move expiration into a new `ExpiringStore` that wraps a +`StoreCore` / `StoreMut`. Policies stay TTL-unaware; the store returns `None` +(and increments `evictions`) on expired reads. + +- **Pros:** pure to the policy ↔ storage separation; works for *every* + policy without code change; natural fit for `HashMapStore`. +- **Cons:** policies that hold their own metadata for a now-expired key + (intrusive list links, frequency buckets, ghost entries) end up with + dangling metadata until the policy notices on its next eviction. Needs an + "evict by key" callback on the policy. + +### d) Observer / callback (entry-aware policy) + +Combine (b) and (c) by giving policies an `on_evict(key)` hook the store can +invoke when it lazily detects expiration. + +- **Pros:** keeps both sides consistent. +- **Cons:** requires a new policy trait and re-plumbing; adds an indirection. + +### e) Trait-object mixin (not recommended) + +A `Box` injected into any policy. Conflicts with the +.cursorrules guidance to "minimize Arc usage in hot paths" and "avoid heavy +Rust ergonomics in hot loops (trait objects, …)". + +### Recommendation + +Ship (a) first as a `ttl` feature, but be explicit that the wrapper gives +logical expiration over the current `Cache` trait rather than a zero-overhead +embedded TTL. + +For builder integration, prefer a **separate `DynExpiringCache` +type** returned by a TTL-specific builder path over implementing +`Cache` for `DynCache` and wrapping it. The decisive reason +is that the first option permits `Expiring>` to type- +check — two clocks, two indexes, surprising semantics — and we have no +clean way to disallow it at the type level once `DynCache: Cache`. A +distinct expiring type makes double-wrapping impossible by construction. +The cost is one extra public type and minor delegation boilerplate; the +benefit is that the only TTL surface is the one the builder hands out. + +Then, where profiling justifies it, embed `expires_at` into specific +policies (b) — LRU, FastLRU and S3-FIFO are the high-value targets. The +embed must be opt-in per-node so non-TTL users do not pay 8 bytes per +entry (see §6, step 7). + +--- + +## 2. Which Policies Can Use It + +TTL is orthogonal to eviction, but the *interaction* differs by policy. + +| Policy | Embedded TTL fit | Interaction notes | +|---|---|---| +| FIFO, LIFO | Trivial — single VecDeque/Vec; one extra `u64` per entry | Eviction order independent; expire-first then evict-by-policy | +| LRU, FastLRU | Excellent — `Node` already has `prev/next/key/value`, add `u64` | Expired LRU node short-circuits `pop_lru` | +| S3-FIFO | Excellent — slot arena nodes; expiry can drop from Small/Main without changing admission history | Ghost list should track *capacity evictions*, not TTL expiry | +| LRU-K, SLRU, 2Q | Natural — multi-segment node already exists; expiry can drop probationary entries cheaply | Promote-and-expire interaction matters: do not promote already-expired | +| Clock, Clock-PRO, NRU | Natural — clock entry already has a reference bit; add `u64`; sweep can expire on the way past | Clock-PRO's adaptive logic must treat expiry-evictions separately from cold-list evictions | +| LFU, Heap-LFU, MFU | Works, but TTL-evictions distort frequency stats. Either decay frequency on expire or accept skew | Co-locate with the existing `LazyMinHeap` if desired | +| ARC, CAR | Same as LFU — adaptive parameter `p` should not move on a TTL eviction | Track TTL-evictions separately to avoid mis-tuning. CAR is not currently a `DynCache` variant; TTL-via-builder for CAR is gated on closing that gap first. | +| Random | Trivial; no ordering to corrupt | — | + +The least useful combinations are MFU and pure ARC where TTL competes with +the policy's signal. Document the warning rather than disabling. + +--- + +## 3. Data Structures & Algorithms + +The codebase already owns most of the building blocks: `SlotArena`, +`LazyMinHeap`, `IntrusiveList`, `GhostList`, `ClockRing`. Concrete options +for the expiration index follow. + +### A) Lazy min-heap of `(expires_at, key)` + +`ds::LazyMinHeap` already exists at +[`src/ds/lazy_heap.rs`](../../src/ds/lazy_heap.rs) and explicitly lists TTL +in its use cases. Insertion is O(log n); `pop_best` is amortized O(log n); +`update` is O(log n) with `maybe_rebuild` to bound staleness. + +Used as a TTL index, this needs a thin `ExpirationIndex` wrapper over +`LazyMinHeap` rather than using the heap directly. The wrapper should expose: + +```rust +pub struct ExpirationIndex { /* LazyMinHeap */ } + +impl ExpirationIndex { + pub fn set_deadline(&mut self, key: K, expires_at: u64) -> Option { + /* ... */ + } + + pub fn remove(&mut self, key: &Q) -> Option + where + K: Borrow, + Q: Hash + Eq + ?Sized, + { + /* ... */ + } + + pub fn peek_deadline(&mut self) -> Option<(&K, u64)> { + /* ... */ + } + + pub fn pop_expired(&mut self, now: u64) -> Option<(K, u64)> { + /* ... */ + } +} +``` + +`LazyMinHeap` currently has destructive `pop_best` but no non-destructive +live-minimum peek (verified against [`src/ds/lazy_heap.rs`](../../src/ds/lazy_heap.rs): +only `update`, `pop_best`, `with_auto_rebuild`, `maybe_rebuild` are public). +The first slice should **add a `peek_best` primitive to `LazyMinHeap`** +rather than reimplementing live-minimum logic inside `ExpirationIndex`. +The wrapper approach would have to inspect the heap's internal staleness +state to skip popped entries, which couples `ExpirationIndex` to +`LazyMinHeap`'s representation. A `peek_best(&mut self) -> Option<(&K, &S)>` +that drains stale-tombstoned roots in place (mutating because lazy +deletion may need to advance past tombstones, immutable observation +otherwise) is the right primitive and is reusable outside TTL. + +- **Pros:** smallest delta — reuse an existing primitive, single allocation + pool, no clock-tick budget. +- **Cons:** `purge_expired(now)` is O(k log n) for k newly-expired entries; + per-insert log n is heavier than LRU's O(1); keys are cloned into the heap + and backing `HashMap`; stale heap entries must be bounded with + `with_auto_rebuild` / `maybe_rebuild`. + +### B) Hashed timer wheel (single wheel) + +N slots, each a `Vec` (or `IntrusiveList`); insert at +`slot = (expires_at / tick) % N`; on advance, drain the current slot. + +- **Pros:** O(1) insert; O(1) per-tick expire; no per-insert log. +- **Cons:** bounded TTL range = `N * tick`; long TTLs need overflow lists; + one-shot timers only. + +### C) Hierarchical timer wheel (Linux-style) + +Multiple wheels (e.g., 256 ms, 16 s, 1024 s, …) cascading on overflow. + +- **Pros:** O(1) amortized for arbitrary TTL ranges; widely deployed (Netty, + the older Tokio `time` module). +- **Cons:** most complex; cascading is bookkeeping-heavy and increases code + size; overkill for a cache library. + +### D) Sorted index over `expires_at` + +A `BTreeMap>` or `IntrusiveList` per bucket time. + +- **Pros:** easy `purge_expired(now)` via `range(..=now)`; predictable. +- **Cons:** `BTreeMap` is allocation-heavy and branchy; violates the + "favor memory layout efficiency" rule. + +### E) Intrusive expiry list per slot/segment + +For `LruCore`-style policies, a *second* doubly-linked list through the same +`SlotArena`, ordered by insertion-time TTL. If TTL is uniform per-cache (a +single global `default_ttl`), this becomes O(1): a simple FIFO over +expiration order, no priority queue needed. + +- **Pros:** zero heap allocation; O(1) for the common "all entries share the + same TTL" case; perfect fit for `SlotArena` + `IntrusiveList`. +- **Cons:** does not help when TTL is per-entry-variable. + +### F) Generation / epoch counter (invalidation, not TTL) + +Tag every entry with the cache's `epoch`; bump epoch to invalidate +everything; lazy purge on access. + +- **Pros:** O(1) full invalidation, useful primitive for "burst flush". +- **Cons:** not real TTL — closer to a `clear`-on-mismatch. + +### Time source + +Do not hardcode `std::time::Instant`. Introduce a small `Clock` trait so +tests/benches can use a mock and so users on `no_std`-adjacent targets can +plug their own: + +```rust +pub trait Clock { + /// Monotonic ticks. Recommended unit: milliseconds. + fn now(&self) -> u64; +} +``` + +- `StdClock(Instant)` is the default. +- `MockClock(AtomicU64)` is essential for deterministic tests, `proptest` + strategies, and fuzz seeds. +- The trait deliberately takes `&self` (not `&mut self`). This keeps + `Cache::peek`/`Cache::contains` capable of consulting the clock through + shared references, and it keeps the clock free to live behind the same + read lock as the inner cache. The cost is that any clock with mutable + state (notably `MockClock`) must use interior mutability — `AtomicU64` + is the recommended choice because it is also `Send + Sync` and so + composes with the `Concurrent*` wrappers without further work. +- Storing `u64` ticks (not `Instant`) shrinks the hot path to a single + integer compare (one branch in any sane codegen), avoids the + `Option`-returning `Instant::checked_duration_since` round-trip, and + keeps the deadline cheaply comparable, serializable, and 8-byte aligned. + +### Algorithm cheat sheet + +- **On insert:** convert `ttl` to ticks, compute + `expires_at = clock.now().checked_add(ttl_ticks)`, and index the deadline. + On overflow, **saturate to `u64::MAX`** with documented "effectively + never expires" semantics. Saturation (rather than returning an error) + is chosen because `insert_with_ttl` returns `Option` with no error + channel; changing the return type to `Result, _>` for a + failure mode that only triggers on TTLs of ≥500 million years is a poor + trade. Document this in the rustdoc so callers passing + `Duration::MAX` get expected behavior. +- **On zero TTL:** do not store a new entry. For an existing key, + `insert_with_ttl(key, value, Duration::ZERO)` should remove the existing + entry and return the previous value only if that entry is still live. If it + is already expired, purge it and return `None`. Per-entry TTL always + wins over `default_ttl`, including `Duration::ZERO` — i.e. a caller + explicitly opting into immediate expiry must not be silently upgraded + to the default TTL. +- **On read:** if `entry.expires_at <= clock.now()` → policy-remove + count + as miss. The comparison is `<=` (not `<`) because the deadline is the + first tick at which the entry is no longer live — equality means "this + tick has begun and the entry is already past it." With millisecond + ticks and `Duration::ZERO` defined as immediate expiry, `<=` is the + only choice consistent with both: a one-tick TTL inserted at tick `t` + expires at exactly tick `t + 1`. `peek` / `contains` may report the + key as absent without removing it when only `&self` is available. +- **On remove:** an expired resident entry is purged and returns `None`; + callers should not observe stale values through mutation APIs. +- **On insert/update:** check the prior entry's deadline before returning the + replaced value. Replacing a live entry returns `Some(old_value)`; replacing + an expired resident entry purges it first and returns `None`. +- **Periodic (or on insert when full):** + while `peek_deadline()` returns a deadline `<= now`, call + `pop_expired(now)` and remove that key from the wrapped cache. +- **Eviction precedence:** "evict expired first, then policy victim" — the + rule already documented in + [`docs/policies/roadmap/ttl.md`](../policies/roadmap/ttl.md). + +--- + +## 4. Unified API & Integration + +`cachekit` already has a strong pattern for optional capabilities: +extension traits in [`src/traits.rs`](../../src/traits.rs) and a `DynCache` +enum in [`src/builder.rs`](../../src/builder.rs). TTL fits the same shape. + +### a) New capability trait alongside the others + +```rust +pub trait ExpiringCache: Cache { + fn insert_with_ttl(&mut self, key: K, value: V, ttl: Duration) -> Option; + fn ttl_status(&self, key: &K) -> TtlStatus; + fn set_ttl(&mut self, key: &K, ttl: Duration) -> bool; + fn purge_expired(&mut self) -> usize; +} + +/// Monotonic deadline expressed in the cache's tick unit. +/// +/// Wrapping the raw `u64` keeps the tick representation out of the +/// public API surface; users compare it via `Tick`'s methods or convert +/// to/from `Duration` via the cache. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Debug)] +pub struct Tick(u64); + +pub enum TtlStatus { + Missing, + Immortal, + Expired, + Live { remaining: Duration, deadline: Tick }, +} +``` + +This sits beside `RecencyTracking` / `FrequencyTracking` / +`HistoryTracking` and stays object-safe. Prefer `ttl_status` over separate +`expires_at` / `ttl_remaining` methods so users can distinguish missing keys, +non-expiring keys, expired-but-not-yet-purged keys, and live TTL entries. + +Exposing `Tick` rather than a bare `u64` matters because the tick unit +(ms vs. ns vs. wall-clock seconds) is a private implementation choice. +The newtype lets the unit move without breaking downstream callers, and +gives a natural place to hang `Tick::saturating_add(Duration)`, +`Tick::as_duration_since(&self, Clock)`, and `serde` glue. + +**Precedence rule for `default_ttl` and per-entry TTL:** per-entry TTL +always wins. Calling `insert_with_ttl(k, v, Duration::ZERO)` on a cache +with `default_ttl(Some(60s))` configured must produce immediate expiry, +not a 60-second TTL. Plain `insert(k, v)` uses the default; specifying +any TTL — including zero — overrides it. State this explicitly in both +the trait docs and the builder docs because the "default + explicit +override" pattern is ambiguous in user code. + +### b) `Clock` abstraction for testability + +A `Clock` parameter on `Expiring` (default `StdClock`) and on any +policy that embeds expiry. Mirrors how `RandomCore` keeps `rng_state` +rather than calling `rand::thread_rng()` directly. + +### c) Builder integration + +Two complementary surfaces: + +```rust +let mut cache = CacheBuilder::new(1000) + .with_default_ttl(Duration::from_secs(60)) + .build::(CachePolicy::Lru); + +cache.insert_with_ttl(1, v, Duration::from_secs(5)); +``` + +Internally, `with_default_ttl(Some(_))` switches the builder to produce +a `DynExpiringCache` (separate public type) rather than a +`DynCache`. The two paths considered were: + +1. Add `impl Cache for DynCache` and store + `CacheInner::Ttl(Expiring>)` / an equivalent wrapper + around the already-built `DynCache`. +2. Introduce a separate `DynExpiringCache` returned by a TTL-specific + builder path, avoiding a recursive enum at the cost of another public type. + +Option (2) is recommended (see §1 Recommendation). The deciding factor is +that option (1) lets `Expiring>` type-check, which is +silently wrong (two clocks, two indexes). Option (2) makes double- +wrapping unrepresentable: `Expiring` is only constructed through the +builder, and the builder never returns an inner expiring cache. The +document's "one new variant, not 18" goal still holds — the new type +delegates `Cache::insert` etc. via a single match arm per inner policy, +identical to the existing `DynCache` boilerplate but with the expiry +check threaded through. The duplication is real but bounded. + +### d) Feature gating + +A `ttl` feature; `chrono` is already a dev-dep (see [`Cargo.toml`](../../Cargo.toml)), +but TTL itself should depend only on `std::time` and the existing +`LazyMinHeap` / `SlotArena`. The `ExpirationIndex` lives at +`src/ds/expiration_index.rs` but is gated behind `#[cfg(feature = "ttl")]` +so the `ds` module does not grow a time abstraction when TTL is disabled. +The new `Clock` trait at `src/time.rs` is similarly gated. Keep `metrics` +integration lightweight: extend `LruMetrics` / `StoreMetrics` with +`expirations: u64` behind `metrics`, similar to how `evictions` is tracked +today (see [`src/store/traits.rs`](../../src/store/traits.rs) `StoreMetrics`). + +### e) Concurrent variants + +The existing `Concurrent*` wrappers (`ConcurrentLruCache` in +[`src/policy/lru.rs`](../../src/policy/lru.rs), `ConcurrentSlotArena` in +[`src/ds/slot_arena.rs`](../../src/ds/slot_arena.rs), +`ConcurrentClockRing` in [`src/ds/clock_ring.rs`](../../src/ds/clock_ring.rs)) +wrap their single-threaded core in `parking_lot::RwLock`. TTL follows that +shape with two non-negotiable rules: + +1. **Return owned/`Arc`, not `&V`.** The `Cache::get(&mut self) -> Option<&V>` + signature cannot be implemented safely on `ConcurrentExpiring` + without holding the write lock across the borrow, which serializes + readers and defeats the point of `RwLock`. `ConcurrentExpiring` + therefore exposes `fn get(&self, key: &K) -> Option>` (and the + sibling mutators), and does **not** implement `Cache`. It is a + concrete type with its own API, mirroring how `ConcurrentLruCache` + already deviates from `Cache`. +2. **Atomic expiry-and-removal.** The expiry check, policy removal, and + index removal must be one atomic write-locked operation. Splitting + them allows a concurrent `set_ttl` or `insert` to race with a stale + expiry decision and produce a "you remove an entry I just renewed" + outcome. A read-locked fast path (check `expires_at <= now` under a + read lock, escalate to write lock for the actual removal) is safe so + long as the write-locked path re-checks the deadline before acting. + +### f) `DynCache` touchpoint + +With the `DynExpiringCache` route chosen in §4(c), `DynCache` itself +is **untouched** by TTL. The new type lives next to `DynCache` and mirrors +its match-arm boilerplate one level out (the expiry check happens before +delegating to the inner policy's `Cache::insert`/`get`/etc.). The `Debug` +impl on `DynExpiringCache` should report TTL mode (default TTL, clock +type) without exposing keys or deadlines. + +### g) `prelude.rs` + +Re-export `ExpiringCache`, `Clock`, `StdClock`, `Expiring` so users get them +via `use cachekit::prelude::*;`. + +--- + +## 5. Trade-offs Side-by-Side + +### Pattern trade-offs + +| Pattern | Hot-path read cost | Hot-path insert cost | Code churn | Fits .cursorrules | When to pick | +|---|---|---|---|---|---| +| (a1) `Expiring` + heap index (A) | +1 hash probe, +1 cmp | O(log n) heap insert + key clone | Low, plus `DynExpiringCache` plumbing | Mostly — preserves separation, not allocation-free | First TTL iteration; benchmark the overhead | +| (a2) `Expiring` + intrusive list (E) | +1 cmp (no hash probe) | O(1) list push | Low–medium (intrusive list reuse) | Yes for uniform TTL | When TTL is uniform across all entries | +| (b) Per-policy embedded | +1 cmp | +1 cmp | High (18 policies × edits) | Yes — best layout | Long-term, after profiling shows the decorator overhead matters | +| (c) Storage-level | +1 cmp inside store | +1 cmp inside store | Medium (new store + callbacks) | Mostly | If also adding size-aware eviction or weight-aware stores | +| (d) Observer/callback | medium | medium | High | Mixed (adds vtable-ish hop) | Multi-tenant caches with heterogeneous policies | +| (e) Trait-object mixin | high (vcall) | high (vcall) | Low | No — violates trait-object/Arc rule | Don't | + +### Index data-structure trade-offs + +| Index | Insert | Expire batch | Memory / entry | Variable TTL? | Fits `ds` module | +|---|---|---|---|---|---| +| Lazy min-heap (A) | O(log n) + key clone | O(k log n) amortized | HashMap entry + heap entry + sequence; stale entries bounded by rebuild | Yes | Already exists (`LazyMinHeap`) | +| Single timer wheel (B) | O(1) | O(slot size) per tick | ~8 B + slot vec | Bounded | New ds module (`TimerWheel`) | +| Hierarchical wheel (C) | O(1) am. | O(1) am. with cascade | ~8 B + multi-wheel | Yes | New, large addition | +| BTreeMap (D) | O(log n) | O(log n) range drain | ~32 B + alloc | Yes | Doesn't fit (allocations) | +| Intrusive expiry list (E) | O(1) | O(k) | 2 × usize per entry | No (uniform TTL) | Reuses `SlotArena` + `IntrusiveList` | +| Epoch tag (F) | O(1) | O(1) (lazy) | 8 B per entry | N/A | Trivial | + +### Eviction-policy interaction trade-offs + +- **TTL-eviction-first** is universally good (cheap miss, frees space without + using the policy's signal), but it changes hit/miss accounting. Either + expose `evictions_ttl` and `evictions_capacity` separately in metrics, or + benchmarks will be misread. +- For frequency-sensitive policies (LFU, MFU, ARC, CAR), TTL-evictions + should **not** update frequency or the adaptive parameter `p`. This is + the single biggest correctness footgun. +- For LRU-K and SLRU, an expired probationary entry is fine to drop + silently; an expired protected/hot entry should still call the demotion + path so segment counters stay consistent. +- For S3-FIFO, an expired entry **should not** seed the ghost list. The ghost + list should represent keys rejected by capacity pressure, not keys whose + freshness window elapsed. TTL expiry should remove resident state without + teaching the admission policy that the key deserved to survive. + +### Time source trade-offs + +- `Instant` → `u64` ticks: lossy but predictable; `Duration::as_millis()` + cast to `u64` covers ~585 million-year cache lifetimes (u64::MAX ms ≈ + 5.85 × 10⁸ years). +- Calling `Instant::now()` on every `get` is ~15–25 ns on modern Linux + (vDSO `CLOCK_MONOTONIC`), ~30–60 ns on macOS, and noticeably more on + Windows. The overhead is small but measurable against an LRU `get` of + ~30 ns. Consider amortizing via a coarse-clock thread (read once per + ms) when latency matters; benchmark before optimizing. +- `MockClock(AtomicU64)` is essential for deterministic tests and for + `proptest` / fuzz strategies. `AtomicU64` (rather than `Cell` or + bare `u64`) is required because `Clock::now(&self)` is `&self`-only + and `MockClock` must remain `Send + Sync` to compose with concurrent + cache wrappers. + +### API trade-offs + +- **Single `default_ttl` vs. per-entry `insert_with_ttl`:** support both. + The default is the 90% case (CDN, API caches); per-entry is needed for + negative caching ("not found" entries with shorter TTL). +- **Return value of `get` on expired key:** `None` is the right call. + Returning `Some(&V)` with a side-channel "stale" flag is over-engineering + for a cache library. +- **Sliding vs. absolute TTL:** pick *absolute* by default (`expires_at` set + on insert) and add `touch_extends_ttl: bool` as an opt-in; sliding TTL + silently corrupts time-based bounds. +- **Status reporting:** use a status enum rather than `Option` so + users can distinguish missing, immortal, expired, and live entries. +- **Serialization:** monotonic ticks are not portable across process restarts. + If `serde` support is added, serialize TTL entries as relative remaining + durations captured at serialization time, not raw `Instant`-derived ticks. + +--- + +## 6. Recommended First Slice + +A pragmatic phased roadmap: + +1. New module `src/policy/expiring.rs` with `Expiring` + decorator. Define `peek` / `contains` as logical reads; `Cache::len` + reports physical occupancy; add an inherent `Expiring::live_len(&mut self)` + for callers that need the live count (see §1(a) Decision). +2. New ds module `src/ds/expiration_index.rs` backed by + `LazyMinHeap` (cheap reuse) with auto-rebuild enabled to bound + stale heap growth. Add a `peek_best` primitive to `LazyMinHeap` + itself (see §3.A) so `ExpirationIndex` can implement + `peek_deadline` / `pop_expired(now)` without coupling to heap + internals. Leave the door open to swap in a timer wheel later. Both + files are gated behind `#[cfg(feature = "ttl")]`. +3. `Clock` trait + `StdClock` / `MockClock` in a new `src/time.rs`. +4. `ExpiringCache` capability trait in `src/traits.rs`, using + `TtlStatus` for unambiguous status reporting. +5. `CacheBuilder::with_default_ttl(Duration)` returns a separate + `DynExpiringCache` (not `DynCache`) — see §1 Recommendation + and §4(c). This makes `Expiring>` structurally + unrepresentable. +6. Feature flag `ttl`; metrics field `expirations` (gated on `metrics`); + doctests + a fuzz seed; benchmark group `ttl_overhead` that compares + plain LRU vs. `Expiring` under the existing Zipfian and scan + workloads. +7. **Phase 2:** profile (a) and, if the extra hash hit shows up in + flamegraphs, embed `expires_at: u64` into `LruCore::Node` and + `S3FifoCache::Node` (the two highest-traffic policies in the existing + benches at [`benches/`](../../benches)) — but **opt-in per node**, not + unconditionally. Two viable shapes: + - A const generic `Node` so non-TTL caches + monomorphize to the slimmer layout. + - A separate type `LruWithTtl` (and `S3FifoWithTtl`) + that wraps the slot arena with a parallel `Vec` keyed by slot + handle. + Embedding `expires_at` unconditionally would add 8 bytes per node to + every LRU and S3-FIFO instance — a 10–25% memory regression for the + common case of fixed-size value caches — and would regress the very + benchmarks step 6 is using as a gate. The `.cursorrules` "keep + metadata tight" rule applies here. + +This sequence preserves policy/storage separation and keeps TTL opt-in, but +the decorator does not preserve every hot-path invariant: inserts pay heap +maintenance, the index clones keys, and expired entries may remain physically +resident until a mutable operation purges them. The benchmark gate in step 6 +is therefore part of the design, not optional cleanup. + +--- + +## 7. Open Questions + +- Should `purge_expired` be exposed publicly, run on a background thread, + triggered on insert-when-full, or all three (configurable)? +- Should the `Clock` trait live in a top-level `time` module or inside `ds`? + Step 6.3 currently picks `src/time.rs`; revisit if `no_std` support + becomes a constraint. +- How should serialization (under `serde` feature) handle `expires_at` — + the current recommendation is relative remaining duration, but restoring + long-lived caches may need wall-clock deadlines. Open until a + serialization API is proposed. +- Is there demand for *negative* TTL (entries that become valid only after + a delay)? Probably no, but worth confirming before locking the API. +- Should `purge_expired` return a `usize` count, the evicted `(K, V)` + pairs, or both (via separate methods)? The current trait sketch returns + `usize`; users who need the values can iterate `pop_expired` directly + through a lower-level API. + +Resolved during this design pass (kept here for posterity): +- `len` reports physical occupancy (matches `Cache::len`'s `&self` + constraint); add `live_len(&mut self)` if/when the wrapper grows a + mutable counterpart — see §1(a). +- Builder integration uses a separate `DynExpiringCache` rather + than `impl Cache for DynCache` — see §1 Recommendation and §4(c). + +--- + +## References + +- [`docs/policies/roadmap/ttl.md`](../policies/roadmap/ttl.md) — high-level + stub +- [`docs/policy-ds/lazy-heap.md`](../policy-ds/lazy-heap.md) — lazy heap + primitive used as the index +- [`src/ds/lazy_heap.rs`](../../src/ds/lazy_heap.rs) — implementation that + already lists TTL as a use case +- [`src/traits.rs`](../../src/traits.rs) — capability-trait pattern this + design extends +- [`src/builder.rs`](../../src/builder.rs) — `DynCache` integration point +- [Wikipedia: Cache replacement policies](https://en.wikipedia.org/wiki/Cache_replacement_policies) diff --git a/docs/policies/roadmap/ttl.md b/docs/policies/roadmap/ttl.md index f1187ad..c39640e 100644 --- a/docs/policies/roadmap/ttl.md +++ b/docs/policies/roadmap/ttl.md @@ -1,27 +1,60 @@ # TTL / Time-Based Expiration -TTL is not a replacement policy; it’s an expiration rule that often coexists with an eviction policy. +TTL is not a replacement policy; it's an expiration rule that coexists +with an eviction policy. The implementation status below tracks how +`cachekit` exposes it today. -## Implementation Patterns +## Status -### 1) Lazy expiration on access -Store `expires_at` per entry. -- On `get`: if expired, remove and treat as miss. -- On `insert`: set `expires_at = now + ttl`. +Phase 1 (the "First Slice" from [`docs/design/ttl.md`](../../design/ttl.md)) +is **landed** behind the `ttl` feature flag. -Pros: no background work. Cons: expired entries can occupy space until touched. +| Piece | Status | Location | +|---|---|---| +| `Clock` trait, `StdClock`, `MockClock` | landed | [`src/time.rs`](../../../src/time.rs) | +| `ExpirationIndex` over `LazyMinHeap` | landed | [`src/ds/expiration_index.rs`](../../../src/ds/expiration_index.rs) | +| `LazyMinHeap::peek_best` primitive | landed | [`src/ds/lazy_heap.rs`](../../../src/ds/lazy_heap.rs) | +| `Tick`, `TtlStatus`, `ExpiringCache` capability trait | landed | [`src/traits.rs`](../../../src/traits.rs) | +| `Expiring` decorator | landed | [`src/policy/expiring.rs`](../../../src/policy/expiring.rs) | +| `impl Cache for DynCache` | landed | [`src/builder.rs`](../../../src/builder.rs) | +| `DynExpiringCache` + `CacheBuilder::with_default_ttl` | landed | [`src/builder.rs`](../../../src/builder.rs) | +| `expirations` counter on `Expiring` (gated by `metrics`) | landed | same | +| Integration tests + proptest | landed | [`tests/ttl_integration_test.rs`](../../../tests/ttl_integration_test.rs) | +| Overhead bench (`ttl_overhead`) | landed | [`benches/ttl_overhead.rs`](../../../benches/ttl_overhead.rs) | -### 2) Timer wheel / min-heap expiry -Maintain an expiration index: -- min-heap keyed by `expires_at` (lazy stale entries), or -- timer wheel buckets for O(1) amortized expiry +Phase 2 (deferred): -Pros: can proactively free space. Cons: extra metadata and background/maintenance work. +- Per-policy embedded `expires_at: u64` in `LruCore::Node` / + `S3FifoCache::Node` via opt-in const generic or `LruWithTtl` type. + Gated on bench results from Phase 1. +- `ConcurrentExpiring` with `Arc` returns (no `Cache` impl). +- Timer-wheel swap-in for `ExpirationIndex` as an alternative to the + min-heap. +- `serde` support for `Tick` / `TtlStatus`. -## Interaction With Eviction -When cache is full: -- Prefer evicting expired entries first (cheap win). -- Then fall back to your policy (LRU/LFU/etc). +## Quick Start + +```rust,ignore +use cachekit::builder::{CacheBuilder, CachePolicy}; +use std::time::Duration; + +let mut cache = CacheBuilder::new(1024) + .with_default_ttl(Duration::from_secs(60)) + .build::(CachePolicy::FastLru); + +cache.insert(1, "value".to_string()); +cache.insert_with_ttl(2, "fast".to_string(), Duration::from_millis(10)); + +// `peek` and `contains` hide expired entries logically; `get`, `insert`, +// `remove`, `purge_expired` physically purge them. +``` + +## Design Doc + +The authoritative semantic contract — composition decision, ordering +invariant, clock semantics, overflow behaviour, and serialization notes — +lives in [`docs/design/ttl.md`](../../design/ttl.md). ## References -- Wikipedia: https://en.wikipedia.org/wiki/Cache_replacement_policies + +- Wikipedia: diff --git a/src/builder.rs b/src/builder.rs index 6a4336f..1041253 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -912,6 +912,62 @@ where } } +/// Trait impl that mirrors the inherent methods on [`DynCache`]. +/// +/// Enables generic code (and the `Expiring` decorator) to work +/// against the same runtime-selected policy through the universal +/// [`Cache`](crate::traits::Cache) trait. +impl crate::traits::Cache for DynCache +where + K: Copy + Eq + Hash + Ord, + V: Clone + Debug, +{ + #[inline] + fn contains(&self, key: &K) -> bool { + DynCache::contains(self, key) + } + + #[inline] + fn len(&self) -> usize { + DynCache::len(self) + } + + #[inline] + fn is_empty(&self) -> bool { + DynCache::is_empty(self) + } + + #[inline] + fn capacity(&self) -> usize { + DynCache::capacity(self) + } + + #[inline] + fn peek(&self, key: &K) -> Option<&V> { + DynCache::peek(self, key) + } + + #[inline] + fn get(&mut self, key: &K) -> Option<&V> { + DynCache::get(self, key) + } + + #[inline] + fn insert(&mut self, key: K, value: V) -> Option { + DynCache::insert(self, key, value) + } + + #[inline] + fn remove(&mut self, key: &K) -> Option { + DynCache::remove(self, key) + } + + #[inline] + fn clear(&mut self) { + DynCache::clear(self) + } +} + impl fmt::Debug for DynCache where K: Copy + Eq + Hash + Ord, @@ -995,6 +1051,34 @@ impl CacheBuilder { Self { capacity } } + /// Switches the builder into TTL mode with a default per-entry TTL. + /// + /// `build` on the returned builder produces a [`DynExpiringCache`] + /// rather than a [`DynCache`]. Per-entry TTLs supplied via + /// `insert_with_ttl` always override the default, including + /// `Duration::ZERO` for immediate expiry. + /// + /// Available with the `ttl` feature. + /// + /// # Example + /// + /// ``` + /// use cachekit::builder::{CacheBuilder, CachePolicy}; + /// use std::time::Duration; + /// + /// let mut cache = CacheBuilder::new(100) + /// .with_default_ttl(Duration::from_secs(60)) + /// .build::(CachePolicy::FastLru); + /// cache.insert(1, "value".to_string()); + /// ``` + #[cfg(feature = "ttl")] + pub fn with_default_ttl(self, default_ttl: std::time::Duration) -> ExpiringBuilder { + ExpiringBuilder { + capacity: self.capacity, + default_ttl: Some(default_ttl), + } + } + /// Build a cache with the specified policy. /// /// # Type Parameters @@ -1120,6 +1204,224 @@ impl CacheBuilder { } } +// ============================================================================= +// TTL builder integration (`cfg(feature = "ttl")`). +// ============================================================================= + +#[cfg(feature = "ttl")] +mod ttl_support { + use std::fmt; + use std::fmt::Debug; + use std::hash::Hash; + use std::time::Duration; + + use crate::policy::expiring::Expiring; + use crate::time::StdClock; + use crate::traits::{Cache as CacheTrait, ExpiringCache, TtlStatus}; + + use super::{CachePolicy, DynCache}; + + /// Builder produced by [`super::CacheBuilder::with_default_ttl`]. + /// + /// Identical to [`CacheBuilder`](super::CacheBuilder) except that + /// [`build`](ExpiringBuilder::build) returns a [`DynExpiringCache`] + /// pre-wrapped with the configured default TTL. + /// + /// `ExpiringBuilder` cannot be constructed from a `DynExpiringCache`; + /// the only entry point is `CacheBuilder::with_default_ttl(...)`. + /// This keeps the type system honest about the "only one TTL layer" + /// invariant documented in `docs/design/ttl.md` §1 Recommendation — + /// `Expiring>` is unrepresentable through the + /// public API. + #[derive(Debug, Clone, Copy)] + pub struct ExpiringBuilder { + pub(super) capacity: usize, + pub(super) default_ttl: Option, + } + + impl ExpiringBuilder { + /// Sets the default TTL for entries inserted without an explicit + /// per-entry TTL. + pub fn default_ttl(mut self, default_ttl: Duration) -> Self { + self.default_ttl = Some(default_ttl); + self + } + + /// Builds a [`DynExpiringCache`] with the configured policy. + /// + /// # Type Parameters + /// + /// - `K`: Key type, must be `Copy + Eq + Hash + Ord` + /// - `V`: Value type, must be `Clone + Debug` + /// + /// # Panics + /// + /// Same conditions as [`CacheBuilder::build`](super::CacheBuilder::build). + pub fn build(self, policy: CachePolicy) -> DynExpiringCache + where + K: Copy + Eq + Hash + Ord, + V: Clone + Debug, + { + let inner = super::CacheBuilder { + capacity: self.capacity, + } + .build::(policy); + let wrapper = Expiring::with_default_ttl(inner, StdClock::new(), self.default_ttl); + DynExpiringCache { inner: wrapper } + } + } + + /// Expiring cache returned by [`ExpiringBuilder::build`]. + /// + /// Wraps an [`Expiring, K, V, StdClock>`](Expiring) + /// behind a private constructor; the inner [`DynCache`] dispatches to + /// the runtime-selected policy. Construct only via + /// `CacheBuilder::with_default_ttl(...).build(...)`. + pub struct DynExpiringCache + where + K: Copy + Eq + Hash + Ord, + V: Clone + Debug, + { + inner: Expiring, K, V, StdClock>, + } + + impl DynExpiringCache + where + K: Copy + Eq + Hash + Ord, + V: Clone + Debug, + { + // ---------- universal Cache surface (mirrors DynCache) ---------- + + /// Side-effect-free lookup; hides expired entries. + #[inline] + pub fn peek(&self, key: &K) -> Option<&V> { + CacheTrait::peek(&self.inner, key) + } + + /// Policy-tracked lookup; physically purges an expired entry as a + /// side effect and returns `None`. + #[inline] + pub fn get(&mut self, key: &K) -> Option<&V> { + CacheTrait::get(&mut self.inner, key) + } + + /// Inserts a key/value pair, returning the previous live value if + /// any. Applies the configured default TTL. + #[inline] + pub fn insert(&mut self, key: K, value: V) -> Option { + CacheTrait::insert(&mut self.inner, key, value) + } + + /// Removes a key and returns the previous value if it was live. + #[inline] + pub fn remove(&mut self, key: &K) -> Option { + CacheTrait::remove(&mut self.inner, key) + } + + /// Logical membership; hides expired entries. + #[inline] + pub fn contains(&self, key: &K) -> bool { + CacheTrait::contains(&self.inner, key) + } + + /// Physical occupancy. Use [`live_len`](Self::live_len) for the + /// exact count of non-expired entries. + #[inline] + pub fn len(&self) -> usize { + CacheTrait::len(&self.inner) + } + + /// Returns `true` if the cache is physically empty. + #[inline] + pub fn is_empty(&self) -> bool { + CacheTrait::is_empty(&self.inner) + } + + /// Returns the cache's capacity. + #[inline] + pub fn capacity(&self) -> usize { + CacheTrait::capacity(&self.inner) + } + + /// Removes every entry from the cache. + #[inline] + pub fn clear(&mut self) { + CacheTrait::clear(&mut self.inner); + } + + // ---------- TTL surface ---------- + + /// Inserts with an explicit per-entry TTL, overriding the default. + /// + /// Per-entry TTL always wins, including [`Duration::ZERO`] which + /// means "expire immediately". + #[inline] + pub fn insert_with_ttl(&mut self, key: K, value: V, ttl: Duration) -> Option { + ExpiringCache::insert_with_ttl(&mut self.inner, key, value, ttl) + } + + /// Reports the TTL state of `key`. + #[inline] + pub fn ttl_status(&self, key: &K) -> TtlStatus { + ExpiringCache::ttl_status(&self.inner, key) + } + + /// Sets a new TTL on a live entry. Returns `true` if the entry was + /// live; an expired-resident entry is purged and `false` is + /// returned. + #[inline] + pub fn set_ttl(&mut self, key: &K, ttl: Duration) -> bool { + ExpiringCache::set_ttl(&mut self.inner, key, ttl) + } + + /// Removes every entry whose deadline is `<= now`, returning the + /// count removed. + #[inline] + pub fn purge_expired(&mut self) -> usize { + ExpiringCache::purge_expired(&mut self.inner) + } + + /// Exact count of currently-live entries. + #[inline] + pub fn live_len(&mut self) -> usize { + self.inner.live_len() + } + + /// Returns the cache's configured default TTL, if any. + #[inline] + pub fn default_ttl(&self) -> Option { + self.inner.default_ttl() + } + + /// Cumulative count of entries removed because their TTL elapsed. + /// + /// Returns `0` unless the `metrics` feature is enabled. + #[inline] + pub fn expirations(&self) -> u64 { + self.inner.expirations() + } + } + + impl fmt::Debug for DynExpiringCache + where + K: Copy + Eq + Hash + Ord, + V: Clone + Debug, + { + /// Reports the inner policy plus the default TTL without leaking + /// keys or deadlines. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DynExpiringCache") + .field("default_ttl", &self.inner.default_ttl()) + .field("len", &CacheTrait::len(&self.inner)) + .field("capacity", &CacheTrait::capacity(&self.inner)) + .finish_non_exhaustive() + } + } +} + +#[cfg(feature = "ttl")] +pub use ttl_support::{DynExpiringCache, ExpiringBuilder}; + #[cfg(test)] mod tests { use super::*; diff --git a/src/ds/expiration_index.rs b/src/ds/expiration_index.rs new file mode 100644 index 0000000..2454aac --- /dev/null +++ b/src/ds/expiration_index.rs @@ -0,0 +1,341 @@ +//! Key-keyed deadline index used by the TTL decorator. +//! +//! ## Architecture +//! +//! ```text +//! ┌──────────────────────────────────────────────────────────────┐ +//! │ ExpirationIndex │ +//! │ │ +//! │ LazyMinHeap (key -> deadline_ms) │ +//! │ ▲ │ +//! │ ├── set_deadline(key, expires_at) │ +//! │ ├── remove(key) │ +//! │ ├── peek_deadline() -> earliest live (key, deadline) │ +//! │ └── pop_expired(now) -> earliest live if deadline <= now │ +//! └──────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! ## Key Components +//! +//! [`ExpirationIndex`] is a thin wrapper around +//! [`LazyMinHeap`]`` with `auto_rebuild` +//! enabled. The wrapper hides the score type (always a `u64` 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. +//! - [`pop_expired`](ExpirationIndex::pop_expired) atomically peeks the +//! earliest entry and, if its deadline `<= now`, removes and returns it. +//! +//! ## 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 +//! 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 +//! epoch can tighten this with [`set_auto_rebuild`]. +//! +//! ## Thread Safety +//! +//! `ExpirationIndex` is **not** thread-safe by itself; the TTL decorator +//! provides exterior locking. The wrapper inherits the underlying heap's +//! redacted `Debug` output, so no keys leak through tracing. +//! +//! ## Example Usage +//! +//! ``` +//! use cachekit::ds::expiration_index::ExpirationIndex; +//! +//! let mut idx: ExpirationIndex<&str> = ExpirationIndex::new(); +//! idx.set_deadline("a", 100); +//! idx.set_deadline("b", 50); +//! +//! assert_eq!(idx.peek_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))); +//! ``` +//! +//! [`set_auto_rebuild`]: ExpirationIndex::set_auto_rebuild + +use std::borrow::Borrow; +use std::hash::Hash; + +use crate::ds::lazy_heap::LazyMinHeap; + +/// Default rebuild factor: bound stale heap growth to `2 * live_len`. +/// +/// Picked to keep amortised maintenance cheap while preventing heap bloat +/// from repeated `set_deadline` updates on the same key. +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 +/// [`Clock`](crate::time::Clock) the TTL decorator uses (conventionally +/// milliseconds in `cachekit`). +#[derive(Debug)] +pub struct ExpirationIndex { + heap: LazyMinHeap, +} + +impl ExpirationIndex +where + K: Eq + Hash + Clone, +{ + /// Creates an empty index with the default rebuild factor. + pub fn new() -> Self { + Self { + heap: LazyMinHeap::with_auto_rebuild(DEFAULT_REBUILD_FACTOR), + } + } + + /// Creates an empty index with capacity pre-reserved for `capacity` + /// distinct keys. + /// + /// Useful when the TTL decorator wraps a fixed-capacity cache. + pub fn with_capacity(capacity: usize) -> Self { + let mut heap = LazyMinHeap::with_capacity(capacity); + heap.set_auto_rebuild(Some(DEFAULT_REBUILD_FACTOR)); + Self { heap } + } + + /// Returns the number of live entries. + pub fn len(&self) -> usize { + self.heap.len() + } + + /// Returns `true` if there are no live entries. + pub fn is_empty(&self) -> bool { + self.heap.is_empty() + } + + /// Removes all entries. + pub fn clear(&mut self) { + self.heap.clear(); + } + + /// Sets `key`'s deadline and returns the previous deadline, if any. + /// + /// `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 { + self.heap.update(key, expires_at) + } + + /// Returns the current deadline for `key`, if any. + pub fn deadline_of(&self, key: &Q) -> Option + where + K: Borrow, + Q: Hash + Eq + ?Sized, + { + self.heap.score_of(key).copied() + } + + /// Returns `true` if `key` has a deadline tracked here. + pub fn contains(&self, key: &Q) -> bool + where + K: Borrow, + Q: Hash + Eq + ?Sized, + { + self.heap.score_of(key).is_some() + } + + /// Removes `key` and returns its deadline, if any. + pub fn remove(&mut self, key: &Q) -> Option + where + K: Borrow, + Q: Hash + Eq + ?Sized, + { + self.heap.remove(key) + } + + /// 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)> { + self.heap.peek_best().map(|(k, s)| (k, *s)) + } + + /// Removes and returns the earliest entry if its deadline is `<= now`. + /// + /// 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() { + Some((_, deadline)) if deadline <= now => self.heap.pop_best(), + _ => None, + } + } + + /// 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) { + self.heap.set_auto_rebuild(factor); + } +} + +impl Default for ExpirationIndex +where + K: Eq + Hash + Clone, +{ + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn set_deadline_returns_previous_and_updates() { + let mut idx: ExpirationIndex<&str> = ExpirationIndex::new(); + assert_eq!(idx.set_deadline("a", 100), None); + assert_eq!(idx.set_deadline("a", 200), Some(100)); + assert_eq!(idx.deadline_of(&"a"), Some(200)); + assert_eq!(idx.len(), 1); + } + + #[test] + fn peek_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))); + } + + #[test] + fn peek_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))); + } + + #[test] + fn pop_expired_respects_inclusive_comparison() { + let mut idx: ExpirationIndex<&str> = ExpirationIndex::new(); + idx.set_deadline("a", 100); + // 99 -> not yet expired + assert_eq!(idx.pop_expired(99), None); + // 100 -> expired (inclusive) + assert_eq!(idx.pop_expired(100), Some(("a", 100))); + assert_eq!(idx.pop_expired(200), None); + } + + #[test] + fn pop_expired_drains_in_deadline_order() { + let mut idx: ExpirationIndex<&str> = ExpirationIndex::new(); + idx.set_deadline("a", 100); + idx.set_deadline("b", 200); + idx.set_deadline("c", 300); + let mut out = Vec::new(); + while let Some(entry) = idx.pop_expired(250) { + out.push(entry); + } + assert_eq!(out, vec![("a", 100), ("b", 200)]); + // "c" not yet expired. + assert_eq!(idx.deadline_of(&"c"), Some(300)); + } + + #[test] + fn remove_clears_deadline() { + let mut idx: ExpirationIndex<&str> = ExpirationIndex::new(); + idx.set_deadline("a", 100); + assert_eq!(idx.remove(&"a"), Some(100)); + assert_eq!(idx.remove(&"a"), None); + assert!(idx.is_empty()); + } + + #[test] + fn auto_rebuild_bounds_stale_entries() { + let mut idx: ExpirationIndex = ExpirationIndex::new(); + for i in 0..100 { + idx.set_deadline(1, i as u64); + } + // With factor 2, heap_len should stay bounded relative to len=1. + assert_eq!(idx.len(), 1); + // Drain to confirm correctness despite stale churn. + assert_eq!(idx.pop_expired(99), Some((1, 99))); + assert!(idx.is_empty()); + } +} + +#[cfg(test)] +mod property_tests { + use super::*; + use proptest::prelude::*; + + proptest! { + /// `peek_deadline` always returns the earliest live deadline. + #[cfg_attr(miri, ignore)] + #[test] + fn prop_peek_returns_earliest( + entries in prop::collection::vec((any::(), any::()), 0..32) + ) { + let mut idx: ExpirationIndex = ExpirationIndex::new(); + // Build the index. Later writes for the same key replace earlier ones. + use std::collections::HashMap; + let mut latest: HashMap = HashMap::new(); + for (k, s) in entries { + let s = s as u64; + idx.set_deadline(k, s); + latest.insert(k, s); + } + let expected_min = latest.values().min().copied(); + let actual_min = idx.peek_deadline().map(|(_, d)| d); + prop_assert_eq!(actual_min, expected_min); + } + + /// `pop_expired` drains everything with deadline <= now in ascending order. + #[cfg_attr(miri, ignore)] + #[test] + fn prop_pop_expired_drains_in_order( + entries in prop::collection::vec((any::(), 0u64..1_000_000), 0..32), + now in 0u64..1_000_000 + ) { + let mut idx: ExpirationIndex = ExpirationIndex::new(); + use std::collections::HashMap; + let mut latest: HashMap = HashMap::new(); + for (k, s) in entries { + idx.set_deadline(k, s); + latest.insert(k, s); + } + + let mut popped: Vec<(u16, u64)> = Vec::new(); + while let Some(e) = idx.pop_expired(now) { + popped.push(e); + } + + // 1. Order is non-decreasing in deadline. + for win in popped.windows(2) { + prop_assert!(win[0].1 <= win[1].1); + } + // 2. Every popped (k, d) was the latest live deadline for k and d <= now. + for (k, d) in &popped { + prop_assert_eq!(latest.get(k).copied(), Some(*d)); + prop_assert!(*d <= now); + } + // 3. Everything still in the index has deadline > now. + for (k, d) in latest.iter() { + if popped.iter().any(|(pk, _)| pk == k) { + continue; + } + prop_assert!(*d > now); + prop_assert_eq!(idx.deadline_of(k), Some(*d)); + } + } + } +} diff --git a/src/ds/lazy_heap.rs b/src/ds/lazy_heap.rs index 21d2eb4..d97f147 100644 --- a/src/ds/lazy_heap.rs +++ b/src/ds/lazy_heap.rs @@ -874,6 +874,52 @@ where } } + /// Returns references to the minimum `(key, score)` without removing it. + /// + /// Stale heap roots are discarded in place so the returned reference always + /// points at a live entry. Takes `&mut self` for that reason — repeated + /// calls without intervening updates are O(1). + /// + /// # Example + /// + /// ``` + /// use cachekit::ds::LazyMinHeap; + /// + /// let mut heap: LazyMinHeap<&str, i32> = LazyMinHeap::new(); + /// heap.update("high", 1); + /// heap.update("low", 10); + /// + /// // peek does not consume. + /// assert_eq!(heap.peek_best(), Some((&"high", &1))); + /// assert_eq!(heap.peek_best(), Some((&"high", &1))); + /// assert_eq!(heap.len(), 2); + /// + /// // pop_best returns the same entry peek_best showed. + /// assert_eq!(heap.pop_best(), Some(("high", 1))); + /// assert_eq!(heap.peek_best(), Some((&"low", &10))); + /// ``` + pub fn peek_best(&mut self) -> Option<(&K, &S)> { + loop { + match self.heap.peek() { + Some(Reverse(top)) => { + let live = self.scores.get(&top.key).is_some_and(|current| { + current.score == top.score && current.seq == top.seq + }); + if live { + break; + } + }, + None => return None, + } + self.heap.pop(); + } + + let Reverse(top) = self.heap.peek()?; + self.scores + .get_key_value(&top.key) + .map(|(k, entry)| (k, &entry.score)) + } + /// Rebuilds the heap from the authoritative `scores` map. /// /// Removes all stale entries. Call this periodically or when @@ -1244,6 +1290,59 @@ mod tests { assert_eq!(heap.pop_best(), None); } + #[test] + fn peek_best_returns_min_without_removing() { + let mut heap = LazyMinHeap::new(); + heap.update("a", 5); + heap.update("b", 2); + heap.update("c", 9); + + assert_eq!(heap.peek_best(), Some((&"b", &2))); + assert_eq!(heap.peek_best(), Some((&"b", &2))); + assert_eq!(heap.len(), 3); + } + + #[test] + fn peek_best_skips_stale_roots() { + let mut heap = LazyMinHeap::new(); + heap.update("a", 1); + heap.update("a", 5); // makes the (a, 1) entry stale + heap.update("b", 3); + + // (a,1) was the old min but is stale; live min is (b, 3). + assert_eq!(heap.peek_best(), Some((&"b", &3))); + // Stale roots removed in place; subsequent pop matches. + assert_eq!(heap.pop_best(), Some(("b", 3))); + assert_eq!(heap.peek_best(), Some((&"a", &5))); + } + + #[test] + fn peek_best_returns_none_when_empty() { + let mut heap: LazyMinHeap<&str, u32> = LazyMinHeap::new(); + assert_eq!(heap.peek_best(), None); + + heap.update("a", 1); + assert!(heap.peek_best().is_some()); + heap.remove(&"a"); + assert_eq!(heap.peek_best(), None); + } + + #[test] + fn peek_best_matches_pop_best() { + let mut heap = LazyMinHeap::new(); + for (k, s) in [("a", 5), ("b", 2), ("c", 9), ("d", 1), ("a", 3)] { + heap.update(k, s); + } + // Iterate peek/pop pairs and confirm peek predicts the next pop result. + let mut peeked = Vec::new(); + let mut popped = Vec::new(); + while let Some((k, s)) = heap.peek_best() { + peeked.push((*k, *s)); + popped.push(heap.pop_best().unwrap()); + } + assert_eq!(peeked, popped); + } + #[test] fn lazy_heap_rebuild_cleans_stale_entries() { let mut heap = LazyMinHeap::new(); diff --git a/src/ds/mod.rs b/src/ds/mod.rs index b05903c..c6dc813 100644 --- a/src/ds/mod.rs +++ b/src/ds/mod.rs @@ -1,4 +1,6 @@ pub mod clock_ring; +#[cfg(feature = "ttl")] +pub mod expiration_index; pub mod fixed_history; pub mod frequency_buckets; pub mod ghost_list; @@ -14,6 +16,8 @@ pub use clock_ring::{ ClockRing, ClockRingError, IntoIter, Iter, IterMut, Keys, KeysAreTrusted, MAX_CAPACITY, Values, ValuesMut, }; +#[cfg(feature = "ttl")] +pub use expiration_index::ExpirationIndex; 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/lib.rs b/src/lib.rs index eb19423..007b2d0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,10 +36,14 @@ //! │ │ //! │ traits Cache trait + capability traits │ //! │ builder CacheBuilder + DynCache runtime wrapper │ +//! │ (+ DynExpiringCache via `ttl`) │ //! │ policy 18 eviction policies behind feature flags │ +//! │ (+ `policy::expiring::Expiring` via `ttl`) │ //! │ ds Arena, ring buffer, intrusive list, ghost list, … │ +//! │ (+ `ExpirationIndex` via `ttl`) │ //! │ store Storage backends (HashMap, slab, weighted) │ //! │ metrics Hit/miss counters and snapshots (feature-gated) │ +//! │ time Clock trait, StdClock, MockClock (`ttl` feature) │ //! │ error ConfigError and InvariantError types │ //! └──────────────────────────────────────────────────────────────────────┘ //! ``` @@ -114,6 +118,7 @@ //! | `policy-all` | no | Enable every policy | //! | `metrics` | no | Hit/miss counters, [`metrics::snapshot::CacheMetricsSnapshot`] | //! | `concurrency` | no | `parking_lot`-backed concurrent data structures | +//! | `ttl` | no | Time-based expiration: [`Expiring`](policy::expiring::Expiring), [`DynExpiringCache`](builder::DynExpiringCache), [`Clock`](time::Clock) — see `docs/design/ttl.md` | //! //! Disable defaults and cherry-pick for smaller builds: //! @@ -189,6 +194,9 @@ pub mod store; #[cfg(feature = "metrics")] pub mod metrics; +#[cfg(feature = "ttl")] +pub mod time; + pub mod builder; pub mod prelude; pub mod traits; diff --git a/src/policy/expiring.rs b/src/policy/expiring.rs new file mode 100644 index 0000000..4c853ae --- /dev/null +++ b/src/policy/expiring.rs @@ -0,0 +1,646 @@ +//! `Expiring` — decorator that adds time-based expiration to any +//! `Cache`. +//! +//! ## Architecture +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────────┐ +//! │ Expiring │ +//! │ │ +//! │ inner: C ◀──── policy / storage │ +//! │ index: ExpirationIndex ◀──── deadlines │ +//! │ clock: T : Clock ◀──── current tick │ +//! │ default_ttl: Option ◀──── fallback │ +//! └─────────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! ## Key Components +//! +//! - The inner cache stores values and runs the eviction policy. +//! - The expiration index tracks deadlines, keyed by `K`. +//! - The clock is `&self`-readable; `StdClock` for production and +//! `MockClock` for tests. +//! +//! ## Core Operations +//! +//! - `peek`/`contains` are logical reads — expired entries report as +//! absent without physical removal, since the underlying [`Cache`] +//! trait takes those by `&self`. +//! - `get`/`insert`/`remove`/`clear` physically purge expired entries. +//! - `purge_expired` drains everything with `deadline <= now`. +//! +//! ## Performance Trade-offs +//! +//! Each read pays at most one hash probe into the `ExpirationIndex`. Each +//! insert pays an `O(log n)` heap update. Per the design doc, this +//! overhead is acceptable for Phase 1; Phase 2 will embed `expires_at` +//! into specific policies' node layouts. +//! +//! ## Ordering Invariant +//! +//! The wrapper always removes from the **inner cache first**, then from +//! the `ExpirationIndex`. A panic in inner removal leaves the index +//! pointing at a key the cache still holds (next `pop_expired` is a +//! no-op). The reverse order would silently lose the deadline. The +//! ordering is enforced by `purge_one` and the `Cache` / +//! `ExpiringCache` impls below. +//! +//! ## Thread Safety +//! +//! `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). +//! +//! ## Example Usage +//! +//! ``` +//! use cachekit::policy::expiring::Expiring; +//! use cachekit::policy::fast_lru::FastLru; +//! use cachekit::time::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".to_string()); +//! assert_eq!(cache.peek(&1), Some(&"value".to_string())); +//! +//! cache.clock().advance(Duration::from_secs(61)); +//! assert_eq!(cache.peek(&1), None); +//! ``` + +use std::hash::Hash; +use std::marker::PhantomData; +use std::time::Duration; + +use crate::ds::expiration_index::ExpirationIndex; +use crate::time::{Clock, MockClock, StdClock, duration_to_ticks}; +use crate::traits::{Cache, ExpiringCache, Tick, TtlStatus}; + +/// Decorator adding TTL semantics around an inner `Cache`. +/// +/// `V` is recorded as `PhantomData` so the value type the inner cache +/// works with is fixed at construction time; the wrapper itself does not +/// store values directly. +/// +/// See the module documentation for the contract, ordering invariant, and +/// usage examples. +#[derive(Debug)] +pub struct Expiring { + inner: C, + index: ExpirationIndex, + clock: T, + default_ttl_ticks: Option, + #[cfg(feature = "metrics")] + expirations: u64, + _value: PhantomData V>, +} + +impl Expiring +where + C: Cache, + K: Eq + Hash + Clone, +{ + /// Creates an expiring wrapper around `inner` using the system clock + /// and no default TTL. + pub fn new(inner: C) -> Self { + Self::with_clock_and_default(inner, StdClock::new(), None) + } + + /// Creates an expiring wrapper around `inner` using the system clock + /// and a default TTL applied to entries inserted without an explicit + /// TTL. + pub fn with_default_ttl_std(inner: C, default_ttl: Duration) -> Self { + Self::with_clock_and_default(inner, StdClock::new(), Some(default_ttl)) + } +} + +impl Expiring +where + C: Cache, + K: Eq + Hash + Clone, + T: Clock, +{ + /// Creates an expiring wrapper with an explicit clock and a default + /// TTL (or `None` for no default). + pub fn with_default_ttl(inner: C, clock: T, default_ttl: Option) -> Self { + Self::with_clock_and_default(inner, clock, default_ttl) + } + + fn with_clock_and_default(inner: C, clock: T, default_ttl: Option) -> Self { + let default_ttl_ticks = default_ttl.map(duration_to_ticks); + Self { + inner, + index: ExpirationIndex::new(), + clock, + default_ttl_ticks, + #[cfg(feature = "metrics")] + expirations: 0, + _value: PhantomData, + } + } + + /// Cumulative count of entries removed because their TTL deadline + /// elapsed. + /// + /// Updated only when the `metrics` feature is enabled. Returns `0` + /// otherwise so call sites compile regardless of the feature gate. + pub fn expirations(&self) -> u64 { + #[cfg(feature = "metrics")] + { + self.expirations + } + #[cfg(not(feature = "metrics"))] + { + 0 + } + } + + /// Returns a reference to the inner cache. + 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. + pub fn inner_mut(&mut self) -> &mut C { + &mut self.inner + } + + /// Returns a reference to the configured clock. + pub fn clock(&self) -> &T { + &self.clock + } + + /// Returns the cache's default TTL as a `Duration`, if any. + pub fn default_ttl(&self) -> Option { + self.default_ttl_ticks.map(Duration::from_millis) + } + + /// Computes a deadline for a TTL relative to `now`, saturating to + /// `u64::MAX - 1` on overflow. + #[inline] + fn deadline_from(&self, now: u64, ttl_ticks: u64) -> u64 { + // `u64::MAX` is the "effectively never" sentinel for `Tick`. To + // keep `<= now` always false for that sentinel we cap one short. + now.saturating_add(ttl_ticks).min(u64::MAX - 1) + } + + /// Returns `true` if `key`'s deadline (if any) is at or before `now`. + fn is_expired_at(&self, key: &K, now: u64) -> bool { + match self.index.deadline_of(key) { + Some(deadline) => deadline <= now, + None => false, + } + } + + /// 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 { + 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 + } + + /// Removes `key` from inner and index, honouring the ordering + /// invariant: **inner removal happens first**. + fn purge_one(&mut self, key: &K) -> Option { + let removed = self.inner.remove(key); + let _ = self.index.remove(key); + removed + } + + /// Records one TTL-driven removal. No-op without the `metrics` feature. + #[inline] + fn record_expiration(&mut self) { + #[cfg(feature = "metrics")] + { + self.expirations = self.expirations.saturating_add(1); + } + } +} + +// --------------------------------------------------------------------------- +// Cache impl: logical reads via &self, physical purges via &mut self. +// --------------------------------------------------------------------------- + +impl Cache for Expiring +where + C: Cache, + K: Eq + Hash + Clone, + T: Clock, +{ + 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, + } + } + + fn len(&self) -> usize { + // Physical occupancy. See `live_len_value_aware` for the live count. + self.inner.len() + } + + fn capacity(&self) -> usize { + self.inner.capacity() + } + + fn peek(&self, key: &K) -> Option<&V> { + let value = self.inner.peek(key)?; + match self.index.deadline_of(key) { + Some(deadline) if deadline <= self.clock.now() => None, + _ => Some(value), + } + } + + fn get(&mut self, key: &K) -> Option<&V> { + let now = self.clock.now(); + if self.is_expired_at(key, now) { + self.purge_one(key); + self.record_expiration(); + return None; + } + self.inner.get(key) + } + + 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); + } else { + // No default; ensure any stale deadline is cleared so the + // new entry is treated as immortal. + self.index.remove(&key); + } + + let previous = self.inner.insert(key, value); + if was_expired { + self.record_expiration(); + None + } else { + previous + } + } + + fn remove(&mut self, key: &K) -> Option { + let was_expired = self.is_expired_at(key, self.clock.now()); + let removed = self.purge_one(key); + if was_expired { + self.record_expiration(); + None + } else { + removed + } + } + + fn clear(&mut self) { + self.inner.clear(); + self.index.clear(); + } +} + +// --------------------------------------------------------------------------- +// ExpiringCache impl +// --------------------------------------------------------------------------- + +impl ExpiringCache for Expiring +where + C: Cache, + K: Eq + Hash + Clone, + T: Clock, +{ + 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); + + if ttl.is_zero() { + // Zero TTL == remove without inserting. + let removed = self.purge_one(&key); + return if was_expired { + self.record_expiration(); + None + } else { + removed + }; + } + + let ttl_ticks = duration_to_ticks(ttl); + let deadline = self.deadline_from(now, ttl_ticks); + self.index.set_deadline(key.clone(), deadline); + let previous = self.inner.insert(key, value); + if was_expired { + self.record_expiration(); + None + } else { + previous + } + } + + fn ttl_status(&self, key: &K) -> TtlStatus { + if !self.inner.contains(key) { + return TtlStatus::Missing; + } + let now = self.clock.now(); + match self.index.deadline_of(key) { + None => TtlStatus::Immortal, + Some(deadline) if deadline <= now => TtlStatus::Expired, + Some(deadline) => TtlStatus::Live { + remaining: Duration::from_millis(deadline - now), + deadline: Tick(deadline), + }, + } + } + + fn set_ttl(&mut self, key: &K, ttl: Duration) -> bool { + let now = self.clock.now(); + if !self.inner.contains(key) { + return false; + } + if self.is_expired_at(key, now) { + self.purge_one(key); + self.record_expiration(); + return false; + } + let ttl_ticks = duration_to_ticks(ttl); + if ttl_ticks == 0 { + // Zero TTL on a set_ttl call ==> remove the entry. + self.purge_one(key); + return false; + } + let deadline = self.deadline_from(now, ttl_ticks); + self.index.set_deadline(key.clone(), deadline); + true + } + + fn purge_expired(&mut self) -> usize { + let now = self.clock.now(); + let mut count = 0; + // Drain index entries with deadline <= now; for each, remove from + // inner. Ordering invariant: inner removal precedes index removal, + // but `pop_expired` has already removed from the index; we must + // tolerate inner-side panics by treating index as authoritative. + // 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() { + Some((k, deadline)) if deadline <= now => k.clone(), + _ => break, + }; + let _ = self.inner.remove(&key_clone); + let _ = self.index.remove(&key_clone); + count += 1; + } + #[cfg(feature = "metrics")] + { + self.expirations = self.expirations.saturating_add(count as u64); + } + count + } +} + +// Public alias: a clock-backed Expiring wrapper used by the builder. +#[allow(dead_code)] +pub(crate) type ExpiringStdClock = Expiring; +#[allow(dead_code)] +pub(crate) type ExpiringMockClock = Expiring; + +#[cfg(test)] +mod tests { + use super::*; + use crate::policy::fast_lru::FastLru; + + #[test] + fn insert_with_ttl_returns_none_on_first_insert() { + let clock = MockClock::new(); + let inner: FastLru = FastLru::new(8); + let mut cache = Expiring::with_default_ttl(inner, clock, None); + assert_eq!( + cache.insert_with_ttl(1, "a".into(), Duration::from_millis(100)), + None + ); + assert_eq!(cache.peek(&1), Some(&"a".to_string())); + } + + #[test] + fn entry_expires_on_get_after_clock_advance() { + let clock = MockClock::new(); + let inner: FastLru = FastLru::new(8); + let mut cache = Expiring::with_default_ttl(inner, clock, None); + + cache.insert_with_ttl(1, "a".into(), Duration::from_millis(50)); + assert_eq!(cache.get(&1), Some(&"a".to_string())); + + cache.clock().advance(Duration::from_millis(50)); + // Inclusive: deadline equal to now -> expired. + assert_eq!(cache.get(&1), None); + assert_eq!(cache.len(), 0); + } + + #[test] + fn peek_is_logical_does_not_purge() { + let clock = MockClock::new(); + let inner: FastLru = FastLru::new(8); + let mut cache = Expiring::with_default_ttl(inner, clock, None); + + cache.insert_with_ttl(1, "a".into(), Duration::from_millis(50)); + cache.clock().advance(Duration::from_millis(60)); + + // peek hides the expired entry... + assert_eq!(cache.peek(&1), None); + assert!(!cache.contains(&1)); + // ...but the inner cache still physically holds it. + assert_eq!(cache.len(), 1); + // Mutable op purges. + assert_eq!(cache.get(&1), None); + assert_eq!(cache.len(), 0); + } + + #[test] + fn insert_after_expiry_returns_none_not_stale() { + let clock = MockClock::new(); + let inner: FastLru = FastLru::new(8); + let mut cache = Expiring::with_default_ttl(inner, clock, None); + + cache.insert_with_ttl(1, "old".into(), Duration::from_millis(50)); + cache.clock().advance(Duration::from_millis(60)); + // Re-inserting an expired key returns None, not "old". + assert_eq!( + cache.insert_with_ttl(1, "new".into(), Duration::from_millis(100)), + None + ); + assert_eq!(cache.peek(&1), Some(&"new".to_string())); + } + + #[test] + fn ttl_status_distinguishes_all_states() { + let clock = MockClock::new(); + let inner: FastLru = FastLru::new(8); + let mut cache = Expiring::with_default_ttl(inner, clock, None); + + assert_eq!(cache.ttl_status(&1), TtlStatus::Missing); + + cache.insert(2, "immortal".into()); // no default ttl + assert_eq!(cache.ttl_status(&2), TtlStatus::Immortal); + + cache.insert_with_ttl(3, "live".into(), Duration::from_millis(100)); + match cache.ttl_status(&3) { + TtlStatus::Live { + remaining, + deadline, + } => { + assert!(remaining > Duration::ZERO); + assert!(deadline.as_u64() > 0); + }, + other => panic!("expected Live, got {other:?}"), + } + + cache.clock().advance(Duration::from_millis(101)); + assert_eq!(cache.ttl_status(&3), TtlStatus::Expired); + } + + #[test] + fn purge_expired_drains_and_counts() { + let clock = MockClock::new(); + let inner: FastLru = FastLru::new(8); + let mut cache = Expiring::with_default_ttl(inner, clock, None); + + cache.insert_with_ttl(1, "a".into(), Duration::from_millis(50)); + cache.insert_with_ttl(2, "b".into(), Duration::from_millis(150)); + cache.insert_with_ttl(3, "c".into(), Duration::from_millis(250)); + + cache.clock().advance(Duration::from_millis(200)); + let purged = cache.purge_expired(); + assert_eq!(purged, 2); + assert!(!cache.contains(&1)); + assert!(!cache.contains(&2)); + assert!(cache.contains(&3)); + assert_eq!(cache.len(), 1); + } + + #[test] + fn default_ttl_overridden_by_per_entry_ttl() { + let clock = MockClock::new(); + let inner: FastLru = FastLru::new(8); + let mut cache = + Expiring::with_default_ttl(inner, clock, Some(Duration::from_millis(1_000))); + + // Per-entry TTL wins over default. + cache.insert_with_ttl(1, "fast".into(), Duration::from_millis(10)); + cache.clock().advance(Duration::from_millis(15)); + assert_eq!(cache.get(&1), None); + + // Zero TTL also wins (immediate expiry). + cache.insert_with_ttl(2, "instant".into(), Duration::ZERO); + assert!(!cache.contains(&2)); + } + + #[test] + fn set_ttl_extends_live_entry() { + let clock = MockClock::new(); + let inner: FastLru = FastLru::new(8); + let mut cache = Expiring::with_default_ttl(inner, clock, None); + + cache.insert_with_ttl(1, "a".into(), Duration::from_millis(100)); + cache.clock().advance(Duration::from_millis(50)); + + assert!(cache.set_ttl(&1, Duration::from_millis(200))); + // Now deadline is now(50) + 200 = 250. After advancing 100ms total + // (so clock=150), the entry must still be live. + cache.clock().advance(Duration::from_millis(100)); + assert_eq!(cache.get(&1), Some(&"a".to_string())); + + // Missing key returns false. + assert!(!cache.set_ttl(&999, Duration::from_millis(100))); + } + + #[test] + fn remove_returns_value_only_if_live() { + let clock = MockClock::new(); + let inner: FastLru = FastLru::new(8); + let mut cache = Expiring::with_default_ttl(inner, clock, None); + + cache.insert_with_ttl(1, "a".into(), Duration::from_millis(100)); + assert_eq!(cache.remove(&1), Some("a".to_string())); + + cache.insert_with_ttl(2, "b".into(), Duration::from_millis(50)); + cache.clock().advance(Duration::from_millis(60)); + assert_eq!(cache.remove(&2), None); + } + + #[cfg(feature = "metrics")] + #[test] + fn expirations_counter_increments_on_purge_paths() { + let clock = MockClock::new(); + let inner: FastLru = FastLru::new(8); + let mut cache = Expiring::with_default_ttl(inner, 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(50)); + cache.clock().advance(Duration::from_millis(100)); + + // get on expired -> +1 + assert_eq!(cache.get(&1), None); + // purge_expired drains the remaining two + assert_eq!(cache.purge_expired(), 2); + assert_eq!(cache.expirations(), 3); + } + + #[test] + fn expirations_returns_zero_without_metrics_feature() { + // Sanity check: even without metrics, the accessor returns 0 + // rather than panicking. (The body of this test is identical + // shape to the metrics one; the assertion differs.) + let clock = MockClock::new(); + let inner: FastLru = FastLru::new(8); + let cache = Expiring::with_default_ttl(inner, clock, None); + // Always non-negative; with metrics off, always 0. + let _ = cache.expirations(); + } + + #[test] + fn live_len_excludes_expired_but_resident_entries() { + let clock = MockClock::new(); + let inner: FastLru = FastLru::new(8); + let mut cache = Expiring::with_default_ttl(inner, clock, None); + + cache.insert_with_ttl(1, "a".into(), Duration::from_millis(50)); + cache.insert_with_ttl(2, "b".into(), Duration::from_millis(200)); + + cache.clock().advance(Duration::from_millis(100)); + // Physical occupancy still 2, but only 1 entry is live. + assert_eq!(cache.len(), 2); + assert_eq!(cache.live_len(), 1); + } +} diff --git a/src/policy/mod.rs b/src/policy/mod.rs index ae56668..6f591a3 100644 --- a/src/policy/mod.rs +++ b/src/policy/mod.rs @@ -34,3 +34,6 @@ pub mod s3_fifo; pub mod slru; #[cfg(feature = "policy-two-q")] pub mod two_q; + +#[cfg(feature = "ttl")] +pub mod expiring; diff --git a/src/prelude.rs b/src/prelude.rs index eb9b6da..c9ef0bb 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -18,3 +18,10 @@ pub use crate::traits::{ #[cfg(feature = "metrics")] pub use crate::metrics::snapshot::CacheMetricsSnapshot; + +#[cfg(feature = "ttl")] +pub use crate::{ + builder::{DynExpiringCache, ExpiringBuilder}, + time::{Clock, MockClock, StdClock}, + traits::{ExpiringCache, Tick, TtlStatus}, +}; diff --git a/src/store/handle.rs b/src/store/handle.rs index 0533303..9593c1f 100644 --- a/src/store/handle.rs +++ b/src/store/handle.rs @@ -189,6 +189,7 @@ impl StoreCounters { updates: self.updates.get(), removes: self.removes.get(), evictions: self.evictions.get(), + expirations: 0, } } @@ -252,6 +253,7 @@ impl ConcurrentStoreCounters { updates: self.updates.load(Ordering::Relaxed), removes: self.removes.load(Ordering::Relaxed), evictions: self.evictions.load(Ordering::Relaxed), + expirations: 0, } } diff --git a/src/store/hashmap.rs b/src/store/hashmap.rs index 754e2bc..0c02f0e 100644 --- a/src/store/hashmap.rs +++ b/src/store/hashmap.rs @@ -223,6 +223,7 @@ impl StoreCounters { updates: self.updates.load(Ordering::Relaxed), removes: self.removes.load(Ordering::Relaxed), evictions: self.evictions.load(Ordering::Relaxed), + expirations: 0, } } diff --git a/src/store/slab.rs b/src/store/slab.rs index efe3b63..ce06735 100644 --- a/src/store/slab.rs +++ b/src/store/slab.rs @@ -298,6 +298,7 @@ impl StoreCounters { updates: self.updates.load(Ordering::Relaxed), removes: self.removes.load(Ordering::Relaxed), evictions: self.evictions.load(Ordering::Relaxed), + expirations: 0, } } diff --git a/src/store/traits.rs b/src/store/traits.rs index 7b1220b..7c4fce6 100644 --- a/src/store/traits.rs +++ b/src/store/traits.rs @@ -97,6 +97,7 @@ use std::sync::Arc; /// metrics.updates = 20; /// metrics.removes = 10; /// metrics.evictions = 40; +/// metrics.expirations = 5; /// /// let hit_rate = metrics.hits as f64 / (metrics.hits + metrics.misses) as f64; /// assert!((hit_rate - 0.75).abs() < f64::EPSILON); @@ -117,6 +118,14 @@ pub struct StoreMetrics { pub removes: u64, /// Number of entries evicted by the policy. pub evictions: u64, + /// Number of entries removed because their TTL deadline elapsed. + /// + /// Tracked separately from `evictions` so capacity-driven evictions + /// can be distinguished from time-driven expirations. Stays at `0` + /// for stores that do not own a TTL layer; the TTL count for an + /// `Expiring<…>` decorator is exposed via + /// `cachekit::policy::expiring::Expiring::expirations()`. + pub expirations: u64, } /// Error returned when inserting into a store at capacity. diff --git a/src/store/weight.rs b/src/store/weight.rs index d3e2710..a98ef66 100644 --- a/src/store/weight.rs +++ b/src/store/weight.rs @@ -256,6 +256,7 @@ impl StoreCounters { updates: self.updates.load(Ordering::Relaxed), removes: self.removes.load(Ordering::Relaxed), evictions: self.evictions.load(Ordering::Relaxed), + expirations: 0, } } diff --git a/src/time.rs b/src/time.rs new file mode 100644 index 0000000..763dee7 --- /dev/null +++ b/src/time.rs @@ -0,0 +1,286 @@ +//! Pluggable monotonic clock used by the TTL layer. +//! +//! ## Architecture +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────┐ +//! │ Clock trait │ +//! │ fn now(&self) -> u64 │ +//! │ │ +//! │ Implementations │ +//! │ StdClock(Instant anchor) ── ms since anchor │ +//! │ MockClock(AtomicU64) ── advance() / set() in tests │ +//! └─────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! ## Key Components +//! +//! - [`Clock`] — minimal trait the TTL decorator consults on every operation. +//! - [`StdClock`] — anchored to a base [`Instant`] captured at construction; +//! `now()` returns milliseconds since the anchor (saturating cast). +//! - [`MockClock`] — `AtomicU64` ticks for deterministic tests, proptest, and +//! fuzz harnesses. Supports interior mutation through `&self`. +//! +//! ## Core Operations +//! +//! Tick unit is **milliseconds**. `u64::MAX` ms covers roughly 585 million +//! years, so saturating casts and additions never wrap in practice. The TTL +//! layer documents that `u64::MAX` is the "effectively never expires" +//! sentinel; do not return it from `Clock::now`. +//! +//! ## Performance Trade-offs +//! +//! - `StdClock::now()` calls `Instant::now()`, which on modern Linux is +//! ~15–25 ns through the vDSO. The TTL decorator should call it once per +//! operation, not multiple times. +//! - `MockClock::now()` is a single `Ordering::Relaxed` load. +//! +//! ## Thread Safety +//! +//! Every clock here is `Send + Sync`; `now` is `&self` so concurrent caches +//! can share a clock under a single read lock. +//! +//! ## Example Usage +//! +//! ``` +//! use cachekit::time::{Clock, MockClock}; +//! use std::time::Duration; +//! +//! let clock = MockClock::new(); +//! assert_eq!(clock.now(), 0); +//! +//! clock.advance(Duration::from_millis(150)); +//! assert_eq!(clock.now(), 150); +//! ``` + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +/// Saturating cast of `Duration` to millisecond ticks. +/// +/// `Duration::as_millis()` returns `u128`; this clamps to `u64::MAX` rather +/// than truncating. With ms resolution the boundary is unreachable in +/// 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 + } +} + +/// Monotonic millisecond clock consulted by the TTL layer. +/// +/// 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 { + /// Returns the current tick. + /// + /// The tick unit is implementation-defined but is conventionally + /// milliseconds in `cachekit`. + fn now(&self) -> u64; +} + +/// `Clock` backed by `std::time::Instant`. +/// +/// `now()` returns milliseconds elapsed since the anchor captured at +/// construction. Two `StdClock` instances created at different times observe +/// different epochs; deadlines must therefore be computed against a single +/// clock instance. +/// +/// # Example +/// +/// ``` +/// use cachekit::time::{Clock, StdClock}; +/// use std::thread::sleep; +/// use std::time::Duration; +/// +/// let clock = StdClock::new(); +/// let a = clock.now(); +/// sleep(Duration::from_millis(5)); +/// let b = clock.now(); +/// assert!(b >= a); +/// ``` +#[derive(Debug)] +pub struct StdClock { + anchor: Instant, +} + +impl StdClock { + /// Creates a clock anchored at `Instant::now()`. + pub fn new() -> Self { + Self { + anchor: Instant::now(), + } + } + + /// Creates a clock anchored at the supplied `Instant`. + /// + /// Useful for tests that need to derive several clocks from a shared + /// anchor without depending on wall-clock timing. + pub fn with_anchor(anchor: Instant) -> Self { + Self { anchor } + } +} + +impl Default for StdClock { + fn default() -> Self { + Self::new() + } +} + +impl Clock for StdClock { + #[inline] + fn now(&self) -> u64 { + let elapsed = Instant::now().saturating_duration_since(self.anchor); + duration_to_ticks(elapsed) + } +} + +/// `Clock` backed by an `AtomicU64` for deterministic tests. +/// +/// The tick advances only via [`advance`](MockClock::advance) or +/// [`set`](MockClock::set); `now()` never changes on its own. `MockClock` +/// is `Send + Sync` so it can be shared by concurrent cache wrappers. +/// +/// # Example +/// +/// ``` +/// use cachekit::time::{Clock, MockClock}; +/// use std::time::Duration; +/// +/// let clock = MockClock::new(); +/// assert_eq!(clock.now(), 0); +/// +/// clock.advance(Duration::from_secs(1)); +/// assert_eq!(clock.now(), 1_000); +/// +/// clock.set(42); +/// assert_eq!(clock.now(), 42); +/// ``` +#[derive(Debug, Default)] +pub struct MockClock { + now: AtomicU64, +} + +impl MockClock { + /// Creates a clock anchored at tick `0`. + pub fn new() -> Self { + Self::with_tick(0) + } + + /// Creates a clock anchored at the supplied tick. + pub fn with_tick(tick: u64) -> Self { + Self { + now: AtomicU64::new(tick), + } + } + + /// Advances the clock by `delta`, saturating at `u64::MAX - 1`. + /// + /// Saturates one short of `u64::MAX` because the TTL layer reserves + /// `u64::MAX` as the "effectively never expires" sentinel. + 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); + match self.now.compare_exchange_weak( + current, + next, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(observed) => current = observed, + } + } + } + + /// Sets the clock to `tick` directly. + /// + /// Callers are responsible for keeping the clock monotonic; setting a + /// value lower than the current tick violates the `Clock` contract. + pub fn set(&self, tick: u64) { + self.now.store(tick, Ordering::Relaxed); + } +} + +impl Clock for MockClock { + #[inline] + fn now(&self) -> u64 { + self.now.load(Ordering::Relaxed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::thread; + + #[test] + fn std_clock_is_monotonic_non_decreasing() { + let clock = StdClock::new(); + let a = clock.now(); + let b = clock.now(); + assert!(b >= a); + } + + #[test] + fn mock_clock_advances_by_milliseconds() { + let clock = MockClock::new(); + assert_eq!(clock.now(), 0); + clock.advance(Duration::from_millis(100)); + assert_eq!(clock.now(), 100); + clock.advance(Duration::from_secs(1)); + assert_eq!(clock.now(), 1_100); + } + + #[test] + fn mock_clock_set_overrides_value() { + let clock = MockClock::new(); + clock.advance(Duration::from_millis(500)); + clock.set(42); + assert_eq!(clock.now(), 42); + } + + #[test] + fn mock_clock_advance_saturates_below_max() { + let clock = MockClock::with_tick(u64::MAX - 5); + clock.advance(Duration::from_millis(100)); + // Saturates to one below MAX (the "effectively never" sentinel). + assert_eq!(clock.now(), u64::MAX - 1); + clock.advance(Duration::from_millis(50)); + assert_eq!(clock.now(), u64::MAX - 1); + } + + #[test] + fn duration_to_ticks_saturates_on_overflow() { + assert_eq!(duration_to_ticks(Duration::ZERO), 0); + assert_eq!(duration_to_ticks(Duration::from_millis(1)), 1); + // Duration::MAX in ms would overflow u64. + assert_eq!(duration_to_ticks(Duration::MAX), u64::MAX); + } + + #[test] + fn mock_clock_is_send_sync_across_threads() { + let clock = Arc::new(MockClock::new()); + let mut handles = Vec::new(); + for _ in 0..4 { + let c = Arc::clone(&clock); + handles.push(thread::spawn(move || { + c.advance(Duration::from_millis(10)); + })); + } + for h in handles { + h.join().unwrap(); + } + // Each thread added 10ms; total exactly 40ms (no losses). + assert_eq!(clock.now(), 40); + } +} diff --git a/src/traits.rs b/src/traits.rs index 11297b1..79f46b5 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -478,6 +478,92 @@ pub trait AsyncCacheFuture: Send + Sync { } } +// --------------------------------------------------------------------------- +// TTL (feature = "ttl") +// --------------------------------------------------------------------------- + +/// Monotonic deadline expressed in the cache's tick unit. +/// +/// The tick unit (typically milliseconds, set by the +/// [`Clock`](crate::time::Clock) implementation) is intentionally hidden +/// behind this newtype so it can change without breaking downstream code. +/// Use [`Tick::as_u64`] to inspect the raw value when interop demands it. +#[cfg(feature = "ttl")] +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] +pub struct Tick(pub(crate) u64); + +#[cfg(feature = "ttl")] +impl Tick { + /// Returns the underlying tick value. + #[inline] + pub fn as_u64(self) -> u64 { + self.0 + } +} + +/// TTL state for a key in an [`ExpiringCache`]. +/// +/// Distinguishes between "no such key", "key present without a deadline", +/// "key present but already past its deadline", and "key present and live". +/// The decorator may purge expired entries lazily, so callers can observe +/// [`TtlStatus::Expired`] for a brief window before physical cleanup. +#[cfg(feature = "ttl")] +#[derive(Copy, Clone, Eq, PartialEq, Debug)] +pub enum TtlStatus { + /// The key is not in the cache. + Missing, + /// The key is resident with no expiration deadline. + Immortal, + /// The key is resident but its deadline has already passed. + Expired, + /// The key is resident and live. + Live { + /// Time remaining until the entry expires. + remaining: std::time::Duration, + /// Absolute deadline in the cache's tick unit. + deadline: Tick, + }, +} + +/// Capability trait for caches that support time-based expiration. +/// +/// Implemented by the `Expiring` decorator and surfaced through +/// `DynExpiringCache` when the `ttl` feature is enabled. See +/// `docs/design/ttl.md` for the full semantic contract. +/// +/// ## Semantic Highlights +/// +/// - Per-entry TTL always wins over the cache's `default_ttl`, including +/// [`Duration::ZERO`](std::time::Duration::ZERO) which means "expire +/// immediately". +/// - Reads on an expired-but-resident entry physically remove it and +/// return as if the key were missing. +/// - `set_ttl` returns `true` only if the entry was live at the moment of +/// the call; expired-resident entries are purged and return `false`. +#[cfg(feature = "ttl")] +pub trait ExpiringCache: Cache { + /// Inserts `value` under `key` with an explicit per-entry TTL, + /// returning the previous value if it existed *and* was still live. + /// + /// Passing `Duration::ZERO` removes any existing entry without + /// inserting; the returned `Option` follows the same live-only + /// rule. + fn insert_with_ttl(&mut self, key: K, value: V, ttl: std::time::Duration) -> Option; + + /// Returns the TTL state for `key` without mutating cache state. + fn ttl_status(&self, key: &K) -> TtlStatus; + + /// Sets a new TTL on an existing live entry. + /// + /// Returns `true` if the entry was live at the time of the call. An + /// expired-but-resident entry is purged and `false` is returned. + fn set_ttl(&mut self, key: &K, ttl: std::time::Duration) -> bool; + + /// Removes every entry whose deadline is `<= now`, returning the count + /// removed. + fn purge_expired(&mut self) -> usize; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/tests/ttl_integration_test.rs b/tests/ttl_integration_test.rs new file mode 100644 index 0000000..55365d0 --- /dev/null +++ b/tests/ttl_integration_test.rs @@ -0,0 +1,337 @@ +//! Black-box semantics tests for the TTL layer. +//! +//! Driven by `MockClock` so timing is deterministic. The tests exercise +//! the [`DynExpiringCache`] handed out by +//! [`CacheBuilder::with_default_ttl`], plus the raw +//! [`Expiring<…>`](cachekit::policy::expiring::Expiring) decorator wrapped +//! around a couple of concrete policies. + +#![cfg(feature = "ttl")] + +use std::time::Duration; + +use cachekit::builder::{CacheBuilder, CachePolicy}; +use cachekit::policy::expiring::Expiring; +use cachekit::policy::fast_lru::FastLru; +use cachekit::time::MockClock; +use cachekit::traits::{Cache, ExpiringCache, TtlStatus}; + +/// Common setup: a deterministic `Expiring>` with the +/// supplied default TTL. +fn make_expiring( + capacity: usize, + default_ttl: Option, +) -> Expiring, u64, String, MockClock> { + let clock = MockClock::new(); + let inner = FastLru::::new(capacity); + Expiring::with_default_ttl(inner, clock, default_ttl) +} + +// --------------------------------------------------------------------------- +// Decorator semantics +// --------------------------------------------------------------------------- + +#[test] +fn insert_with_zero_ttl_removes_existing_and_returns_none() { + let mut cache = make_expiring(8, None); + cache.insert_with_ttl(1, "a".into(), Duration::from_secs(60)); + + // Zero TTL removes the entry; previous live value is returned. + assert_eq!( + cache.insert_with_ttl(1, "b".into(), Duration::ZERO), + Some("a".to_string()) + ); + assert!(!cache.contains(&1)); +} + +#[test] +fn expired_entry_observed_as_missing_on_read() { + let mut cache = make_expiring(8, None); + cache.insert_with_ttl(1, "a".into(), Duration::from_millis(100)); + cache.clock().advance(Duration::from_millis(99)); + assert_eq!(cache.get(&1), Some(&"a".to_string())); + + cache.clock().advance(Duration::from_millis(1)); + assert_eq!(cache.get(&1), None); + assert_eq!(cache.len(), 0); +} + +#[test] +fn insert_over_expired_entry_returns_none() { + let mut cache = make_expiring(8, None); + cache.insert_with_ttl(1, "old".into(), Duration::from_millis(50)); + cache.clock().advance(Duration::from_millis(60)); + + // Plain insert with no default TTL — the previous entry is expired, + // so the return value is None rather than the stale "old". + assert_eq!(cache.insert(1, "new".into()), None); + assert_eq!(cache.peek(&1), Some(&"new".to_string())); +} + +#[test] +fn set_ttl_extension_works_only_on_live_entries() { + let mut cache = make_expiring(8, None); + cache.insert_with_ttl(1, "a".into(), Duration::from_millis(50)); + cache.clock().advance(Duration::from_millis(40)); + + // Extend live entry. + assert!(cache.set_ttl(&1, Duration::from_millis(200))); + cache.clock().advance(Duration::from_millis(100)); + assert!(cache.contains(&1)); + + // Now let it expire and try to extend. + cache.clock().advance(Duration::from_millis(200)); + assert!(!cache.set_ttl(&1, Duration::from_millis(50))); + // Side effect: expired entry was purged. + assert!(!cache.contains(&1)); +} + +#[test] +fn purge_expired_returns_exact_count() { + let mut cache = make_expiring(16, None); + for i in 0..5 { + cache.insert_with_ttl(i, format!("{i}"), Duration::from_millis(10)); + } + for i in 5..10 { + cache.insert_with_ttl(i, format!("{i}"), Duration::from_secs(60)); + } + cache.clock().advance(Duration::from_millis(50)); + assert_eq!(cache.purge_expired(), 5); + assert_eq!(cache.len(), 5); +} + +#[test] +fn per_entry_ttl_overrides_default_including_zero() { + let mut cache = make_expiring(8, Some(Duration::from_secs(60))); + + // Per-entry TTL shorter than default. + cache.insert_with_ttl(1, "short".into(), Duration::from_millis(10)); + cache.clock().advance(Duration::from_millis(20)); + assert!(!cache.contains(&1)); + + // Per-entry zero overrides default. + cache.insert_with_ttl(2, "instant".into(), Duration::ZERO); + assert!(!cache.contains(&2)); + + // Plain insert inherits the default. + cache.insert(3, "inherits".into()); + cache.clock().advance(Duration::from_secs(59)); + assert!(cache.contains(&3)); + cache.clock().advance(Duration::from_secs(2)); + assert!(cache.get(&3).is_none()); +} + +#[test] +fn ttl_status_reports_all_four_states() { + let mut cache = make_expiring(8, None); + + // Missing. + assert_eq!(cache.ttl_status(&1), TtlStatus::Missing); + + // Immortal (no TTL set, no default). + cache.insert(2, "forever".into()); + assert_eq!(cache.ttl_status(&2), TtlStatus::Immortal); + + // Live. + cache.insert_with_ttl(3, "live".into(), Duration::from_millis(500)); + match cache.ttl_status(&3) { + TtlStatus::Live { + remaining, + deadline, + } => { + assert!(remaining > Duration::ZERO); + assert!(deadline.as_u64() > 0); + }, + other => panic!("expected Live, got {other:?}"), + } + + // Expired (resident but past deadline). + cache.clock().advance(Duration::from_millis(600)); + assert_eq!(cache.ttl_status(&3), TtlStatus::Expired); +} + +// --------------------------------------------------------------------------- +// Builder integration +// --------------------------------------------------------------------------- + +#[test] +fn builder_with_default_ttl_produces_dyn_expiring_cache() { + let mut cache = CacheBuilder::new(8) + .with_default_ttl(Duration::from_secs(60)) + .build::(CachePolicy::FastLru); + + assert_eq!(cache.default_ttl(), Some(Duration::from_secs(60))); + cache.insert(1, "value".into()); + assert_eq!(cache.peek(&1), Some(&"value".to_string())); + cache.insert_with_ttl(2, "short".into(), Duration::from_millis(10)); + assert_eq!(cache.len(), 2); +} + +#[test] +fn dyn_expiring_cache_with_lru_policy_round_trips() { + let mut cache = CacheBuilder::new(8) + .with_default_ttl(Duration::from_secs(60)) + .build::(CachePolicy::Lru); + + cache.insert(1, "a".into()); + cache.insert(2, "b".into()); + assert_eq!(cache.get(&1), Some(&"a".to_string())); + assert_eq!(cache.live_len(), 2); + cache.clear(); + assert_eq!(cache.len(), 0); +} + +#[test] +fn dyn_expiring_cache_purge_expired_works_via_builder_path() { + let mut cache = CacheBuilder::new(8) + .with_default_ttl(Duration::from_millis(50)) + .build::(CachePolicy::FastLru); + + cache.insert(1, "x".into()); + cache.insert(2, "y".into()); + cache.insert_with_ttl(3, "long".into(), Duration::from_secs(60)); + + // Without a way to advance StdClock, just confirm the API shape. + // Real expiry-on-read is exercised via the MockClock-backed tests above. + assert_eq!(cache.purge_expired(), 0); + assert_eq!(cache.live_len(), 3); +} + +// --------------------------------------------------------------------------- +// Property test: random op sequence against a reference model. +// --------------------------------------------------------------------------- + +mod proptests { + use std::collections::HashMap; + use std::time::Duration; + + use cachekit::policy::expiring::Expiring; + use cachekit::policy::fast_lru::FastLru; + use cachekit::time::MockClock; + use cachekit::traits::{Cache, ExpiringCache}; + use proptest::prelude::*; + + /// Operations the reference model and the cache will both execute. + #[derive(Debug, Clone)] + enum Op { + Insert { key: u8, ttl_ms: u32 }, + Get { key: u8 }, + Advance { delta_ms: u32 }, + Purge, + } + + /// Reference: key -> deadline (in ms). Keys with deadline <= now are + /// considered expired even before a `Purge` op. + #[derive(Default)] + struct RefModel { + deadlines: HashMap, + now: u64, + capacity: usize, + } + + impl RefModel { + fn new(capacity: usize) -> Self { + Self { + capacity, + ..Default::default() + } + } + + fn insert(&mut self, key: u8, ttl_ms: u32) { + if ttl_ms == 0 { + self.deadlines.remove(&key); + return; + } + // Saturating arithmetic mirrors `Expiring::deadline_from`. + let deadline = self.now.saturating_add(ttl_ms as u64).min(u64::MAX - 1); + // Eviction model: FastLru with bounded capacity. Because we + // do not model LRU order precisely, we permit any key to be + // evicted; the assertion below is only on "is this key + // *definitely live*?", not on every absent key being expired. + self.deadlines.insert(key, deadline); + if self.deadlines.len() > self.capacity { + // Pick an arbitrary victim — we don't try to match the + // policy exactly; we only insist on liveness for keys + // that are present in BOTH the model and the cache. + let victim = *self.deadlines.keys().next().unwrap(); + if victim != key { + self.deadlines.remove(&victim); + } + } + } + + fn advance(&mut self, delta_ms: u32) { + self.now = self.now.saturating_add(delta_ms as u64); + } + + /// `Some(true)` if the key is definitely live in the model; + /// `Some(false)` if it is definitely expired; `None` if its state + /// is undetermined (e.g. evicted under the FastLru policy in a + /// way the model doesn't track). + fn is_definitely_live(&self, key: u8) -> Option { + match self.deadlines.get(&key) { + Some(&d) if d > self.now => Some(true), + Some(_) => Some(false), + None => None, + } + } + } + + fn op_strategy() -> impl Strategy { + prop_oneof![ + (any::(), 1u32..2_000).prop_map(|(k, t)| Op::Insert { key: k, ttl_ms: t }), + any::().prop_map(|k| Op::Get { key: k }), + (1u32..500).prop_map(|d| Op::Advance { delta_ms: d }), + Just(Op::Purge), + ] + } + + proptest! { + #![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })] + + #[cfg_attr(miri, ignore)] + #[test] + fn random_ops_match_reference_model(ops in prop::collection::vec(op_strategy(), 0..120)) { + const CAPACITY: usize = 16; + + let clock = MockClock::new(); + let inner: FastLru = FastLru::new(CAPACITY); + let mut cache = Expiring::with_default_ttl(inner, clock, None); + let mut model = RefModel::new(CAPACITY); + + for op in ops { + match op { + Op::Insert { key, ttl_ms } => { + cache.insert_with_ttl(key, (), Duration::from_millis(ttl_ms as u64)); + model.insert(key, ttl_ms); + } + Op::Get { key } => { + let hit = cache.get(&key).is_some(); + if let Some(true) = model.is_definitely_live(key) { + prop_assert!( + hit, + "model says key {key} is live but cache miss" + ); + } + if let Some(false) = model.is_definitely_live(key) { + prop_assert!( + !hit, + "model says key {key} is expired but cache hit" + ); + } + } + Op::Advance { delta_ms } => { + cache.clock().advance(Duration::from_millis(delta_ms as u64)); + model.advance(delta_ms); + } + Op::Purge => { + let _ = cache.purge_expired(); + // Model side: drop everything <= now. + let now = model.now; + model.deadlines.retain(|_, d| *d > now); + } + } + } + } + } +}