Skip to main content

rustlavel_cache/
config.rs

1//! Cache configuration and the driver factory.
2//!
3//! An application picks a driver in `config/cache.json` (or `.env`), never in
4//! code, so the same binary runs on a laptop with the memory driver and in
5//! production against Redis:
6//!
7//! ```json
8//! {
9//!   "driver": "${CACHE_DRIVER:memory}",
10//!   "path":   "storage/framework/cache",
11//!   "url":    "${REDIS_URL}",
12//!   "prefix": "${APP_NAME:rustlavel}:"
13//! }
14//! ```
15
16use crate::file::FileStore;
17use crate::memory::MemoryStore;
18use crate::redis::{RedisConfig, RedisStore};
19use crate::store::Cache;
20use rustlavel_core::{Config, Error, Result};
21use std::path::PathBuf;
22use std::sync::Arc;
23
24/// Which backend to build, and what it needs.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Driver {
27    /// Process-local. Fast, and lost on restart — and *not* shared between
28    /// workers, which matters for rate limiting.
29    Memory,
30    /// One file per key under [`CacheConfig::path`].
31    File,
32    /// A Redis server reached over RESP.
33    Redis,
34}
35
36impl Driver {
37    /// Parse a driver name, listing the alternatives when it is not one.
38    ///
39    /// A typo in `.env` is one of the most common ways to lose an afternoon, so
40    /// this refuses rather than silently falling back to memory.
41    pub fn parse(name: &str) -> Result<Driver> {
42        match name.trim().to_ascii_lowercase().as_str() {
43            "memory" | "array" => Ok(Driver::Memory),
44            "file" => Ok(Driver::File),
45            "redis" => Ok(Driver::Redis),
46            other => Err(Error::msg(format!(
47                "`{other}` is not a cache driver. Set cache.driver to one of: memory, file, redis."
48            ))),
49        }
50    }
51
52    pub fn name(&self) -> &'static str {
53        match self {
54            Driver::Memory => "memory",
55            Driver::File => "file",
56            Driver::Redis => "redis",
57        }
58    }
59}
60
61#[derive(Debug, Clone)]
62pub struct CacheConfig {
63    pub driver: Driver,
64    /// Where the file driver keeps its entries.
65    pub path: PathBuf,
66    /// The Redis URL, empty to fall back to `REDIS_URL`.
67    pub url: String,
68    /// Prepended to every key. Two applications sharing one Redis need this;
69    /// note that [`Cache::flush`] on Redis still empties the whole database.
70    pub prefix: String,
71    /// How often the memory driver sweeps expired entries.
72    pub sweep_interval: std::time::Duration,
73}
74
75impl Default for CacheConfig {
76    fn default() -> Self {
77        CacheConfig {
78            driver: Driver::Memory,
79            path: PathBuf::from("storage/framework/cache"),
80            url: String::new(),
81            prefix: String::new(),
82            sweep_interval: std::time::Duration::from_secs(60),
83        }
84    }
85}
86
87impl CacheConfig {
88    /// Read `cache.driver`, `cache.path`, `cache.url` and `cache.prefix`.
89    pub fn from_app_config(config: &Config) -> Result<Self> {
90        Ok(CacheConfig {
91            driver: Driver::parse(&config.string("cache.driver", "memory"))?,
92            path: PathBuf::from(config.string("cache.path", "storage/framework/cache")),
93            url: config.string("cache.url", ""),
94            prefix: config.string("cache.prefix", ""),
95            ..CacheConfig::default()
96        })
97    }
98}
99
100/// The application's handle on the cache.
101///
102/// Holds an `Arc<dyn Cache>` so the driver is a boot-time decision, and is
103/// itself a [`Cache`], so a handler can call `cache.remember(...)` on it
104/// directly without unwrapping anything.
105#[derive(Clone)]
106pub struct CacheStore {
107    inner: Arc<dyn Cache>,
108}
109
110impl CacheStore {
111    /// Build the driver named in the application configuration.
112    ///
113    /// Deliberately synchronous and non-connecting: an application must boot
114    /// even when Redis is momentarily down. Call [`CacheStore::verify`] when
115    /// failing fast is what you want instead.
116    pub fn from_config(config: &Config) -> Result<Self> {
117        CacheStore::build(&CacheConfig::from_app_config(config)?)
118    }
119
120    pub fn build(settings: &CacheConfig) -> Result<Self> {
121        let store: Arc<dyn Cache> = match settings.driver {
122            Driver::Memory => Arc::new(MemoryStore::with_options(
123                settings.prefix.clone(),
124                settings.sweep_interval,
125            )),
126            Driver::File => {
127                Arc::new(FileStore::with_prefix(&settings.path, settings.prefix.clone())?)
128            }
129            Driver::Redis => {
130                let redis = if settings.url.is_empty() {
131                    RedisConfig::from_app_config(&Config::new())?
132                } else {
133                    RedisConfig::from_url(&settings.url)?
134                };
135                Arc::new(RedisStore::new(redis, settings.prefix.clone()))
136            }
137        };
138
139        Ok(CacheStore { inner: store })
140    }
141
142    /// Wrap a driver that was built by hand.
143    pub fn from_driver(store: impl Cache) -> Self {
144        CacheStore { inner: Arc::new(store) }
145    }
146
147    /// The underlying driver, for a caller that needs `Arc<dyn Cache>`.
148    pub fn driver_handle(&self) -> Arc<dyn Cache> {
149        Arc::clone(&self.inner)
150    }
151
152    /// Prove the store actually works, for a `doctor` command or a boot check.
153    pub async fn verify(&self) -> Result<()> {
154        let key = "__rustlavel_cache_probe";
155        self.put(key, rustlavel_core::Json::from(1), std::time::Duration::from_secs(5)).await?;
156        self.forget(key).await?;
157        Ok(())
158    }
159}
160
161/// Delegation, so `CacheStore` is usable everywhere a `Cache` is — including
162/// picking up every default method and all of `CacheExt`.
163impl Cache for CacheStore {
164    fn driver(&self) -> &'static str {
165        self.inner.driver()
166    }
167
168    fn get<'a>(&'a self, key: &'a str) -> crate::store::BoxFuture<'a, Result<Option<rustlavel_core::Json>>> {
169        self.inner.get(key)
170    }
171
172    fn put<'a>(
173        &'a self,
174        key: &'a str,
175        value: rustlavel_core::Json,
176        ttl: std::time::Duration,
177    ) -> crate::store::BoxFuture<'a, Result<()>> {
178        self.inner.put(key, value, ttl)
179    }
180
181    fn forever<'a>(
182        &'a self,
183        key: &'a str,
184        value: rustlavel_core::Json,
185    ) -> crate::store::BoxFuture<'a, Result<()>> {
186        self.inner.forever(key, value)
187    }
188
189    fn forget<'a>(&'a self, key: &'a str) -> crate::store::BoxFuture<'a, Result<bool>> {
190        self.inner.forget(key)
191    }
192
193    fn flush(&self) -> crate::store::BoxFuture<'_, Result<()>> {
194        self.inner.flush()
195    }
196
197    fn has<'a>(&'a self, key: &'a str) -> crate::store::BoxFuture<'a, Result<bool>> {
198        self.inner.has(key)
199    }
200
201    fn increment<'a>(&'a self, key: &'a str, by: i64) -> crate::store::BoxFuture<'a, Result<i64>> {
202        self.inner.increment(key, by)
203    }
204
205    fn decrement<'a>(&'a self, key: &'a str, by: i64) -> crate::store::BoxFuture<'a, Result<i64>> {
206        self.inner.decrement(key, by)
207    }
208
209    fn increment_within<'a>(
210        &'a self,
211        key: &'a str,
212        by: i64,
213        ttl: std::time::Duration,
214    ) -> crate::store::BoxFuture<'a, Result<i64>> {
215        self.inner.increment_within(key, by, ttl)
216    }
217
218    fn ttl<'a>(&'a self, key: &'a str) -> crate::store::BoxFuture<'a, Result<Option<std::time::Duration>>> {
219        self.inner.ttl(key)
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::store::CacheExt;
227    use rustlavel_core::Json;
228    use std::time::Duration;
229
230    #[test]
231    fn a_driver_typo_names_the_valid_choices() {
232        let error = Driver::parse("redsi").unwrap_err().to_string();
233        assert!(error.contains("memory, file, redis"), "got: {error}");
234    }
235
236    #[test]
237    fn driver_names_are_case_insensitive_and_array_means_memory() {
238        assert_eq!(Driver::parse("Redis").unwrap(), Driver::Redis);
239        assert_eq!(Driver::parse(" file ").unwrap(), Driver::File);
240        // Laravel calls the in-process driver `array`; accept both names.
241        assert_eq!(Driver::parse("array").unwrap(), Driver::Memory);
242    }
243
244    #[tokio::test]
245    async fn the_factory_defaults_to_the_memory_driver() {
246        let store = CacheStore::from_config(&Config::new()).unwrap();
247
248        assert_eq!(store.driver(), "memory");
249        store.forever("k", Json::from(1)).await.unwrap();
250        assert_eq!(store.get("k").await.unwrap(), Some(Json::from(1)));
251    }
252
253    #[tokio::test]
254    async fn the_factory_builds_the_file_driver_at_the_configured_path() {
255        let directory = std::env::temp_dir()
256            .join(format!("rustlavel-cache-factory-{}", std::process::id()));
257        let _ = std::fs::remove_dir_all(&directory);
258
259        let config = Config::new();
260        config.set("cache.driver", "file");
261        config.set("cache.path", directory.to_string_lossy().to_string());
262
263        let store = CacheStore::from_config(&config).unwrap();
264        assert_eq!(store.driver(), "file");
265
266        store.forever("on-disk", Json::from("yes")).await.unwrap();
267        assert!(directory.exists());
268        assert_eq!(store.get("on-disk").await.unwrap(), Some(Json::from("yes")));
269
270        let _ = std::fs::remove_dir_all(&directory);
271    }
272
273    #[test]
274    fn the_factory_builds_the_redis_driver_without_connecting() {
275        let config = Config::new();
276        config.set("cache.driver", "redis");
277        config.set("cache.url", "redis://127.0.0.1:1/0");
278
279        // No await, no server: building must not touch the network, or an
280        // application could not boot while Redis restarts.
281        let store = CacheStore::from_config(&config).unwrap();
282        assert_eq!(store.driver(), "redis");
283    }
284
285    #[test]
286    fn a_malformed_redis_url_is_refused_at_boot() {
287        let config = Config::new();
288        config.set("cache.driver", "redis");
289        config.set("cache.url", "http://not-redis");
290
291        assert!(CacheStore::from_config(&config).is_err());
292    }
293
294    #[tokio::test]
295    async fn the_configured_prefix_reaches_the_driver() {
296        let config = Config::new();
297        config.set("cache.prefix", "tenant-a:");
298        let prefixed = CacheStore::from_config(&config).unwrap();
299
300        let bare = CacheStore::from_driver(MemoryStore::new());
301
302        prefixed.forever("who", Json::from("a")).await.unwrap();
303        // A different store with no prefix must not see the prefixed key.
304        assert_eq!(bare.get("who").await.unwrap(), None);
305        assert_eq!(prefixed.get("who").await.unwrap(), Some(Json::from("a")));
306    }
307
308    #[tokio::test]
309    async fn a_store_handle_supports_the_full_cache_api_including_remember() {
310        let store = CacheStore::from_driver(MemoryStore::new());
311
312        let value = store
313            .remember("expensive", Duration::from_secs(60), || async { Ok(Json::from(99)) })
314            .await
315            .unwrap();
316
317        assert_eq!(value, Json::from(99));
318        assert_eq!(store.pull("expensive").await.unwrap(), Some(Json::from(99)));
319        assert!(!store.has("expensive").await.unwrap());
320        store.verify().await.unwrap();
321    }
322}