Skip to main content

exo_authority/
cache.rs

1// Copyright 2026 Exochain Foundation
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at:
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! Authority chain caching — LRU-like cache for resolved chains.
18
19use std::collections::BTreeMap;
20
21use exo_core::{Did, Timestamp};
22
23use crate::chain::AuthorityChain;
24
25/// Maximum number of cached entries.
26const DEFAULT_MAX_ENTRIES: usize = 10000;
27
28/// Key for the chain cache.
29#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
30struct CacheKey {
31    from: String,
32    to: String,
33}
34
35/// A cached chain entry with an access timestamp.
36#[derive(Debug, Clone)]
37struct CacheEntry {
38    chain: AuthorityChain,
39    last_accessed: Timestamp,
40}
41
42/// LRU-like cache for resolved authority chains.
43#[derive(Debug)]
44pub struct ChainCache {
45    entries: BTreeMap<CacheKey, CacheEntry>,
46    max_entries: usize,
47}
48
49impl ChainCache {
50    /// Create a new cache with the default max entries.
51    #[must_use]
52    pub fn new() -> Self {
53        Self {
54            entries: BTreeMap::new(),
55            max_entries: DEFAULT_MAX_ENTRIES,
56        }
57    }
58
59    /// Create a cache with a custom capacity.
60    #[must_use]
61    pub fn with_capacity(max_entries: usize) -> Self {
62        Self {
63            entries: BTreeMap::new(),
64            max_entries,
65        }
66    }
67
68    /// Get a cached chain, updating access time.
69    pub fn get(&mut self, from: &Did, to: &Did, now: &Timestamp) -> Option<&AuthorityChain> {
70        let key = CacheKey {
71            from: from.as_str().to_owned(),
72            to: to.as_str().to_owned(),
73        };
74        if let Some(entry) = self.entries.get_mut(&key) {
75            entry.last_accessed = *now;
76            Some(&entry.chain)
77        } else {
78            None
79        }
80    }
81
82    /// Insert a chain into the cache.
83    pub fn insert(&mut self, from: &Did, to: &Did, chain: AuthorityChain, now: &Timestamp) {
84        if self.max_entries == 0 {
85            self.entries.clear();
86            return;
87        }
88
89        // Evict if at capacity
90        while self.entries.len() >= self.max_entries {
91            self.evict_oldest();
92        }
93
94        let key = CacheKey {
95            from: from.as_str().to_owned(),
96            to: to.as_str().to_owned(),
97        };
98        self.entries.insert(
99            key,
100            CacheEntry {
101                chain,
102                last_accessed: *now,
103            },
104        );
105    }
106
107    /// Invalidate all chains involving a specific DID.
108    pub fn invalidate(&mut self, did: &Did) {
109        let did_str = did.as_str();
110        self.entries
111            .retain(|key, _| key.from != did_str && key.to != did_str);
112    }
113
114    /// Number of cached entries.
115    #[must_use]
116    pub fn len(&self) -> usize {
117        self.entries.len()
118    }
119
120    /// Is the cache empty?
121    #[must_use]
122    pub fn is_empty(&self) -> bool {
123        self.entries.is_empty()
124    }
125
126    /// Clear the entire cache.
127    pub fn clear(&mut self) {
128        self.entries.clear();
129    }
130
131    /// Evict the oldest entry (lowest last_accessed timestamp).
132    fn evict_oldest(&mut self) {
133        if let Some(oldest_key) = self
134            .entries
135            .iter()
136            .min_by_key(|(_, v)| v.last_accessed)
137            .map(|(k, _)| k.clone())
138        {
139            self.entries.remove(&oldest_key);
140        }
141    }
142}
143
144impl Default for ChainCache {
145    fn default() -> Self {
146        Self::new()
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use exo_core::Signature;
153
154    use super::*;
155    use crate::{
156        chain::{AuthorityChain, AuthorityLink},
157        permission::Permission,
158    };
159
160    fn did(name: &str) -> Did {
161        Did::new(&format!("did:exo:{name}")).unwrap()
162    }
163    fn ts(ms: u64) -> Timestamp {
164        Timestamp::new(ms, 0)
165    }
166
167    fn make_chain(from: &str, to: &str) -> AuthorityChain {
168        AuthorityChain {
169            links: vec![AuthorityLink {
170                delegator_did: did(from),
171                delegate_did: did(to),
172                scope: vec![Permission::Read],
173                created: ts(1000),
174                expires: None,
175                signature: Signature::from_bytes([0xA5u8; 64]),
176                depth: 0,
177                delegatee_kind: crate::chain::DelegateeKind::Human,
178            }],
179            max_depth: 5,
180        }
181    }
182
183    #[test]
184    fn insert_and_get() {
185        let mut cache = ChainCache::new();
186        let chain = make_chain("alice", "bob");
187        cache.insert(&did("alice"), &did("bob"), chain, &ts(1000));
188        let got = cache.get(&did("alice"), &did("bob"), &ts(2000));
189        assert!(got.is_some());
190        assert_eq!(got.unwrap().depth(), 1);
191    }
192
193    #[test]
194    fn get_updates_access_time() {
195        let mut cache = ChainCache::new();
196        cache.insert(
197            &did("alice"),
198            &did("bob"),
199            make_chain("alice", "bob"),
200            &ts(1000),
201        );
202        cache.get(&did("alice"), &did("bob"), &ts(5000));
203        // Access updated — won't be evicted first
204        cache.insert(&did("x"), &did("y"), make_chain("x", "y"), &ts(2000));
205        // Both should still be there
206        assert_eq!(cache.len(), 2);
207    }
208
209    #[test]
210    fn get_miss() {
211        let mut cache = ChainCache::new();
212        assert!(cache.get(&did("alice"), &did("bob"), &ts(1000)).is_none());
213    }
214
215    #[test]
216    fn invalidate_removes_related() {
217        let mut cache = ChainCache::new();
218        cache.insert(
219            &did("alice"),
220            &did("bob"),
221            make_chain("alice", "bob"),
222            &ts(1000),
223        );
224        cache.insert(
225            &did("alice"),
226            &did("charlie"),
227            make_chain("alice", "charlie"),
228            &ts(1000),
229        );
230        cache.insert(
231            &did("dave"),
232            &did("eve"),
233            make_chain("dave", "eve"),
234            &ts(1000),
235        );
236        assert_eq!(cache.len(), 3);
237
238        cache.invalidate(&did("alice"));
239        assert_eq!(cache.len(), 1);
240        assert!(cache.get(&did("dave"), &did("eve"), &ts(2000)).is_some());
241    }
242
243    #[test]
244    fn invalidate_as_target() {
245        let mut cache = ChainCache::new();
246        cache.insert(
247            &did("alice"),
248            &did("bob"),
249            make_chain("alice", "bob"),
250            &ts(1000),
251        );
252        cache.invalidate(&did("bob"));
253        assert!(cache.is_empty());
254    }
255
256    #[test]
257    fn eviction_on_capacity() {
258        let mut cache = ChainCache::with_capacity(2);
259        cache.insert(&did("a"), &did("b"), make_chain("a", "b"), &ts(1000));
260        cache.insert(&did("c"), &did("d"), make_chain("c", "d"), &ts(2000));
261        assert_eq!(cache.len(), 2);
262
263        // This should evict the oldest (a->b at ts 1000)
264        cache.insert(&did("e"), &did("f"), make_chain("e", "f"), &ts(3000));
265        assert_eq!(cache.len(), 2);
266        assert!(cache.get(&did("a"), &did("b"), &ts(4000)).is_none());
267        assert!(cache.get(&did("c"), &did("d"), &ts(4000)).is_some());
268    }
269
270    #[test]
271    fn clear() {
272        let mut cache = ChainCache::new();
273        cache.insert(&did("a"), &did("b"), make_chain("a", "b"), &ts(1000));
274        cache.insert(&did("c"), &did("d"), make_chain("c", "d"), &ts(1000));
275        cache.clear();
276        assert!(cache.is_empty());
277    }
278
279    #[test]
280    fn default_is_empty() {
281        let cache = ChainCache::default();
282        assert!(cache.is_empty());
283        assert_eq!(cache.len(), 0);
284    }
285
286    #[test]
287    fn invalidate_unknown_did_is_noop() {
288        let mut cache = ChainCache::new();
289        cache.insert(&did("a"), &did("b"), make_chain("a", "b"), &ts(1000));
290        cache.invalidate(&did("unknown"));
291        assert_eq!(cache.len(), 1);
292    }
293
294    #[test]
295    fn eviction_respects_access_time_update() {
296        let mut cache = ChainCache::with_capacity(2);
297        cache.insert(&did("a"), &did("b"), make_chain("a", "b"), &ts(1000));
298        cache.insert(&did("c"), &did("d"), make_chain("c", "d"), &ts(2000));
299
300        // Access a->b, making it newer than c->d
301        cache.get(&did("a"), &did("b"), &ts(3000));
302
303        // Insert new entry — should evict c->d (oldest access = ts 2000)
304        cache.insert(&did("e"), &did("f"), make_chain("e", "f"), &ts(4000));
305        assert!(cache.get(&did("a"), &did("b"), &ts(5000)).is_some());
306        assert!(cache.get(&did("c"), &did("d"), &ts(5000)).is_none());
307    }
308
309    #[test]
310    fn zero_capacity_insert_returns_without_caching() {
311        let (tx, rx) = std::sync::mpsc::channel();
312        std::thread::spawn(move || {
313            let mut cache = ChainCache::with_capacity(0);
314            cache.insert(&did("a"), &did("b"), make_chain("a", "b"), &ts(1000));
315            tx.send(cache.len()).unwrap();
316        });
317
318        let len = rx
319            .recv_timeout(std::time::Duration::from_millis(100))
320            .expect("zero-capacity insert must return instead of looping forever");
321        assert_eq!(len, 0);
322    }
323}