Skip to main content

armature_cache/
helpers.rs

1//! Helper functions for common cache operations.
2
3use crate::error::CacheResult;
4use crate::traits::CacheStore;
5use serde::{Serialize, de::DeserializeOwned};
6use std::time::Duration;
7
8/// Get a typed value from the cache.
9pub async fn get<S: CacheStore, T: DeserializeOwned>(
10    store: &S,
11    key: &str,
12) -> CacheResult<Option<T>> {
13    if let Some(json) = store.get_json(key).await? {
14        let value: T = serde_json::from_str(&json)
15            .map_err(|e| crate::error::CacheError::Deserialization(e.to_string()))?;
16        Ok(Some(value))
17    } else {
18        Ok(None)
19    }
20}
21
22/// Set a typed value in the cache.
23///
24/// A `ttl` of `None` means "unspecified" and lets the store fall back to its
25/// configured `default_ttl`. Use [`set_forever`] to store an entry that
26/// genuinely never expires.
27pub async fn set<S: CacheStore, T: Serialize>(
28    store: &S,
29    key: &str,
30    value: &T,
31    ttl: Option<Duration>,
32) -> CacheResult<()> {
33    let json = serde_json::to_string(value)
34        .map_err(|e| crate::error::CacheError::Serialization(e.to_string()))?;
35    store.set_json(key, json, ttl).await
36}
37
38/// Set a typed value that never expires, bypassing the store's `default_ttl`.
39///
40/// See [`CacheStore::set_json_forever`] for why this is distinct from
41/// `set(store, key, value, None)`.
42pub async fn set_forever<S: CacheStore, T: Serialize>(
43    store: &S,
44    key: &str,
45    value: &T,
46) -> CacheResult<()> {
47    let json = serde_json::to_string(value)
48        .map_err(|e| crate::error::CacheError::Serialization(e.to_string()))?;
49    store.set_json_forever(key, json).await
50}
51
52/// Remember a value for a given duration.
53///
54/// If the key exists, returns the cached value.
55/// If not, calls the factory function, caches the result, and returns it.
56pub async fn remember<S: CacheStore, T, F, Fut>(
57    store: &S,
58    key: &str,
59    ttl: Duration,
60    factory: F,
61) -> CacheResult<T>
62where
63    T: Serialize + DeserializeOwned,
64    F: FnOnce() -> Fut,
65    Fut: std::future::Future<Output = CacheResult<T>>,
66{
67    if let Some(value) = get(store, key).await? {
68        return Ok(value);
69    }
70
71    let value = factory().await?;
72    set(store, key, &value, Some(ttl)).await?;
73    Ok(value)
74}
75
76/// Remember a value forever (no expiry at all).
77///
78/// The cached entry is written through [`CacheStore::set_json_forever`], so it
79/// is stored without expiry even on a store configured with a `default_ttl`.
80/// Writing it as `set(.., None)` would instead resolve to that default,
81/// turning "forever" into "however long the default happens to be".
82pub async fn remember_forever<S: CacheStore, T, F, Fut>(
83    store: &S,
84    key: &str,
85    factory: F,
86) -> CacheResult<T>
87where
88    T: Serialize + DeserializeOwned,
89    F: FnOnce() -> Fut,
90    Fut: std::future::Future<Output = CacheResult<T>>,
91{
92    if let Some(value) = get(store, key).await? {
93        return Ok(value);
94    }
95
96    let value = factory().await?;
97    set_forever(store, key, &value).await?;
98    Ok(value)
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use async_trait::async_trait;
105    use std::collections::HashMap;
106    use std::sync::Arc;
107    use tokio::sync::RwLock;
108
109    /// A store shaped like the real network backends: it carries a
110    /// `default_ttl` and resolves a `None` TTL against it in `set_json`, and
111    /// overrides `set_json_forever` to skip that fallback. `InMemoryCache` has
112    /// no `default_ttl` concept, so it cannot exercise this distinction.
113    /// key -> (serialized value, effective TTL the write applied).
114    type WrittenEntries = Arc<RwLock<HashMap<String, (String, Option<Duration>)>>>;
115
116    struct DefaultTtlCache {
117        default_ttl: Option<Duration>,
118        data: WrittenEntries,
119    }
120
121    impl DefaultTtlCache {
122        fn new(default_ttl: Duration) -> Self {
123            Self {
124                default_ttl: Some(default_ttl),
125                data: Arc::new(RwLock::new(HashMap::new())),
126            }
127        }
128
129        async fn stored_ttl(&self, key: &str) -> Option<Duration> {
130            self.data.read().await.get(key).and_then(|(_, ttl)| *ttl)
131        }
132    }
133
134    #[async_trait]
135    impl CacheStore for DefaultTtlCache {
136        async fn get_json(&self, key: &str) -> CacheResult<Option<String>> {
137            Ok(self.data.read().await.get(key).map(|(v, _)| v.clone()))
138        }
139
140        async fn set_json(
141            &self,
142            key: &str,
143            value: String,
144            ttl: Option<Duration>,
145        ) -> CacheResult<()> {
146            let effective = ttl.or(self.default_ttl);
147            self.data
148                .write()
149                .await
150                .insert(key.to_string(), (value, effective));
151            Ok(())
152        }
153
154        async fn set_json_forever(&self, key: &str, value: String) -> CacheResult<()> {
155            self.data
156                .write()
157                .await
158                .insert(key.to_string(), (value, None));
159            Ok(())
160        }
161
162        async fn delete(&self, key: &str) -> CacheResult<()> {
163            self.data.write().await.remove(key);
164            Ok(())
165        }
166
167        async fn exists(&self, key: &str) -> CacheResult<bool> {
168            Ok(self.data.read().await.contains_key(key))
169        }
170
171        async fn clear(&self) -> CacheResult<()> {
172            self.data.write().await.clear();
173            Ok(())
174        }
175
176        async fn ttl(&self, key: &str) -> CacheResult<Option<Duration>> {
177            Ok(self.stored_ttl(key).await)
178        }
179
180        async fn expire(&self, _key: &str, _ttl: Duration) -> CacheResult<()> {
181            Ok(())
182        }
183
184        async fn increment(&self, _key: &str, _delta: i64) -> CacheResult<i64> {
185            Ok(0)
186        }
187
188        async fn decrement(&self, _key: &str, _delta: i64) -> CacheResult<i64> {
189            Ok(0)
190        }
191    }
192
193    /// Regression: on a store with a `default_ttl`, `remember_forever` must
194    /// produce an entry with NO expiry. It previously wrote via
195    /// `set(.., None)`, which the backend resolved to `default_ttl`, making a
196    /// non-expiring entry unobtainable.
197    #[tokio::test]
198    async fn test_remember_forever_bypasses_default_ttl() {
199        let store = DefaultTtlCache::new(Duration::from_secs(300));
200
201        let value: i64 = remember_forever(&store, "k", || async { Ok(7) })
202            .await
203            .unwrap();
204        assert_eq!(value, 7);
205
206        assert_eq!(
207            store.stored_ttl("k").await,
208            None,
209            "remember_forever must store without expiry, not with the default TTL"
210        );
211    }
212
213    /// The contrast case: an unspecified TTL still resolves to `default_ttl`,
214    /// so the existing `Option<Duration>` semantics are unchanged.
215    #[tokio::test]
216    async fn test_set_with_none_ttl_still_uses_default_ttl() {
217        let store = DefaultTtlCache::new(Duration::from_secs(300));
218
219        set(&store, "k", &7_i64, None).await.unwrap();
220        assert_eq!(
221            store.stored_ttl("k").await,
222            Some(Duration::from_secs(300)),
223            "an unspecified TTL must keep falling back to default_ttl"
224        );
225
226        set_forever(&store, "k2", &7_i64).await.unwrap();
227        assert_eq!(store.stored_ttl("k2").await, None);
228    }
229
230    /// A cache hit short-circuits before the factory runs, for both
231    /// `remember` and `remember_forever`.
232    #[tokio::test]
233    async fn test_remember_forever_returns_cached_value() {
234        let store = DefaultTtlCache::new(Duration::from_secs(300));
235        set_forever(&store, "k", &1_i64).await.unwrap();
236
237        let value: i64 = remember_forever(&store, "k", || async {
238            panic!("factory must not run on a cache hit")
239        })
240        .await
241        .unwrap();
242        assert_eq!(value, 1);
243    }
244}