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// Why: a per-decision rebuild is the cross-region cost the module head
37// describes; the fingerprint and TTL are the two staleness bounds.
38#[derive(Debug)]
39pub struct ChainIndexCache {
40    slot: RwLock<Option<CachedIndex>>,
41    ttl: Duration,
42    recheck: Duration,
43}
44
45impl Default for ChainIndexCache {
46    fn default() -> Self {
47        Self::new(DEFAULT_TTL, DEFAULT_RECHECK)
48    }
49}
50
51impl ChainIndexCache {
52    #[must_use]
53    pub fn new(ttl: Duration, recheck: Duration) -> Self {
54        Self {
55            slot: RwLock::new(None),
56            ttl,
57            recheck,
58        }
59    }
60
61    pub async fn get(
62        &self,
63        repo: &AccessControlRepository,
64        sources: Arc<ChainSources>,
65    ) -> AuthzResult<Arc<ParentChainIndex>> {
66        let now = Instant::now();
67        let fresh = {
68            let slot = self.slot.read().await;
69            slot.as_ref()
70                .filter(|cached| now.duration_since(cached.checked_at) < self.recheck)
71                .map(|cached| Arc::clone(&cached.index))
72        };
73        if let Some(index) = fresh {
74            return Ok(index);
75        }
76
77        {
78            let mut slot = self.slot.write().await;
79            if let Some(cached) = slot.as_mut() {
80                if now.duration_since(cached.checked_at) < self.recheck {
81                    return Ok(Arc::clone(&cached.index));
82                }
83                // Why: a fingerprint fault falls through to a full reload rather
84                // than serving the cached index, so a database fault never keeps
85                // a stale index alive silently.
86                if let Ok(fingerprint) = repo.chain_fingerprint().await
87                    && fingerprint == cached.fingerprint
88                    && now.duration_since(cached.loaded_at) < self.ttl
89                {
90                    cached.checked_at = now;
91                    return Ok(Arc::clone(&cached.index));
92                }
93            }
94        }
95
96        let fingerprint = repo.chain_fingerprint().await?;
97        let index = Arc::new(ParentChainIndex::load(repo, sources).await?);
98        *self.slot.write().await = Some(CachedIndex {
99            index: Arc::clone(&index),
100            fingerprint,
101            loaded_at: now,
102            checked_at: now,
103        });
104        Ok(index)
105    }
106}