Skip to main content

pas_external/epoch/
shared_cache.rs

1//! [`SharedCacheCache`] — sv-axis cache adapter over `ppoppo_infra::Cache`.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use async_trait::async_trait;
7use ppoppo_infra::{Cache as InfraCache, CacheExt as _};
8
9use super::Cache;
10
11/// Adapter implementing the SDK's [`super::Cache`] (sv-specific shape,
12/// `Option<i64>`) over any substrate that implements
13/// [`ppoppo_infra::Cache`] (the workspace cache trait used by
14/// `ppoppo-kvrocks` in production and any in-memory impl in tests).
15///
16/// # Promoted from chat-api in 0.10.0
17///
18/// chat-api shipped this exact bridge as
19/// `chat_api::session_version::KvCache` since Phase 11.Z Slice 3
20/// (`71af9f45`). Promoting to the SDK in 0.10.0 closes the duplication:
21/// every consumer reading the canonical `STANDARDS_SHARED_CACHE.md §3.1`
22/// `sv:{sub}` namespace uses one shared adapter against any
23/// `ppoppo_infra::Cache` impl. Replaces, doesn't layer (see
24/// RFC_2026-05-08 §4.1 lock).
25///
26/// # Substrate-agnostic by construction
27///
28/// The SDK depends on the `ppoppo-infra` trait crate (small, Redis-free)
29/// and never on the substrate crate (`ppoppo-kvrocks` pulls Redis client
30/// deps). Consumers wire whatever cache they want — `Arc<KvCache>` cast
31/// to `Arc<dyn ppoppo_infra::Cache>` for production, in-memory mocks
32/// for tests. Hexagonal Category 3 (Remote-but-owned).
33///
34/// # Best-effort contract preserved
35///
36/// [`super::Cache::get`] returns `None` on miss OR transient cache
37/// error; [`super::Cache::set`] is fire-and-forget. Errors from the
38/// underlying `ppoppo_infra::Cache` are swallowed per the
39/// [`super::CompositeEpochRevocation`] documented fall-through-to-fetcher
40/// policy.
41///
42/// # TTL clamping
43///
44/// `ppoppo_infra::Cache::set` accepts `Option<i32>` seconds. Sub-second
45/// `Duration` values would round to 0 and trigger immediate-delete on
46/// KVRocks (`EXPIRE 0` semantics) — clamped to `1s` minimum so the
47/// entry is at least visible to the very next request. Upper-bound
48/// clamps to `i32::MAX` guard against a future `SV_CACHE_TTL > 68 years`
49/// silently truncating.
50pub struct SharedCacheCache {
51    inner: Arc<dyn InfraCache>,
52}
53
54impl SharedCacheCache {
55    /// Build from any `Arc<dyn ppoppo_infra::Cache>`. Production
56    /// consumers wire an `Arc<KvCache>` cast at the call site:
57    ///
58    /// ```ignore
59    /// use std::sync::Arc;
60    /// use pas_external::epoch::SharedCacheCache;
61    /// use ppoppo_infra::Cache as InfraCache;
62    /// use ppoppo_kvrocks::KvCache;
63    /// # async fn wire(client: ppoppo_kvrocks::KvClient) -> Arc<dyn pas_external::epoch::Cache> {
64    /// let kv: Arc<dyn InfraCache> = Arc::new(KvCache::new(client));
65    /// Arc::new(SharedCacheCache::new(kv))
66    /// # }
67    /// ```
68    #[must_use]
69    pub fn new(inner: Arc<dyn InfraCache>) -> Self {
70        Self { inner }
71    }
72}
73
74impl std::fmt::Debug for SharedCacheCache {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.debug_struct("SharedCacheCache").finish_non_exhaustive()
77    }
78}
79
80#[async_trait]
81impl Cache for SharedCacheCache {
82    async fn get(&self, key: &str) -> Option<i64> {
83        self.inner.get_typed::<i64>(key).await.ok().flatten()
84    }
85
86    async fn set(&self, key: &str, sv: i64, ttl: Duration) {
87        let ttl_secs = ttl.as_secs().max(1).min(i32::MAX as u64) as i32;
88        let _ = self.inner.set_typed(key, &sv, Some(ttl_secs)).await;
89    }
90}
91
92#[cfg(test)]
93#[allow(
94    clippy::unwrap_used,
95    clippy::expect_used,
96    clippy::panic,
97    clippy::unimplemented
98)]
99mod tests {
100    //! Pins the wire shape — `i64` round-trip via JSON, TTL clamp at the
101    //! sub-second floor and `i32::MAX` ceiling, error swallowing. Boundary
102    //! tests in `tests/epoch_shared_cache_boundary.rs` cover the composer
103    //! integration; this module pins just the adapter's local invariants.
104    use super::*;
105    use async_trait::async_trait;
106    use ppoppo_infra::Result as InfraResult;
107    use ppoppo_token::sv_cache_key;
108    use serde_json::Value as Json;
109    use std::collections::HashMap;
110    use std::sync::Mutex;
111
112    #[derive(Default)]
113    struct MemCache {
114        store: Mutex<HashMap<String, Json>>,
115        last_set_ttl: Mutex<Option<i32>>,
116        get_should_error: Mutex<bool>,
117    }
118
119    #[async_trait]
120    impl InfraCache for MemCache {
121        async fn get(&self, key: &str) -> InfraResult<Option<Json>> {
122            if *self.get_should_error.lock().unwrap() {
123                return Err(ppoppo_infra::Error::NotFound("simulated".into()));
124            }
125            Ok(self.store.lock().unwrap().get(key).cloned())
126        }
127
128        async fn set(
129            &self,
130            key: &str,
131            value: &Json,
132            ttl_seconds: Option<i32>,
133        ) -> InfraResult<()> {
134            *self.last_set_ttl.lock().unwrap() = ttl_seconds;
135            self.store
136                .lock()
137                .unwrap()
138                .insert(key.to_string(), value.clone());
139            Ok(())
140        }
141
142        async fn del(&self, _key: &str) -> InfraResult<bool> {
143            unimplemented!("not exercised by SharedCacheCache")
144        }
145        async fn exists(&self, _key: &str) -> InfraResult<bool> {
146            unimplemented!("not exercised by SharedCacheCache")
147        }
148        async fn ttl(&self, _key: &str) -> InfraResult<Option<i32>> {
149            unimplemented!("not exercised by SharedCacheCache")
150        }
151        async fn mset(
152            &self,
153            _entries: &[(&str, Json, Option<i32>)],
154        ) -> InfraResult<usize> {
155            unimplemented!("not exercised by SharedCacheCache")
156        }
157        async fn mget(&self, _keys: &[&str]) -> InfraResult<Vec<(String, Option<Json>)>> {
158            unimplemented!("not exercised by SharedCacheCache")
159        }
160        async fn mdel(&self, _keys: &[&str]) -> InfraResult<usize> {
161            unimplemented!("not exercised by SharedCacheCache")
162        }
163        async fn keys(&self, _pattern: &str, _limit: i32) -> InfraResult<Vec<String>> {
164            unimplemented!("not exercised by SharedCacheCache")
165        }
166    }
167
168    const SUB: &str = "01HSAB00000000000000000000";
169
170    #[tokio::test]
171    async fn set_writes_through_with_ttl_in_seconds() {
172        let mem = Arc::new(MemCache::default());
173        let cache = SharedCacheCache::new(mem.clone() as Arc<dyn InfraCache>);
174        let key = sv_cache_key(SUB);
175
176        cache.set(&key, 42, Duration::from_secs(60)).await;
177
178        let stored = mem.store.lock().unwrap().get(&key).cloned();
179        assert_eq!(stored, Some(serde_json::json!(42)));
180        assert_eq!(*mem.last_set_ttl.lock().unwrap(), Some(60));
181    }
182
183    #[tokio::test]
184    async fn set_clamps_subsecond_ttl_to_one() {
185        // Sub-second TTL would round to 0 and trigger immediate delete on
186        // KVRocks. The adapter clamps to 1s so the very next request can
187        // still see the entry.
188        let mem = Arc::new(MemCache::default());
189        let cache = SharedCacheCache::new(mem.clone() as Arc<dyn InfraCache>);
190
191        cache
192            .set(&sv_cache_key(SUB), 7, Duration::from_millis(500))
193            .await;
194
195        assert_eq!(*mem.last_set_ttl.lock().unwrap(), Some(1));
196    }
197
198    #[tokio::test]
199    async fn get_returns_stored_i64() {
200        let mem = Arc::new(MemCache::default());
201        let key = sv_cache_key(SUB);
202        mem.store
203            .lock()
204            .unwrap()
205            .insert(key.clone(), serde_json::json!(13));
206
207        let cache = SharedCacheCache::new(mem.clone() as Arc<dyn InfraCache>);
208        assert_eq!(cache.get(&key).await, Some(13));
209    }
210
211    #[tokio::test]
212    async fn get_returns_none_on_miss() {
213        let mem = Arc::new(MemCache::default());
214        let cache = SharedCacheCache::new(mem as Arc<dyn InfraCache>);
215        assert_eq!(cache.get("sv:nonexistent").await, None);
216    }
217
218    #[tokio::test]
219    async fn get_swallows_substrate_errors_as_none() {
220        // Cache::get contract: None on miss OR transient cache error.
221        // The composer falls through to the fetcher in either case, so
222        // surfacing a substrate error here would double-report.
223        let mem = Arc::new(MemCache::default());
224        *mem.get_should_error.lock().unwrap() = true;
225        let cache = SharedCacheCache::new(mem as Arc<dyn InfraCache>);
226        assert_eq!(cache.get("sv:abc").await, None);
227    }
228}