Skip to main content

trillium_cache/
memory.rs

1//! In-memory [`CacheStorage`].
2
3use crate::{CacheKey, CachePolicy, CacheStorage, PutHandle, StoredEntry};
4use futures_lite::{AsyncRead, AsyncWrite};
5use moka::{future::Cache, ops::compute::Op};
6use std::{
7    fmt::{self, Debug, Formatter},
8    io,
9    pin::Pin,
10    sync::Arc,
11    task::{Context, Poll},
12    time::Duration,
13};
14use trillium_http::{Body, BodySource, Headers};
15
16const DEFAULT_MAX_CAPACITY_BYTES: u64 = 256 * 1024 * 1024;
17
18// All variants stored under one CacheKey. Cheap to clone (Arc); held as the moka
19// value type so eviction operates per-CacheKey.
20type Bucket = Arc<[Variant]>;
21
22#[derive(Clone)]
23struct Variant {
24    policy: Arc<CachePolicy>,
25    body: Arc<[u8]>,
26    trailers: Option<Headers>,
27}
28
29impl Debug for Variant {
30    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
31        f.debug_struct("Variant")
32            .field("body_len", &self.body.len())
33            .field("has_trailers", &self.trailers.is_some())
34            .finish_non_exhaustive()
35    }
36}
37
38/// Bounded in-memory cache storage.
39///
40/// Suitable for production reverse-proxy and client-side caching: byte-aware size cap,
41/// scan-resistant admission, and concurrent reads and writes on distinct keys without
42/// contention.
43///
44/// Defaults to a 256 MiB byte cap; override with
45/// [`with_max_capacity_bytes`][Self::with_max_capacity_bytes],
46/// [`unbounded`][Self::unbounded],
47/// [`with_time_to_idle`][Self::with_time_to_idle], and
48/// [`with_time_to_live`][Self::with_time_to_live]. Each setter
49/// discards any previously inserted entries; configure at
50/// construction, before the storage is populated or shared.
51///
52/// `Clone` is cheap — clones share the same backing storage.
53///
54/// # Granularity
55///
56/// Eviction is coarse: the unit is one [`CacheKey`] (method + URL), and all `Vary` variants
57/// stored under that key live and die together during eviction. In typical traffic patterns
58/// variants of the same URL are hot or cold together (a single `Accept-Encoding` is usually
59/// dominant, etc.), so the cost is bounded — at worst we keep a few cold variants resident
60/// alongside one hot variant. This is correct per RFC 9111; the only consequence is slightly
61/// less efficient use of memory than per-variant eviction would give.
62///
63/// # Sizing
64///
65/// The byte cap is enforced over stored *body* bytes only (the dominant cost); headers and
66/// other metadata are not counted. The per-response cap on [`Cache::with_max_cacheable_size`]
67/// interacts independently — that one bounds how large any single response may be; the storage
68/// cap bounds total resident size across the cache.
69///
70/// [`Cache::with_max_cacheable_size`]: crate::Cache::with_max_cacheable_size
71#[derive(Clone)]
72pub struct InMemoryStorage {
73    cache: Cache<CacheKey, Bucket>,
74    max_capacity_bytes: Option<u64>,
75    time_to_idle: Option<Duration>,
76    time_to_live: Option<Duration>,
77}
78
79impl Debug for InMemoryStorage {
80    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
81        f.debug_struct("InMemoryStorage")
82            .field("entry_count", &self.cache.entry_count())
83            .field("weighted_size", &self.cache.weighted_size())
84            .field("max_capacity_bytes", &self.max_capacity_bytes)
85            .field("time_to_idle", &self.time_to_idle)
86            .field("time_to_live", &self.time_to_live)
87            .finish_non_exhaustive()
88    }
89}
90
91impl Default for InMemoryStorage {
92    fn default() -> Self {
93        Self::new()
94    }
95}
96
97impl InMemoryStorage {
98    /// Construct an in-memory storage with default settings: a
99    /// 256 MiB byte cap, no idle eviction, no TTL.
100    pub fn new() -> Self {
101        Self {
102            cache: build_cache(Some(DEFAULT_MAX_CAPACITY_BYTES), None, None),
103            max_capacity_bytes: Some(DEFAULT_MAX_CAPACITY_BYTES),
104            time_to_idle: None,
105            time_to_live: None,
106        }
107    }
108
109    /// Set the maximum total stored body size, in bytes. Entries are
110    /// evicted when inserts would exceed this cap. Defaults to
111    /// 256 MiB.
112    pub fn with_max_capacity_bytes(mut self, bytes: u64) -> Self {
113        self.max_capacity_bytes = Some(bytes);
114        self.rebuild();
115        self
116    }
117
118    /// Remove the size cap. The cache grows without bound. Useful in
119    /// tests and short-lived processes; production deployments should
120    /// prefer the default capped configuration.
121    pub fn unbounded(mut self) -> Self {
122        self.max_capacity_bytes = None;
123        self.rebuild();
124        self
125    }
126
127    /// Evict entries that have not been read in this duration. Off by
128    /// default.
129    pub fn with_time_to_idle(mut self, duration: Duration) -> Self {
130        self.time_to_idle = Some(duration);
131        self.rebuild();
132        self
133    }
134
135    /// Evict entries this duration after their last insert,
136    /// regardless of access. Off by default.
137    ///
138    /// Note: this is independent of RFC 9111 freshness — a stored
139    /// entry may be evicted by TTL while still within its
140    /// `max-age`/`s-maxage` window, or remain past it (the
141    /// [`CachePolicy`] handles freshness on read).
142    pub fn with_time_to_live(mut self, duration: Duration) -> Self {
143        self.time_to_live = Some(duration);
144        self.rebuild();
145        self
146    }
147
148    /// Approximate count of stored [`CacheKey`]s. Each key may hold
149    /// multiple `Vary` variants. Eventually consistent — call
150    /// [`run_pending_tasks`][Self::run_pending_tasks] first for a
151    /// settled value (useful in tests).
152    pub fn entry_count(&self) -> u64 {
153        self.cache.entry_count()
154    }
155
156    /// Approximate total weighted size (sum of stored body bytes
157    /// across all entries). Eventually consistent — call
158    /// [`run_pending_tasks`][Self::run_pending_tasks] first for a
159    /// settled value.
160    pub fn weighted_size(&self) -> u64 {
161        self.cache.weighted_size()
162    }
163
164    /// Flush pending eviction/insertion bookkeeping. Call before
165    /// reading [`entry_count`][Self::entry_count] or
166    /// [`weighted_size`][Self::weighted_size] when an exact value
167    /// matters.
168    pub async fn run_pending_tasks(&self) {
169        self.cache.run_pending_tasks().await;
170    }
171
172    // moka::future::Cache has no resize/set-capacity API — configuration is
173    // fixed at build time. Each setter rebuilds the backing cache.
174    fn rebuild(&mut self) {
175        self.cache = build_cache(
176            self.max_capacity_bytes,
177            self.time_to_idle,
178            self.time_to_live,
179        );
180    }
181}
182
183fn build_cache(
184    max_capacity_bytes: Option<u64>,
185    time_to_idle: Option<Duration>,
186    time_to_live: Option<Duration>,
187) -> Cache<CacheKey, Bucket> {
188    let mut builder = Cache::<CacheKey, Bucket>::builder().weigher(weigh_bucket);
189    if let Some(cap) = max_capacity_bytes {
190        builder = builder.max_capacity(cap);
191    }
192    if let Some(tti) = time_to_idle {
193        builder = builder.time_to_idle(tti);
194    }
195    if let Some(ttl) = time_to_live {
196        builder = builder.time_to_live(ttl);
197    }
198    builder.build()
199}
200
201fn weigh_bucket(_key: &CacheKey, bucket: &Bucket) -> u32 {
202    let total: u64 = bucket.iter().map(|v| v.body.len() as u64).sum();
203    u32::try_from(total).unwrap_or(u32::MAX)
204}
205
206/// In-memory [`StoredEntry`]. Cheap to clone — fields are `Arc`-shared
207/// with the backing cache.
208#[derive(Clone)]
209pub struct InMemoryEntry {
210    variant: Variant,
211    cache: Cache<CacheKey, Bucket>,
212    key: CacheKey,
213}
214
215impl Debug for InMemoryEntry {
216    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
217        f.debug_struct("InMemoryEntry")
218            .field("key", &self.key)
219            .field("variant", &self.variant)
220            .finish_non_exhaustive()
221    }
222}
223
224impl StoredEntry for InMemoryEntry {
225    fn policy(&self) -> &CachePolicy {
226        &self.variant.policy
227    }
228
229    async fn refresh_policy(&mut self, new_policy: CachePolicy) -> io::Result<()> {
230        let new_arc = Arc::new(new_policy);
231        // Update the local view first so an immediately-following policy() call sees the new
232        // value even if the cache update below has nothing to write back (e.g. the entry was
233        // already evicted).
234        self.variant.policy = Arc::clone(&new_arc);
235
236        self.cache
237            .entry(self.key.clone())
238            .and_compute_with(|maybe_entry| async move {
239                let Some(entry) = maybe_entry else {
240                    return Op::Nop;
241                };
242                let bucket = entry.into_value();
243                let mut updated = false;
244                let new_variants: Vec<Variant> = bucket
245                    .iter()
246                    .map(|v| {
247                        if !updated && v.policy.same_variant_as(&new_arc) {
248                            updated = true;
249                            Variant {
250                                policy: Arc::clone(&new_arc),
251                                body: Arc::clone(&v.body),
252                                trailers: v.trailers.clone(),
253                            }
254                        } else {
255                            v.clone()
256                        }
257                    })
258                    .collect();
259                if updated {
260                    Op::Put(Arc::from(new_variants.into_boxed_slice()))
261                } else {
262                    Op::Nop
263                }
264            })
265            .await;
266        Ok(())
267    }
268
269    async fn open(self) -> io::Result<Body> {
270        let Variant { body, trailers, .. } = self.variant;
271        let len = u64::try_from(body.len()).ok();
272        let source = ReplayBodySource {
273            body,
274            position: 0,
275            trailers,
276        };
277        Ok(Body::new_with_trailers(source, len))
278    }
279}
280
281// BodySource over a shared Arc<[u8]>. No copy on open; reads slice through the Arc.
282struct ReplayBodySource {
283    body: Arc<[u8]>,
284    position: usize,
285    trailers: Option<Headers>,
286}
287
288impl AsyncRead for ReplayBodySource {
289    fn poll_read(
290        mut self: Pin<&mut Self>,
291        _cx: &mut Context<'_>,
292        buf: &mut [u8],
293    ) -> Poll<io::Result<usize>> {
294        let remaining = self.body.len() - self.position;
295        let n = remaining.min(buf.len());
296        if n > 0 {
297            buf[..n].copy_from_slice(&self.body[self.position..self.position + n]);
298            self.position += n;
299        }
300        Poll::Ready(Ok(n))
301    }
302}
303
304impl BodySource for ReplayBodySource {
305    fn trailers(self: Pin<&mut Self>) -> Option<Headers> {
306        self.get_mut().trailers.take()
307    }
308}
309
310impl CacheStorage for InMemoryStorage {
311    type PutHandle = InMemoryPutHandle;
312    type StoredEntry = InMemoryEntry;
313
314    async fn get(&self, key: &CacheKey) -> Vec<Self::StoredEntry> {
315        let Some(bucket) = self.cache.get(key).await else {
316            return Vec::new();
317        };
318        bucket
319            .iter()
320            .map(|variant| InMemoryEntry {
321                variant: variant.clone(),
322                cache: self.cache.clone(),
323                key: key.clone(),
324            })
325            .collect()
326    }
327
328    async fn put(&self, key: CacheKey, policy: CachePolicy) -> io::Result<Self::PutHandle> {
329        Ok(InMemoryPutHandle {
330            cache: self.cache.clone(),
331            key,
332            policy,
333            buffer: Vec::new(),
334        })
335    }
336
337    async fn invalidate(&self, key: &CacheKey) {
338        self.cache.invalidate(key).await;
339    }
340}
341
342/// Streaming [`PutHandle`] for [`InMemoryStorage`].
343///
344/// Buffers writes internally; [`finalize`][Self::finalize] commits the
345/// buffered bytes and any trailers to the cache atomically. Drop
346/// without finalize discards the buffered bytes.
347#[derive(Debug)]
348pub struct InMemoryPutHandle {
349    cache: Cache<CacheKey, Bucket>,
350    key: CacheKey,
351    policy: CachePolicy,
352    buffer: Vec<u8>,
353}
354
355impl AsyncWrite for InMemoryPutHandle {
356    fn poll_write(
357        mut self: Pin<&mut Self>,
358        _cx: &mut Context<'_>,
359        buf: &[u8],
360    ) -> Poll<io::Result<usize>> {
361        self.buffer.extend_from_slice(buf);
362        Poll::Ready(Ok(buf.len()))
363    }
364
365    fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
366        Poll::Ready(Ok(()))
367    }
368
369    fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
370        Poll::Ready(Ok(()))
371    }
372}
373
374impl PutHandle for InMemoryPutHandle {
375    async fn finalize(self, trailers: Option<Headers>) -> io::Result<()> {
376        let Self {
377            cache,
378            key,
379            policy,
380            buffer,
381        } = self;
382        let new_variant = Variant {
383            policy: Arc::new(policy),
384            body: Arc::from(buffer.into_boxed_slice()),
385            trailers,
386        };
387
388        cache
389            .entry(key)
390            .and_upsert_with(|maybe_entry| async move {
391                let mut variants: Vec<Variant> = match maybe_entry {
392                    Some(entry) => entry.into_value().to_vec(),
393                    None => Vec::new(),
394                };
395                variants.retain(|v| !v.policy.same_variant_as(&new_variant.policy));
396                variants.push(new_variant);
397                Arc::from(variants.into_boxed_slice())
398            })
399            .await;
400        Ok(())
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use crate::test_helpers::*;
408    use futures_lite::{AsyncReadExt, AsyncWriteExt};
409    use std::time::SystemTime;
410    use trillium_client::Conn;
411    use trillium_http::{KnownHeaderName::*, Method, Status};
412    use trillium_testing::{TestResult, harness, test};
413
414    fn key() -> CacheKey {
415        CacheKey::new(Method::Get, "http://example.com/".parse().unwrap())
416    }
417
418    async fn store(storage: &InMemoryStorage, conn: &Conn, body: &[u8]) {
419        let policy = policy_from(conn, SystemTime::now(), private_cache());
420        let mut handle = storage.put(key(), policy).await.unwrap();
421        handle.write_all(body).await.unwrap();
422        handle.finalize(None).await.unwrap();
423    }
424
425    async fn read_body(entry: InMemoryEntry) -> Vec<u8> {
426        let mut body = entry.open().await.unwrap();
427        let mut buf = Vec::new();
428        body.read_to_end(&mut buf).await.unwrap();
429        buf
430    }
431
432    #[test(harness)]
433    async fn get_missing_key_returns_empty() -> TestResult {
434        let storage = InMemoryStorage::new();
435        assert!(storage.get(&key()).await.is_empty());
436        Ok(())
437    }
438
439    #[test(harness)]
440    async fn put_then_get_returns_entry() -> TestResult {
441        let storage = InMemoryStorage::new();
442        let conn = exchange(
443            Method::Get,
444            &[],
445            Status::Ok,
446            &[(CacheControl, "max-age=600")],
447        );
448        store(&storage, &conn, b"hello").await;
449        let result = storage.get(&key()).await;
450        assert_eq!(result.len(), 1);
451        assert_eq!(read_body(result[0].clone()).await, b"hello");
452        Ok(())
453    }
454
455    #[test(harness)]
456    async fn put_with_same_vary_replaces() -> TestResult {
457        let storage = InMemoryStorage::new();
458        let conn = exchange(
459            Method::Get,
460            &[(AcceptEncoding, "gzip")],
461            Status::Ok,
462            &[(CacheControl, "max-age=600"), (Vary, "Accept-Encoding")],
463        );
464        store(&storage, &conn, b"v1").await;
465        store(&storage, &conn, b"v2").await;
466        let result = storage.get(&key()).await;
467        assert_eq!(result.len(), 1);
468        assert_eq!(read_body(result[0].clone()).await, b"v2");
469        Ok(())
470    }
471
472    #[test(harness)]
473    async fn put_with_different_vary_appends() -> TestResult {
474        let storage = InMemoryStorage::new();
475        let gzip = exchange(
476            Method::Get,
477            &[(AcceptEncoding, "gzip")],
478            Status::Ok,
479            &[(CacheControl, "max-age=600"), (Vary, "Accept-Encoding")],
480        );
481        let br = exchange(
482            Method::Get,
483            &[(AcceptEncoding, "br")],
484            Status::Ok,
485            &[(CacheControl, "max-age=600"), (Vary, "Accept-Encoding")],
486        );
487        store(&storage, &gzip, b"gz").await;
488        store(&storage, &br, b"br").await;
489        let result = storage.get(&key()).await;
490        assert_eq!(result.len(), 2);
491        Ok(())
492    }
493
494    #[test(harness)]
495    async fn invalidate_removes_all_entries_for_key() -> TestResult {
496        let storage = InMemoryStorage::new();
497        let conn = exchange(
498            Method::Get,
499            &[],
500            Status::Ok,
501            &[(CacheControl, "max-age=600")],
502        );
503        store(&storage, &conn, b"x").await;
504        storage.run_pending_tasks().await;
505        assert_eq!(storage.entry_count(), 1);
506        storage.invalidate(&key()).await;
507        assert!(storage.get(&key()).await.is_empty());
508        storage.run_pending_tasks().await;
509        assert_eq!(storage.entry_count(), 0);
510        Ok(())
511    }
512
513    #[test(harness)]
514    async fn invalidate_does_not_touch_other_keys() -> TestResult {
515        let storage = InMemoryStorage::new();
516        let conn = exchange(
517            Method::Get,
518            &[],
519            Status::Ok,
520            &[(CacheControl, "max-age=600")],
521        );
522        let key_a = CacheKey::new(Method::Get, "http://a.example/".parse().unwrap());
523        let key_b = CacheKey::new(Method::Get, "http://b.example/".parse().unwrap());
524        {
525            let policy_a = policy_from(&conn, SystemTime::now(), private_cache());
526            let mut h = storage.put(key_a.clone(), policy_a).await.unwrap();
527            h.write_all(b"a").await.unwrap();
528            h.finalize(None).await.unwrap();
529        }
530        {
531            let policy_b = policy_from(&conn, SystemTime::now(), private_cache());
532            let mut h = storage.put(key_b.clone(), policy_b).await.unwrap();
533            h.write_all(b"b").await.unwrap();
534            h.finalize(None).await.unwrap();
535        }
536        storage.invalidate(&key_a).await;
537        assert!(storage.get(&key_a).await.is_empty());
538        assert_eq!(storage.get(&key_b).await.len(), 1);
539        Ok(())
540    }
541
542    #[test(harness)]
543    async fn drop_put_handle_without_finalize_discards() -> TestResult {
544        let storage = InMemoryStorage::new();
545        let conn = exchange(
546            Method::Get,
547            &[],
548            Status::Ok,
549            &[(CacheControl, "max-age=600")],
550        );
551        let policy = policy_from(&conn, SystemTime::now(), private_cache());
552        let mut handle = storage.put(key(), policy).await.unwrap();
553        handle.write_all(b"partial").await.unwrap();
554        drop(handle);
555        assert!(storage.get(&key()).await.is_empty());
556        Ok(())
557    }
558
559    #[test(harness)]
560    async fn refresh_policy_updates_storage() -> TestResult {
561        let storage = InMemoryStorage::new();
562        let conn = exchange(
563            Method::Get,
564            &[],
565            Status::Ok,
566            &[(CacheControl, "max-age=600")],
567        );
568        store(&storage, &conn, b"body").await;
569
570        let mut entries = storage.get(&key()).await;
571        let original_time = entries[0].policy().response_time;
572        let refreshed = exchange(
573            Method::Get,
574            &[],
575            Status::Ok,
576            &[(CacheControl, "max-age=1200")],
577        );
578        let new_policy = policy_from(
579            &refreshed,
580            original_time + Duration::from_secs(100),
581            private_cache(),
582        );
583        entries[0].refresh_policy(new_policy).await.unwrap();
584
585        let fresh = storage.get(&key()).await;
586        assert_eq!(fresh.len(), 1);
587        assert_ne!(fresh[0].policy().response_time, original_time);
588        Ok(())
589    }
590
591    // Size-bounded: insert past the cap and verify that the cache stays within bounds.
592    #[test(harness)]
593    async fn size_cap_evicts_old_entries() -> TestResult {
594        // Cap at 1 KiB; insert several 600-byte responses under distinct URLs.
595        let storage = InMemoryStorage::new().with_max_capacity_bytes(1024);
596        let conn = exchange(
597            Method::Get,
598            &[],
599            Status::Ok,
600            &[(CacheControl, "max-age=600")],
601        );
602        let body = vec![b'x'; 600];
603        for i in 0..10 {
604            let key = CacheKey::new(
605                Method::Get,
606                format!("http://example.com/{i}").parse().unwrap(),
607            );
608            let policy = policy_from(&conn, SystemTime::now(), private_cache());
609            let mut h = storage.put(key, policy).await.unwrap();
610            h.write_all(&body).await.unwrap();
611            h.finalize(None).await.unwrap();
612        }
613        storage.run_pending_tasks().await;
614        assert!(
615            storage.weighted_size() <= 1024,
616            "weighted size {} should be within cap of 1024",
617            storage.weighted_size()
618        );
619        Ok(())
620    }
621}