Skip to main content

keepsake_sqlx/repository/
cache.rs

1use keepsake::{RelationDefinition, RelationId, RelationKey};
2
3#[cfg(feature = "cache")]
4use std::collections::BTreeMap;
5use std::fmt::Debug;
6#[cfg(feature = "cache")]
7use std::sync::{Arc, RwLock};
8#[cfg(feature = "cache")]
9use std::time::{Duration, Instant};
10
11/// Adapter for relation definition caching.
12#[async_trait::async_trait]
13pub trait RelationCache: Send + Sync + Debug {
14    /// Gets a cached relation by stable id.
15    async fn get_by_id(&self, relation_id: RelationId) -> Option<RelationDefinition>;
16
17    /// Gets a cached relation by natural relation key.
18    async fn get_by_key(&self, key: &RelationKey) -> Option<RelationDefinition>;
19
20    /// Stores or refreshes a relation definition.
21    async fn store(&self, relation: &RelationDefinition);
22
23    /// Removes cached entries for a relation id.
24    async fn remove_by_id(&self, relation_id: RelationId);
25}
26
27/// Relation cache implementation that never stores entries.
28#[derive(Debug, Clone, Copy, Default)]
29pub struct NoopRelationCache;
30
31#[async_trait::async_trait]
32impl RelationCache for NoopRelationCache {
33    async fn get_by_id(&self, _relation_id: RelationId) -> Option<RelationDefinition> {
34        None
35    }
36
37    async fn get_by_key(&self, _key: &RelationKey) -> Option<RelationDefinition> {
38        None
39    }
40
41    async fn store(&self, _relation: &RelationDefinition) {}
42
43    async fn remove_by_id(&self, _relation_id: RelationId) {}
44}
45
46/// Configuration for local in-process relation definition caching.
47#[cfg(feature = "cache")]
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct LocalRelationCacheConfig {
50    /// Time before a cached relation definition must be refreshed from Postgres.
51    pub ttl: Duration,
52}
53
54#[cfg(feature = "cache")]
55impl LocalRelationCacheConfig {
56    /// Creates a local relation cache configuration.
57    #[must_use]
58    pub const fn new(ttl: Duration) -> Self {
59        Self { ttl }
60    }
61}
62
63/// Local in-process relation definition cache.
64#[cfg(feature = "cache")]
65#[derive(Debug, Clone)]
66pub struct LocalRelationCache {
67    config: LocalRelationCacheConfig,
68    // Local cache handles may be cloned or shared across repository clones.
69    // Locks protect a small in-process map and are never held across `.await`.
70    // Cross-pod invalidation belongs in another `RelationCache` adapter.
71    state: Arc<RwLock<LocalRelationCacheState>>,
72}
73
74#[cfg(feature = "cache")]
75impl LocalRelationCache {
76    /// Creates a local in-process relation definition cache.
77    #[must_use]
78    pub fn new(config: LocalRelationCacheConfig) -> Self {
79        Self {
80            config,
81            state: Arc::new(RwLock::new(LocalRelationCacheState::default())),
82        }
83    }
84}
85
86#[cfg(feature = "cache")]
87#[async_trait::async_trait]
88impl RelationCache for LocalRelationCache {
89    async fn get_by_id(&self, relation_id: RelationId) -> Option<RelationDefinition> {
90        self.state
91            .read()
92            .ok()
93            .and_then(|state| state.by_id.get(&relation_id).cloned())
94            .and_then(CacheEntry::fresh_relation)
95    }
96
97    async fn get_by_key(&self, key: &RelationKey) -> Option<RelationDefinition> {
98        self.state
99            .read()
100            .ok()
101            .and_then(|state| state.by_key.get(key).cloned())
102            .and_then(CacheEntry::fresh_relation)
103    }
104
105    async fn store(&self, relation: &RelationDefinition) {
106        let entry = CacheEntry {
107            relation: relation.clone(),
108            expires_at: Instant::now() + self.config.ttl,
109        };
110        if let Ok(mut state) = self.state.write() {
111            state.by_id.insert(relation.id, entry.clone());
112            state.by_key.insert(relation.key.clone(), entry);
113        }
114    }
115
116    async fn remove_by_id(&self, relation_id: RelationId) {
117        if let Ok(mut state) = self.state.write()
118            && let Some(entry) = state.by_id.remove(&relation_id)
119        {
120            state.by_key.remove(&entry.relation.key);
121        }
122    }
123}
124
125#[cfg(feature = "cache")]
126#[derive(Debug, Default)]
127struct LocalRelationCacheState {
128    by_id: BTreeMap<RelationId, CacheEntry>,
129    by_key: BTreeMap<RelationKey, CacheEntry>,
130}
131
132#[cfg(feature = "cache")]
133#[derive(Debug, Clone)]
134struct CacheEntry {
135    relation: RelationDefinition,
136    expires_at: Instant,
137}
138
139#[cfg(feature = "cache")]
140impl CacheEntry {
141    fn fresh_relation(self) -> Option<RelationDefinition> {
142        (Instant::now() <= self.expires_at).then_some(self.relation)
143    }
144}
145
146#[cfg(all(test, feature = "cache"))]
147mod tests {
148    use super::*;
149    use keepsake::ExpiryPolicy;
150    use std::thread;
151    use uuid::Uuid;
152
153    #[test]
154    fn cache_entry_expires_after_ttl() -> keepsake::Result<()> {
155        let relation = RelationDefinition::enabled(
156            Uuid::nil(),
157            RelationKey::new("tag", "trusted")?,
158            ExpiryPolicy::ManualOnly,
159        )?;
160        let entry = CacheEntry {
161            relation,
162            expires_at: Instant::now() + Duration::from_millis(1),
163        };
164
165        thread::sleep(Duration::from_millis(5));
166
167        assert_eq!(entry.fresh_relation(), None);
168        Ok(())
169    }
170}