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 operator from the cold-store `URSULA_COLD_S3_*` env.
264        /// Snapshot blobs share the cold bucket/credentials and use
265        /// `URSULA_SNAPSHOT_S3_PREFIX` (defaults to `snapshots`) for separation.
266        pub fn s3_from_env() -> Result<Self, SnapshotStoreError> {
267            let bucket = std::env::var("URSULA_COLD_S3_BUCKET").map_err(|_| {
268                SnapshotStoreError::Backend(
269                    "URSULA_COLD_S3_BUCKET is required for snapshot s3 backend".into(),
270                )
271            })?;
272            if bucket.trim().is_empty() {
273                return Err(SnapshotStoreError::Backend(
274                    "snapshot s3 bucket must not be empty".into(),
275                ));
276            }
277            let mut builder = opendal::services::S3::default().bucket(&bucket);
278            // Root pins all blobs into a snapshot-only sub-tree of the bucket,
279            // letting the cold store reuse the same bucket with different keys.
280            if let Ok(root) = std::env::var("URSULA_COLD_ROOT")
281                && !root.trim().is_empty()
282            {
283                builder = builder.root(&root);
284            }
285            if let Ok(region) = std::env::var("URSULA_COLD_S3_REGION")
286                && !region.trim().is_empty()
287            {
288                builder = builder.region(&region);
289            }
290            if let Ok(endpoint) = std::env::var("URSULA_COLD_S3_ENDPOINT")
291                && !endpoint.trim().is_empty()
292            {
293                builder = builder.endpoint(&endpoint);
294            }
295            if let Ok(access) = std::env::var("URSULA_COLD_S3_ACCESS_KEY_ID")
296                && !access.trim().is_empty()
297            {
298                builder = builder.access_key_id(&access);
299            }
300            if let Ok(secret) = std::env::var("URSULA_COLD_S3_SECRET_ACCESS_KEY")
301                && !secret.trim().is_empty()
302            {
303                builder = builder.secret_access_key(&secret);
304            }
305            if let Ok(token) = std::env::var("URSULA_COLD_S3_SESSION_TOKEN")
306                && !token.trim().is_empty()
307            {
308                builder = builder.session_token(&token);
309            }
310            let operator = crate::cold_store::with_s3_resilience(
311                Operator::new(builder)
312                    .map_err(|err| SnapshotStoreError::Backend(err.to_string()))?
313                    .finish(),
314            );
315            let prefix = std::env::var("URSULA_SNAPSHOT_S3_PREFIX")
316                .unwrap_or_else(|_| "snapshots".to_owned());
317            Ok(Self::new(operator, prefix))
318        }
319
320        /// Build a per-attempt-unique S3 key. The openraft `snapshot_id` is
321        /// derived from `last_applied_log_id`, so two builds during an
322        /// apply-idle window compute the SAME snapshot_id. If the S3 key also
323        /// matched, two distinct published pointers could alias one physical
324        /// object. A nanosecond + per-process counter suffix keeps the physical
325        /// S3 key unique per upload attempt without changing the openraft-visible
326        /// snapshot_id.
327        fn object_key(&self, key: &SnapshotKey) -> String {
328            use std::sync::atomic::AtomicU64;
329            use std::sync::atomic::Ordering;
330
331            use crossbeam_utils::CachePadded;
332            // Keep the nonce counter isolated from unrelated statics. Snapshot
333            // uploads are low frequency, so this is a defensive false-sharing
334            // guard rather than a fix for contention on the counter itself.
335            static COUNTER: CachePadded<AtomicU64> = CachePadded::new(AtomicU64::new(0));
336            let nonce_nanos = std::time::SystemTime::now()
337                .duration_since(std::time::UNIX_EPOCH)
338                .map(|d| d.as_nanos())
339                .unwrap_or(0);
340            let nonce_seq = COUNTER.fetch_add(1, Ordering::Relaxed);
341            format!(
342                "{}/group-{}/{}-{nonce_nanos:032}-{nonce_seq:020}.snap",
343                self.prefix, key.raft_group_id, key.snapshot_id,
344            )
345        }
346    }
347
348    impl SnapshotStore for S3SnapshotStore {
349        fn upload<'a>(
350            &'a self,
351            key: SnapshotKey,
352            bytes: Vec<u8>,
353        ) -> SnapshotStoreFuture<'a, SnapshotLocation> {
354            Box::pin(async move {
355                let object_key = self.object_key(&key);
356                let size_bytes = bytes.len() as u64;
357                self.operator
358                    .write(&object_key, bytes)
359                    .await
360                    .map_err(|err| SnapshotStoreError::Backend(err.to_string()))?;
361                Ok(SnapshotLocation::S3 {
362                    key: object_key,
363                    size_bytes,
364                })
365            })
366        }
367
368        fn download<'a>(
369            &'a self,
370            location: &'a SnapshotLocation,
371        ) -> SnapshotStoreFuture<'a, Vec<u8>> {
372            Box::pin(async move {
373                let SnapshotLocation::S3 { key, size_bytes } = location else {
374                    return Err(SnapshotStoreError::Backend(format!(
375                        "s3 snapshot store cannot download {location:?}"
376                    )));
377                };
378                let buf = self.operator.read(key).await.map_err(|err| {
379                    if matches!(err.kind(), opendal::ErrorKind::NotFound) {
380                        SnapshotStoreError::NotFound(format!("s3 snapshot missing at {key}"))
381                    } else {
382                        SnapshotStoreError::Backend(err.to_string())
383                    }
384                })?;
385                let bytes = buf.to_vec();
386                if bytes.len() as u64 != *size_bytes {
387                    return Err(SnapshotStoreError::Integrity(format!(
388                        "s3 snapshot {key} size {} != expected {}",
389                        bytes.len(),
390                        size_bytes
391                    )));
392                }
393                Ok(bytes)
394            })
395        }
396
397        fn delete<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()> {
398            Box::pin(async move {
399                let SnapshotLocation::S3 { key, .. } = location else {
400                    return Ok(());
401                };
402                match self.operator.delete(key).await {
403                    Ok(()) => Ok(()),
404                    Err(err) if matches!(err.kind(), opendal::ErrorKind::NotFound) => Ok(()),
405                    Err(err) => Err(SnapshotStoreError::Backend(err.to_string())),
406                }
407            })
408        }
409
410        fn verify_uploaded<'a>(
411            &'a self,
412            location: &'a SnapshotLocation,
413        ) -> SnapshotStoreFuture<'a, ()> {
414            Box::pin(async move {
415                let SnapshotLocation::S3 { key, size_bytes } = location else {
416                    return Ok(());
417                };
418                let meta = self.operator.stat(key).await.map_err(|err| {
419                    if matches!(err.kind(), opendal::ErrorKind::NotFound) {
420                        SnapshotStoreError::NotFound(format!(
421                            "s3 snapshot upload verification failed: {key} not present after upload"
422                        ))
423                    } else {
424                        SnapshotStoreError::Backend(err.to_string())
425                    }
426                })?;
427                let actual = meta.content_length();
428                if actual != *size_bytes {
429                    return Err(SnapshotStoreError::Integrity(format!(
430                        "s3 snapshot {key} size mismatch post-upload: stat={actual} expected={size_bytes}"
431                    )));
432                }
433                Ok(())
434            })
435        }
436
437        fn health_check(&self) -> SnapshotStoreFuture<'_, ()> {
438            Box::pin(async move {
439                // A `stat` on a probe key is a single cheap round-trip that goes
440                // through the same TimeoutLayer/RetryLayer as real writes, so it
441                // reports unreachable S3 (timeout / connection error) without
442                // building a snapshot. `NotFound` means S3 answered — healthy.
443                let probe = format!("{}/.health-probe", self.prefix);
444                match self.operator.stat(&probe).await {
445                    Ok(_) => Ok(()),
446                    Err(err) if matches!(err.kind(), opendal::ErrorKind::NotFound) => Ok(()),
447                    Err(err) => Err(SnapshotStoreError::Backend(err.to_string())),
448                }
449            })
450        }
451    }
452}
453
454#[cfg(not(madsim))]
455pub use s3::S3SnapshotStore;
456
457/// Pick a snapshot store from env. Returns `None` when the backend is
458/// "inline" (the default) so callers can fall back to [`default_snapshot_store`]
459/// without instantiating anything.
460///
461/// Recognized values for `URSULA_SNAPSHOT_BACKEND`: `inline`, `local`, `s3`.
462/// Under `madsim`, only `inline` is recognized; the others have no I/O.
463pub fn snapshot_store_from_env() -> Result<Option<SharedSnapshotStore>, SnapshotStoreError> {
464    let backend = std::env::var("URSULA_SNAPSHOT_BACKEND")
465        .unwrap_or_else(|_| "inline".to_owned())
466        .to_ascii_lowercase();
467    match backend.as_str() {
468        "inline" | "default" | "" => Ok(None),
469        #[cfg(not(madsim))]
470        "local" => {
471            let root = std::env::var("URSULA_SNAPSHOT_LOCAL_ROOT").map_err(|_| {
472                SnapshotStoreError::Backend(
473                    "URSULA_SNAPSHOT_LOCAL_ROOT is required for snapshot local backend".into(),
474                )
475            })?;
476            if root.trim().is_empty() {
477                return Err(SnapshotStoreError::Backend(
478                    "URSULA_SNAPSHOT_LOCAL_ROOT must not be empty".into(),
479                ));
480            }
481            Ok(Some(Arc::new(LocalSnapshotStore::new(root))))
482        }
483        #[cfg(not(madsim))]
484        "s3" => Ok(Some(Arc::new(S3SnapshotStore::s3_from_env()?))),
485        #[cfg(madsim)]
486        "local" | "s3" => Err(SnapshotStoreError::Backend(format!(
487            "URSULA_SNAPSHOT_BACKEND '{backend}' has no I/O under madsim; use 'inline'"
488        ))),
489        other => Err(SnapshotStoreError::Backend(format!(
490            "unsupported URSULA_SNAPSHOT_BACKEND '{other}' (expected inline | local | s3)"
491        ))),
492    }
493}
494
495#[cfg(not(madsim))]
496mod local {
497    use std::io;
498    use std::path::PathBuf;
499
500    use super::SnapshotKey;
501    use super::SnapshotLocation;
502    use super::SnapshotStore;
503    use super::SnapshotStoreError;
504    use super::SnapshotStoreFuture;
505
506    /// Bytes live on the local filesystem under a root directory.
507    #[derive(Debug, Clone)]
508    pub struct LocalSnapshotStore {
509        root: PathBuf,
510    }
511
512    impl LocalSnapshotStore {
513        pub fn new(root: impl Into<PathBuf>) -> Self {
514            Self { root: root.into() }
515        }
516
517        fn path_for(&self, key: SnapshotKey) -> PathBuf {
518            self.root
519                .join(format!("group-{}", key.raft_group_id))
520                .join(key.filename())
521        }
522    }
523
524    impl SnapshotStore for LocalSnapshotStore {
525        fn upload<'a>(
526            &'a self,
527            key: SnapshotKey,
528            bytes: Vec<u8>,
529        ) -> SnapshotStoreFuture<'a, SnapshotLocation> {
530            Box::pin(async move {
531                let path = self.path_for(key);
532                if let Some(parent) = path.parent() {
533                    tokio::fs::create_dir_all(parent).await?;
534                }
535                let size_bytes = bytes.len() as u64;
536                tokio::fs::write(&path, &bytes).await?;
537                Ok(SnapshotLocation::Local { path, size_bytes })
538            })
539        }
540
541        fn download<'a>(
542            &'a self,
543            location: &'a SnapshotLocation,
544        ) -> SnapshotStoreFuture<'a, Vec<u8>> {
545            Box::pin(async move {
546                let SnapshotLocation::Local { path, size_bytes } = location else {
547                    return Err(SnapshotStoreError::Backend(format!(
548                        "local snapshot store cannot download {location:?}"
549                    )));
550                };
551                let bytes = tokio::fs::read(path).await.map_err(|err| {
552                    if err.kind() == io::ErrorKind::NotFound {
553                        SnapshotStoreError::NotFound(format!(
554                            "local snapshot missing at {}",
555                            path.display()
556                        ))
557                    } else {
558                        SnapshotStoreError::Io(err)
559                    }
560                })?;
561                if bytes.len() as u64 != *size_bytes {
562                    return Err(SnapshotStoreError::Integrity(format!(
563                        "local snapshot at {} size {} != expected {}",
564                        path.display(),
565                        bytes.len(),
566                        size_bytes
567                    )));
568                }
569                Ok(bytes)
570            })
571        }
572
573        fn delete<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()> {
574            Box::pin(async move {
575                let SnapshotLocation::Local { path, .. } = location else {
576                    return Ok(());
577                };
578                match tokio::fs::remove_file(path).await {
579                    Ok(()) => Ok(()),
580                    Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
581                    Err(err) => Err(SnapshotStoreError::Io(err)),
582                }
583            })
584        }
585    }
586}
587
588#[cfg(not(madsim))]
589pub use local::LocalSnapshotStore;
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594
595    fn test_key(raft_group_id: u32, snapshot_id: &str) -> SnapshotKey {
596        SnapshotKey {
597            raft_group_id,
598            snapshot_id: snapshot_id.to_owned(),
599        }
600    }
601
602    #[tokio::test]
603    async fn inline_roundtrip() {
604        let store = InlineSnapshotStore;
605        let key = test_key(0, "group-0-T1-N1-100");
606        let loc = store.upload(key, b"hello world".to_vec()).await.unwrap();
607        assert!(matches!(loc, SnapshotLocation::Inline { .. }));
608        let bytes = store.download(&loc).await.unwrap();
609        assert_eq!(bytes, b"hello world");
610        store.delete(&loc).await.unwrap();
611    }
612
613    #[tokio::test]
614    async fn inline_rejects_other_location() {
615        let store = InlineSnapshotStore;
616        let loc = SnapshotLocation::Local {
617            path: PathBuf::from("/tmp/nope"),
618            size_bytes: 4,
619        };
620        assert!(matches!(
621            store.download(&loc).await,
622            Err(SnapshotStoreError::Backend(_))
623        ));
624    }
625
626    #[test]
627    fn pointer_encode_decode_inline() {
628        let pointer = SnapshotPointer {
629            snapshot_id: "group-0-1-100".into(),
630            location: SnapshotLocation::Inline {
631                bytes: vec![1, 2, 3, 4],
632            },
633        };
634        let bytes = pointer.encode().unwrap();
635        let back = SnapshotPointer::decode(&bytes).unwrap();
636        assert_eq!(back.snapshot_id, pointer.snapshot_id);
637        match back.location {
638            SnapshotLocation::Inline { bytes } => assert_eq!(bytes, vec![1, 2, 3, 4]),
639            other => panic!("unexpected location: {other:?}"),
640        }
641    }
642
643    #[test]
644    fn pointer_encode_decode_local() {
645        let pointer = SnapshotPointer {
646            snapshot_id: "group-7-2-500".into(),
647            location: SnapshotLocation::Local {
648                path: PathBuf::from("/var/snap/group-7-term-2-log-500.snap"),
649                size_bytes: 12345,
650            },
651        };
652        let bytes = pointer.encode().unwrap();
653        let back = SnapshotPointer::decode(&bytes).unwrap();
654        assert_eq!(back.snapshot_id, pointer.snapshot_id);
655        assert_eq!(back.location.size_hint(), 12345);
656    }
657
658    #[cfg(not(madsim))]
659    #[tokio::test]
660    async fn local_roundtrip() {
661        let dir = tempfile::tempdir().unwrap();
662        let store = LocalSnapshotStore::new(dir.path());
663        let key = test_key(7, "group-7-T2-N1-500");
664        let loc = store
665            .upload(key, b"some snapshot bytes".to_vec())
666            .await
667            .unwrap();
668        let bytes = store.download(&loc).await.unwrap();
669        assert_eq!(bytes, b"some snapshot bytes");
670        store.delete(&loc).await.unwrap();
671        let again = store.download(&loc).await;
672        assert!(matches!(again, Err(SnapshotStoreError::NotFound(_))));
673        // Second delete is a no-op.
674        store.delete(&loc).await.unwrap();
675    }
676
677    #[cfg(not(madsim))]
678    #[tokio::test]
679    async fn s3_memory_roundtrip() {
680        let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
681        let key = test_key(3, "group-3-T5-N2-9876");
682        let loc = store
683            .upload(key, b"raw snapshot bytes".to_vec())
684            .await
685            .unwrap();
686        match &loc {
687            SnapshotLocation::S3 { key, size_bytes } => {
688                assert!(key.starts_with("snapshots/group-3/"));
689                assert_eq!(*size_bytes, b"raw snapshot bytes".len() as u64);
690            }
691            other => panic!("expected S3 location, got {other:?}"),
692        }
693        let bytes = store.download(&loc).await.unwrap();
694        assert_eq!(bytes, b"raw snapshot bytes");
695        store.delete(&loc).await.unwrap();
696        assert!(matches!(
697            store.download(&loc).await,
698            Err(SnapshotStoreError::NotFound(_))
699        ));
700        // Second delete is a no-op.
701        store.delete(&loc).await.unwrap();
702    }
703
704    #[cfg(not(madsim))]
705    #[tokio::test]
706    async fn s3_two_uploads_with_same_snapshot_id_get_different_keys() {
707        // Regression for the 2026-05-31 wedge: snapshot_id is derived from
708        // last_applied_log_id, so two builds during apply-idle compute the
709        // same id. The S3 object key must still be unique per attempt so
710        // distinct published pointers never alias one physical object.
711        let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
712        let key1 = test_key(4, "group-4-T18-N3-264150");
713        let key2 = test_key(4, "group-4-T18-N3-264150");
714        let loc1 = store.upload(key1, b"body1".to_vec()).await.unwrap();
715        let loc2 = store.upload(key2, b"body2".to_vec()).await.unwrap();
716        let (k1, k2) = match (&loc1, &loc2) {
717            (SnapshotLocation::S3 { key: k1, .. }, SnapshotLocation::S3 { key: k2, .. }) => {
718                (k1.clone(), k2.clone())
719            }
720            _ => panic!("expected S3 locations"),
721        };
722        assert_ne!(k1, k2, "same snapshot_id must yield distinct S3 keys");
723        // Both stay independently readable — deleting one must not nuke the
724        // other (the self-GC failure mode).
725        assert_eq!(store.download(&loc1).await.unwrap(), b"body1");
726        assert_eq!(store.download(&loc2).await.unwrap(), b"body2");
727        store.delete(&loc1).await.unwrap();
728        assert!(matches!(
729            store.download(&loc1).await,
730            Err(SnapshotStoreError::NotFound(_))
731        ));
732        assert_eq!(store.download(&loc2).await.unwrap(), b"body2");
733    }
734
735    #[cfg(not(madsim))]
736    #[tokio::test]
737    async fn s3_verify_uploaded_catches_missing_object() {
738        let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
739        let key = test_key(2, "group-2-T1-N1-7");
740        let loc = store.upload(key, b"payload".to_vec()).await.unwrap();
741        // Round-trip after a real upload: must succeed.
742        store.verify_uploaded(&loc).await.unwrap();
743        // Same location, after an out-of-band delete: must report missing so
744        // the snapshot build path can fail fast instead of publishing a
745        // pointer to a 404.
746        store.delete(&loc).await.unwrap();
747        let err = store.verify_uploaded(&loc).await.unwrap_err();
748        assert!(
749            matches!(err, SnapshotStoreError::NotFound(_)),
750            "expected NotFound after delete, got {err:?}"
751        );
752    }
753
754    #[cfg(not(madsim))]
755    #[tokio::test]
756    async fn snapshot_store_from_env_inline_default() {
757        // No env set → inline default.
758        // Sanity check: clear any backend var that might leak from the host env.
759        // SAFETY: tests run single-threaded for env, and the value is restored
760        // below. The harness is expected to be single-threaded for env-based
761        // tests anyway.
762        let prev = std::env::var("URSULA_SNAPSHOT_BACKEND").ok();
763        // SAFETY: removing/setting env vars in a test guarded by single env
764        // mutation per test; the workspace test harness is multi-threaded but
765        // this test only inspects the absence path.
766        unsafe {
767            std::env::remove_var("URSULA_SNAPSHOT_BACKEND");
768        }
769        let result = snapshot_store_from_env().unwrap();
770        assert!(result.is_none());
771        if let Some(prev) = prev {
772            unsafe {
773                std::env::set_var("URSULA_SNAPSHOT_BACKEND", prev);
774            }
775        }
776    }
777
778    #[cfg(not(madsim))]
779    #[tokio::test]
780    async fn local_integrity_detects_size_mismatch() {
781        let dir = tempfile::tempdir().unwrap();
782        let store = LocalSnapshotStore::new(dir.path());
783        let key = test_key(1, "group-1-T1-N1-1");
784        let loc = store.upload(key, b"abcd".to_vec()).await.unwrap();
785        let SnapshotLocation::Local { path, .. } = &loc else {
786            unreachable!()
787        };
788        tokio::fs::write(path, b"abcde").await.unwrap();
789        let result = store.download(&loc).await;
790        assert!(matches!(result, Err(SnapshotStoreError::Integrity(_))));
791    }
792}