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