cnidarium/
snapshot_cache.rs

1use crate::Snapshot;
2use std::{cmp, collections::VecDeque};
3
4/// A circular cache for storing [`Snapshot`]s.
5///
6/// # Usage
7///
8/// [`Snapshot`]s are inserted in the cache using the [`push`] or [`try_push`]
9/// methods. If the cache is full, the oldest entry will be evicted to make space
10/// for the newer entry.
11///
12/// # Constraints
13///
14/// [`Snapshot`]s must be inserted sequentially relative to their [`jmt::Version`]
15/// numbers, and have consecutive version numbers.
16pub struct SnapshotCache {
17    /// A sequence of increasingly recent [`Snapshot`]s.
18    cache: VecDeque<Snapshot>,
19    /// The max length and capacity of the cache.
20    max_size: usize,
21}
22
23impl SnapshotCache {
24    /// Creates a [`SnapshotCache`] with `max_size` capacity, and inserts an initial `Snapshot` in
25    /// it. If the specified capacity is zero, the cache will default to having size 1.
26    pub fn new(initial: Snapshot, max_size: usize) -> Self {
27        let max_size = cmp::max(max_size, 1);
28        let mut cache = VecDeque::with_capacity(max_size);
29        cache.push_front(initial);
30
31        Self { cache, max_size }
32    }
33
34    /// Attempts to insert a [`Snapshot`] entry into the cache. If the cache is full, the oldest
35    /// entry will be evicted to make space.
36    ///
37    /// [`Snapshot`]s must be inserted sequentially relative to their `jmt::Version`s and have
38    /// consecutive version numbers.
39    ///
40    /// ## Errors
41    ///
42    /// The method will return an error if the supplied `snapshot` has a version number that is:
43    ///
44    /// - stale i.e. older than the latest snapshot
45    ///
46    /// - skipping a version i.e. the difference between their version numbers is greater than 1
47    pub fn try_push(&mut self, snapshot: Snapshot) -> anyhow::Result<()> {
48        let latest_version = self.latest().version();
49        if latest_version.wrapping_add(1) != snapshot.version() {
50            anyhow::bail!("snapshot_cache: trying to insert stale snapshots.");
51        }
52
53        if self.cache.len() >= self.max_size {
54            self.cache.pop_back();
55        }
56
57        self.cache.push_front(snapshot);
58        Ok(())
59    }
60
61    /// Returns the latest inserted `Snapshot`.
62    pub fn latest(&self) -> Snapshot {
63        self.cache
64            .front()
65            .map(Clone::clone)
66            .expect("snapshot_cache cannot be empty")
67    }
68
69    /// Attempts to fetch a [`Snapshot`] with a matching `jmt::Version`, and returns `None` if none
70    /// was found.
71    pub fn get(&self, version: jmt::Version) -> Option<Snapshot> {
72        let latest_version = self.latest().version();
73        // We compute the offset assuming that snapshot entries are cached
74        // such that the delta between entries is always 1.
75        let offset = latest_version.wrapping_sub(version) as usize;
76        self.cache
77            .get(offset)
78            .map(Clone::clone)
79            .filter(|s| s.version() == version)
80    }
81
82    /// Empties the cache.
83    pub fn clear(&mut self) {
84        self.cache.clear();
85    }
86}
87
88#[cfg(test)]
89mod test {
90
91    use crate::snapshot::Snapshot;
92    use crate::snapshot_cache::SnapshotCache;
93    use crate::storage::Storage;
94    use crate::store::multistore::MultistoreCache;
95
96    async fn create_storage_instance() -> Storage {
97        use tempfile::tempdir;
98        // create a storage backend for testing
99        let dir = tempdir().expect("unable to create tempdir");
100        let file_path = dir.path().join("snapshot-cache-testing.db");
101
102        Storage::load(file_path, vec![])
103            .await
104            .expect("unable to load storage")
105    }
106
107    #[tokio::test]
108    /// `SnapshotCache` constructed with zero capacity instead defaults to one.
109    async fn fail_zero_capacity() {
110        let storage = create_storage_instance().await;
111        let db = storage.db();
112        let snapshot = storage.latest_snapshot();
113        let mut cache = SnapshotCache::new(snapshot, 0);
114
115        // Check that the cache has a capacity at least 1
116        assert!(cache.get(u64::MAX).is_some());
117        let new_snapshot = Snapshot::new(db, 0, MultistoreCache::default());
118        cache
119            .try_push(new_snapshot)
120            .expect("should not fail to insert a new entry");
121
122        // Check that the cache has a capacity of exactly 1
123        assert!(cache.get(u64::MAX).is_none());
124        assert!(cache.get(0).is_some());
125    }
126
127    #[tokio::test]
128    /// Fails to insert snapshot entries that are older than the latest'
129    async fn fail_insert_stale_snapshot() {
130        let storage = create_storage_instance().await;
131        let db_handle = storage.db();
132        let snapshot = storage.latest_snapshot();
133        let mut cache = SnapshotCache::new(snapshot, 1);
134        let stale_snapshot = Snapshot::new(db_handle, 1, MultistoreCache::default());
135        cache
136            .try_push(stale_snapshot)
137            .expect_err("should fail to insert a stale entry in the snapshot cache");
138    }
139
140    #[tokio::test]
141    /// Fails to insert snapshot entries that have a version gap.
142    async fn fail_insert_gapped_snapshot() {
143        let storage = create_storage_instance().await;
144        let db_handle = storage.db();
145        let snapshot = Snapshot::new(db_handle.clone(), 0, MultistoreCache::default());
146        let mut cache = SnapshotCache::new(snapshot, 2);
147        let snapshot = Snapshot::new(db_handle, 2, MultistoreCache::default());
148        cache
149            .try_push(snapshot)
150            .expect_err("should fail to insert snapshot with skipped version number");
151    }
152
153    #[tokio::test]
154    /// Checks that we handle pre-genesis `jmt::Version` correctly.
155    async fn cache_manage_pre_genesis() {
156        let storage = create_storage_instance().await;
157        let db_handle = storage.db();
158        let snapshot = storage.latest_snapshot();
159
160        // Create a cache of size 10, populated with one entry with version: u64::MAX
161        let mut cache = SnapshotCache::new(snapshot, 10);
162
163        // Fill the entire cache by inserting 9 more entries.
164        for i in 0..9 {
165            let snapshot = Snapshot::new(db_handle.clone(), i, MultistoreCache::default());
166            cache
167                .try_push(snapshot)
168                .expect("should not fail to insert a new entry");
169        }
170
171        // The cache is full, check that the oldest entry is still in the cache.
172        assert!(cache.get(u64::MAX).is_some());
173
174        // Push another snapshot in the cache, this should cause eviction of the oldest entry
175        // alone.
176        let new_snapshot = Snapshot::new(db_handle, 9, MultistoreCache::default());
177        cache
178            .try_push(new_snapshot)
179            .expect("should not fail to insert a new entry");
180
181        // Check that the pre-genesis entry has been evicted!
182        assert!(cache.get(u64::MAX).is_none());
183
184        // Check that all the other entries are still in the cache.
185        for i in 0..10 {
186            assert!(cache.get(i).is_some());
187        }
188    }
189
190    #[tokio::test]
191    /// Checks that inserting on a full cache exclusively evicts the oldest snapshots.
192    async fn drop_oldest_snapshot() {
193        let storage = create_storage_instance().await;
194        let db_handle = storage.db();
195        let snapshot = Snapshot::new(db_handle.clone(), 0, MultistoreCache::default());
196
197        // Create a cache of size 10, populated with a snapshot at version 0.
198        let mut cache = SnapshotCache::new(snapshot, 10);
199
200        // Saturate the cache by inserting 9 more entries.
201        for i in 1..10 {
202            let snapshot = Snapshot::new(db_handle.clone(), i, MultistoreCache::default());
203            cache
204                .try_push(snapshot)
205                .expect("should be able to insert new entries")
206        }
207
208        // Check that the oldest value is still present:
209        assert!(cache.get(0).is_some());
210
211        // Insert a new value that should overflow the cache.
212        let snapshot = Snapshot::new(db_handle, 10, MultistoreCache::default());
213        cache
214            .try_push(snapshot)
215            .expect("should be able to insert a new entry");
216
217        // Check that the oldest value has been dropped.
218        assert!(cache.get(0).is_none());
219
220        // Check that the front of the cache is the latest inserted snapshot.
221        assert_eq!(cache.latest().version(), 10);
222
223        // Check that all the other snapshots are still present in the cache.
224        for i in 1..11 {
225            assert!(cache.get(i).is_some());
226        }
227    }
228}