Skip to main content

renox_core/
cache.rs

1//! A key-value cache for expensive results.
2//!
3//! ```
4//! # use renox::prelude::*;
5//! # use std::time::Duration;
6//! # #[derive(Model, serde::Serialize, serde::Deserialize, Default)] struct Product { id: i64, name: String }
7//! # async fn demo(state: AppState) -> Result {
8//! let menu: Vec<Product> = state.cache.remember("menu", Duration::from_secs(600), || async {
9//!     Product::query().order_by("name").get(&state.db).await
10//! }).await?;
11//! state.cache.forget("menu").await?;   // after the menu changes
12//! # let _ = menu; Ok(()) }
13//! ```
14//!
15//! Counters, one-time values and locks:
16//!
17//! ```
18//! # use renox::prelude::*;
19//! # use std::time::Duration;
20//! # async fn demo(state: AppState, order_id: i64) -> Result {
21//! let views = state.cache.increment("views:home", 1).await?; // atomic; 1 the first time
22//! if state.cache.add("welcome-sent:7", &true, Some(Duration::from_secs(86_400))).await? {
23//!     // only the first caller gets here
24//! }
25//! let code: Option<String> = state.cache.pull("otp:7").await?; // read once, then gone
26//!
27//! // One process at a time handles this order; others wait up to 5 s.
28//! let lock = state.cache.lock(&format!("order:{order_id}"), Duration::from_secs(30));
29//! let guard = lock.block(Duration::from_secs(5)).await?;
30//! // ... work on the order ...
31//! guard.release().await?; // or let it drop
32//! # let _ = (views, code); Ok(()) }
33//! ```
34//!
35//! `CACHE_STORE=memory` (default) keeps values in this process; `database`
36//! keeps them in the `cache` table, so they survive restarts and are shared
37//! with `queue:work` processes and other servers (locks included). Values are
38//! stored as JSON. Keys starting with `renox:` belong to the framework (e.g.
39//! scheduler claims, locks). Expired rows of the database store are deleted
40//! now and then as values are written, and by `my-app cache:prune`.
41
42use std::collections::HashMap;
43use std::future::Future;
44use std::sync::atomic::{AtomicI64, Ordering};
45use std::sync::{Arc, Mutex, Weak};
46use std::time::Duration;
47
48use serde::Serialize;
49use serde::de::DeserializeOwned;
50use serde_json::Value;
51
52use crate::Result;
53use crate::db::{Db, Migration};
54use crate::queue::unix_now;
55
56pub(crate) const MIGRATION: Migration =
57    crate::db::framework_migration!("cache", "00010101000200_create_cache_table");
58
59/// Entries the memory store keeps before dropping expired ones on write.
60const SWEEP_AT: usize = 10_000;
61
62/// Value and expiry (unix seconds) per key.
63type Entries = HashMap<String, (Value, Option<i64>)>;
64
65#[derive(Clone)]
66enum Store {
67    Memory(Arc<Mutex<Entries>>),
68    Database(Db),
69}
70
71/// The app's cache (`state.cache`), in memory or the `cache` table (`CACHE_STORE`).
72#[derive(Clone)]
73pub struct Cache {
74    store: Store,
75    /// One lock per key being computed by `remember` in this process.
76    computing: Arc<Mutex<HashMap<String, Weak<tokio::sync::Mutex<()>>>>>,
77}
78
79/// Unix seconds when a value stored now for `ttl` expires, rounded up.
80fn expiry(ttl: Option<Duration>) -> Option<i64> {
81    ttl.map(|ttl| unix_now() + ttl.as_secs() as i64 + i64::from(ttl.subsec_nanos() > 0))
82}
83
84/// Keys of framework rows, which `flush` keeps.
85const FRAMEWORK_PREFIX: &str = "renox:";
86/// Keys of locks.
87const LOCK_PREFIX: &str = "renox:lock:";
88/// Seconds between the database store's automatic prunes, per process.
89const PRUNE_EVERY: i64 = 60 * 60;
90
91fn live(expires: Option<i64>, now: i64) -> bool {
92    expires.is_none_or(|at| at > now)
93}
94
95impl Cache {
96    pub(crate) fn new(store: crate::CacheStore, db: Db) -> anyhow::Result<Self> {
97        let store = match store {
98            crate::CacheStore::Memory => Store::Memory(Arc::default()),
99            crate::CacheStore::Database => Store::Database(db),
100        };
101        Ok(Self {
102            store,
103            computing: Arc::default(),
104        })
105    }
106
107    async fn raw(&self, key: &str) -> Result<Option<Value>> {
108        let now = unix_now();
109        match &self.store {
110            Store::Memory(map) => {
111                let map = map.lock().unwrap_or_else(|e| e.into_inner());
112                Ok(map
113                    .get(key)
114                    .filter(|(_, expires)| expires.is_none_or(|at| at > now))
115                    .map(|(value, _)| value.clone()))
116            }
117            Store::Database(db) => {
118                let text: Option<String> = crate::db::sql(
119                    "SELECT value FROM cache WHERE key = ? AND (expires_at IS NULL OR expires_at > ?)",
120                )
121                .bind(key)
122                .bind(now)
123                .scalar_optional(db)
124                .await?;
125                Ok(text.and_then(|t| serde_json::from_str(&t).ok()))
126            }
127        }
128    }
129
130    /// The cached value, if present and not expired. A value of another
131    /// type than `T` is an error.
132    pub async fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {
133        match self.raw(key).await? {
134            None => Ok(None),
135            Some(value) => serde_json::from_value(value).map(Some).map_err(|err| {
136                anyhow::Error::new(err)
137                    .context(format!(
138                        "the cached `{key}` is not a {}",
139                        std::any::type_name::<T>()
140                    ))
141                    .into()
142            }),
143        }
144    }
145
146    /// Whether `key` holds a value that hasn't expired.
147    pub async fn has(&self, key: &str) -> Result<bool> {
148        Ok(self.raw(key).await?.is_some())
149    }
150
151    /// Stores `value` for `ttl`, or until forgotten when `ttl` is `None`. A
152    /// zero `ttl` stores nothing (and forgets the key).
153    pub async fn put(&self, key: &str, value: &impl Serialize, ttl: Option<Duration>) -> Result {
154        if ttl == Some(Duration::ZERO) {
155            return self.forget(key).await;
156        }
157        let value = serde_json::to_value(value)?;
158        let expires = expiry(ttl);
159        match &self.store {
160            Store::Memory(map) => {
161                let mut map = map.lock().unwrap_or_else(|e| e.into_inner());
162                if map.len() >= SWEEP_AT {
163                    let now = unix_now();
164                    map.retain(|_, (_, at)| at.is_none_or(|at| at > now));
165                }
166                map.insert(key.to_owned(), (value, expires));
167            }
168            Store::Database(db) => {
169                self.prune_now_and_then(db).await;
170                crate::db::sql(
171                    "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) \
172                     ON CONFLICT (key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at",
173                )
174                .bind(key)
175                .bind(value.to_string())
176                .bind(expires)
177                .execute(db)
178                .await?;
179            }
180        }
181        Ok(())
182    }
183
184    /// Stores `value` only if `key` has no live value; returns whether it
185    /// did. Atomic, so of several callers exactly one gets `true`.
186    pub async fn add(
187        &self,
188        key: &str,
189        value: &impl Serialize,
190        ttl: Option<Duration>,
191    ) -> Result<bool> {
192        let value = serde_json::to_value(value)?;
193        let expires = expiry(ttl);
194        let now = unix_now();
195        match &self.store {
196            Store::Memory(map) => {
197                let mut map = map.lock().unwrap_or_else(|e| e.into_inner());
198                if map.get(key).is_some_and(|(_, at)| live(*at, now)) {
199                    return Ok(false);
200                }
201                map.insert(key.to_owned(), (value, expires));
202                Ok(true)
203            }
204            Store::Database(db) => {
205                self.prune_now_and_then(db).await;
206                let added = crate::db::sql(
207                    "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) \
208                     ON CONFLICT (key) DO UPDATE SET value = excluded.value, \
209                     expires_at = excluded.expires_at \
210                     WHERE cache.expires_at IS NOT NULL AND cache.expires_at <= ?",
211                )
212                .bind(key)
213                .bind(value.to_string())
214                .bind(expires)
215                .bind(now)
216                .execute(db)
217                .await?;
218                Ok(added == 1)
219            }
220        }
221    }
222
223    /// The cached value, removed from the cache in the same step (a one-time
224    /// code, a flash of data between requests).
225    pub async fn pull<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {
226        let now = unix_now();
227        let value = match &self.store {
228            Store::Memory(map) => map
229                .lock()
230                .unwrap_or_else(|e| e.into_inner())
231                .remove(key)
232                .filter(|(_, at)| live(*at, now))
233                .map(|(value, _)| value),
234            Store::Database(db) => {
235                let row: Option<(String, Option<i64>)> =
236                    crate::db::sql("DELETE FROM cache WHERE key = ? RETURNING value, expires_at")
237                        .bind(key)
238                        .fetch_as(db)
239                        .await?
240                        .into_iter()
241                        .next();
242                row.filter(|(_, at)| live(*at, now))
243                    .and_then(|(text, _)| serde_json::from_str(&text).ok())
244            }
245        };
246        match value {
247            None => Ok(None),
248            Some(value) => Ok(Some(serde_json::from_value(value).map_err(|err| {
249                anyhow::Error::new(err).context(format!("the cached `{key}` is not that type"))
250            })?)),
251        }
252    }
253
254    /// Adds `by` (negative to subtract) to the number at `key` and returns
255    /// the new value; a missing or expired key counts as 0 and never
256    /// expires. Atomic, so concurrent calls don't lose counts. A live value
257    /// that isn't a whole number is an error.
258    pub async fn increment(&self, key: &str, by: i64) -> Result<i64> {
259        let now = unix_now();
260        match &self.store {
261            Store::Memory(map) => {
262                let mut map = map.lock().unwrap_or_else(|e| e.into_inner());
263                let (current, expires) = match map.get(key) {
264                    Some((value, at)) if live(*at, now) => {
265                        let Some(n) = value.as_i64() else {
266                            return Err(anyhow::anyhow!(
267                                "the cached `{key}` is not a whole number"
268                            )
269                            .into());
270                        };
271                        (n, *at)
272                    }
273                    _ => (0, None),
274                };
275                let next = current
276                    .checked_add(by)
277                    .ok_or_else(|| anyhow::anyhow!("the cached `{key}` would overflow"))?;
278                map.insert(key.to_owned(), (Value::from(next), expires));
279                Ok(next)
280            }
281            Store::Database(db) => {
282                if let Some(value) = self.raw(key).await?
283                    && !value.is_i64()
284                {
285                    return Err(anyhow::anyhow!("the cached `{key}` is not a whole number").into());
286                }
287                let next: String = crate::db::sql(
288                    "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, NULL) \
289                     ON CONFLICT (key) DO UPDATE SET \
290                     value = CASE WHEN cache.expires_at IS NOT NULL AND cache.expires_at <= ? \
291                         THEN excluded.value \
292                         ELSE CAST(CAST(cache.value AS BIGINT) + ? AS TEXT) END, \
293                     expires_at = CASE WHEN cache.expires_at IS NOT NULL AND cache.expires_at <= ? \
294                         THEN NULL ELSE cache.expires_at END \
295                     RETURNING value",
296                )
297                .bind(key)
298                .bind(by.to_string())
299                .bind(now)
300                .bind(by)
301                .bind(now)
302                .scalar(db)
303                .await?;
304                Ok(next
305                    .parse()
306                    .map_err(|_| anyhow::anyhow!("the cached `{key}` is not a whole number"))?)
307            }
308        }
309    }
310
311    /// `increment(key, -by)`.
312    pub async fn decrement(&self, key: &str, by: i64) -> Result<i64> {
313        self.increment(key, -by).await
314    }
315
316    /// A lock named `name`, held for `ttl` (up to a second more, never
317    /// less) once acquired unless released earlier, so a crashed holder
318    /// doesn't block others forever. With the database store
319    /// it works across processes and servers; with the memory store, within
320    /// this process.
321    pub fn lock(&self, name: &str, ttl: Duration) -> Lock {
322        Lock {
323            cache: self.clone(),
324            key: format!("{LOCK_PREFIX}{name}"),
325            ttl: ttl.max(Duration::from_secs(1)),
326        }
327    }
328
329    /// Deletes expired entries; returns how many (the database store's
330    /// count; the memory store returns 0 after sweeping).
331    pub async fn prune(&self) -> Result<u64> {
332        let now = unix_now();
333        match &self.store {
334            Store::Memory(map) => {
335                map.lock()
336                    .unwrap_or_else(|e| e.into_inner())
337                    .retain(|_, (_, at)| live(*at, now));
338                Ok(0)
339            }
340            Store::Database(db) => Ok(crate::db::sql(
341                "DELETE FROM cache WHERE expires_at IS NOT NULL AND expires_at <= ?",
342            )
343            .bind(now)
344            .execute(db)
345            .await?),
346        }
347    }
348
349    /// Prunes expired rows at most once an hour per process.
350    async fn prune_now_and_then(&self, db: &Db) {
351        static LAST: AtomicI64 = AtomicI64::new(0);
352        let now = unix_now();
353        let last = LAST.load(Ordering::Relaxed);
354        if now - last < PRUNE_EVERY
355            || LAST
356                .compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
357                .is_err()
358        {
359            return;
360        }
361        delete_expired(db, now).await;
362    }
363
364    /// Deletes `key` if it holds `value`; returns whether it did.
365    async fn forget_if(&self, key: &str, value: &Value) -> Result<bool> {
366        match &self.store {
367            Store::Memory(map) => {
368                let mut map = map.lock().unwrap_or_else(|e| e.into_inner());
369                if map.get(key).is_some_and(|(held, _)| held == value) {
370                    map.remove(key);
371                    return Ok(true);
372                }
373                Ok(false)
374            }
375            Store::Database(db) => Ok(crate::db::sql(
376                "DELETE FROM cache WHERE key = ? AND value = ?",
377            )
378            .bind(key)
379            .bind(value.to_string())
380            .execute(db)
381            .await?
382                == 1),
383        }
384    }
385
386    /// Returns the cached value, or runs `compute`, caches its result for
387    /// `ttl` and returns it. Errors are not cached. Concurrent calls for the
388    /// same key in this process compute once; the others wait for it. A
389    /// cached value of another type (e.g. after a deploy) is computed again.
390    pub async fn remember<T, F, Fut>(&self, key: &str, ttl: Duration, compute: F) -> Result<T>
391    where
392        T: Serialize + DeserializeOwned,
393        F: FnOnce() -> Fut,
394        Fut: Future<Output = Result<T>>,
395    {
396        if let Some(value) = self.cached(key).await? {
397            return Ok(value);
398        }
399        let lock = self.computing_lock(key);
400        let _computing = lock.lock().await;
401        if let Some(value) = self.cached(key).await? {
402            return Ok(value);
403        }
404        let value = compute().await?;
405        self.put(key, &value, Some(ttl)).await?;
406        Ok(value)
407    }
408
409    /// Like `get`, but a value of another type is a miss.
410    async fn cached<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {
411        Ok(self
412            .raw(key)
413            .await?
414            .and_then(|v| serde_json::from_value(v).ok()))
415    }
416
417    fn computing_lock(&self, key: &str) -> Arc<tokio::sync::Mutex<()>> {
418        let mut locks = self.computing.lock().unwrap_or_else(|e| e.into_inner());
419        if let Some(lock) = locks.get(key).and_then(Weak::upgrade) {
420            return lock;
421        }
422        locks.retain(|_, lock| lock.strong_count() > 0);
423        let lock = Arc::new(tokio::sync::Mutex::new(()));
424        locks.insert(key.to_owned(), Arc::downgrade(&lock));
425        lock
426    }
427
428    /// Removes `key` (nothing happens if it's missing).
429    pub async fn forget(&self, key: &str) -> Result {
430        match &self.store {
431            Store::Memory(map) => {
432                map.lock().unwrap_or_else(|e| e.into_inner()).remove(key);
433            }
434            Store::Database(db) => {
435                crate::db::sql("DELETE FROM cache WHERE key = ?")
436                    .bind(key)
437                    .execute(db)
438                    .await?;
439            }
440        }
441        Ok(())
442    }
443
444    /// Removes everything the app cached (not the framework's `renox:` rows).
445    pub async fn flush(&self) -> Result {
446        match &self.store {
447            Store::Memory(map) => map
448                .lock()
449                .unwrap_or_else(|e| e.into_inner())
450                .retain(|key, _| key.starts_with(FRAMEWORK_PREFIX)),
451            Store::Database(db) => {
452                crate::db::sql("DELETE FROM cache WHERE key NOT LIKE ?")
453                    .bind(format!("{FRAMEWORK_PREFIX}%"))
454                    .execute(db)
455                    .await?;
456            }
457        }
458        Ok(())
459    }
460}
461
462/// A named lock from [`Cache::lock`].
463#[derive(Clone)]
464pub struct Lock {
465    cache: Cache,
466    key: String,
467    ttl: Duration,
468}
469
470/// A held [`Lock`]. Released by [`LockGuard::release`], when dropped (in the
471/// background), or when its `ttl` runs out.
472#[must_use = "the lock is released when the guard is dropped"]
473pub struct LockGuard {
474    cache: Cache,
475    key: String,
476    owner: Value,
477    released: bool,
478}
479
480impl Lock {
481    /// Takes the lock if it's free.
482    pub async fn try_acquire(&self) -> Result<Option<LockGuard>> {
483        let owner = Value::String(crate::crypto::random_token());
484        // Expiry is kept in whole seconds, so a second more: never less than `ttl`.
485        let ttl = self.ttl + Duration::from_secs(1);
486        if self.cache.add(&self.key, &owner, Some(ttl)).await? {
487            return Ok(Some(LockGuard {
488                cache: self.cache.clone(),
489                key: self.key.clone(),
490                owner,
491                released: false,
492            }));
493        }
494        Ok(None)
495    }
496
497    /// Waits up to `wait` for the lock, then gives up with a 423 Locked
498    /// error.
499    pub async fn block(&self, wait: Duration) -> Result<LockGuard> {
500        let deadline = tokio::time::Instant::now() + wait;
501        let mut pause = Duration::from_millis(25);
502        loop {
503            if let Some(guard) = self.try_acquire().await? {
504                return Ok(guard);
505            }
506            let now = tokio::time::Instant::now();
507            if now >= deadline {
508                let name = self.key.trim_start_matches(LOCK_PREFIX);
509                return Err(crate::abort(
510                    axum::http::StatusCode::LOCKED,
511                    format!("the lock `{name}` is held elsewhere"),
512                ));
513            }
514            tokio::time::sleep(pause.min(deadline - now)).await;
515            pause = (pause * 2).min(Duration::from_millis(250));
516        }
517    }
518
519    /// Whether someone holds the lock now.
520    pub async fn is_held(&self) -> Result<bool> {
521        self.cache.has(&self.key).await
522    }
523
524    /// Releases the lock whoever holds it (e.g. from an admin command).
525    pub async fn force_release(&self) -> Result {
526        self.cache.forget(&self.key).await
527    }
528}
529
530impl LockGuard {
531    /// Releases the lock now, if this guard still holds it; returns whether
532    /// it did (false when the `ttl` ran out and someone else took it).
533    pub async fn release(mut self) -> Result<bool> {
534        self.released = true;
535        self.cache.forget_if(&self.key, &self.owner).await
536    }
537}
538
539impl Drop for LockGuard {
540    fn drop(&mut self) {
541        if self.released {
542            return;
543        }
544        let (cache, key, owner) = (
545            self.cache.clone(),
546            std::mem::take(&mut self.key),
547            self.owner.take(),
548        );
549        if let Ok(runtime) = tokio::runtime::Handle::try_current() {
550            runtime.spawn(async move {
551                if let Err(err) = cache.forget_if(&key, &owner).await {
552                    tracing::warn!(lock = %key, error = ?err, "could not release a lock");
553                }
554            });
555        }
556    }
557}
558
559/// Deletes the rows expired at `now`; a failure is logged, not returned
560/// (pruning is housekeeping).
561async fn delete_expired(db: &Db, now: i64) {
562    let pruned =
563        crate::db::sql("DELETE FROM cache WHERE expires_at IS NOT NULL AND expires_at <= ?")
564            .bind(now)
565            .execute(db)
566            .await;
567    if let Err(err) = pruned {
568        tracing::warn!(error = %err, "could not prune expired cache rows");
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575
576    async fn db() -> Db {
577        crate::db::connect(&crate::Config::default()).await.unwrap()
578    }
579
580    /// Past `SWEEP_AT` keys, the memory store drops what expired.
581    #[tokio::test]
582    async fn the_memory_store_sweeps_expired_values_once_it_is_large() {
583        let cache = Cache::new(crate::CacheStore::Memory, db().await).unwrap();
584        for i in 0..SWEEP_AT {
585            cache
586                .put(&format!("k{i}"), &i, Some(Duration::from_secs(1)))
587                .await
588                .unwrap();
589        }
590        crate::clock::with_offset(10, cache.put("late", &1, None))
591            .await
592            .unwrap();
593        let Store::Memory(map) = &cache.store else {
594            unreachable!()
595        };
596        assert_eq!(map.lock().unwrap().len(), 1);
597    }
598
599    /// The database store when its table is missing or goes away: writing
600    /// is an error (after a failed prune, logged), and a lock whose release
601    /// fails stays held until its time runs out.
602    #[tokio::test]
603    async fn the_database_store_without_its_table() {
604        let (logs, _logged) = crate::test_logs::capture();
605        let db = db().await;
606        let cache = Cache::new(crate::CacheStore::Database, db.clone()).unwrap();
607        assert!(cache.put("k", &1, None).await.is_err());
608        delete_expired(&db, 0).await;
609        assert!(
610            logs.has(&["could not prune expired cache rows"]),
611            "{}",
612            logs.text()
613        );
614
615        crate::db::sql("CREATE TABLE cache (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL, expires_at BIGINT)")
616            .execute(&db)
617            .await
618            .unwrap();
619        let lock = cache.lock("report", Duration::from_secs(60));
620        let guard = lock.try_acquire().await.unwrap().expect("free");
621        crate::db::sql("ALTER TABLE cache RENAME TO cache_away")
622            .execute(&db)
623            .await
624            .unwrap();
625        drop(guard);
626        tokio::time::sleep(Duration::from_millis(100)).await;
627        crate::db::sql("ALTER TABLE cache_away RENAME TO cache")
628            .execute(&db)
629            .await
630            .unwrap();
631        assert!(lock.is_held().await.unwrap());
632        assert!(logs.has(&["could not release a lock"]), "{}", logs.text());
633    }
634}