Skip to main content

nectar_postage/
snapshot_store.rs

1//! A store for recovered issuer snapshot state, keyed by [`BatchId`].
2//!
3//! Issuing postage stamps needs per-bucket counters so every stamp claims a
4//! fresh storage slot. That issuer state can always be rebuilt from the network:
5//! it is published inside the batch it describes, as single-owner chunks at
6//! addresses derived from the batch id and owner alone, so a user can recover it
7//! on any machine from just their key and batch id. The network is therefore the
8//! source of truth.
9//!
10//! A [`SnapshotStore`] is a *cache* in front of that recovery path, not an
11//! authority. It lets an issuer avoid a network round trip on the warm path by
12//! keeping the most recently observed state for each batch locally. A cold or
13//! evicted entry is never an error: the caller falls back to network recovery
14//! and may then [`persist`](SnapshotStore::persist) the rebuilt state to warm
15//! the cache again. Because the trait is a cache, an implementation is free to
16//! drop entries (bounded memory, eviction, a fresh process) without violating
17//! any invariant, and a returned snapshot must still be validated against the
18//! network before it is trusted for issuance.
19//!
20//! The trait is generic over the snapshot state type `S` so this crate stays
21//! free of the issuer-side snapshot encoding: a consumer such as the
22//! `nectar-postage-usage` crate supplies its own snapshot type. The store only
23//! ever moves opaque values keyed by [`BatchId`].
24
25use crate::BatchId;
26
27/// A cache for recovered issuer snapshot state, keyed by [`BatchId`].
28///
29/// Implementations persist and load the snapshot state `S` for a batch. The
30/// network is the source of truth for this state (see the module-level
31/// docs); a store is only a warm-path cache, so a missing entry is
32/// reported as `Ok(None)` rather than an error and the caller recovers from the
33/// network instead.
34///
35/// # Synchronous Design
36///
37/// The methods are synchronous. The known cache backends (in memory, a
38/// key-value database such as redb) are themselves synchronous, so there is no
39/// genuinely async work to drive here; any async behaviour belongs at the true
40/// edges where it is added by the edge, not by this cache. Keeping the trait
41/// synchronous avoids colouring callers with `async` and keeps it object-safe.
42///
43/// # Example
44///
45/// ```ignore
46/// use nectar_postage::{BatchId, SnapshotStore};
47///
48/// fn warm<S, T: SnapshotStore<S>>(store: &T, id: &BatchId) -> Option<S> {
49///     // Try the cache; on a miss the caller would recover from the network.
50///     store.load(id).ok().flatten()
51/// }
52/// ```
53pub trait SnapshotStore<S> {
54    /// The error type returned by store operations.
55    type Error: std::error::Error;
56
57    /// Loads the snapshot state for `id`.
58    ///
59    /// Returns `Ok(None)` on a cache miss. A miss is expected on a cold store
60    /// and is not an error: the caller recovers the state from the network and
61    /// may [`persist`](Self::persist) it afterwards. A returned value is a
62    /// cached hint and must still be validated against the network before it is
63    /// trusted for issuance. When `S` is a `nectar-postage-usage` snapshot the
64    /// loaded value is unvalidated and carries no persist capability; it must be
65    /// admitted through that crate's network-floor check before any persist.
66    fn load(&self, id: &BatchId) -> Result<Option<S>, Self::Error>;
67
68    /// Persists the snapshot state for `id`, overwriting any cached entry.
69    ///
70    /// This only updates the local cache; it does not publish to the network
71    /// and confers no authority on the stored value.
72    fn persist(&self, id: &BatchId, snapshot: S) -> Result<(), Self::Error>;
73
74    /// Removes any cached snapshot state for `id`.
75    ///
76    /// Returns `true` if an entry existed and was removed. Dropping an entry is
77    /// always safe: the state can be recovered from the network.
78    fn remove(&self, id: &BatchId) -> Result<bool, Self::Error>;
79
80    /// Returns whether a snapshot state is cached for `id`.
81    fn contains(&self, id: &BatchId) -> Result<bool, Self::Error>;
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use alloy_primitives::B256;
88    use std::collections::HashMap;
89    use std::convert::Infallible;
90    use std::sync::Mutex;
91
92    /// An in-memory [`SnapshotStore`] for tests.
93    ///
94    /// Backed by a plain map behind a mutex, it models the cache contract
95    /// exactly: entries can be loaded, overwritten, and removed, and a miss is a
96    /// plain `None`. It performs no network recovery of its own.
97    #[derive(Debug, Default)]
98    struct InMemorySnapshotStore<S> {
99        entries: Mutex<HashMap<BatchId, S>>,
100    }
101
102    impl<S> InMemorySnapshotStore<S> {
103        fn new() -> Self {
104            Self {
105                entries: Mutex::new(HashMap::new()),
106            }
107        }
108
109        fn len(&self) -> usize {
110            self.entries.lock().expect("poisoned").len()
111        }
112    }
113
114    impl<S: Clone> SnapshotStore<S> for InMemorySnapshotStore<S> {
115        type Error = Infallible;
116
117        fn load(&self, id: &BatchId) -> Result<Option<S>, Self::Error> {
118            Ok(self.entries.lock().expect("poisoned").get(id).cloned())
119        }
120
121        fn persist(&self, id: &BatchId, snapshot: S) -> Result<(), Self::Error> {
122            self.entries.lock().expect("poisoned").insert(*id, snapshot);
123            Ok(())
124        }
125
126        fn remove(&self, id: &BatchId) -> Result<bool, Self::Error> {
127            Ok(self.entries.lock().expect("poisoned").remove(id).is_some())
128        }
129
130        fn contains(&self, id: &BatchId) -> Result<bool, Self::Error> {
131            Ok(self.entries.lock().expect("poisoned").contains_key(id))
132        }
133    }
134
135    fn id(byte: u8) -> BatchId {
136        B256::repeat_byte(byte)
137    }
138
139    #[test]
140    fn load_misses_on_cold_store() {
141        let store: InMemorySnapshotStore<u64> = InMemorySnapshotStore::new();
142        // A cold load is a miss, not an error: the caller recovers from the
143        // network instead.
144        assert_eq!(store.load(&id(1)).unwrap(), None);
145        assert!(!store.contains(&id(1)).unwrap());
146    }
147
148    #[test]
149    fn persist_then_load_round_trips() {
150        let store = InMemorySnapshotStore::new();
151        store.persist(&id(2), 42u64).unwrap();
152
153        assert!(store.contains(&id(2)).unwrap());
154        assert_eq!(store.load(&id(2)).unwrap(), Some(42));
155        // A different batch id is still a miss: entries are keyed by batch id.
156        assert_eq!(store.load(&id(3)).unwrap(), None);
157    }
158
159    #[test]
160    fn persist_overwrites_existing_entry() {
161        let store = InMemorySnapshotStore::new();
162        store.persist(&id(4), 1u64).unwrap();
163        store.persist(&id(4), 2u64).unwrap();
164
165        // The later persist wins; the cache holds one entry per batch id.
166        assert_eq!(store.load(&id(4)).unwrap(), Some(2));
167        assert_eq!(store.len(), 1);
168    }
169
170    #[test]
171    fn remove_reports_prior_presence() {
172        let store = InMemorySnapshotStore::new();
173        store.persist(&id(5), 7u64).unwrap();
174
175        // Removing a present entry reports true and clears it; the state can
176        // still be recovered from the network, so this is always safe.
177        assert!(store.remove(&id(5)).unwrap());
178        assert_eq!(store.load(&id(5)).unwrap(), None);
179        // Removing an absent entry reports false.
180        assert!(!store.remove(&id(5)).unwrap());
181    }
182
183    #[test]
184    fn entries_are_isolated_by_batch_id() {
185        let store = InMemorySnapshotStore::new();
186        store.persist(&id(6), 60u64).unwrap();
187        store.persist(&id(7), 70u64).unwrap();
188
189        // Distinct batch ids do not alias one another.
190        assert_eq!(store.load(&id(6)).unwrap(), Some(60));
191        assert_eq!(store.load(&id(7)).unwrap(), Some(70));
192        assert!(store.remove(&id(6)).unwrap());
193        assert_eq!(store.load(&id(6)).unwrap(), None);
194        assert_eq!(store.load(&id(7)).unwrap(), Some(70));
195        assert_eq!(store.len(), 1);
196    }
197}