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