Skip to main content

identity_kit/resolver/
cache.rs

1//! `DocumentCache`: resolution results kept for a while.
2
3use core::fmt;
4use std::collections::HashMap;
5use std::sync::{PoisonError, RwLock};
6use std::time::{Duration, Instant};
7
8use crate::did::{Did, ResolutionResult};
9
10/// A bounded cache of resolution results, keyed by DID, where every entry expires after the
11/// same time to live.
12///
13/// A [`Resolver`](super::Resolver) built with [`cache`](super::ResolverBuilder::cache) serves
14/// repeated resolutions of a DID from it, for resolutions without options only: options such
15/// as `versionId` or `accept` change what is returned, so those always reach the method.
16///
17/// The cache is `Sync`: readers share a lock, and an insert takes it exclusively. When it is
18/// full, expired entries go first and then the entry that expires soonest.
19///
20/// # Examples
21///
22/// ```
23/// use std::time::Duration;
24///
25/// use identity_kit::did::{Did, DidDocument, ResolutionResult};
26/// use identity_kit::resolver::DocumentCache;
27///
28/// let cache = DocumentCache::new(Duration::from_secs(300));
29/// let did = Did::parse("did:example:123")?;
30/// cache.insert(did.clone(), ResolutionResult::new(DidDocument::new(did.clone())));
31/// assert!(cache.get(&did).is_some());
32/// assert_eq!(cache.len(), 1);
33///
34/// cache.remove(&did);
35/// assert!(cache.is_empty());
36/// # Ok::<(), identity_kit::did::DidError>(())
37/// ```
38pub struct DocumentCache {
39    ttl: Duration,
40    capacity: usize,
41    entries: RwLock<HashMap<Did, Entry>>,
42}
43
44struct Entry {
45    result: ResolutionResult,
46    /// `None` when `now + ttl` overflows: the entry never expires.
47    expires_at: Option<Instant>,
48}
49
50impl Entry {
51    fn is_live(&self, now: Instant) -> bool {
52        self.expires_at.is_none_or(|expires_at| expires_at > now)
53    }
54}
55
56impl DocumentCache {
57    /// The number of entries [`new`](Self::new) allows.
58    pub const DEFAULT_CAPACITY: usize = 1024;
59
60    /// A cache whose entries live for `ttl`, holding up to
61    /// [`DEFAULT_CAPACITY`](Self::DEFAULT_CAPACITY) entries.
62    pub fn new(ttl: Duration) -> Self {
63        Self::with_capacity(ttl, Self::DEFAULT_CAPACITY)
64    }
65
66    /// A cache whose entries live for `ttl`, holding up to `capacity` entries (at least one).
67    pub fn with_capacity(ttl: Duration, capacity: usize) -> Self {
68        Self {
69            ttl,
70            capacity: capacity.max(1),
71            entries: RwLock::new(HashMap::new()),
72        }
73    }
74
75    /// How long an entry lives.
76    pub fn ttl(&self) -> Duration {
77        self.ttl
78    }
79
80    /// How many entries the cache holds at most.
81    pub fn capacity(&self) -> usize {
82        self.capacity
83    }
84
85    /// The cached result for `did`, if one is present and has not expired.
86    pub fn get(&self, did: &Did) -> Option<ResolutionResult> {
87        let now = Instant::now();
88        let entries = self.entries.read().unwrap_or_else(PoisonError::into_inner);
89        entries
90            .get(did)
91            .filter(|entry| entry.is_live(now))
92            .map(|entry| entry.result.clone())
93    }
94
95    /// Caches `result` for `did`, replacing any earlier entry.
96    pub fn insert(&self, did: Did, result: ResolutionResult) {
97        let now = Instant::now();
98        let mut entries = self.entries.write().unwrap_or_else(PoisonError::into_inner);
99        if !entries.contains_key(&did) && entries.len() >= self.capacity {
100            entries.retain(|_, entry| entry.is_live(now));
101            if entries.len() >= self.capacity {
102                // Every entry is live: evict the one that expires first.
103                let soonest = entries
104                    .iter()
105                    .min_by_key(|(_, entry)| entry.expires_at)
106                    .map(|(did, _)| did.clone());
107                if let Some(soonest) = soonest {
108                    entries.remove(&soonest);
109                }
110            }
111        }
112        entries.insert(
113            did,
114            Entry {
115                result,
116                expires_at: now.checked_add(self.ttl),
117            },
118        );
119    }
120
121    /// Removes the entry for `did`, returning it if it had not expired.
122    pub fn remove(&self, did: &Did) -> Option<ResolutionResult> {
123        let now = Instant::now();
124        let mut entries = self.entries.write().unwrap_or_else(PoisonError::into_inner);
125        entries
126            .remove(did)
127            .filter(|entry| entry.is_live(now))
128            .map(|entry| entry.result)
129    }
130
131    /// Removes every entry.
132    pub fn clear(&self) {
133        self.entries
134            .write()
135            .unwrap_or_else(PoisonError::into_inner)
136            .clear();
137    }
138
139    /// Removes the expired entries, returning how many there were.
140    pub fn purge_expired(&self) -> usize {
141        let now = Instant::now();
142        let mut entries = self.entries.write().unwrap_or_else(PoisonError::into_inner);
143        let before = entries.len();
144        entries.retain(|_, entry| entry.is_live(now));
145        before - entries.len()
146    }
147
148    /// The number of entries that have not expired.
149    pub fn len(&self) -> usize {
150        let now = Instant::now();
151        self.entries
152            .read()
153            .unwrap_or_else(PoisonError::into_inner)
154            .values()
155            .filter(|entry| entry.is_live(now))
156            .count()
157    }
158
159    /// Whether no entry is live.
160    pub fn is_empty(&self) -> bool {
161        self.len() == 0
162    }
163}
164
165impl fmt::Debug for DocumentCache {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        f.debug_struct("DocumentCache")
168            .field("ttl", &self.ttl)
169            .field("capacity", &self.capacity)
170            .field("len", &self.len())
171            .finish_non_exhaustive()
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::did::DidDocument;
179
180    #[test]
181    fn an_entry_expires_at_its_instant() {
182        let did: Did = "did:example:123".parse().unwrap();
183        let now = Instant::now();
184        let entry = Entry {
185            result: ResolutionResult::new(DidDocument::new(did)),
186            expires_at: Some(now),
187        };
188        assert!(entry.is_live(now.checked_sub(Duration::from_nanos(1)).unwrap()));
189        assert!(!entry.is_live(now));
190    }
191}