Skip to main content

trillium_cache/
tiered.rs

1//! Tiered [`CacheStorage`] composing a fast hot tier over a durable cold tier.
2
3use crate::{CacheKey, CachePolicy, CacheStorage, PutHandle, StoredEntry, tee::TeeingReader};
4use futures_lite::{AsyncReadExt, AsyncWrite, AsyncWriteExt};
5use std::{
6    fmt::{self, Debug, Formatter},
7    io,
8    pin::Pin,
9    task::{Context, Poll},
10};
11use trillium_http::{Body, Headers};
12use trillium_server_common::{Runtime, RuntimeTrait};
13
14/// Two-tier cache storage: a fast hot tier over a durable cold tier.
15///
16/// `TieredStorage` layers two backends: a `Hot` tier serving the working set from fast storage
17/// and a `Cold` tier holding the larger, durable set. It is itself a [`CacheStorage`], so it
18/// drops in wherever a single backend would go — the headline pairing is an [`InMemoryStorage`]
19/// hot tier over a [`FileSystemStorage`] cold tier, but any two backends compose.
20///
21/// `Clone` is available when both tiers are `Clone`, and shares their backing storage.
22///
23/// # Runtime
24///
25/// The write path finishes asynchronously (see below), so a `TieredStorage` is constructed with
26/// the [`Runtime`] it spawns that background work on — and it must be the runtime actually
27/// driving the process, or the flush never makes progress. Construct the adapter for your
28/// runtime directly (for example `trillium_smol::SmolRuntime::default()` or
29/// `trillium_tokio::TokioRuntime::default()`); on a client you can instead take it from the
30/// connector with `client.connector().runtime()`. The `tiered_cache` example wires this up end
31/// to end.
32///
33/// # Read path
34///
35/// [`get`] consults the hot tier first and, on a hit, serves from it alone. On a hot miss it
36/// reads the cold tier; opening a cold entry *promotes* it, streaming the body to the reader
37/// and into the hot tier at once (the same teeing used on the origin→user+storage path), so
38/// the working set migrates into fast storage as it is served. A hot tier emptied by a restart
39/// repopulates from cold as entries are read.
40///
41/// The hot-first lookup assumes the hot tier evicts a whole [`CacheKey`] at once — all `Vary`
42/// variants of a URL together — so a hot hit implies the full variant set for that key is
43/// present. [`InMemoryStorage`] satisfies this. A hot tier that evicts individual variants
44/// could leave siblings only in cold and hide them behind a hot hit; pair `TieredStorage` with
45/// a whole-key-eviction hot tier.
46///
47/// # Write path
48///
49/// [`put`] writes the body into the hot tier as it streams, then finalizing the entry spawns a
50/// background task that copies it into the cold tier — a write-back. The hot tier is populated
51/// synchronously; cold durability follows shortly after, off the request path. A crash in that
52/// window loses the not-yet-flushed entry, which for a cache means only an extra origin fetch.
53/// Because cold ends up holding every stored entry, evicting from hot only drops a fast-path
54/// copy — the entry stays served from cold and re-promotes on its next read.
55///
56/// # Policy refresh
57///
58/// A 304 revalidation refreshes the policy on whichever tier served the entry. After a hot
59/// eviction a request may fall through to a cold copy carrying the pre-refresh policy and
60/// revalidate once more; the content served is always correct.
61///
62/// [`InMemoryStorage`]: crate::InMemoryStorage
63/// [`FileSystemStorage`]: crate::FileSystemStorage
64/// [`get`]: CacheStorage::get
65/// [`put`]: CacheStorage::put
66pub struct TieredStorage<Hot, Cold> {
67    hot: Hot,
68    cold: Cold,
69    runtime: Runtime,
70}
71
72impl<Hot, Cold> TieredStorage<Hot, Cold> {
73    /// Compose `hot` and `cold` into a tiered storage, spawning background write-back onto
74    /// `runtime`. Lookups and promotions favor `hot`; every stored entry is flushed through to
75    /// `cold`.
76    ///
77    /// Pass the runtime the surrounding server or client already runs on.
78    pub fn new(hot: Hot, cold: Cold, runtime: impl RuntimeTrait) -> Self {
79        Self {
80            hot,
81            cold,
82            runtime: runtime.into(),
83        }
84    }
85
86    /// Borrow the hot tier.
87    pub fn hot(&self) -> &Hot {
88        &self.hot
89    }
90
91    /// Borrow the cold tier.
92    pub fn cold(&self) -> &Cold {
93        &self.cold
94    }
95}
96
97impl<Hot: Clone, Cold: Clone> Clone for TieredStorage<Hot, Cold> {
98    fn clone(&self) -> Self {
99        Self {
100            hot: self.hot.clone(),
101            cold: self.cold.clone(),
102            runtime: self.runtime.clone(),
103        }
104    }
105}
106
107impl<Hot: CacheStorage, Cold: CacheStorage> Debug for TieredStorage<Hot, Cold> {
108    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
109        f.debug_struct("TieredStorage")
110            .field("hot", &self.hot)
111            .field("cold", &self.cold)
112            .finish_non_exhaustive()
113    }
114}
115
116impl<Hot, Cold> CacheStorage for TieredStorage<Hot, Cold>
117where
118    Hot: CacheStorage + Clone,
119    Cold: CacheStorage + Clone,
120{
121    type StoredEntry = TieredEntry<Hot, Cold>;
122    type PutHandle = TieredPutHandle<Hot, Cold>;
123
124    async fn get(&self, key: &CacheKey) -> Vec<Self::StoredEntry> {
125        let hot = self.hot.get(key).await;
126        if !hot.is_empty() {
127            return hot.into_iter().map(TieredEntry::Hot).collect();
128        }
129        self.cold
130            .get(key)
131            .await
132            .into_iter()
133            .map(|entry| TieredEntry::Cold {
134                entry,
135                hot: self.hot.clone(),
136                key: key.clone(),
137            })
138            .collect()
139    }
140
141    async fn put(&self, key: CacheKey, policy: CachePolicy) -> io::Result<Self::PutHandle> {
142        let hot = self.hot.put(key.clone(), policy.clone()).await?;
143        Ok(TieredPutHandle {
144            hot,
145            hot_store: self.hot.clone(),
146            cold: self.cold.clone(),
147            runtime: self.runtime.clone(),
148            key,
149            policy,
150        })
151    }
152
153    async fn invalidate(&self, key: &CacheKey) {
154        self.hot.invalidate(key).await;
155        self.cold.invalidate(key).await;
156    }
157}
158
159/// One stored response from a [`TieredStorage`], held in either tier.
160///
161/// A cold-tier entry carries a handle to the hot tier and its key so that
162/// [`open`][StoredEntry::open] can promote it — streaming the body to the reader and into the
163/// hot tier at once.
164pub enum TieredEntry<Hot: CacheStorage, Cold: CacheStorage> {
165    /// An entry served from the hot tier.
166    Hot(Hot::StoredEntry),
167    /// An entry served from the cold tier, promoted into hot on open.
168    Cold {
169        /// The cold-tier entry.
170        entry: Cold::StoredEntry,
171        /// Hot tier to promote into.
172        hot: Hot,
173        /// Key the entry is stored under.
174        key: CacheKey,
175    },
176}
177
178impl<Hot, Cold> Clone for TieredEntry<Hot, Cold>
179where
180    Hot: CacheStorage + Clone,
181    Cold: CacheStorage,
182{
183    fn clone(&self) -> Self {
184        match self {
185            Self::Hot(entry) => Self::Hot(entry.clone()),
186            Self::Cold { entry, hot, key } => Self::Cold {
187                entry: entry.clone(),
188                hot: hot.clone(),
189                key: key.clone(),
190            },
191        }
192    }
193}
194
195impl<Hot: CacheStorage, Cold: CacheStorage> Debug for TieredEntry<Hot, Cold> {
196    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
197        match self {
198            Self::Hot(entry) => f.debug_tuple("Hot").field(entry).finish(),
199            Self::Cold { entry, key, .. } => f
200                .debug_struct("Cold")
201                .field("entry", entry)
202                .field("key", key)
203                .finish_non_exhaustive(),
204        }
205    }
206}
207
208impl<Hot, Cold> StoredEntry for TieredEntry<Hot, Cold>
209where
210    Hot: CacheStorage + Clone,
211    Cold: CacheStorage,
212{
213    fn policy(&self) -> &CachePolicy {
214        match self {
215            Self::Hot(entry) => entry.policy(),
216            Self::Cold { entry, .. } => entry.policy(),
217        }
218    }
219
220    async fn refresh_policy(&mut self, new_policy: CachePolicy) -> io::Result<()> {
221        match self {
222            Self::Hot(entry) => entry.refresh_policy(new_policy).await,
223            Self::Cold { entry, .. } => entry.refresh_policy(new_policy).await,
224        }
225    }
226
227    async fn open(self) -> io::Result<Body> {
228        match self {
229            Self::Hot(entry) => entry.open().await,
230            Self::Cold { entry, hot, key } => {
231                let policy = entry.policy().clone();
232                let cold_body = entry.open().await?;
233                let len = cold_body.len();
234                match hot.put(key, policy).await {
235                    Ok(put_handle) => {
236                        let tee = TeeingReader::new(cold_body, put_handle, u64::MAX);
237                        Ok(Body::new_with_trailers(tee, len))
238                    }
239                    Err(e) => {
240                        log::warn!("cache: promotion put failed: {e}, serving cold entry only");
241                        Ok(cold_body)
242                    }
243                }
244            }
245        }
246    }
247}
248
249/// Streaming [`PutHandle`] for [`TieredStorage`].
250///
251/// Body bytes stream into the hot tier; [`finalize`][PutHandle::finalize] commits the hot entry
252/// and spawns a background task that copies it into the cold tier. Dropping without finalizing
253/// aborts the hot write, and nothing reaches either tier.
254pub struct TieredPutHandle<Hot: CacheStorage, Cold: CacheStorage> {
255    hot: Hot::PutHandle,
256    hot_store: Hot,
257    cold: Cold,
258    runtime: Runtime,
259    key: CacheKey,
260    policy: CachePolicy,
261}
262
263impl<Hot: CacheStorage, Cold: CacheStorage> Debug for TieredPutHandle<Hot, Cold> {
264    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
265        f.debug_struct("TieredPutHandle")
266            .field("key", &self.key)
267            .finish_non_exhaustive()
268    }
269}
270
271// Only the hot `PutHandle` is ever polled through a pin (and `PutHandle: Unpin`); the storage
272// handles and metadata are plain data, moved but never pin-projected. So the composite is
273// `Unpin` regardless of whether the tier types are.
274impl<Hot: CacheStorage, Cold: CacheStorage> Unpin for TieredPutHandle<Hot, Cold> {}
275
276impl<Hot: CacheStorage, Cold: CacheStorage> AsyncWrite for TieredPutHandle<Hot, Cold> {
277    fn poll_write(
278        self: Pin<&mut Self>,
279        cx: &mut Context<'_>,
280        buf: &[u8],
281    ) -> Poll<io::Result<usize>> {
282        Pin::new(&mut self.get_mut().hot).poll_write(cx, buf)
283    }
284
285    fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
286        Pin::new(&mut self.get_mut().hot).poll_flush(cx)
287    }
288
289    fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
290        Pin::new(&mut self.get_mut().hot).poll_close(cx)
291    }
292}
293
294impl<Hot, Cold> PutHandle for TieredPutHandle<Hot, Cold>
295where
296    Hot: CacheStorage + Clone,
297    Cold: CacheStorage,
298{
299    async fn finalize(self, trailers: Option<Headers>) -> io::Result<()> {
300        let Self {
301            hot,
302            hot_store,
303            cold,
304            runtime,
305            key,
306            policy,
307        } = self;
308        hot.finalize(trailers).await?;
309
310        let log_key = key.clone();
311        let _detached = runtime.spawn(async move {
312            if let Err(e) = flush_to_cold(hot_store, cold, key, policy).await {
313                log::warn!("cache: tiered background flush to cold failed for {log_key}: {e}");
314            }
315        });
316        Ok(())
317    }
318}
319
320// Copy the just-committed hot entry into the cold tier. Reads the entry back from hot (cheap
321// when hot is in-memory) and streams it into a cold `put`, carrying over any trailers the hot
322// body surfaces. A hot eviction between finalize and flush leaves nothing to copy — the entry
323// is simply not yet durable in cold, which a later read re-promotes and re-flushes.
324async fn flush_to_cold<Hot, Cold>(
325    hot_store: Hot,
326    cold: Cold,
327    key: CacheKey,
328    policy: CachePolicy,
329) -> io::Result<()>
330where
331    Hot: CacheStorage,
332    Cold: CacheStorage,
333{
334    let Some(entry) = hot_store
335        .get(&key)
336        .await
337        .into_iter()
338        .find(|entry| entry.policy().same_variant_as(&policy))
339    else {
340        return Ok(());
341    };
342
343    let mut body = entry.open().await?;
344    let mut put = cold.put(key, policy).await?;
345    let mut buf = [0u8; 8192];
346    loop {
347        let n = body.read(&mut buf).await?;
348        if n == 0 {
349            break;
350        }
351        put.write_all(&buf[..n]).await?;
352    }
353    let trailers = body.trailers();
354    put.finalize(trailers).await
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use crate::{InMemoryStorage, test_helpers::*};
361    use std::time::{Duration, SystemTime};
362    use trillium_http::{KnownHeaderName::*, Method, Status};
363    use trillium_testing::{TestResult, harness, runtime, test};
364
365    fn key() -> CacheKey {
366        CacheKey::new(Method::Get, "http://example.com/".parse().unwrap())
367    }
368
369    fn tiered() -> TieredStorage<InMemoryStorage, InMemoryStorage> {
370        TieredStorage::new(InMemoryStorage::new(), InMemoryStorage::new(), runtime())
371    }
372
373    async fn store_into(storage: &impl CacheStorage, key: CacheKey, body: &[u8]) {
374        let conn = exchange(
375            Method::Get,
376            &[],
377            Status::Ok,
378            &[(CacheControl, "max-age=600")],
379        );
380        let policy = policy_from(&conn, SystemTime::now(), private_cache());
381        let mut handle = storage.put(key, policy).await.unwrap();
382        handle.write_all(body).await.unwrap();
383        handle.finalize(None).await.unwrap();
384    }
385
386    async fn read_body(entry: impl StoredEntry) -> Vec<u8> {
387        let mut body = entry.open().await.unwrap();
388        let mut buf = Vec::new();
389        body.read_to_end(&mut buf).await.unwrap();
390        buf
391    }
392
393    // Write-back finalizes cold on a spawned task; poll until it lands (or give up).
394    async fn cold_settles<Hot, Cold>(
395        storage: &TieredStorage<Hot, Cold>,
396        key: &CacheKey,
397    ) -> Vec<Cold::StoredEntry>
398    where
399        Hot: CacheStorage + Clone,
400        Cold: CacheStorage + Clone,
401    {
402        for _ in 0..200 {
403            let entries = storage.cold().get(key).await;
404            if !entries.is_empty() {
405                return entries;
406            }
407            storage.runtime.delay(Duration::from_millis(5)).await;
408        }
409        panic!("cold tier never populated");
410    }
411
412    #[test(harness)]
413    async fn hot_populated_synchronously_cold_written_back() -> TestResult {
414        let storage = tiered();
415        store_into(&storage, key(), b"hello").await;
416
417        // Hot is populated before finalize returns.
418        let entries = storage.get(&key()).await;
419        assert_eq!(entries.len(), 1);
420        assert!(matches!(entries[0], TieredEntry::Hot(_)));
421        assert_eq!(read_body(entries[0].clone()).await, b"hello");
422
423        // Cold catches up on the background task.
424        let cold = cold_settles(&storage, &key()).await;
425        assert_eq!(cold.len(), 1);
426        assert_eq!(read_body(cold[0].clone()).await, b"hello");
427        Ok(())
428    }
429
430    #[test(harness)]
431    async fn cold_hit_promotes_into_hot() -> TestResult {
432        let storage = tiered();
433        // Seed cold directly so hot starts empty — the post-restart / post-eviction shape.
434        store_into(storage.cold(), key(), b"promoted").await;
435        assert!(storage.hot().get(&key()).await.is_empty());
436
437        let entries = storage.get(&key()).await;
438        assert_eq!(entries.len(), 1);
439        assert!(matches!(entries[0], TieredEntry::Cold { .. }));
440        // Opening the cold entry streams it through into hot.
441        assert_eq!(read_body(entries[0].clone()).await, b"promoted");
442
443        let hot = storage.hot().get(&key()).await;
444        assert_eq!(hot.len(), 1);
445        assert_eq!(read_body(hot[0].clone()).await, b"promoted");
446        Ok(())
447    }
448
449    #[test(harness)]
450    async fn invalidate_clears_both_tiers() -> TestResult {
451        let storage = tiered();
452        store_into(&storage, key(), b"x").await;
453        cold_settles(&storage, &key()).await;
454        storage.invalidate(&key()).await;
455        assert!(storage.get(&key()).await.is_empty());
456        assert!(storage.hot().get(&key()).await.is_empty());
457        assert!(storage.cold().get(&key()).await.is_empty());
458        Ok(())
459    }
460
461    #[test(harness)]
462    async fn drop_put_handle_without_finalize_stores_nothing() -> TestResult {
463        let storage = tiered();
464        let conn = exchange(
465            Method::Get,
466            &[],
467            Status::Ok,
468            &[(CacheControl, "max-age=600")],
469        );
470        let policy = policy_from(&conn, SystemTime::now(), private_cache());
471        let mut handle = storage.put(key(), policy).await.unwrap();
472        handle.write_all(b"partial").await.unwrap();
473        drop(handle);
474        assert!(storage.hot().get(&key()).await.is_empty());
475        assert!(storage.cold().get(&key()).await.is_empty());
476        Ok(())
477    }
478
479    // The headline pairing: memory hot tier over a filesystem cold tier. A cold copy survives a
480    // fresh hot tier (the post-restart shape) and re-promotes into memory on read.
481    #[cfg(feature = "fs")]
482    #[test(harness)]
483    async fn memory_over_filesystem_promotes_from_disk() -> TestResult {
484        use crate::FileSystemStorage;
485
486        let dir = tempfile::tempdir().unwrap();
487        {
488            let storage = TieredStorage::new(
489                InMemoryStorage::new(),
490                FileSystemStorage::new(dir.path()),
491                runtime(),
492            );
493            store_into(&storage, key(), b"on-disk").await;
494            cold_settles(&storage, &key()).await;
495        }
496
497        // A fresh instance over the same directory: hot is empty, cold holds the entry on disk.
498        let reopened = TieredStorage::new(
499            InMemoryStorage::new(),
500            FileSystemStorage::new(dir.path()),
501            runtime(),
502        );
503        assert!(reopened.hot().get(&key()).await.is_empty());
504
505        let entries = reopened.get(&key()).await;
506        assert_eq!(entries.len(), 1);
507        assert!(matches!(entries[0], TieredEntry::Cold { .. }));
508        assert_eq!(read_body(entries[0].clone()).await, b"on-disk");
509
510        // Promotion pulled it into memory.
511        let hot = reopened.hot().get(&key()).await;
512        assert_eq!(hot.len(), 1);
513        assert_eq!(read_body(hot[0].clone()).await, b"on-disk");
514        Ok(())
515    }
516}