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/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            // One driver, two names. Valkey is the same protocol, and asking
46            // somebody running Valkey to write `redis` in their configuration
47            // is asking them to describe their system incorrectly.
48            "redis" | "valkey" => Ok(Driver::Redis),
49            other => Err(Error::msg(format!(
50                "`{other}` is not a cache driver. Set cache.driver to one of: memory, file, redis, valkey."
51            ))),
52        }
53    }
54
55    pub fn name(&self) -> &'static str {
56        match self {
57            Driver::Memory => "memory",
58            Driver::File => "file",
59            Driver::Redis => "redis",
60        }
61    }
62}
63
64#[derive(Debug, Clone)]
65pub struct CacheConfig {
66    pub driver: Driver,
67    /// Where the file driver keeps its entries.
68    pub path: PathBuf,
69    /// The Redis URL, empty to fall back to `REDIS_URL`.
70    pub url: String,
71    /// Prepended to every key. Two applications sharing one Redis need this;
72    /// note that [`Cache::flush`] on Redis still empties the whole database.
73    pub prefix: String,
74    /// How often the memory driver sweeps expired entries.
75    pub sweep_interval: std::time::Duration,
76}
77
78impl Default for CacheConfig {
79    fn default() -> Self {
80        CacheConfig {
81            driver: Driver::Memory,
82            // `storage/cache` is the directory `rustlavel new --with cache`
83            // creates. The default used to be `storage/framework/cache`, which
84            // is Laravel's path and not this scaffold's, so the file driver
85            // wrote into a directory nothing had made.
86            path: PathBuf::from("storage/cache"),
87            url: String::new(),
88            prefix: String::new(),
89            sweep_interval: std::time::Duration::from_secs(60),
90        }
91    }
92}
93
94impl CacheConfig {
95    /// Read `cache.driver`, `cache.path`, `cache.url` and `cache.prefix`.
96    pub fn from_app_config(config: &Config) -> Result<Self> {
97        Ok(CacheConfig {
98            driver: Driver::parse(&config.string("cache.driver", "memory"))?,
99            path: PathBuf::from(config.string("cache.path", "storage/cache")),
100            url: config.string("cache.url", ""),
101            prefix: config.string("cache.prefix", ""),
102            ..CacheConfig::default()
103        })
104    }
105}
106
107/// The application's handle on the cache.
108///
109/// Holds an `Arc<dyn Cache>` so the driver is a boot-time decision, and is
110/// itself a [`Cache`], so a handler can call `cache.remember(...)` on it
111/// directly without unwrapping anything.
112#[derive(Clone)]
113pub struct CacheStore {
114    inner: Arc<dyn Cache>,
115}
116
117impl CacheStore {
118    /// Build the driver named in the application configuration.
119    ///
120    /// Deliberately synchronous and non-connecting: an application must boot
121    /// even when Redis is momentarily down. Call [`CacheStore::verify`] when
122    /// failing fast is what you want instead.
123    pub fn from_config(config: &Config) -> Result<Self> {
124        CacheStore::build(&CacheConfig::from_app_config(config)?)
125    }
126
127    pub fn build(settings: &CacheConfig) -> Result<Self> {
128        let store: Arc<dyn Cache> = match settings.driver {
129            Driver::Memory => Arc::new(MemoryStore::with_options(
130                settings.prefix.clone(),
131                settings.sweep_interval,
132            )),
133            Driver::File => {
134                Arc::new(FileStore::with_prefix(&settings.path, settings.prefix.clone())?)
135            }
136            Driver::Redis => {
137                let redis = if settings.url.is_empty() {
138                    RedisConfig::from_app_config(&Config::new())?
139                } else {
140                    RedisConfig::from_url(&settings.url)?
141                };
142                Arc::new(RedisStore::new(redis, settings.prefix.clone()))
143            }
144        };
145
146        Ok(CacheStore { inner: store })
147    }
148
149    /// Wrap a driver that was built by hand.
150    pub fn from_driver(store: impl Cache) -> Self {
151        CacheStore { inner: Arc::new(store) }
152    }
153
154    /// The underlying driver, for a caller that needs `Arc<dyn Cache>`.
155    pub fn driver_handle(&self) -> Arc<dyn Cache> {
156        Arc::clone(&self.inner)
157    }
158
159    /// Prove the store actually works, for a `doctor` command or a boot check.
160    pub async fn verify(&self) -> Result<()> {
161        let key = "__rustlavel_cache_probe";
162        self.put(key, rustlavel_core::Json::from(1), std::time::Duration::from_secs(5)).await?;
163        self.forget(key).await?;
164        Ok(())
165    }
166}
167
168/// Delegation, so `CacheStore` is usable everywhere a `Cache` is — including
169/// picking up every default method and all of `CacheExt`.
170impl Cache for CacheStore {
171    fn driver(&self) -> &'static str {
172        self.inner.driver()
173    }
174
175    fn get<'a>(&'a self, key: &'a str) -> crate::store::BoxFuture<'a, Result<Option<rustlavel_core::Json>>> {
176        self.inner.get(key)
177    }
178
179    fn put<'a>(
180        &'a self,
181        key: &'a str,
182        value: rustlavel_core::Json,
183        ttl: std::time::Duration,
184    ) -> crate::store::BoxFuture<'a, Result<()>> {
185        self.inner.put(key, value, ttl)
186    }
187
188    fn forever<'a>(
189        &'a self,
190        key: &'a str,
191        value: rustlavel_core::Json,
192    ) -> crate::store::BoxFuture<'a, Result<()>> {
193        self.inner.forever(key, value)
194    }
195
196    fn forget<'a>(&'a self, key: &'a str) -> crate::store::BoxFuture<'a, Result<bool>> {
197        self.inner.forget(key)
198    }
199
200    fn flush(&self) -> crate::store::BoxFuture<'_, Result<()>> {
201        self.inner.flush()
202    }
203
204    fn has<'a>(&'a self, key: &'a str) -> crate::store::BoxFuture<'a, Result<bool>> {
205        self.inner.has(key)
206    }
207
208    fn increment<'a>(&'a self, key: &'a str, by: i64) -> crate::store::BoxFuture<'a, Result<i64>> {
209        self.inner.increment(key, by)
210    }
211
212    fn decrement<'a>(&'a self, key: &'a str, by: i64) -> crate::store::BoxFuture<'a, Result<i64>> {
213        self.inner.decrement(key, by)
214    }
215
216    fn increment_within<'a>(
217        &'a self,
218        key: &'a str,
219        by: i64,
220        ttl: std::time::Duration,
221    ) -> crate::store::BoxFuture<'a, Result<i64>> {
222        self.inner.increment_within(key, by, ttl)
223    }
224
225    fn ttl<'a>(&'a self, key: &'a str) -> crate::store::BoxFuture<'a, Result<Option<std::time::Duration>>> {
226        self.inner.ttl(key)
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::store::CacheExt;
234    use rustlavel_core::Json;
235    use std::time::Duration;
236
237    /// Asking somebody running Valkey to write `redis` in their configuration
238    /// is asking them to describe their system incorrectly.
239    #[test]
240    fn valkey_names_the_same_driver_as_redis() {
241        assert_eq!(Driver::parse("valkey").unwrap(), Driver::Redis);
242        assert_eq!(Driver::parse("redis").unwrap(), Driver::Redis);
243        // And the refusal lists both, so the next person sees the spelling.
244        let error = Driver::parse("memcached").unwrap_err().to_string();
245        assert!(error.contains("valkey"), "{error}");
246    }
247
248    #[test]
249    fn a_driver_typo_names_the_valid_choices() {
250        let error = Driver::parse("redsi").unwrap_err().to_string();
251        assert!(error.contains("memory, file, redis"), "got: {error}");
252    }
253
254    #[test]
255    fn driver_names_are_case_insensitive_and_array_means_memory() {
256        assert_eq!(Driver::parse("Redis").unwrap(), Driver::Redis);
257        assert_eq!(Driver::parse(" file ").unwrap(), Driver::File);
258        // Laravel calls the in-process driver `array`; accept both names.
259        assert_eq!(Driver::parse("array").unwrap(), Driver::Memory);
260    }
261
262    #[tokio::test]
263    async fn the_factory_defaults_to_the_memory_driver() {
264        let store = CacheStore::from_config(&Config::new()).unwrap();
265
266        assert_eq!(store.driver(), "memory");
267        store.forever("k", Json::from(1)).await.unwrap();
268        assert_eq!(store.get("k").await.unwrap(), Some(Json::from(1)));
269    }
270
271    #[tokio::test]
272    async fn the_factory_builds_the_file_driver_at_the_configured_path() {
273        let directory = std::env::temp_dir()
274            .join(format!("rustlavel-cache-factory-{}", std::process::id()));
275        let _ = std::fs::remove_dir_all(&directory);
276
277        let config = Config::new();
278        config.set("cache.driver", "file");
279        config.set("cache.path", directory.to_string_lossy().to_string());
280
281        let store = CacheStore::from_config(&config).unwrap();
282        assert_eq!(store.driver(), "file");
283
284        store.forever("on-disk", Json::from("yes")).await.unwrap();
285        assert!(directory.exists());
286        assert_eq!(store.get("on-disk").await.unwrap(), Some(Json::from("yes")));
287
288        let _ = std::fs::remove_dir_all(&directory);
289    }
290
291    #[test]
292    fn the_factory_builds_the_redis_driver_without_connecting() {
293        let config = Config::new();
294        config.set("cache.driver", "redis");
295        config.set("cache.url", "redis://127.0.0.1:1/0");
296
297        // No await, no server: building must not touch the network, or an
298        // application could not boot while Redis restarts.
299        let store = CacheStore::from_config(&config).unwrap();
300        assert_eq!(store.driver(), "redis");
301    }
302
303    #[test]
304    fn a_malformed_redis_url_is_refused_at_boot() {
305        let config = Config::new();
306        config.set("cache.driver", "redis");
307        config.set("cache.url", "http://not-redis");
308
309        assert!(CacheStore::from_config(&config).is_err());
310    }
311
312    #[tokio::test]
313    async fn the_configured_prefix_reaches_the_driver() {
314        let config = Config::new();
315        config.set("cache.prefix", "tenant-a:");
316        let prefixed = CacheStore::from_config(&config).unwrap();
317
318        let bare = CacheStore::from_driver(MemoryStore::new());
319
320        prefixed.forever("who", Json::from("a")).await.unwrap();
321        // A different store with no prefix must not see the prefixed key.
322        assert_eq!(bare.get("who").await.unwrap(), None);
323        assert_eq!(prefixed.get("who").await.unwrap(), Some(Json::from("a")));
324    }
325
326    #[tokio::test]
327    async fn a_store_handle_supports_the_full_cache_api_including_remember() {
328        let store = CacheStore::from_driver(MemoryStore::new());
329
330        let value = store
331            .remember("expensive", Duration::from_secs(60), || async { Ok(Json::from(99)) })
332            .await
333            .unwrap();
334
335        assert_eq!(value, Json::from(99));
336        assert_eq!(store.pull("expensive").await.unwrap(), Some(Json::from(99)));
337        assert!(!store.has("expensive").await.unwrap());
338        store.verify().await.unwrap();
339    }
340}