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/// # 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}