Skip to main content

ursula_runtime/
snapshot_store.rs

1//! Pluggable backends for raft state-machine snapshot bytes.
2//!
3//! Decouples "what a snapshot contains" from "where the bytes live". The raft
4//! state machine asks a [`SnapshotStore`] to persist serialized snapshot bytes
5//! and gets back a [`SnapshotLocation`]; only a [`SnapshotPointer`] then rides
6//! openraft's `SnapshotData`. The receiver decodes the pointer and pulls the
7//! actual bytes back through the same backend.
8//!
9//! Default backend [`InlineSnapshotStore`] keeps bytes inside the pointer
10//! itself, preserving today's "snapshot rides through openraft" behavior.
11//! [`LocalSnapshotStore`] persists to the filesystem. The S3 backend is added
12//! in a follow-up PR and reuses the cold-store opendal client.
13
14use std::fmt::Debug;
15use std::future::Future;
16use std::io;
17use std::path::PathBuf;
18use std::pin::Pin;
19use std::sync::Arc;
20
21use serde::Deserialize;
22use serde::Serialize;
23
24/// Identifier the store uses to derive a key/path for a snapshot blob.
25///
26/// `snapshot_id` is the openraft-provided id (group + leader + log index),
27/// guaranteed unique per snapshot build attempt.
28#[derive(Debug, Clone, PartialEq, Eq, Hash)]
29pub struct SnapshotKey {
30    pub raft_group_id: u32,
31    pub snapshot_id: String,
32}
33
34impl SnapshotKey {
35    /// Canonical leaf filename for filesystem / object key derivation.
36    pub fn filename(&self) -> String {
37        format!("{}.snap", self.snapshot_id)
38    }
39}
40
41/// Where a snapshot blob lives. Carried in [`SnapshotPointer`] over openraft.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(tag = "kind", rename_all = "snake_case")]
44pub enum SnapshotLocation {
45    /// Bytes live inline in the location. Round-trips through openraft with no
46    /// external store touch — matches the legacy in-memory snapshot shape.
47    Inline {
48        #[serde(with = "serde_bytes_vec")]
49        bytes: Vec<u8>,
50    },
51    /// Bytes live on the local filesystem at `path` (dev / single-host).
52    Local { path: PathBuf, size_bytes: u64 },
53    /// Bytes live in an object storage backend at `key` (S3-compatible).
54    S3 { key: String, size_bytes: u64 },
55}
56
57impl SnapshotLocation {
58    pub fn size_hint(&self) -> u64 {
59        match self {
60            Self::Inline { bytes } => bytes.len() as u64,
61            Self::Local { size_bytes, .. } => *size_bytes,
62            Self::S3 { size_bytes, .. } => *size_bytes,
63        }
64    }
65}
66
67mod serde_bytes_vec {
68    use serde::Deserialize;
69    use serde::Deserializer;
70    use serde::Serializer;
71
72    pub fn serialize<S: Serializer>(bytes: &[u8], ser: S) -> Result<S::Ok, S::Error> {
73        ser.serialize_bytes(bytes)
74    }
75
76    pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Vec<u8>, D::Error> {
77        // Accept both `bytes` (efficient binary) and the JSON-array fallback
78        // that serde_json uses by default; we go through Vec<u8> directly.
79        Vec::<u8>::deserialize(de)
80    }
81}
82
83/// Reference shipped through openraft `SnapshotData`. Tiny when the backend
84/// stores the actual bytes out of line.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct SnapshotPointer {
87    pub snapshot_id: String,
88    pub location: SnapshotLocation,
89}
90
91impl SnapshotPointer {
92    pub fn encode(&self) -> Result<Vec<u8>, SnapshotStoreError> {
93        serde_json::to_vec(self).map_err(|err| SnapshotStoreError::Serialize(err.to_string()))
94    }
95
96    pub fn decode(bytes: &[u8]) -> Result<Self, SnapshotStoreError> {
97        serde_json::from_slice(bytes)
98            .map_err(|err| SnapshotStoreError::Deserialize(err.to_string()))
99    }
100}
101
102#[derive(Debug)]
103pub enum SnapshotStoreError {
104    Backend(String),
105    NotFound(String),
106    Integrity(String),
107    Serialize(String),
108    Deserialize(String),
109    Io(io::Error),
110}
111
112impl std::fmt::Display for SnapshotStoreError {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        match self {
115            Self::Backend(m) => write!(f, "snapshot store backend: {m}"),
116            Self::NotFound(m) => write!(f, "snapshot not found: {m}"),
117            Self::Integrity(m) => write!(f, "snapshot integrity: {m}"),
118            Self::Serialize(m) => write!(f, "snapshot serialize: {m}"),
119            Self::Deserialize(m) => write!(f, "snapshot deserialize: {m}"),
120            Self::Io(err) => write!(f, "snapshot io: {err}"),
121        }
122    }
123}
124
125impl std::error::Error for SnapshotStoreError {}
126
127impl From<io::Error> for SnapshotStoreError {
128    fn from(err: io::Error) -> Self {
129        Self::Io(err)
130    }
131}
132
133impl SnapshotStoreError {
134    pub fn into_io(self) -> io::Error {
135        match self {
136            Self::Io(err) => err,
137            other => io::Error::other(other.to_string()),
138        }
139    }
140}
141
142pub type SnapshotStoreFuture<'a, T> =
143    Pin<Box<dyn Future<Output = Result<T, SnapshotStoreError>> + Send + 'a>>;
144
145pub trait SnapshotStore: Send + Sync + Debug {
146    /// Persist a snapshot blob and return its location. Stores own naming and
147    /// MAY ignore parts of `key` (Inline does).
148    fn upload<'a>(
149        &'a self,
150        key: SnapshotKey,
151        bytes: Vec<u8>,
152    ) -> SnapshotStoreFuture<'a, SnapshotLocation>;
153
154    /// Retrieve a snapshot blob given its location.
155    fn download<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, Vec<u8>>;
156
157    /// Best-effort delete; missing is not an error.
158    fn delete<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()>;
159
160    /// Lightweight liveness probe for the backend, used by the snapshot driver
161    /// to detect local S3 loss WITHOUT triggering a `build_snapshot` (whose
162    /// failure openraft treats as fatal). The default is "always healthy":
163    /// in-memory and local-filesystem backends cannot be remotely unavailable.
164    /// The S3 backend overrides this with a cheap `stat`.
165    fn health_check(&self) -> SnapshotStoreFuture<'_, ()> {
166        Box::pin(async move { Ok(()) })
167    }
168
169    /// Verify that a freshly-uploaded snapshot is actually retrievable from
170    /// the backend. Called immediately after `upload` returns Ok, before the
171    /// new pointer is published. Catches silent partial-success modes
172    /// (multipart upload Init/Part Ok but Complete failed, opendal retry
173    /// returning Ok on cached state, etc.) that would otherwise leave
174    /// `current_snapshot` pointing at a 404. Default no-op for backends that
175    /// can't lie about persistence (Inline keeps bytes in the pointer; Local
176    /// uses a single fs syscall whose Ok means present). The S3 backend
177    /// overrides this with a `stat` round-trip.
178    fn verify_uploaded<'a>(
179        &'a self,
180        _location: &'a SnapshotLocation,
181    ) -> SnapshotStoreFuture<'a, ()> {
182        Box::pin(async move { Ok(()) })
183    }
184}
185
186pub type SharedSnapshotStore = Arc<dyn SnapshotStore>;
187
188/// Default backend used when none is wired: bytes ride inline in the pointer.
189pub fn default_snapshot_store() -> SharedSnapshotStore {
190    Arc::new(InlineSnapshotStore)
191}
192
193/// Bytes live inside the pointer. Equivalent to today's in-memory snapshot.
194#[derive(Debug, Default, Clone, Copy)]
195pub struct InlineSnapshotStore;
196
197impl SnapshotStore for InlineSnapshotStore {
198    fn upload<'a>(
199        &'a self,
200        _key: SnapshotKey,
201        bytes: Vec<u8>,
202    ) -> SnapshotStoreFuture<'a, SnapshotLocation> {
203        Box::pin(async move { Ok(SnapshotLocation::Inline { bytes }) })
204    }
205
206    fn download<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, Vec<u8>> {
207        Box::pin(async move {
208            match location {
209                SnapshotLocation::Inline { bytes } => Ok(bytes.clone()),
210                other => Err(SnapshotStoreError::Backend(format!(
211                    "inline snapshot store cannot download {other:?}"
212                ))),
213            }
214        })
215    }
216
217    fn delete<'a>(&'a self, _location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()> {
218        Box::pin(async move { Ok(()) })
219    }
220}
221
222#[cfg(not(madsim))]
223mod s3 {
224    use opendal::Operator;
225    use opendal::Scheme;
226
227    use super::SnapshotKey;
228    use super::SnapshotLocation;
229    use super::SnapshotStore;
230    use super::SnapshotStoreError;
231    use super::SnapshotStoreFuture;
232
233    /// Bytes live in an opendal-managed S3 bucket under `{prefix}/group-{gid}/`.
234    pub struct S3SnapshotStore {
235        operator: Operator,
236        prefix: String,
237    }
238
239    impl std::fmt::Debug for S3SnapshotStore {
240        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241            f.debug_struct("S3SnapshotStore")
242                .field("prefix", &self.prefix)
243                .finish_non_exhaustive()
244        }
245    }
246
247    impl S3SnapshotStore {
248        pub fn new(operator: Operator, prefix: impl Into<String>) -> Self {
249            let mut prefix = prefix.into();
250            while prefix.ends_with('/') {
251                prefix.pop();
252            }
253            Self { operator, prefix }
254        }
255
256        /// In-memory opendal operator under `prefix`, for tests.
257        pub fn memory_for_tests(prefix: impl Into<String>) -> Result<Self, SnapshotStoreError> {
258            let operator = Operator::via_iter(Scheme::Memory, [])
259                .map_err(|err| SnapshotStoreError::Backend(err.to_string()))?;
260            Ok(Self::new(operator, prefix))
261        }
262
263        /// Build an S3 snapshot store from a [`ColdConfig`].
264        /// Snapshot blobs share the cold bucket/credentials and use `prefix`
265        /// (defaults to `snapshots`) for separation.
266        pub fn try_new(
267            config: &crate::ColdConfig,
268            prefix: impl Into<String>,
269        ) -> Result<Self, SnapshotStoreError> {
270            let s3 = config.s3.as_ref().ok_or_else(|| {
271                SnapshotStoreError::Backend("S3 config is required for snapshot s3 backend".into())
272            })?;
273            let bucket = s3.bucket.as_deref().ok_or_else(|| {
274                SnapshotStoreError::Backend("S3 bucket is required for snapshot s3 backend".into())
275            })?;
276            if bucket.trim().is_empty() {
277                return Err(SnapshotStoreError::Backend(
278                    "snapshot s3 bucket must not be empty".into(),
279                ));
280            }
281            let mut builder = opendal::services::S3::default().bucket(bucket);
282            if let Some(root) = config.root.as_deref()
283                && !root.trim().is_empty()
284            {
285                builder = builder.root(root);
286            }
287            if let Some(region) = s3.region.as_deref()
288                && !region.trim().is_empty()
289            {
290                builder = builder.region(region);
291            }
292            if let Some(endpoint) = s3.endpoint.as_deref()
293                && !endpoint.trim().is_empty()
294            {
295                builder = builder.endpoint(endpoint);
296            }
297            if let Some(access) = s3.access_key_id.as_deref()
298                && !access.trim().is_empty()
299            {
300                builder = builder.access_key_id(access);
301            }
302            if let Some(secret) = s3.secret_access_key.as_deref()
303                && !secret.trim().is_empty()
304            {
305                builder = builder.secret_access_key(secret);
306            }
307            if let Some(token) = s3.session_token.as_deref()
308                && !token.trim().is_empty()
309            {
310                builder = builder.session_token(token);
311            }
312            let operator = crate::cold_store::with_s3_resilience(
313                Operator::new(builder)
314                    .map_err(|err| SnapshotStoreError::Backend(err.to_string()))?
315                    .finish(),
316                s3.timeout.as_duration(),
317                s3.max_retries,
318            );
319            Ok(Self::new(operator, prefix))
320        }
321
322        /// Build a per-attempt-unique S3 key. The openraft `snapshot_id` is
323        /// derived from `last_applied_log_id`, so two builds during an
324        /// apply-idle window compute the SAME snapshot_id. If the S3 key also
325        /// matched, two distinct published pointers could alias one physical
326        /// object. A nanosecond + per-process counter suffix keeps the physical
327        /// S3 key unique per upload attempt without changing the openraft-visible
328        /// snapshot_id.
329        fn object_key(&self, key: &SnapshotKey) -> String {
330            use std::sync::atomic::AtomicU64;
331            use std::sync::atomic::Ordering;
332
333            use crossbeam_utils::CachePadded;
334            // Keep the nonce counter isolated from unrelated statics. Snapshot
335            // uploads are low frequency, so this is a defensive false-sharing
336            // guard rather than a fix for contention on the counter itself.
337            static COUNTER: CachePadded<AtomicU64> = CachePadded::new(AtomicU64::new(0));
338            let nonce_nanos = std::time::SystemTime::now()
339                .duration_since(std::time::UNIX_EPOCH)
340                .map(|d| d.as_nanos())
341                .unwrap_or(0);
342            let nonce_seq = COUNTER.fetch_add(1, Ordering::Relaxed);
343            format!(
344                "{}/group-{}/{}-{nonce_nanos:032}-{nonce_seq:020}.snap",
345                self.prefix, key.raft_group_id, key.snapshot_id,
346            )
347        }
348    }
349
350    impl SnapshotStore for S3SnapshotStore {
351        fn upload<'a>(
352            &'a self,
353            key: SnapshotKey,
354            bytes: Vec<u8>,
355        ) -> SnapshotStoreFuture<'a, SnapshotLocation> {
356            Box::pin(async move {
357                let object_key = self.object_key(&key);
358                let size_bytes = bytes.len() as u64;
359                self.operator
360                    .write(&object_key, bytes)
361                    .await
362                    .map_err(|err| SnapshotStoreError::Backend(err.to_string()))?;
363                Ok(SnapshotLocation::S3 {
364                    key: object_key,
365                    size_bytes,
366                })
367            })
368        }
369
370        fn download<'a>(
371            &'a self,
372            location: &'a SnapshotLocation,
373        ) -> SnapshotStoreFuture<'a, Vec<u8>> {
374            Box::pin(async move {
375                let SnapshotLocation::S3 { key, size_bytes } = location else {
376                    return Err(SnapshotStoreError::Backend(format!(
377                        "s3 snapshot store cannot download {location:?}"
378                    )));
379                };
380                let buf = self.operator.read(key).await.map_err(|err| {
381                    if matches!(err.kind(), opendal::ErrorKind::NotFound) {
382                        SnapshotStoreError::NotFound(format!("s3 snapshot missing at {key}"))
383                    } else {
384                        SnapshotStoreError::Backend(err.to_string())
385                    }
386                })?;
387                let bytes = buf.to_vec();
388                if bytes.len() as u64 != *size_bytes {
389                    return Err(SnapshotStoreError::Integrity(format!(
390                        "s3 snapshot {key} size {} != expected {}",
391                        bytes.len(),
392                        size_bytes
393                    )));
394                }
395                Ok(bytes)
396            })
397        }
398
399        fn delete<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()> {
400            Box::pin(async move {
401                let SnapshotLocation::S3 { key, .. } = location else {
402                    return Ok(());
403                };
404                match self.operator.delete(key).await {
405                    Ok(()) => Ok(()),
406                    Err(err) if matches!(err.kind(), opendal::ErrorKind::NotFound) => Ok(()),
407                    Err(err) => Err(SnapshotStoreError::Backend(err.to_string())),
408                }
409            })
410        }
411
412        fn verify_uploaded<'a>(
413            &'a self,
414            location: &'a SnapshotLocation,
415        ) -> SnapshotStoreFuture<'a, ()> {
416            Box::pin(async move {
417                let SnapshotLocation::S3 { key, size_bytes } = location else {
418                    return Ok(());
419                };
420                let meta = self.operator.stat(key).await.map_err(|err| {
421                    if matches!(err.kind(), opendal::ErrorKind::NotFound) {
422                        SnapshotStoreError::NotFound(format!(
423                            "s3 snapshot upload verification failed: {key} not present after upload"
424                        ))
425                    } else {
426                        SnapshotStoreError::Backend(err.to_string())
427                    }
428                })?;
429                let actual = meta.content_length();
430                if actual != *size_bytes {
431                    return Err(SnapshotStoreError::Integrity(format!(
432                        "s3 snapshot {key} size mismatch post-upload: stat={actual} expected={size_bytes}"
433                    )));
434                }
435                Ok(())
436            })
437        }
438
439        fn health_check(&self) -> SnapshotStoreFuture<'_, ()> {
440            Box::pin(async move {
441                // A `stat` on a probe key is a single cheap round-trip that goes
442                // through the same TimeoutLayer/RetryLayer as real writes, so it
443                // reports unreachable S3 (timeout / connection error) without
444                // building a snapshot. `NotFound` means S3 answered — healthy.
445                let probe = format!("{}/.health-probe", self.prefix);
446                match self.operator.stat(&probe).await {
447                    Ok(_) => Ok(()),
448                    Err(err) if matches!(err.kind(), opendal::ErrorKind::NotFound) => Ok(()),
449                    Err(err) => Err(SnapshotStoreError::Backend(err.to_string())),
450                }
451            })
452        }
453    }
454}
455
456#[cfg(not(madsim))]
457pub use s3::S3SnapshotStore;
458
459/// Pick a snapshot store from a typed `ursula_config::RaftSnapshotConfig`. Returns `None`
460/// when the backend is "inline" (the default) so callers can fall back to
461/// [`default_snapshot_store`] without instantiating anything.
462pub fn snapshot_store_from_config(
463    cfg: &ursula_config::RaftSnapshotConfig,
464    cold_cfg: &crate::ColdConfig,
465) -> Result<Option<SharedSnapshotStore>, SnapshotStoreError> {
466    let _ = cold_cfg;
467    match cfg.backend {
468        ursula_config::RaftSnapshotBackend::Inline => Ok(None),
469        #[cfg(not(madsim))]
470        ursula_config::RaftSnapshotBackend::Local => {
471            let root = cfg.local_root.as_ref().ok_or_else(|| {
472                SnapshotStoreError::Backend("snapshot local_root required for local backend".into())
473            })?;
474            let root_str = root.to_string_lossy();
475            if root_str.trim().is_empty() {
476                return Err(SnapshotStoreError::Backend(
477                    "snapshot local_root must not be empty".into(),
478                ));
479            }
480            Ok(Some(Arc::new(LocalSnapshotStore::new(root))))
481        }
482        #[cfg(not(madsim))]
483        ursula_config::RaftSnapshotBackend::S3 => {
484            let prefix = cfg.s3_prefix.as_deref().unwrap_or("snapshots");
485            Ok(Some(Arc::new(S3SnapshotStore::try_new(cold_cfg, prefix)?)))
486        }
487        #[cfg(madsim)]
488        ursula_config::RaftSnapshotBackend::Local | ursula_config::RaftSnapshotBackend::S3 => {
489            Err(SnapshotStoreError::Backend(format!(
490                "snapshot backend {:?} has no I/O under madsim; use 'inline'",
491                cfg.backend
492            )))
493        }
494    }
495}
496
497#[cfg(not(madsim))]
498mod local {
499    use std::io;
500    use std::path::PathBuf;
501
502    use super::SnapshotKey;
503    use super::SnapshotLocation;
504    use super::SnapshotStore;
505    use super::SnapshotStoreError;
506    use super::SnapshotStoreFuture;
507
508    /// Bytes live on the local filesystem under a root directory.
509    #[derive(Debug, Clone)]
510    pub struct LocalSnapshotStore {
511        root: PathBuf,
512    }
513
514    impl LocalSnapshotStore {
515        pub fn new(root: impl Into<PathBuf>) -> Self {
516            Self { root: root.into() }
517        }
518
519        fn path_for(&self, key: SnapshotKey) -> PathBuf {
520            self.root
521                .join(format!("group-{}", key.raft_group_id))
522                .join(key.filename())
523        }
524    }
525
526    impl SnapshotStore for LocalSnapshotStore {
527        fn upload<'a>(
528            &'a self,
529            key: SnapshotKey,
530            bytes: Vec<u8>,
531        ) -> SnapshotStoreFuture<'a, SnapshotLocation> {
532            Box::pin(async move {
533                let path = self.path_for(key);
534                if let Some(parent) = path.parent() {
535                    tokio::fs::create_dir_all(parent).await?;
536                }
537                let size_bytes = bytes.len() as u64;
538                tokio::fs::write(&path, &bytes).await?;
539                Ok(SnapshotLocation::Local { path, size_bytes })
540            })
541        }
542
543        fn download<'a>(
544            &'a self,
545            location: &'a SnapshotLocation,
546        ) -> SnapshotStoreFuture<'a, Vec<u8>> {
547            Box::pin(async move {
548                let SnapshotLocation::Local { path, size_bytes } = location else {
549                    return Err(SnapshotStoreError::Backend(format!(
550                        "local snapshot store cannot download {location:?}"
551                    )));
552                };
553                let bytes = tokio::fs::read(path).await.map_err(|err| {
554                    if err.kind() == io::ErrorKind::NotFound {
555                        SnapshotStoreError::NotFound(format!(
556                            "local snapshot missing at {}",
557                            path.display()
558                        ))
559                    } else {
560                        SnapshotStoreError::Io(err)
561                    }
562                })?;
563                if bytes.len() as u64 != *size_bytes {
564                    return Err(SnapshotStoreError::Integrity(format!(
565                        "local snapshot at {} size {} != expected {}",
566                        path.display(),
567                        bytes.len(),
568                        size_bytes
569                    )));
570                }
571                Ok(bytes)
572            })
573        }
574
575        fn delete<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()> {
576            Box::pin(async move {
577                let SnapshotLocation::Local { path, .. } = location else {
578                    return Ok(());
579                };
580                match tokio::fs::remove_file(path).await {
581                    Ok(()) => Ok(()),
582                    Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
583                    Err(err) => Err(SnapshotStoreError::Io(err)),
584                }
585            })
586        }
587    }
588}
589
590#[cfg(not(madsim))]
591pub use local::LocalSnapshotStore;
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    fn test_key(raft_group_id: u32, snapshot_id: &str) -> SnapshotKey {
598        SnapshotKey {
599            raft_group_id,
600            snapshot_id: snapshot_id.to_owned(),
601        }
602    }
603
604    #[tokio::test]
605    async fn inline_roundtrip() {
606        let store = InlineSnapshotStore;
607        let key = test_key(0, "group-0-T1-N1-100");
608        let loc = store.upload(key, b"hello world".to_vec()).await.unwrap();
609        assert!(matches!(loc, SnapshotLocation::Inline { .. }));
610        let bytes = store.download(&loc).await.unwrap();
611        assert_eq!(bytes, b"hello world");
612        store.delete(&loc).await.unwrap();
613    }
614
615    #[tokio::test]
616    async fn inline_rejects_other_location() {
617        let store = InlineSnapshotStore;
618        let loc = SnapshotLocation::Local {
619            path: PathBuf::from("/tmp/nope"),
620            size_bytes: 4,
621        };
622        assert!(matches!(
623            store.download(&loc).await,
624            Err(SnapshotStoreError::Backend(_))
625        ));
626    }
627
628    #[test]
629    fn pointer_encode_decode_inline() {
630        let pointer = SnapshotPointer {
631            snapshot_id: "group-0-1-100".into(),
632            location: SnapshotLocation::Inline {
633                bytes: vec![1, 2, 3, 4],
634            },
635        };
636        let bytes = pointer.encode().unwrap();
637        let back = SnapshotPointer::decode(&bytes).unwrap();
638        assert_eq!(back.snapshot_id, pointer.snapshot_id);
639        match back.location {
640            SnapshotLocation::Inline { bytes } => assert_eq!(bytes, vec![1, 2, 3, 4]),
641            other => panic!("unexpected location: {other:?}"),
642        }
643    }
644
645    #[test]
646    fn pointer_encode_decode_local() {
647        let pointer = SnapshotPointer {
648            snapshot_id: "group-7-2-500".into(),
649            location: SnapshotLocation::Local {
650                path: PathBuf::from("/var/snap/group-7-term-2-log-500.snap"),
651                size_bytes: 12345,
652            },
653        };
654        let bytes = pointer.encode().unwrap();
655        let back = SnapshotPointer::decode(&bytes).unwrap();
656        assert_eq!(back.snapshot_id, pointer.snapshot_id);
657        assert_eq!(back.location.size_hint(), 12345);
658    }
659
660    #[cfg(not(madsim))]
661    #[tokio::test]
662    async fn local_roundtrip() {
663        let dir = tempfile::tempdir().unwrap();
664        let store = LocalSnapshotStore::new(dir.path());
665        let key = test_key(7, "group-7-T2-N1-500");
666        let loc = store
667            .upload(key, b"some snapshot bytes".to_vec())
668            .await
669            .unwrap();
670        let bytes = store.download(&loc).await.unwrap();
671        assert_eq!(bytes, b"some snapshot bytes");
672        store.delete(&loc).await.unwrap();
673        let again = store.download(&loc).await;
674        assert!(matches!(again, Err(SnapshotStoreError::NotFound(_))));
675        // Second delete is a no-op.
676        store.delete(&loc).await.unwrap();
677    }
678
679    #[cfg(not(madsim))]
680    #[tokio::test]
681    async fn s3_memory_roundtrip() {
682        let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
683        let key = test_key(3, "group-3-T5-N2-9876");
684        let loc = store
685            .upload(key, b"raw snapshot bytes".to_vec())
686            .await
687            .unwrap();
688        match &loc {
689            SnapshotLocation::S3 { key, size_bytes } => {
690                assert!(key.starts_with("snapshots/group-3/"));
691                assert_eq!(*size_bytes, b"raw snapshot bytes".len() as u64);
692            }
693            other => panic!("expected S3 location, got {other:?}"),
694        }
695        let bytes = store.download(&loc).await.unwrap();
696        assert_eq!(bytes, b"raw snapshot bytes");
697        store.delete(&loc).await.unwrap();
698        assert!(matches!(
699            store.download(&loc).await,
700            Err(SnapshotStoreError::NotFound(_))
701        ));
702        // Second delete is a no-op.
703        store.delete(&loc).await.unwrap();
704    }
705
706    #[cfg(not(madsim))]
707    #[tokio::test]
708    async fn s3_two_uploads_with_same_snapshot_id_get_different_keys() {
709        // Regression for the 2026-05-31 wedge: snapshot_id is derived from
710        // last_applied_log_id, so two builds during apply-idle compute the
711        // same id. The S3 object key must still be unique per attempt so
712        // distinct published pointers never alias one physical object.
713        let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
714        let key1 = test_key(4, "group-4-T18-N3-264150");
715        let key2 = test_key(4, "group-4-T18-N3-264150");
716        let loc1 = store.upload(key1, b"body1".to_vec()).await.unwrap();
717        let loc2 = store.upload(key2, b"body2".to_vec()).await.unwrap();
718        let (k1, k2) = match (&loc1, &loc2) {
719            (SnapshotLocation::S3 { key: k1, .. }, SnapshotLocation::S3 { key: k2, .. }) => {
720                (k1.clone(), k2.clone())
721            }
722            _ => panic!("expected S3 locations"),
723        };
724        assert_ne!(k1, k2, "same snapshot_id must yield distinct S3 keys");
725        // Both stay independently readable — deleting one must not nuke the
726        // other (the self-GC failure mode).
727        assert_eq!(store.download(&loc1).await.unwrap(), b"body1");
728        assert_eq!(store.download(&loc2).await.unwrap(), b"body2");
729        store.delete(&loc1).await.unwrap();
730        assert!(matches!(
731            store.download(&loc1).await,
732            Err(SnapshotStoreError::NotFound(_))
733        ));
734        assert_eq!(store.download(&loc2).await.unwrap(), b"body2");
735    }
736
737    #[cfg(not(madsim))]
738    #[tokio::test]
739    async fn s3_verify_uploaded_catches_missing_object() {
740        let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
741        let key = test_key(2, "group-2-T1-N1-7");
742        let loc = store.upload(key, b"payload".to_vec()).await.unwrap();
743        // Round-trip after a real upload: must succeed.
744        store.verify_uploaded(&loc).await.unwrap();
745        // Same location, after an out-of-band delete: must report missing so
746        // the snapshot build path can fail fast instead of publishing a
747        // pointer to a 404.
748        store.delete(&loc).await.unwrap();
749        let err = store.verify_uploaded(&loc).await.unwrap_err();
750        assert!(
751            matches!(err, SnapshotStoreError::NotFound(_)),
752            "expected NotFound after delete, got {err:?}"
753        );
754    }
755
756    #[cfg(not(madsim))]
757    #[tokio::test]
758    async fn local_integrity_detects_size_mismatch() {
759        let dir = tempfile::tempdir().unwrap();
760        let store = LocalSnapshotStore::new(dir.path());
761        let key = test_key(1, "group-1-T1-N1-1");
762        let loc = store.upload(key, b"abcd".to_vec()).await.unwrap();
763        let SnapshotLocation::Local { path, .. } = &loc else {
764            unreachable!()
765        };
766        tokio::fs::write(path, b"abcde").await.unwrap();
767        let result = store.download(&loc).await;
768        assert!(matches!(result, Err(SnapshotStoreError::Integrity(_))));
769    }
770}