Skip to main content

pas_external/epoch/
ttl_cache.rs

1//! [`InProcessTtlCache`] — per-pod in-process [`super::Cache`] impl.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::time::Duration;
6
7use async_trait::async_trait;
8use ppoppo_clock::ArcClock;
9use ppoppo_clock::native::WallClock;
10use tokio::sync::RwLock;
11
12use super::Cache;
13
14/// In-process TTL cache backing the [`super::Cache`] port. Per-pod —
15/// cross-pod convergence happens at TTL expiry, NOT via shared
16/// substrate. Default for RCW/CTW Slice 4/5 wiring (RFC §3.5 Row 5*).
17///
18/// ## TTL semantics
19///
20/// Entries are evicted lazily: a `get` past `inserted_at + ttl` returns
21/// `None` and removes the row. There is no background reaper; memory
22/// reclamation happens at next access. For pre-launch workloads with
23/// bounded user counts this is appropriate; production hot-paths with
24/// millions of unique subs should swap in a bounded-capacity Cache impl
25/// (e.g. an LRU). The capacity question is intentionally NOT folded
26/// into this struct — `STANDARDS_SHARED_CACHE.md` §3.1 documents the
27/// canonical key shape, and bounded-capacity is a substrate concern
28/// not visible at the port surface.
29///
30/// ## Concurrency
31///
32/// `RwLock<HashMap>` — reads scale; writes serialize. Under sv-axis
33/// load profile (95%+ cache hits past warmup, writes only on miss),
34/// the lock split is well-suited.
35///
36/// ## Why not shared KVRocks
37///
38/// Shared KVRocks (`STANDARDS_SHARED_CACHE.md` §3.1) is the textbook
39/// chat-as-infrastructure substrate — sub-millisecond reads,
40/// real-time cross-pod convergence, single canonical write site (PAS
41/// on break-glass). 11.Z deliberately defers KVRocks ACL extension to
42/// RCW/CTW (per user direction 2026-05-09). Wiring is a one-line
43/// `Arc<dyn Cache>` swap when ACL extends in 11.AB+.
44pub struct InProcessTtlCache {
45    inner: RwLock<HashMap<String, (i64, i64)>>,
46    ttl: Duration,
47    clock: ArcClock,
48}
49
50impl InProcessTtlCache {
51    /// Build with the specified TTL. Use [`super::SV_CACHE_TTL`]
52    /// (re-exported from `ppoppo-token`, currently 60 seconds) to match
53    /// the canonical `sv:{sub}` cache contract.
54    #[must_use]
55    pub fn new(ttl: Duration) -> Self {
56        Self {
57            inner: RwLock::new(HashMap::new()),
58            ttl,
59            clock: Arc::new(WallClock),
60        }
61    }
62
63    #[must_use]
64    pub fn with_clock(mut self, clock: ArcClock) -> Self {
65        self.clock = clock;
66        self
67    }
68}
69
70impl std::fmt::Debug for InProcessTtlCache {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        f.debug_struct("InProcessTtlCache")
73            .field("ttl", &self.ttl)
74            .finish_non_exhaustive()
75    }
76}
77
78#[async_trait]
79impl Cache for InProcessTtlCache {
80    async fn get(&self, key: &str) -> Option<i64> {
81        let now_ms = self.clock.now_unix_millis();
82        let ttl_ms = self.ttl.as_millis() as i64;
83        // Read-fast path: hit + not-expired returns immediately under
84        // the read guard. Expired-or-miss falls through to the write
85        // path (eviction if expired, return None either way).
86        {
87            let read = self.inner.read().await;
88            if let Some((sv, inserted_at_ms)) = read.get(key) {
89                if now_ms - inserted_at_ms < ttl_ms {
90                    return Some(*sv);
91                }
92            } else {
93                return None;
94            }
95        }
96
97        // Expired entry — acquire writer to evict so memory reclaims.
98        let mut write = self.inner.write().await;
99        if let Some((_, inserted_at_ms)) = write.get(key)
100            && now_ms - inserted_at_ms >= ttl_ms {
101                write.remove(key);
102            }
103        None
104    }
105
106    async fn set(&self, key: &str, sv: i64, _ttl: Duration) {
107        // We use this cache's own configured TTL, not the per-call
108        // value — the call-site `ttl` is the contract surface for
109        // backends with native expiry (Redis `SETEX`, KVRocks). The
110        // in-process impl's TTL is fixed at construction.
111        let mut write = self.inner.write().await;
112        write.insert(key.to_string(), (sv, self.clock.now_unix_millis()));
113    }
114}
115
116#[cfg(test)]
117#[allow(clippy::unwrap_used)]
118mod tests {
119    use super::*;
120
121    #[tokio::test]
122    async fn miss_returns_none() {
123        let cache = InProcessTtlCache::new(Duration::from_secs(60));
124        assert_eq!(cache.get("sv:nonexistent").await, None);
125    }
126
127    #[tokio::test]
128    async fn hit_after_set() {
129        let cache = InProcessTtlCache::new(Duration::from_secs(60));
130        cache.set("sv:abc", 7, Duration::from_secs(60)).await;
131        assert_eq!(cache.get("sv:abc").await, Some(7));
132    }
133
134    #[tokio::test]
135    async fn expired_entry_evicts_on_access() {
136        let cache = InProcessTtlCache::new(Duration::from_millis(10));
137        cache.set("sv:abc", 5, Duration::from_millis(10)).await;
138        assert_eq!(cache.get("sv:abc").await, Some(5));
139        tokio::time::sleep(Duration::from_millis(25)).await;
140        assert_eq!(cache.get("sv:abc").await, None);
141
142        // Eviction occurred — the next set succeeds and returns the
143        // new value, not a stale ghost.
144        cache.set("sv:abc", 11, Duration::from_secs(60)).await;
145        assert_eq!(cache.get("sv:abc").await, Some(11));
146    }
147
148    #[tokio::test]
149    async fn set_overwrites_existing_entry() {
150        let cache = InProcessTtlCache::new(Duration::from_secs(60));
151        cache.set("sv:abc", 7, Duration::from_secs(60)).await;
152        cache.set("sv:abc", 8, Duration::from_secs(60)).await;
153        assert_eq!(cache.get("sv:abc").await, Some(8));
154    }
155
156    #[tokio::test]
157    async fn distinct_keys_dont_collide() {
158        let cache = InProcessTtlCache::new(Duration::from_secs(60));
159        cache.set("sv:alpha", 1, Duration::from_secs(60)).await;
160        cache.set("sv:beta", 2, Duration::from_secs(60)).await;
161        assert_eq!(cache.get("sv:alpha").await, Some(1));
162        assert_eq!(cache.get("sv:beta").await, Some(2));
163    }
164}