Skip to main content

origin_storage/
cache.rs

1use crate::{Record, Storage, StorageKey};
2use origin_domain::{AppError, Clock, Result};
3use serde::Serialize;
4use serde::de::DeserializeOwned;
5use std::sync::Arc;
6use time::Duration;
7
8/// Typed, TTL-aware access to [`Storage`].
9///
10/// Expiry is decided here rather than in the backend, so SQLite, memory and any future
11/// backend agree on what "stale" means. Time comes from a [`Clock`], which makes TTL
12/// behaviour testable without sleeping.
13#[derive(Debug, Clone)]
14pub struct Cache {
15    storage: Arc<dyn Storage>,
16    clock: Arc<dyn Clock>,
17}
18
19impl Cache {
20    pub fn new(storage: Arc<dyn Storage>, clock: Arc<dyn Clock>) -> Self {
21        Self { storage, clock }
22    }
23
24    /// Fresh value, or `None` when absent or expired.
25    pub async fn get<T: DeserializeOwned>(&self, key: &StorageKey) -> Result<Option<T>> {
26        let Some(record) = self.storage.get(key).await? else {
27            return Ok(None);
28        };
29
30        if record.is_expired_at(self.clock.now()) {
31            return Ok(None);
32        }
33
34        let value = serde_json::from_str(&record.value)
35            .map_err(|error| AppError::storage(format!("cannot decode {key:?}: {error}")))?;
36        Ok(Some(value))
37    }
38
39    /// Store a value. `ttl` of `None` means it stays until explicitly invalidated.
40    pub async fn put<T: Serialize>(
41        &self,
42        key: &StorageKey,
43        value: &T,
44        ttl: Option<Duration>,
45    ) -> Result<()> {
46        let encoded = serde_json::to_string(value)
47            .map_err(|error| AppError::storage(format!("cannot encode {key:?}: {error}")))?;
48
49        let now = self.clock.now();
50        let mut record = Record::new(encoded, now);
51        if let Some(ttl) = ttl {
52            record = record.expiring_at(now + ttl);
53        }
54
55        self.storage.put(key, record).await
56    }
57
58    pub async fn invalidate(&self, key: &StorageKey) -> Result<()> {
59        self.storage.delete(key).await
60    }
61
62    pub async fn invalidate_namespace(&self, namespace: &str) -> Result<()> {
63        self.storage.clear(namespace).await
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70    use crate::MemoryStorage;
71    use origin_domain::testing::FakeClock;
72    use time::macros::datetime;
73
74    fn cache() -> (Cache, Arc<FakeClock>) {
75        let clock = Arc::new(FakeClock::new(datetime!(2026-08-23 10:00 UTC)));
76        let cache = Cache::new(Arc::new(MemoryStorage::new()), clock.clone());
77        (cache, clock)
78    }
79
80    #[tokio::test]
81    async fn a_value_survives_until_its_ttl_expires() {
82        let (cache, clock) = cache();
83        let key = StorageKey::new("github", "notifications");
84
85        cache
86            .put(&key, &vec!["a", "b"], Some(Duration::minutes(5)))
87            .await
88            .unwrap();
89
90        clock.advance(Duration::minutes(4));
91        let fresh: Option<Vec<String>> = cache.get(&key).await.unwrap();
92        assert_eq!(
93            fresh.as_deref(),
94            Some(&["a".to_string(), "b".to_string()][..])
95        );
96
97        clock.advance(Duration::minutes(2));
98        let stale: Option<Vec<String>> = cache.get(&key).await.unwrap();
99        assert_eq!(
100            stale, None,
101            "the value must be treated as stale after its TTL"
102        );
103    }
104
105    #[tokio::test]
106    async fn a_value_without_ttl_never_expires() {
107        let (cache, clock) = cache();
108        let key = StorageKey::new("settings", "theme");
109
110        cache.put(&key, &"dark", None).await.unwrap();
111        clock.advance(Duration::days(365));
112
113        assert_eq!(
114            cache.get::<String>(&key).await.unwrap().as_deref(),
115            Some("dark")
116        );
117    }
118
119    #[tokio::test]
120    async fn decoding_a_value_as_the_wrong_type_is_a_storage_error() {
121        let (cache, _clock) = cache();
122        let key = StorageKey::new("github", "count");
123        cache.put(&key, &"not-a-number", None).await.unwrap();
124
125        let error = cache.get::<u32>(&key).await.unwrap_err();
126        assert_eq!(error.kind(), origin_domain::ErrorKind::Storage);
127    }
128}