Skip to main content

umbral_cache/
lib.rs

1//! umbral-cache — pluggable cache for umbral.
2//!
3//! Django's cache framework, the slice that matters for production:
4//! a [`Cache`] handle over a [`CacheBackend`] trait, three built-in
5//! backends (in-memory, SQLite, Redis), and a [`cache_page`] view
6//! middleware that caches full GET responses, matching Django's
7//! `@cache_page` decorator.
8//!
9//! ```ignore
10//! // Boot wiring (App::builder)
11//! let cache = Cache::memory();
12//! // … or for Redis in production:
13//! // let cache = Cache::redis("redis://localhost:6379/0").await?;
14//! CachePlugin::init(cache.clone());
15//!
16//! // In a handler — explicit cache access
17//! cache.set("homepage:html", &rendered, Some(Duration::from_secs(60))).await;
18//! if let Some(html) = cache.get::<String>("homepage:html").await {
19//!     return Ok(Html(html));
20//! }
21//!
22//! // View-level caching (wraps a Router subtree)
23//! use umbral_cache::cache_page;
24//! let public = Router::new()
25//!     .route("/", get(home))
26//!     .layer(cache_page(Duration::from_secs(60)));
27//! ```
28//!
29//! ## Surface
30//!
31//! - [`CacheBackend`] — the trait. Bytes in, bytes out, async.
32//! - [`CacheError`] — unified error type for backends that can fail.
33//! - [`Cache`] — the handle. Generic-over-T methods wrap the backend
34//!   with serde encoding so callers traffic in their own types.
35//! - [`MemoryBackend`] — `tokio::sync::Mutex<HashMap>` with per-key
36//!   expiry. Lost on process exit. Default choice for development
37//!   and single-process deployments.
38//! - [`SqliteBackend`] — table-backed, durable across restarts.
39//!   Expired rows are lazily skipped on read and cleared on a
40//!   background pass when [`SqliteBackend::sweep`] is called.
41//! - [`RedisBackend`] — (feature = `"redis"`) production backend via
42//!   `redis::aio::ConnectionManager`. Handles reconnect transparently.
43//! - [`cache_page`] — tower [`Layer`] that caches full GET/HEAD responses.
44//!   Only status 200 is cached; skips when `Cache-Control: no-store`
45//!   or `Set-Cookie` appears on the response.
46//! - [`CachePlugin`] — empty Plugin impl so other plugins can name
47//!   "cache" as a dependency.
48//!
49//! ## Deferred past v0
50//!
51//! - `get_or_set` helper that fills on miss inside a single round-trip.
52//! - Versioned keys + `incr/decr` atomic ops.
53//! - Memcached backend.
54//! - Distributed cache invalidation (tag-based).
55//! - ETag / 304 conditional caching inside `cache_page` — the current
56//!   implementation always serves the cached body in full.
57
58use std::collections::HashMap;
59use std::sync::{Arc, OnceLock};
60use std::time::Duration;
61
62use async_trait::async_trait;
63use chrono::{DateTime, Utc};
64use http::header::{HeaderValue, CACHE_CONTROL, VARY};
65use serde::{Serialize, de::DeserializeOwned};
66use sqlx::SqlitePool;
67use tokio::sync::Mutex;
68use tower_http::compression::CompressionLayer;
69use tower_http::set_header::SetResponseHeaderLayer;
70use umbral::prelude::*;
71
72pub mod cache_page;
73pub use cache_page::cache_page;
74
75// ── Ambient cache handle ─────────────────────────────────────────────────────
76
77/// Process-wide ambient cache, set once during `App::build()` (or manually
78/// by calling [`CachePlugin::init`]). `cache_page` reads this automatically.
79static AMBIENT_CACHE: OnceLock<Cache> = OnceLock::new();
80
81/// Return the ambient cache, or `None` if [`CachePlugin::init`] hasn't run.
82pub fn ambient() -> Option<&'static Cache> {
83    AMBIENT_CACHE.get()
84}
85
86// ── Error type ───────────────────────────────────────────────────────────────
87
88/// Error variants emitted by cache backends that can fail (Redis, SQLite).
89/// `MemoryBackend` is infallible — its methods are fire-and-forget.
90#[derive(Debug)]
91pub enum CacheError {
92    /// A Redis-level error (connection, protocol, server).
93    #[cfg(feature = "redis")]
94    Redis(redis::RedisError),
95    /// A SQLite-level error.
96    Sqlx(sqlx::Error),
97    /// Any other I/O or configuration error.
98    Other(String),
99}
100
101impl std::fmt::Display for CacheError {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        match self {
104            #[cfg(feature = "redis")]
105            CacheError::Redis(e) => write!(f, "cache redis error: {e}"),
106            CacheError::Sqlx(e) => write!(f, "cache sqlite error: {e}"),
107            CacheError::Other(s) => write!(f, "cache error: {s}"),
108        }
109    }
110}
111
112impl std::error::Error for CacheError {}
113
114#[cfg(feature = "redis")]
115impl From<redis::RedisError> for CacheError {
116    fn from(e: redis::RedisError) -> Self {
117        CacheError::Redis(e)
118    }
119}
120
121impl From<sqlx::Error> for CacheError {
122    fn from(e: sqlx::Error) -> Self {
123        CacheError::Sqlx(e)
124    }
125}
126
127// ── CacheBackend trait ───────────────────────────────────────────────────────
128
129/// Bytes-in / bytes-out backend. All methods are async because the
130/// SQLite and Redis implementations need to be.
131///
132/// `get_bytes` / `set_bytes` / `delete` / `clear` are infallible at the
133/// trait level — backends swallow errors internally and log them rather
134/// than propagating. Constructors (`new`, `connect`) surface errors via
135/// [`CacheError`] so misconfiguration is caught at boot.
136#[async_trait]
137pub trait CacheBackend: Send + Sync {
138    async fn get(&self, key: &str) -> Option<Vec<u8>>;
139    async fn set(&self, key: &str, value: Vec<u8>, ttl: Option<Duration>);
140    async fn delete(&self, key: &str);
141    async fn clear(&self);
142}
143
144// ── Cache handle ─────────────────────────────────────────────────────────────
145
146/// Public handle. Owns its backend behind an Arc so views can clone
147/// it freely (typically stashed in the request context or accessed via
148/// the ambient [`AMBIENT_CACHE`]).
149#[derive(Clone)]
150pub struct Cache {
151    backend: Arc<dyn CacheBackend>,
152}
153
154impl Cache {
155    /// Build a cache backed by a freshly-allocated [`MemoryBackend`].
156    pub fn memory() -> Self {
157        Self {
158            backend: Arc::new(MemoryBackend::default()),
159        }
160    }
161
162    /// Build a cache backed by a SQLite table. The constructor
163    /// creates the table on first call; it's idempotent.
164    pub async fn sqlite(pool: SqlitePool) -> Result<Self, CacheError> {
165        let backend = SqliteBackend::new(pool).await?;
166        Ok(Self {
167            backend: Arc::new(backend),
168        })
169    }
170
171    /// Build a cache backed by Redis.
172    ///
173    /// `url` is a Redis connection string: `redis://[user:pass@]host:port/[db]`.
174    /// Examples: `redis://localhost:6379/0`, `redis://:password@redis.example.com:6379`.
175    ///
176    /// The underlying [`redis::aio::ConnectionManager`] reconnects automatically
177    /// on dropped connections so the handle is safe to clone and reuse for the
178    /// lifetime of the process.
179    #[cfg(feature = "redis")]
180    pub async fn redis(url: &str) -> Result<Self, CacheError> {
181        let backend = RedisBackend::connect(url).await?;
182        Ok(Self {
183            backend: Arc::new(backend),
184        })
185    }
186
187    /// Wrap an arbitrary backend.
188    pub fn with_backend(backend: Arc<dyn CacheBackend>) -> Self {
189        Self { backend }
190    }
191
192    /// Look up a key, deserialise to T. Returns None on miss, on
193    /// expiry, or on a decode error (the entry is treated as
194    /// poisoned and ignored rather than crashing the caller).
195    pub async fn get<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
196        let bytes = self.backend.get(key).await?;
197        serde_json::from_slice(&bytes).ok()
198    }
199
200    /// Set a key. The value is serialised with serde_json. `ttl =
201    /// None` means no expiry.
202    pub async fn set<T: Serialize + ?Sized>(
203        &self,
204        key: &str,
205        value: &T,
206        ttl: Option<Duration>,
207    ) -> Result<(), serde_json::Error> {
208        let bytes = serde_json::to_vec(value)?;
209        self.backend.set(key, bytes, ttl).await;
210        Ok(())
211    }
212
213    pub async fn delete(&self, key: &str) {
214        self.backend.delete(key).await;
215    }
216
217    pub async fn clear(&self) {
218        self.backend.clear().await;
219    }
220
221    // ── Raw bytes access for cache_page (avoids double-serialisation) ──
222
223    pub(crate) async fn get_bytes_raw(&self, key: &str) -> Option<Vec<u8>> {
224        self.backend.get(key).await
225    }
226
227    pub(crate) async fn set_bytes_raw(&self, key: &str, bytes: Vec<u8>, ttl: Option<Duration>) {
228        self.backend.set(key, bytes, ttl).await;
229    }
230}
231
232// ── MemoryBackend ────────────────────────────────────────────────────────────
233
234struct MemoryEntry {
235    value: Vec<u8>,
236    expires_at: Option<DateTime<Utc>>,
237}
238
239#[derive(Default)]
240pub struct MemoryBackend {
241    inner: Mutex<HashMap<String, MemoryEntry>>,
242}
243
244#[async_trait]
245impl CacheBackend for MemoryBackend {
246    async fn get(&self, key: &str) -> Option<Vec<u8>> {
247        let mut map = self.inner.lock().await;
248        if let Some(entry) = map.get(key) {
249            if let Some(exp) = entry.expires_at {
250                if Utc::now() >= exp {
251                    map.remove(key);
252                    return None;
253                }
254            }
255            return Some(entry.value.clone());
256        }
257        None
258    }
259
260    async fn set(&self, key: &str, value: Vec<u8>, ttl: Option<Duration>) {
261        let expires_at = ttl.and_then(|d| {
262            chrono::Duration::from_std(d)
263                .ok()
264                .and_then(|cd| Utc::now().checked_add_signed(cd))
265        });
266        self.inner
267            .lock()
268            .await
269            .insert(key.to_string(), MemoryEntry { value, expires_at });
270    }
271
272    async fn delete(&self, key: &str) {
273        self.inner.lock().await.remove(key);
274    }
275
276    async fn clear(&self) {
277        self.inner.lock().await.clear();
278    }
279}
280
281// ── SqliteBackend ────────────────────────────────────────────────────────────
282//
283// CLAUDE.md exception — backend-specific raw SQL is allowed here.
284//
285// The original blockers (no `Vec<u8>` field type, no `upsert`
286// terminal) both shipped in subsequent commits — see
287// `SqlType::Bytes` and `Manager::upsert`. The remaining reason this
288// backend keeps `sqlx::query(...)` calls:
289//
290//   `SqliteBackend` takes an EXPLICIT `SqlitePool` by design (not
291//   the framework's ambient pool). The ORM's `Manager` terminals
292//   read `umbral::db::pool()` for ambient routing; binding them to
293//   a different pool requires an `Manager::upsert_with(&pool, ...)`
294//   escape hatch that doesn't yet exist. Adding it lands when the
295//   first non-ambient-pool consumer asks for it.
296//
297// `Cache::sqlite(pool)` is the explicit-pool entry point — a user
298// who calls it opted into SQLite by name AND into a pool that may
299// be separate from the framework's main pool (cache I/O frequently
300// runs against its own smaller, dedicated pool). The Redis backend
301// below handles the non-SQLite case; an eventual `PgBackend` would
302// be its own sibling.
303
304/// SQLite-backed cache. Table: `umbral_cache(key TEXT PRIMARY KEY,
305/// value BLOB NOT NULL, expires_at TIMESTAMP NULL)`. Expired rows
306/// are skipped on read and removed by [`SqliteBackend::sweep`] for
307/// periodic cleanup.
308pub struct SqliteBackend {
309    pool: SqlitePool,
310}
311
312impl SqliteBackend {
313    pub async fn new(pool: SqlitePool) -> Result<Self, CacheError> {
314        sqlx::query(
315            "CREATE TABLE IF NOT EXISTS umbral_cache (
316                key        TEXT PRIMARY KEY,
317                value      BLOB NOT NULL,
318                expires_at TIMESTAMP NULL
319            )",
320        )
321        .execute(&pool)
322        .await
323        .map_err(CacheError::Sqlx)?;
324        Ok(Self { pool })
325    }
326
327    /// Remove every expired row. Call from a periodic task; reads
328    /// already skip expired rows so a call is never required for
329    /// correctness, only for keeping the table small.
330    pub async fn sweep(&self) -> Result<u64, CacheError> {
331        let result =
332            sqlx::query("DELETE FROM umbral_cache WHERE expires_at IS NOT NULL AND expires_at <= ?")
333                .bind(Utc::now())
334                .execute(&self.pool)
335                .await
336                .map_err(CacheError::Sqlx)?;
337        Ok(result.rows_affected())
338    }
339}
340
341#[async_trait]
342impl CacheBackend for SqliteBackend {
343    async fn get(&self, key: &str) -> Option<Vec<u8>> {
344        let row: Option<(Vec<u8>, Option<DateTime<Utc>>)> =
345            sqlx::query_as("SELECT value, expires_at FROM umbral_cache WHERE key = ?")
346                .bind(key)
347                .fetch_optional(&self.pool)
348                .await
349                .ok()?;
350        let (value, expires_at) = row?;
351        if let Some(exp) = expires_at {
352            if Utc::now() >= exp {
353                return None;
354            }
355        }
356        Some(value)
357    }
358
359    async fn set(&self, key: &str, value: Vec<u8>, ttl: Option<Duration>) {
360        let expires_at = ttl.and_then(|d| {
361            chrono::Duration::from_std(d)
362                .ok()
363                .and_then(|cd| Utc::now().checked_add_signed(cd))
364        });
365        // BROKEN-12: log swallowed write errors. A cache backend is
366        // best-effort (a failed write must not break the request), but a
367        // locked SQLite / dead pool that no-ops every write forever should
368        // not be invisible — the trait doc promised "and log them".
369        if let Err(e) = sqlx::query(
370            "INSERT INTO umbral_cache (key, value, expires_at) VALUES (?, ?, ?)
371             ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at",
372        )
373        .bind(key)
374        .bind(value)
375        .bind(expires_at)
376        .execute(&self.pool)
377        .await
378        {
379            tracing::warn!(error = %e, key, "umbral-cache: SQLite cache set failed (swallowed)");
380        }
381    }
382
383    async fn delete(&self, key: &str) {
384        if let Err(e) = sqlx::query("DELETE FROM umbral_cache WHERE key = ?")
385            .bind(key)
386            .execute(&self.pool)
387            .await
388        {
389            tracing::warn!(error = %e, key, "umbral-cache: SQLite cache delete failed (swallowed)");
390        }
391    }
392
393    async fn clear(&self) {
394        if let Err(e) = sqlx::query("DELETE FROM umbral_cache")
395            .execute(&self.pool)
396            .await
397        {
398            tracing::warn!(error = %e, "umbral-cache: SQLite cache clear failed (swallowed)");
399        }
400    }
401}
402
403// ── RedisBackend ─────────────────────────────────────────────────────────────
404
405/// Redis-backed cache. Requires the `redis` cargo feature.
406///
407/// Uses `redis::aio::ConnectionManager` for automatic reconnection. TTL
408/// is stored natively via Redis `SETEX` when a duration is supplied, so
409/// expiry is handled server-side and does not require a background sweep.
410///
411/// `clear()` uses `FLUSHDB` which removes ALL keys in the selected
412/// database — use a dedicated Redis database (e.g. `/1`) when sharing
413/// a Redis instance with other data.
414#[cfg(feature = "redis")]
415pub struct RedisBackend {
416    client: redis::aio::ConnectionManager,
417}
418
419#[cfg(feature = "redis")]
420impl RedisBackend {
421    /// Connect to Redis at `url`. Returns a ready-to-use backend or a
422    /// [`CacheError::Redis`] if the initial connection fails.
423    ///
424    /// `url` form: `redis://[user:pass@]host:port/[db]`
425    /// Example: `redis://localhost:6379/0`
426    pub async fn connect(url: &str) -> Result<Self, CacheError> {
427        let client = redis::Client::open(url).map_err(CacheError::Redis)?;
428        let manager = redis::aio::ConnectionManager::new(client)
429            .await
430            .map_err(CacheError::Redis)?;
431        Ok(Self { client: manager })
432    }
433}
434
435#[cfg(feature = "redis")]
436#[async_trait]
437impl CacheBackend for RedisBackend {
438    async fn get(&self, key: &str) -> Option<Vec<u8>> {
439        use redis::AsyncCommands;
440        let mut conn = self.client.clone();
441        conn.get::<_, Option<Vec<u8>>>(key).await.ok().flatten()
442    }
443
444    async fn set(&self, key: &str, value: Vec<u8>, ttl: Option<Duration>) {
445        use redis::AsyncCommands;
446        let mut conn = self.client.clone();
447        // BROKEN-12: log swallowed errors — a dead Redis that no-ops every
448        // write should not be silent (the trait doc promised "and log them").
449        let res: Result<(), _> = if let Some(dur) = ttl {
450            let secs = dur.as_secs().max(1);
451            conn.set_ex(key, value, secs).await
452        } else {
453            conn.set(key, value).await
454        };
455        if let Err(e) = res {
456            tracing::warn!(error = %e, key, "umbral-cache: Redis cache set failed (swallowed)");
457        }
458    }
459
460    async fn delete(&self, key: &str) {
461        use redis::AsyncCommands;
462        let mut conn = self.client.clone();
463        if let Err(e) = conn.del::<_, ()>(key).await {
464            tracing::warn!(error = %e, key, "umbral-cache: Redis cache delete failed (swallowed)");
465        }
466    }
467
468    async fn clear(&self) {
469        let mut conn = self.client.clone();
470        // FLUSHDB removes all keys in the currently selected database.
471        // Document this prominently: use a dedicated Redis DB for cache.
472        if let Err(e) = redis::cmd("FLUSHDB").query_async::<()>(&mut conn).await {
473            tracing::warn!(error = %e, "umbral-cache: Redis cache clear failed (swallowed)");
474        }
475    }
476}
477
478// ── CacheHeaders config ──────────────────────────────────────────────────────
479
480/// Opt-in HTTP response-header config for `CachePlugin`.
481///
482/// Both knobs are **off by default** — wiring a `CachePlugin` without calling
483/// [`CachePlugin::with_compression`] or [`CachePlugin::cache_control`] leaves
484/// the response pipeline unchanged.
485///
486/// They are independent of and composable with the server-side `cache_page`
487/// store: `cache_page` caches full response bodies, while these knobs emit
488/// HTTP headers that tell downstream clients and proxies how to treat responses.
489///
490/// # Example
491///
492/// ```ignore
493/// App::builder()
494///     .plugin(
495///         CachePlugin::new(Cache::memory())
496///             .with_compression()
497///             .cache_control("public, max-age=3600")
498///             .vary("Accept-Encoding"),
499///     )
500///     .build()
501///     .await?;
502/// ```
503#[derive(Debug, Clone, Default)]
504pub struct CacheHeaders {
505    /// When `true`, applies `tower_http::compression::CompressionLayer` to the
506    /// router. The layer negotiates encoding with the client via
507    /// `Accept-Encoding` and compresses responses with gzip, brotli, deflate,
508    /// or zstd as available.
509    pub compression: bool,
510    /// When `Some(value)`, emits a `Cache-Control` response header on every
511    /// response (using `SetResponseHeaderLayer::overriding`). The value is the
512    /// raw directive string, e.g. `"public, max-age=3600"` or `"no-store"`.
513    pub cache_control: Option<String>,
514    /// When `Some(value)`, emits a `Vary` response header. Common value:
515    /// `"Accept-Encoding"` to tell caches that responses differ by encoding.
516    pub vary: Option<String>,
517}
518
519// ── CachePlugin ──────────────────────────────────────────────────────────────
520
521/// The plugin. Carries no models, no routes — just a `Cache` handle it
522/// installs as the ambient cache at boot, so `cache_page` and any handler
523/// that calls [`ambient()`] find it without explicit dependency injection.
524///
525/// Idiomatic registration (the carried cache is wired in `on_ready`):
526///
527/// ```ignore
528/// App::builder()
529///     .plugin(CachePlugin::new(Cache::memory()))
530///     // or: CachePlugin::new(Cache::redis("redis://localhost:6379/0").await?)
531///     .build()?;
532/// ```
533///
534/// `CachePlugin::init(cache)` remains for manual/test wiring outside the
535/// plugin lifecycle.
536///
537/// ## Opt-in compression and Cache-Control headers
538///
539/// ```ignore
540/// CachePlugin::new(Cache::memory())
541///     .with_compression()               // enables gzip/br/zstd negotiation
542///     .cache_control("public, max-age=3600")
543///     .vary("Accept-Encoding")
544/// ```
545#[derive(Default)]
546pub struct CachePlugin {
547    /// Cache to install as the ambient handle in [`Plugin::on_ready`].
548    /// `None` for the legacy unit-style registration (where the ambient
549    /// cache is wired separately via [`CachePlugin::init`]).
550    cache: Option<Cache>,
551    /// Opt-in HTTP header + compression config. Default: nothing applied.
552    headers: CacheHeaders,
553}
554
555impl CachePlugin {
556    /// Build the plugin carrying `cache`. The idiomatic
557    /// `App::builder().plugin(CachePlugin::new(Cache::memory()))` then
558    /// installs it as the ambient handle at boot (BROKEN-9) — no separate
559    /// `init` call, so `cache_page` actually caches.
560    pub fn new(cache: Cache) -> Self {
561        Self {
562            cache: Some(cache),
563            headers: CacheHeaders::default(),
564        }
565    }
566
567    /// Store `cache` as the ambient handle directly, outside the plugin
568    /// lifecycle. Prefer [`CachePlugin::new`] in app code; this stays for
569    /// manual / test wiring. Calling it twice panics (same contract as
570    /// `settings::init`).
571    pub fn init(cache: Cache) {
572        if AMBIENT_CACHE.set(cache).is_err() {
573            panic!("CachePlugin::init called more than once");
574        }
575    }
576
577    /// Enable response compression. Applies `tower_http::compression::CompressionLayer`
578    /// to the router; negotiates gzip / brotli / deflate / zstd via `Accept-Encoding`.
579    /// Default: off.
580    pub fn with_compression(mut self) -> Self {
581        self.headers.compression = true;
582        self
583    }
584
585    /// Emit a `Cache-Control` header on every response. `value` is the raw
586    /// directive string (e.g. `"public, max-age=3600"`, `"no-store"`).
587    /// Default: not set.
588    pub fn cache_control(mut self, value: impl Into<String>) -> Self {
589        self.headers.cache_control = Some(value.into());
590        self
591    }
592
593    /// Emit a `Vary` header on every response. Typically paired with
594    /// [`with_compression`][Self::with_compression]: `"Accept-Encoding"` tells
595    /// caches that different encodings are distinct variants of the same URL.
596    /// Default: not set.
597    pub fn vary(mut self, value: impl Into<String>) -> Self {
598        self.headers.vary = Some(value.into());
599        self
600    }
601}
602
603impl Plugin for CachePlugin {
604    fn name(&self) -> &'static str {
605        "cache"
606    }
607
608    fn wrap_router(&self, router: Router) -> Router {
609        let h = &self.headers;
610        let mut router = router;
611
612        // Cache-Control header (overriding — the plugin's policy takes
613        // precedence over whatever a handler set).
614        if let Some(ref val) = h.cache_control {
615            if let Ok(hv) = HeaderValue::from_str(val) {
616                router = router.layer(SetResponseHeaderLayer::overriding(CACHE_CONTROL, hv));
617            } else {
618                tracing::warn!(
619                    value = %val,
620                    "CachePlugin: cache_control value contains invalid header characters; \
621                     Cache-Control header will NOT be emitted"
622                );
623            }
624        }
625
626        // Vary header (overriding).
627        if let Some(ref val) = h.vary {
628            if let Ok(hv) = HeaderValue::from_str(val) {
629                router = router.layer(SetResponseHeaderLayer::overriding(VARY, hv));
630            } else {
631                tracing::warn!(
632                    value = %val,
633                    "CachePlugin: vary value contains invalid header characters; \
634                     Vary header will NOT be emitted"
635                );
636            }
637        }
638
639        // Compression (outermost so the body is already compressed before any
640        // header-setter above runs on the response on the way out).
641        if h.compression {
642            router = router.layer(CompressionLayer::new());
643        }
644
645        router
646    }
647
648    fn on_ready(&self, _ctx: &umbral::plugin::AppContext) -> Result<(), umbral::plugin::PluginError> {
649        // BROKEN-9: registering the plugin must actually wire the cache,
650        // otherwise `cache_page` silently no-ops on every request. If a
651        // cache was supplied via `new`, install it as the ambient handle.
652        match &self.cache {
653            Some(cache) => {
654                if AMBIENT_CACHE.set(cache.clone()).is_err() {
655                    tracing::warn!(
656                        "CachePlugin::new: an ambient cache was already installed (via \
657                         CachePlugin::init or another CachePlugin); ignoring this one."
658                    );
659                }
660            }
661            None if AMBIENT_CACHE.get().is_none() => {
662                tracing::warn!(
663                    "CachePlugin registered with no cache and none set via CachePlugin::init — \
664                     cache_page layers will silently no-op. Use \
665                     CachePlugin::new(Cache::memory())."
666                );
667            }
668            None => {}
669        }
670        Ok(())
671    }
672}