Skip to main content

pas_external/epoch/
composite.rs

1//! [`CompositeEpochRevocation`] — combines [`Cache`] + [`Fetcher`].
2
3use std::sync::Arc;
4
5use async_trait::async_trait;
6
7use ppoppo_token::access_token::{EpochRevocation, EpochRevocationError};
8use ppoppo_token::{SV_CACHE_TTL, sv_cache_key};
9
10use super::{Cache, Fetcher};
11
12/// Composes a best-effort [`Cache`] and an authoritative [`Fetcher`]
13/// into the engine's [`EpochRevocation`] port.
14///
15/// Promoted to the SDK in Phase 11.Z from chat-auth's
16/// `epoch_adapter::ChatAuthEpochRevocation` (§3 Row 6 "replace, don't
17/// layer"). All consumers wire this shape — only their `Cache` +
18/// `Fetcher` impl identities differ:
19///
20/// | Consumer  | Cache                                     | Fetcher                                    |
21/// |-----------|-------------------------------------------|--------------------------------------------|
22/// | chat-api  | [`super::SharedCacheCache`] (`sv:{sub}`)  | Cross-schema SQL (`scaccounts.ppnums`)     |
23/// | RCW       | [`super::SharedCacheCache`] (`sv:{sub}`)  | (Slice 3 deferred — RFC_2026-05-08 §4.4)   |
24/// | CTW       | [`super::SharedCacheCache`] (`sv:{sub}`)  | (Slice 4 deferred — RFC_2026-05-08 §4.4)   |
25///
26/// ## Algorithm — preserved from chat-auth's pre-promotion shape
27///
28/// 1. `cache.get(sv:{sub})` — hit short-circuits.
29/// 2. Miss → `fetcher.fetch(sub)` (authoritative read).
30/// 3. Fetcher Ok → `cache.set(sv:{sub}, value, SV_CACHE_TTL)` (best-effort
31///    write-back; failures swallowed per [`Cache::set`] contract).
32/// 4. Fetcher Err → [`EpochRevocationError::Transient`] (fail-closed).
33///
34/// The engine's `check_epoch::run` then maps:
35/// - `Ok(c)` and `token.sv >= c` → admit
36/// - `Ok(c)` and `token.sv < c` → `AuthError::SessionVersionStale`
37/// - `Err(Transient)` → `AuthError::SessionVersionLookupUnavailable`
38///
39/// The SDK then maps those to typed
40/// [`crate::TokenVerifyError::SessionVersionStale`] /
41/// [`crate::TokenVerifyError::SessionVersionLookupUnavailable`].
42pub struct CompositeEpochRevocation {
43    cache: Arc<dyn Cache>,
44    fetcher: Arc<dyn Fetcher>,
45}
46
47impl CompositeEpochRevocation {
48    /// Build from injected `Cache` + `Fetcher` impls. Both are
49    /// `Arc<dyn>` so the same composer can be wrapped in
50    /// `Arc<dyn EpochRevocation>` at the wiring site without further
51    /// indirection.
52    #[must_use]
53    pub fn new(cache: Arc<dyn Cache>, fetcher: Arc<dyn Fetcher>) -> Self {
54        Self { cache, fetcher }
55    }
56}
57
58impl std::fmt::Debug for CompositeEpochRevocation {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        f.debug_struct("CompositeEpochRevocation")
61            .finish_non_exhaustive()
62    }
63}
64
65#[async_trait]
66impl EpochRevocation for CompositeEpochRevocation {
67    async fn current(&self, sub: &str) -> Result<i64, EpochRevocationError> {
68        let key = sv_cache_key(sub);
69
70        if let Some(v) = self.cache.get(&key).await {
71            return Ok(v);
72        }
73
74        let fresh = self
75            .fetcher
76            .fetch(sub)
77            .await
78            .map_err(|e| EpochRevocationError::Transient(e.to_string()))?;
79
80        self.cache.set(&key, fresh, SV_CACHE_TTL).await;
81        Ok(fresh)
82    }
83}
84
85#[cfg(test)]
86#[allow(clippy::unwrap_used)]
87mod tests {
88    use std::collections::HashMap;
89    use std::sync::Mutex;
90    use std::time::Duration;
91
92    use super::super::FetchError;
93    use super::*;
94
95    #[derive(Default)]
96    struct TestCache {
97        store: Mutex<HashMap<String, i64>>,
98        set_count: Mutex<u32>,
99    }
100
101    #[async_trait]
102    impl Cache for TestCache {
103        async fn get(&self, key: &str) -> Option<i64> {
104            self.store.lock().unwrap().get(key).copied()
105        }
106        async fn set(&self, key: &str, sv: i64, _ttl: Duration) {
107            *self.set_count.lock().unwrap() += 1;
108            self.store.lock().unwrap().insert(key.to_string(), sv);
109        }
110    }
111
112    struct TestFetcher {
113        value: Result<i64, &'static str>,
114        fetch_count: Mutex<u32>,
115    }
116
117    impl TestFetcher {
118        fn ok(v: i64) -> Self {
119            Self {
120                value: Ok(v),
121                fetch_count: Mutex::new(0),
122            }
123        }
124        fn failing() -> Self {
125            Self {
126                value: Err("substrate down"),
127                fetch_count: Mutex::new(0),
128            }
129        }
130    }
131
132    #[async_trait]
133    impl Fetcher for TestFetcher {
134        async fn fetch(&self, _sub: &str) -> Result<i64, FetchError> {
135            *self.fetch_count.lock().unwrap() += 1;
136            self.value.map_err(|e| FetchError::Other(e.to_string()))
137        }
138    }
139
140    const SUB: &str = "01HSAB00000000000000000000";
141
142    #[tokio::test]
143    async fn cache_hit_short_circuits_fetcher() {
144        let cache = Arc::new(TestCache::default());
145        cache.store.lock().unwrap().insert(sv_cache_key(SUB), 7);
146        let fetcher = Arc::new(TestFetcher::ok(99));
147
148        let composer = CompositeEpochRevocation::new(cache, fetcher.clone());
149        assert_eq!(composer.current(SUB).await.unwrap(), 7);
150        assert_eq!(*fetcher.fetch_count.lock().unwrap(), 0);
151    }
152
153    #[tokio::test]
154    async fn cache_miss_fetches_then_writes_back() {
155        let cache = Arc::new(TestCache::default());
156        let fetcher = Arc::new(TestFetcher::ok(42));
157
158        let composer = CompositeEpochRevocation::new(cache.clone(), fetcher.clone());
159        assert_eq!(composer.current(SUB).await.unwrap(), 42);
160        assert_eq!(*fetcher.fetch_count.lock().unwrap(), 1);
161        assert_eq!(*cache.set_count.lock().unwrap(), 1);
162        assert_eq!(
163            cache.store.lock().unwrap().get(&sv_cache_key(SUB)).copied(),
164            Some(42),
165        );
166    }
167
168    #[tokio::test]
169    async fn second_call_after_miss_hits_cache() {
170        let cache = Arc::new(TestCache::default());
171        let fetcher = Arc::new(TestFetcher::ok(11));
172
173        let composer = CompositeEpochRevocation::new(cache, fetcher.clone());
174        let _ = composer.current(SUB).await.unwrap();
175        let _ = composer.current(SUB).await.unwrap();
176        assert_eq!(*fetcher.fetch_count.lock().unwrap(), 1);
177    }
178
179    #[tokio::test]
180    async fn fetcher_transient_maps_to_epoch_revocation_transient() {
181        let cache = Arc::new(TestCache::default());
182        let fetcher = Arc::new(TestFetcher::failing());
183
184        let composer = CompositeEpochRevocation::new(cache, fetcher);
185        let err = composer.current(SUB).await.unwrap_err();
186        match err {
187            EpochRevocationError::Transient(detail) => {
188                assert!(detail.contains("substrate down"), "{detail}");
189            }
190        }
191    }
192}