Skip to main content

systemprompt_security/authz/parent_chain/
cache.rs

1//! Process-wide cache for the [`ParentChainIndex`], revalidated against a
2//! table fingerprint instead of rebuilt per decision.
3//!
4//! Loading the index costs three sequential round trips, which against a
5//! cross-region database is 0.5–1 s on every authz decision. The cache
6//! bounds staleness two ways. Within `recheck` of the last check it answers
7//! from memory. Past that it spends one round trip on the fingerprint (row
8//! counts plus `MAX(updated_at)` of both tables) and reloads only when it
9//! moved, so a rule change is visible within `recheck` of its `updated_at`
10//! bump and a delete moves the count. The `ttl` forces a reload regardless,
11//! bounding any change the fingerprint cannot see.
12//!
13//! Copyright (c) systemprompt.io — Business Source License 1.1.
14//! See <https://systemprompt.io> for licensing details.
15
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18
19use tokio::sync::RwLock;
20
21use super::{ChainSources, ParentChainIndex};
22use crate::authz::error::AuthzResult;
23use crate::authz::repository::{AccessControlRepository, ChainFingerprint};
24
25const DEFAULT_TTL: Duration = Duration::from_secs(60);
26const DEFAULT_RECHECK: Duration = Duration::from_secs(5);
27
28#[derive(Debug)]
29struct CachedIndex {
30    index: Arc<ParentChainIndex>,
31    fingerprint: ChainFingerprint,
32    loaded_at: Instant,
33    checked_at: Instant,
34}
35
36#[derive(Debug)]
37pub struct ChainIndexCache {
38    slot: RwLock<Option<CachedIndex>>,
39    ttl: Duration,
40    recheck: Duration,
41}
42
43impl Default for ChainIndexCache {
44    fn default() -> Self {
45        Self::new(DEFAULT_TTL, DEFAULT_RECHECK)
46    }
47}
48
49impl ChainIndexCache {
50    #[must_use]
51    pub fn new(ttl: Duration, recheck: Duration) -> Self {
52        Self {
53            slot: RwLock::new(None),
54            ttl,
55            recheck,
56        }
57    }
58
59    pub async fn get(
60        &self,
61        repo: &AccessControlRepository,
62        sources: Arc<ChainSources>,
63    ) -> AuthzResult<Arc<ParentChainIndex>> {
64        let now = Instant::now();
65        let fresh = {
66            let slot = self.slot.read().await;
67            slot.as_ref()
68                .filter(|cached| now.duration_since(cached.checked_at) < self.recheck)
69                .map(|cached| Arc::clone(&cached.index))
70        };
71        if let Some(index) = fresh {
72            return Ok(index);
73        }
74
75        {
76            let mut slot = self.slot.write().await;
77            if let Some(cached) = slot.as_mut() {
78                if now.duration_since(cached.checked_at) < self.recheck {
79                    return Ok(Arc::clone(&cached.index));
80                }
81                if let Ok(fingerprint) = repo.chain_fingerprint().await
82                    && fingerprint == cached.fingerprint
83                    && now.duration_since(cached.loaded_at) < self.ttl
84                {
85                    cached.checked_at = now;
86                    return Ok(Arc::clone(&cached.index));
87                }
88            }
89        }
90
91        let fingerprint = repo.chain_fingerprint().await?;
92        let index = Arc::new(ParentChainIndex::load(repo, sources).await?);
93        *self.slot.write().await = Some(CachedIndex {
94            index: Arc::clone(&index),
95            fingerprint,
96            loaded_at: now,
97            checked_at: now,
98        });
99        Ok(index)
100    }
101}