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}