dwara_core/extensions/
cache.rs

1//! Cache store extension point.
2//!
3//! # Contract: [`CacheStore`]
4//!
5//! **Purpose:** opaque byte-level get/set/delete storage for response
6//! caching and any hot-path shared state. Keys are strings; values are
7//! owned byte vectors so backends can move them without copying.
8//!
9//! **Semantics:** a best-effort store, not a durable database. `set`
10//! overwrites any existing value; `delete` of a missing key is a no-op
11//! reporting `false`. DW-037 added the optional TTL seam the M1 doc
12//! anticipated: [`CacheStore::set_with_ttl`] carries a per-entry
13//! lifetime hint, DEFAULTED to plain `set` so every existing
14//! implementation keeps compiling and remains a valid backend — the
15//! response cache layer stamps expiry into the value envelope and
16//! enforces it at READ, so backend TTL is a memory-reclamation
17//! optimization, never a correctness dependency.
18//!
19//! **Failure model:** backend failures map to [`ExtensionsError::Backend`];
20//! call sites should treat cache errors as a miss (degrade, do not fail the
21//! request). No retries.
22//!
23//! **Editions:** OSS ships [`InMemoryCache`] (process-local LRU-bounded
24//! map) and [`MokaCache`] (the DW-037 response-cache backend:
25//! concurrent, byte-weighed, per-entry TTL). Additional distributed
26//! backends (DW-068 Redis) may be provided separately in future
27//! editions.
28
29use std::collections::{BTreeMap, HashMap};
30use std::sync::Mutex;
31use std::time::Duration;
32
33use async_trait::async_trait;
34
35use super::ExtensionsError;
36
37/// Default [`InMemoryCache`] capacity (entries, not bytes): enough for a
38/// small deployment's hot response set while keeping the store bounded
39/// against key-flooding (#128, DW-004 review).
40pub const DEFAULT_CACHE_CAPACITY: usize = 1024;
41
42/// Default [`MokaCache`] capacity (bytes, DW-037): the weigher counts
43/// key + value bytes plus a fixed per-entry overhead, so the bound is a
44/// real memory ceiling for the response cache (entries at or under the
45/// per-route `max_body_bytes` cap, of which 64 MiB holds hundreds).
46pub const DEFAULT_CACHE_STORE_BYTES: u64 = 64 * 1024 * 1024;
47
48/// Swappable key/value cache backend.
49#[async_trait]
50pub trait CacheStore: Send + Sync {
51    /// Fetch the value for `key`, if present.
52    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, ExtensionsError>;
53    /// Store `value` under `key`, overwriting any previous value.
54    async fn set(&self, key: String, value: Vec<u8>) -> Result<(), ExtensionsError>;
55    /// Remove `key`; returns whether a value existed.
56    async fn delete(&self, key: &str) -> Result<bool, ExtensionsError>;
57    /// Store `value` under `key` with a per-entry lifetime hint
58    /// (DW-037). Backends that cannot honor a per-entry TTL implement
59    /// the default (store without the hint); every reader re-checks
60    /// expiry from the value itself, so correctness never depends on
61    /// this. Defaulted, not abstract: existing implementations stay
62    /// valid backends without a change (extend, do not break).
63    async fn set_with_ttl(
64        &self,
65        key: String,
66        value: Vec<u8>,
67        ttl: Duration,
68    ) -> Result<(), ExtensionsError> {
69        let _ = ttl;
70        self.set(key, value).await
71    }
72    /// Approximate number of live entries, when the backend can report
73    /// it cheaply (DW-037 metrics gauge; None = not reportable). An
74    /// estimate is acceptable — the consumer renders it as a gauge.
75    fn entry_count(&self) -> Option<u64> {
76        None
77    }
78}
79
80/// One entry: its value plus the last-use stamp for LRU ordering.
81#[derive(Debug)]
82struct Entry {
83    value: Vec<u8>,
84    last_used: u64,
85}
86
87/// Bounded internal state: the entries plus a `last_used -> key` index so
88/// eviction finds the least-recently-used entry without a scan.
89#[derive(Debug, Default)]
90struct Bounded {
91    map: HashMap<String, Entry>,
92    recency: BTreeMap<u64, String>,
93    tick: u64,
94}
95
96impl Bounded {
97    /// Move `key` (present in `map`) to "just used" under a fresh tick and
98    /// keep the recency index in sync. A no-op when the key is absent.
99    fn touch(&mut self, key: &str) {
100        let Some(last_used) = self.map.get(key).map(|e| e.last_used) else {
101            return;
102        };
103        self.tick += 1;
104        self.recency.remove(&last_used);
105        if let Some(entry) = self.map.get_mut(key) {
106            entry.last_used = self.tick;
107        }
108        self.recency.insert(self.tick, key.to_string());
109    }
110
111    /// Evict least-recently-used entries until the map fits `capacity`.
112    fn evict_to(&mut self, capacity: usize) {
113        while self.map.len() > capacity {
114            let Some((_, victim)) = self.recency.pop_first() else {
115                return;
116            };
117            self.map.remove(&victim);
118        }
119    }
120}
121
122/// In-memory LRU cache (OSS local impl, #128).
123///
124/// Capacity-bounded with least-recently-used eviction: `get` and `set`
125/// refresh recency; once the entry count exceeds the capacity the
126/// least-recently-used entry is evicted. The capacity is a constructor
127/// parameter ([`InMemoryCache::with_capacity`],
128/// [`DEFAULT_CACHE_CAPACITY`] by default, clamped to at least 1) — it
129/// bounds ENTRY COUNT, not bytes (values are opaque to the store); a
130/// byte- or TTL-bounded backend can slot in behind the same trait later.
131#[derive(Debug)]
132pub struct InMemoryCache {
133    entries: Mutex<Bounded>,
134    capacity: usize,
135}
136
137impl Default for InMemoryCache {
138    fn default() -> Self {
139        Self::new()
140    }
141}
142
143impl InMemoryCache {
144    /// Cache with the default capacity ([`DEFAULT_CACHE_CAPACITY`]
145    /// entries).
146    pub fn new() -> Self {
147        Self::with_capacity(DEFAULT_CACHE_CAPACITY)
148    }
149
150    /// Cache bounded to `capacity` entries (values below 1 are clamped
151    /// to 1, so the store is always minimally usable).
152    pub fn with_capacity(capacity: usize) -> Self {
153        Self {
154            entries: Mutex::new(Bounded::default()),
155            capacity: capacity.max(1),
156        }
157    }
158}
159
160#[async_trait]
161impl CacheStore for InMemoryCache {
162    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, ExtensionsError> {
163        let mut state = self.entries.lock().expect("cache state poisoned");
164        let value = state.map.get(key).map(|e| e.value.clone());
165        // Refresh recency; a no-op when the key is absent (touch checks).
166        state.touch(key);
167        Ok(value)
168    }
169
170    async fn set(&self, key: String, value: Vec<u8>) -> Result<(), ExtensionsError> {
171        let mut state = self.entries.lock().expect("cache state poisoned");
172        if let Some(old) = state.map.remove(&key) {
173            state.recency.remove(&old.last_used);
174        }
175        state.tick += 1;
176        // Copy the tick out: the guard's DerefMut borrows all of `state`
177        // for the insert, so the literal cannot read `state.tick` inline.
178        let tick = state.tick;
179        state.recency.insert(tick, key.clone());
180        state.map.insert(
181            key,
182            Entry {
183                value,
184                last_used: tick,
185            },
186        );
187        state.evict_to(self.capacity);
188        Ok(())
189    }
190
191    async fn delete(&self, key: &str) -> Result<bool, ExtensionsError> {
192        let mut state = self.entries.lock().expect("cache state poisoned");
193        match state.map.remove(key) {
194            Some(entry) => {
195                state.recency.remove(&entry.last_used);
196                Ok(true)
197            }
198            None => Ok(false),
199        }
200    }
201}
202
203/// Byte-weighed TTL cache over `moka` (DW-037 response-cache backend).
204///
205/// Bounds, honestly stated:
206///
207/// - **Bytes, not entries**: `max_capacity` is interpreted through a
208///   weigher counting key + value bytes plus a fixed per-entry overhead
209///   (`ENTRY_OVERHEAD_BYTES`), so the store is bounded by MEMORY, the
210///   property a gateway cache actually needs (entry-count bounds let N
211///   one-byte-below-the-cap entries allocate N times the cap).
212/// - **Time-to-idle** ([`MokaCache::TIME_TO_IDLE`]): entries nobody
213///   looks at for an hour are evicted even when still nominally fresh —
214///   a cold entry costs memory and a future miss, nothing else.
215/// - **Per-entry TTL** via [`CacheStore::set_with_ttl`]: the hint rides
216///   the stored pair and drives moka's `Expiry` policy (plus 60 s of
217///   slack over the hint, so the envelope's read-side expiry always
218///   fires first and the backend only reclaims what the policy already
219///   abandoned).
220///
221/// All eviction (size, idle, TTL) is approximate and concurrent — moka
222/// maintains shards internally; no global lock sits on the hot path.
223#[derive(Debug)]
224pub struct MokaCache {
225    inner: moka::sync::Cache<String, (Vec<u8>, Option<Duration>)>,
226}
227
228/// Fixed per-entry weigher overhead: moka's own node/bookkeeping bytes
229/// charged to the capacity budget so the byte bound is conservative.
230const ENTRY_OVERHEAD_BYTES: u64 = 256;
231
232/// Per-entry TTL policy: the duration stored alongside each value (set
233/// by `set_with_ttl`, `None` for plain `set`) becomes the entry's
234/// post-creation lifetime; updates keep the original expiry clock.
235struct TtlExpiry;
236
237impl moka::Expiry<String, (Vec<u8>, Option<Duration>)> for TtlExpiry {
238    fn expire_after_create(
239        &self,
240        _key: &String,
241        value: &(Vec<u8>, Option<Duration>),
242        _created_at: std::time::Instant,
243    ) -> Option<Duration> {
244        value.1
245    }
246}
247
248impl Default for MokaCache {
249    fn default() -> Self {
250        Self::with_max_bytes(DEFAULT_CACHE_STORE_BYTES)
251    }
252}
253
254impl MokaCache {
255    /// Idle-eviction window (see the type docs).
256    pub const TIME_TO_IDLE: Duration = Duration::from_secs(3600);
257
258    /// Slack added to every TTL hint (see the type docs).
259    pub const TTL_SLACK: Duration = Duration::from_secs(60);
260
261    /// Cache bounded to `max_bytes` of key+value+overhead bytes.
262    pub fn with_max_bytes(max_bytes: u64) -> Self {
263        MokaCache {
264            inner: moka::sync::Cache::builder()
265                .max_capacity(max_bytes)
266                .weigher(|k: &String, v: &(Vec<u8>, Option<Duration>)| {
267                    (k.len() as u64 + v.0.len() as u64 + ENTRY_OVERHEAD_BYTES) as u32
268                })
269                .time_to_idle(Self::TIME_TO_IDLE)
270                .expire_after(TtlExpiry)
271                .build(),
272        }
273    }
274}
275
276#[async_trait]
277impl CacheStore for MokaCache {
278    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, ExtensionsError> {
279        Ok(self.inner.get(key).map(|(value, _)| value))
280    }
281
282    async fn set(&self, key: String, value: Vec<u8>) -> Result<(), ExtensionsError> {
283        self.inner.insert(key, (value, None));
284        Ok(())
285    }
286
287    async fn delete(&self, key: &str) -> Result<bool, ExtensionsError> {
288        Ok(self.inner.remove(key).is_some())
289    }
290
291    async fn set_with_ttl(
292        &self,
293        key: String,
294        value: Vec<u8>,
295        ttl: Duration,
296    ) -> Result<(), ExtensionsError> {
297        // Read-side envelope expiry is the source of truth; the backend
298        // TTL only reclaims memory the policy already gave up on.
299        self.inner.insert(key, (value, Some(ttl + Self::TTL_SLACK)));
300        Ok(())
301    }
302
303    fn entry_count(&self) -> Option<u64> {
304        Some(self.inner.entry_count())
305    }
306}