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/// # Async Design
36///
37/// The methods are async so an implementation may sit in front of a slow
38/// backend (disk, a key-value database) without forcing callers to block.
39///
40/// # Example
41///
42/// ```ignore
43/// use nectar_postage::{BatchId, SnapshotStore};
44///
45/// async fn warm<S, T: SnapshotStore<S>>(store: &T, id: &BatchId) -> Option<S> {
46/// // Try the cache; on a miss the caller would recover from the network.
47/// store.load(id).await.ok().flatten()
48/// }
49/// ```
50pub trait SnapshotStore<S> {
51 /// The error type returned by store operations.
52 type Error: std::error::Error;
53
54 /// Loads the snapshot state for `id`.
55 ///
56 /// Returns `Ok(None)` on a cache miss. A miss is expected on a cold store
57 /// and is not an error: the caller recovers the state from the network and
58 /// may [`persist`](Self::persist) it afterwards. A returned value is a
59 /// cached hint and must still be validated against the network before it is
60 /// trusted for issuance. When `S` is a `nectar-postage-usage` snapshot the
61 /// loaded value is unvalidated and carries no persist capability; it must be
62 /// admitted through that crate's network-floor check before any persist.
63 fn load(
64 &self,
65 id: &BatchId,
66 ) -> impl std::future::Future<Output = Result<Option<S>, Self::Error>> + Send;
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(
73 &self,
74 id: &BatchId,
75 snapshot: S,
76 ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
77
78 /// Removes any cached snapshot state for `id`.
79 ///
80 /// Returns `true` if an entry existed and was removed. Dropping an entry is
81 /// always safe: the state can be recovered from the network.
82 fn remove(
83 &self,
84 id: &BatchId,
85 ) -> impl std::future::Future<Output = Result<bool, Self::Error>> + Send;
86
87 /// Returns whether a snapshot state is cached for `id`.
88 fn contains(
89 &self,
90 id: &BatchId,
91 ) -> impl std::future::Future<Output = Result<bool, Self::Error>> + Send;
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use alloy_primitives::B256;
98 use std::collections::HashMap;
99 use std::convert::Infallible;
100 use std::sync::Mutex;
101
102 /// An in-memory [`SnapshotStore`] for tests.
103 ///
104 /// Backed by a plain map behind a mutex, it models the cache contract
105 /// exactly: entries can be loaded, overwritten, and removed, and a miss is a
106 /// plain `None`. It performs no network recovery of its own.
107 #[derive(Debug, Default)]
108 struct InMemorySnapshotStore<S> {
109 entries: Mutex<HashMap<BatchId, S>>,
110 }
111
112 impl<S> InMemorySnapshotStore<S> {
113 fn new() -> Self {
114 Self {
115 entries: Mutex::new(HashMap::new()),
116 }
117 }
118
119 fn len(&self) -> usize {
120 self.entries.lock().expect("poisoned").len()
121 }
122 }
123
124 impl<S: Clone + Send + Sync> SnapshotStore<S> for InMemorySnapshotStore<S> {
125 type Error = Infallible;
126
127 async fn load(&self, id: &BatchId) -> Result<Option<S>, Self::Error> {
128 Ok(self.entries.lock().expect("poisoned").get(id).cloned())
129 }
130
131 async fn persist(&self, id: &BatchId, snapshot: S) -> Result<(), Self::Error> {
132 self.entries.lock().expect("poisoned").insert(*id, snapshot);
133 Ok(())
134 }
135
136 async fn remove(&self, id: &BatchId) -> Result<bool, Self::Error> {
137 Ok(self.entries.lock().expect("poisoned").remove(id).is_some())
138 }
139
140 async fn contains(&self, id: &BatchId) -> Result<bool, Self::Error> {
141 Ok(self.entries.lock().expect("poisoned").contains_key(id))
142 }
143 }
144
145 fn id(byte: u8) -> BatchId {
146 B256::repeat_byte(byte)
147 }
148
149 #[tokio::test]
150 async fn load_misses_on_cold_store() {
151 let store: InMemorySnapshotStore<u64> = InMemorySnapshotStore::new();
152 // A cold load is a miss, not an error: the caller recovers from the
153 // network instead.
154 assert_eq!(store.load(&id(1)).await.unwrap(), None);
155 assert!(!store.contains(&id(1)).await.unwrap());
156 }
157
158 #[tokio::test]
159 async fn persist_then_load_round_trips() {
160 let store = InMemorySnapshotStore::new();
161 store.persist(&id(2), 42u64).await.unwrap();
162
163 assert!(store.contains(&id(2)).await.unwrap());
164 assert_eq!(store.load(&id(2)).await.unwrap(), Some(42));
165 // A different batch id is still a miss: entries are keyed by batch id.
166 assert_eq!(store.load(&id(3)).await.unwrap(), None);
167 }
168
169 #[tokio::test]
170 async fn persist_overwrites_existing_entry() {
171 let store = InMemorySnapshotStore::new();
172 store.persist(&id(4), 1u64).await.unwrap();
173 store.persist(&id(4), 2u64).await.unwrap();
174
175 // The later persist wins; the cache holds one entry per batch id.
176 assert_eq!(store.load(&id(4)).await.unwrap(), Some(2));
177 assert_eq!(store.len(), 1);
178 }
179
180 #[tokio::test]
181 async fn remove_reports_prior_presence() {
182 let store = InMemorySnapshotStore::new();
183 store.persist(&id(5), 7u64).await.unwrap();
184
185 // Removing a present entry reports true and clears it; the state can
186 // still be recovered from the network, so this is always safe.
187 assert!(store.remove(&id(5)).await.unwrap());
188 assert_eq!(store.load(&id(5)).await.unwrap(), None);
189 // Removing an absent entry reports false.
190 assert!(!store.remove(&id(5)).await.unwrap());
191 }
192
193 #[tokio::test]
194 async fn entries_are_isolated_by_batch_id() {
195 let store = InMemorySnapshotStore::new();
196 store.persist(&id(6), 60u64).await.unwrap();
197 store.persist(&id(7), 70u64).await.unwrap();
198
199 // Distinct batch ids do not alias one another.
200 assert_eq!(store.load(&id(6)).await.unwrap(), Some(60));
201 assert_eq!(store.load(&id(7)).await.unwrap(), Some(70));
202 assert!(store.remove(&id(6)).await.unwrap());
203 assert_eq!(store.load(&id(6)).await.unwrap(), None);
204 assert_eq!(store.load(&id(7)).await.unwrap(), Some(70));
205 assert_eq!(store.len(), 1);
206 }
207}