Skip to main content

keepsake_sqlx/repository/
cache.rs

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