pas_external/epoch/
composite.rs1use 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
12pub struct CompositeEpochRevocation {
43 cache: Arc<dyn Cache>,
44 fetcher: Arc<dyn Fetcher>,
45}
46
47impl CompositeEpochRevocation {
48 #[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}