Skip to main content

sova_store/
cache.rs

1//! Thin JSON cache helper over any [`KvStore`] (Memory / File / Sql / Redis).
2
3use crate::{namespace, AppStore, KvStore};
4use bytes::Bytes;
5use serde::de::DeserializeOwned;
6use serde::Serialize;
7use std::future::Future;
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10use thiserror::Error;
11
12#[derive(Debug, Error)]
13pub enum CacheError {
14    #[error("serialize: {0}")]
15    Serialize(String),
16    #[error("deserialize: {0}")]
17    Deserialize(String),
18    #[error("{0}")]
19    Msg(String),
20}
21
22/// JSON get/set/remember on a [`KvStore`].
23#[derive(Clone)]
24pub struct Cache {
25    store: Arc<dyn KvStore>,
26}
27
28fn trunc(key: &str) -> String {
29    const MAX: usize = 120;
30    if key.len() <= MAX {
31        key.to_string()
32    } else {
33        format!("{}…", &key[..MAX - 1])
34    }
35}
36
37fn rid() -> Option<String> {
38    sova_core::current_request_id()
39}
40
41impl Cache {
42    pub fn new(store: Arc<dyn KvStore>) -> Self {
43        Self { store }
44    }
45
46    pub async fn get_json<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
47        let started = Instant::now();
48        let bytes = self.store.get(key).await;
49        let hit = bytes.is_some();
50        let n = bytes.as_ref().map(|b| b.len() as u64);
51        let ms = started.elapsed().as_secs_f64() * 1000.0;
52        tracing::debug!(
53            target: "sova.store",
54            op = "get",
55            backend = "cache",
56            key = %trunc(key),
57            hit,
58            bytes = n,
59            duration_ms = ms,
60            request_id = rid().as_deref().unwrap_or(""),
61            "sova.store"
62        );
63        let bytes = bytes?;
64        serde_json::from_slice(&bytes).ok()
65    }
66
67    pub async fn set_json<T: Serialize>(
68        &self,
69        key: &str,
70        val: &T,
71        ttl: Option<Duration>,
72    ) -> Result<(), CacheError> {
73        let started = Instant::now();
74        let raw = serde_json::to_vec(val).map_err(|e| CacheError::Serialize(e.to_string()))?;
75        let n = raw.len() as u64;
76        self.store.set(key, Bytes::from(raw), ttl).await;
77        let ms = started.elapsed().as_secs_f64() * 1000.0;
78        tracing::debug!(
79            target: "sova.store",
80            op = "set",
81            backend = "cache",
82            key = %trunc(key),
83            bytes = n,
84            duration_ms = ms,
85            request_id = rid().as_deref().unwrap_or(""),
86            "sova.store"
87        );
88        Ok(())
89    }
90
91    /// Return cached value or compute, store, and return.
92    pub async fn remember<T, F, Fut>(
93        &self,
94        key: &str,
95        ttl: Option<Duration>,
96        f: F,
97    ) -> Result<T, CacheError>
98    where
99        T: Serialize + DeserializeOwned,
100        F: FnOnce() -> Fut,
101        Fut: Future<Output = Result<T, CacheError>>,
102    {
103        if let Some(hit) = self.get_json::<T>(key).await {
104            return Ok(hit);
105        }
106        let val = f().await?;
107        self.set_json(key, &val, ttl).await?;
108        Ok(val)
109    }
110
111    pub async fn invalidate(&self, key: &str) {
112        let started = Instant::now();
113        self.store.remove(key).await;
114        let ms = started.elapsed().as_secs_f64() * 1000.0;
115        tracing::debug!(
116            target: "sova.store",
117            op = "remove",
118            backend = "cache",
119            key = %trunc(key),
120            duration_ms = ms,
121            request_id = rid().as_deref().unwrap_or(""),
122            "sova.store"
123        );
124    }
125}
126
127impl AppStore {
128    /// Namespaced JSON cache (`cache:` prefix on the shared backend).
129    pub fn cache(&self) -> Cache {
130        Cache::new(Arc::new(namespace(Arc::clone(&self.inner), "cache")))
131    }
132}