Skip to main content

umbral_cache/
lib.rs

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