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}