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::collections::BTreeMap;
14use std::collections::BTreeSet;
15use std::fmt::Debug;
16use std::future::Future;
17use std::io;
18use std::path::PathBuf;
19use std::pin::Pin;
20use std::sync::Arc;
21
22use bytes::Bytes;
23use serde::Deserialize;
24use serde::Serialize;
25
26/// Node identities that may persist an external snapshot pointer for each
27/// group. S3 pruning is enabled only after every expected voter has published
28/// its current reference, which makes rolling upgrades fail closed.
29#[derive(Debug, Clone)]
30pub struct SnapshotReferenceConfig {
31    pub node_id: u64,
32    pub default_voters: BTreeSet<u64>,
33    pub per_group_voters: BTreeMap<u32, BTreeSet<u64>>,
34}
35
36impl SnapshotReferenceConfig {
37    fn voters_for(&self, raft_group_id: u32) -> &BTreeSet<u64> {
38        self.per_group_voters
39            .get(&raft_group_id)
40            .unwrap_or(&self.default_voters)
41    }
42}
43
44/// Identifier the store uses to derive a key/path for a snapshot blob.
45///
46/// `snapshot_id` is the openraft-provided id (group + leader + log index).
47/// Repeated builds at the same applied index may reuse it, so stores must not
48/// treat it as a unique physical-object identity.
49#[derive(Debug, Clone, PartialEq, Eq, Hash)]
50pub struct SnapshotKey {
51    pub raft_group_id: u32,
52    pub snapshot_id: String,
53}
54
55/// Where a snapshot blob lives. Carried in [`SnapshotPointer`] over openraft.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(tag = "kind", rename_all = "snake_case")]
58pub enum SnapshotLocation {
59    /// Bytes live inline in the location. Round-trips through openraft with no
60    /// external store touch — matches the legacy in-memory snapshot shape.
61    Inline {
62        #[serde(with = "serde_bytes_vec")]
63        bytes: Vec<u8>,
64    },
65    /// Bytes live on the local filesystem at `path` (dev / single-host).
66    Local { path: PathBuf, size_bytes: u64 },
67    /// Bytes live in an object storage backend at `key` (S3-compatible).
68    S3 {
69        key: String,
70        /// Logical snapshot size after decompression.
71        size_bytes: u64,
72        /// Physical object size in S3. Legacy pointers omit this and use
73        /// `size_bytes` as both logical and physical size.
74        #[serde(default, skip_serializing_if = "Option::is_none")]
75        stored_size_bytes: Option<u64>,
76        /// Compression applied to the S3 object body.
77        #[serde(default)]
78        compression: SnapshotCompression,
79        /// The object key is content-addressed and may be referenced by
80        /// several replicas or snapshot pointers. Shared objects must only be
81        /// removed by reference-aware pruning.
82        #[serde(default)]
83        shared_object: bool,
84    },
85}
86
87#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum SnapshotCompression {
90    #[default]
91    None,
92    Zstd,
93}
94
95impl SnapshotLocation {
96    pub fn size_hint(&self) -> u64 {
97        match self {
98            Self::Inline { bytes } => bytes.len() as u64,
99            Self::Local { size_bytes, .. } => *size_bytes,
100            Self::S3 { size_bytes, .. } => *size_bytes,
101        }
102    }
103
104    pub fn stored_size_hint(&self) -> u64 {
105        match self {
106            Self::Inline { bytes } => bytes.len() as u64,
107            Self::Local { size_bytes, .. } => *size_bytes,
108            Self::S3 {
109                size_bytes,
110                stored_size_bytes,
111                ..
112            } => stored_size_bytes.unwrap_or(*size_bytes),
113        }
114    }
115
116    pub fn compression(&self) -> SnapshotCompression {
117        match self {
118            Self::S3 { compression, .. } => *compression,
119            Self::Inline { .. } | Self::Local { .. } => SnapshotCompression::None,
120        }
121    }
122}
123
124mod serde_bytes_vec {
125    use serde::Deserialize;
126    use serde::Deserializer;
127    use serde::Serializer;
128
129    pub fn serialize<S: Serializer>(bytes: &[u8], ser: S) -> Result<S::Ok, S::Error> {
130        ser.serialize_bytes(bytes)
131    }
132
133    pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Vec<u8>, D::Error> {
134        // Accept both `bytes` (efficient binary) and the JSON-array fallback
135        // that serde_json uses by default; we go through Vec<u8> directly.
136        Vec::<u8>::deserialize(de)
137    }
138}
139
140/// Reference shipped through openraft `SnapshotData`. Tiny when the backend
141/// stores the actual bytes out of line.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct SnapshotPointer {
144    pub snapshot_id: String,
145    pub location: SnapshotLocation,
146}
147
148impl SnapshotPointer {
149    pub fn encode(&self) -> Result<Vec<u8>, SnapshotStoreError> {
150        serde_json::to_vec(self).map_err(|err| SnapshotStoreError::Serialize(err.to_string()))
151    }
152
153    pub fn decode(bytes: &[u8]) -> Result<Self, SnapshotStoreError> {
154        serde_json::from_slice(bytes)
155            .map_err(|err| SnapshotStoreError::Deserialize(err.to_string()))
156    }
157}
158
159#[derive(Debug, thiserror::Error)]
160pub enum SnapshotStoreError {
161    #[error("snapshot store backend: {0}")]
162    Backend(String),
163    #[error("snapshot not found: {0}")]
164    NotFound(String),
165    #[error("snapshot integrity: {0}")]
166    Integrity(String),
167    #[error("snapshot serialize: {0}")]
168    Serialize(String),
169    #[error("snapshot deserialize: {0}")]
170    Deserialize(String),
171    #[error("snapshot io: {0}")]
172    Io(#[from] io::Error),
173}
174
175impl SnapshotStoreError {
176    pub fn into_io(self) -> io::Error {
177        match self {
178            Self::Io(err) => err,
179            other => io::Error::other(other.to_string()),
180        }
181    }
182}
183
184pub type SnapshotStoreFuture<'a, T> =
185    Pin<Box<dyn Future<Output = Result<T, SnapshotStoreError>> + Send + 'a>>;
186pub type SnapshotBytesIterator = Box<dyn Iterator<Item = Result<Bytes, SnapshotStoreError>> + Send>;
187
188pub trait SnapshotStore: Send + Sync + Debug {
189    /// Persist a snapshot blob and return its location. Stores own naming and
190    /// MAY ignore parts of `key` (Inline does).
191    fn upload<'a>(
192        &'a self,
193        key: SnapshotKey,
194        bytes: Bytes,
195    ) -> SnapshotStoreFuture<'a, SnapshotLocation>;
196
197    /// Persist snapshot bytes from an incremental producer.
198    fn upload_iter<'a>(
199        &'a self,
200        key: SnapshotKey,
201        chunks: SnapshotBytesIterator,
202    ) -> SnapshotStoreFuture<'a, SnapshotLocation> {
203        Box::pin(async move {
204            let mut bytes = Vec::new();
205            for chunk in chunks {
206                bytes.extend_from_slice(chunk?.as_ref());
207            }
208            self.upload(key, Bytes::from(bytes)).await
209        })
210    }
211
212    /// Retrieve a snapshot blob given its location.
213    fn download<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, Vec<u8>>;
214
215    /// Best-effort delete; missing is not an error.
216    fn delete<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()>;
217
218    /// Best-effort prune of retired snapshots for one Raft group. Backends may
219    /// only delete objects that cannot still be referenced by an OpenRaft
220    /// snapshot pointer. Inline snapshots have no external lifecycle, so the
221    /// default is a no-op.
222    fn prune_retired<'a>(
223        &'a self,
224        _raft_group_id: u32,
225        _current: &'a SnapshotLocation,
226        _retain_latest: usize,
227    ) -> SnapshotStoreFuture<'a, ()> {
228        Box::pin(async move { Ok(()) })
229    }
230
231    /// Publish this node's current durable pointer. External stores use these
232    /// references to prove that an object is unreachable before deleting it;
233    /// callers persist local metadata first and rely on the GC grace period
234    /// while publishing the corresponding external reference.
235    fn publish_reference<'a>(
236        &'a self,
237        _raft_group_id: u32,
238        _location: &'a SnapshotLocation,
239    ) -> SnapshotStoreFuture<'a, ()> {
240        Box::pin(async move { Ok(()) })
241    }
242
243    /// Lightweight liveness probe for the backend, used by the snapshot driver
244    /// to detect local S3 loss WITHOUT triggering a `build_snapshot` (whose
245    /// failure openraft treats as fatal). The default is "always healthy":
246    /// in-memory and local-filesystem backends cannot be remotely unavailable.
247    /// The S3 backend overrides this with a cheap `stat`.
248    fn health_check(&self) -> SnapshotStoreFuture<'_, ()> {
249        Box::pin(async move { Ok(()) })
250    }
251
252    /// Verify that a freshly-uploaded snapshot is actually retrievable from
253    /// the backend. Called immediately after `upload` returns Ok, before the
254    /// new pointer is published. Catches silent partial-success modes
255    /// (multipart upload Init/Part Ok but Complete failed, opendal retry
256    /// returning Ok on cached state, etc.) that would otherwise leave
257    /// `current_snapshot` pointing at a 404. Default no-op for backends that
258    /// can't lie about persistence (Inline keeps bytes in the pointer; Local
259    /// uses a single fs syscall whose Ok means present). The S3 backend
260    /// overrides this with a `stat` round-trip.
261    fn verify_uploaded<'a>(
262        &'a self,
263        _location: &'a SnapshotLocation,
264    ) -> SnapshotStoreFuture<'a, ()> {
265        Box::pin(async move { Ok(()) })
266    }
267}
268
269pub type SharedSnapshotStore = Arc<dyn SnapshotStore>;
270
271/// Default backend used when none is wired: bytes ride inline in the pointer.
272pub fn default_snapshot_store() -> SharedSnapshotStore {
273    Arc::new(InlineSnapshotStore)
274}
275
276/// Bytes live inside the pointer. Equivalent to today's in-memory snapshot.
277#[derive(Debug, Default, Clone, Copy)]
278pub struct InlineSnapshotStore;
279
280impl SnapshotStore for InlineSnapshotStore {
281    fn upload<'a>(
282        &'a self,
283        _key: SnapshotKey,
284        bytes: Bytes,
285    ) -> SnapshotStoreFuture<'a, SnapshotLocation> {
286        Box::pin(async move {
287            Ok(SnapshotLocation::Inline {
288                bytes: bytes.to_vec(),
289            })
290        })
291    }
292
293    fn upload_iter<'a>(
294        &'a self,
295        _key: SnapshotKey,
296        chunks: SnapshotBytesIterator,
297    ) -> SnapshotStoreFuture<'a, SnapshotLocation> {
298        Box::pin(async move {
299            let bytes = collect_inline_snapshot(chunks).await?;
300            Ok(SnapshotLocation::Inline { bytes })
301        })
302    }
303
304    fn download<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, Vec<u8>> {
305        Box::pin(async move {
306            match location {
307                SnapshotLocation::Inline { bytes } => Ok(bytes.clone()),
308                other => Err(SnapshotStoreError::Backend(format!(
309                    "inline snapshot store cannot download {other:?}"
310                ))),
311            }
312        })
313    }
314
315    fn delete<'a>(&'a self, _location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()> {
316        Box::pin(async move { Ok(()) })
317    }
318}
319
320#[cfg(not(madsim))]
321async fn collect_inline_snapshot(
322    chunks: SnapshotBytesIterator,
323) -> Result<Vec<u8>, SnapshotStoreError> {
324    tokio::task::spawn_blocking(move || collect_snapshot_chunks(chunks))
325        .await
326        .map_err(|err| {
327            SnapshotStoreError::Io(io::Error::other(format!(
328                "join inline snapshot encoder: {err}"
329            )))
330        })?
331}
332
333#[cfg(madsim)]
334async fn collect_inline_snapshot(
335    chunks: SnapshotBytesIterator,
336) -> Result<Vec<u8>, SnapshotStoreError> {
337    collect_snapshot_chunks(chunks)
338}
339
340fn collect_snapshot_chunks(chunks: SnapshotBytesIterator) -> Result<Vec<u8>, SnapshotStoreError> {
341    let mut bytes = Vec::new();
342    for chunk in chunks {
343        bytes.extend_from_slice(chunk?.as_ref());
344    }
345    Ok(bytes)
346}
347
348#[cfg(not(madsim))]
349mod s3 {
350    use std::collections::HashSet;
351    use std::io;
352    use std::io::Write;
353    use std::time::Duration;
354    use std::time::SystemTime;
355
356    use bytes::Bytes;
357    use opendal::ErrorKind;
358    use opendal::Operator;
359    use opendal::Scheme;
360
361    use super::SnapshotBytesIterator;
362    use super::SnapshotCompression;
363    use super::SnapshotKey;
364    use super::SnapshotLocation;
365    use super::SnapshotReferenceConfig;
366    use super::SnapshotStore;
367    use super::SnapshotStoreError;
368    use super::SnapshotStoreFuture;
369
370    const S3_SNAPSHOT_ZSTD_LEVEL: i32 = 3;
371    const S3_SNAPSHOT_GC_GRACE: Duration = Duration::from_secs(60 * 60);
372    const SNAPSHOT_REFERENCE_VERSION: u32 = 1;
373
374    #[derive(serde::Deserialize, serde::Serialize)]
375    struct SnapshotReference {
376        version: u32,
377        node_id: u64,
378        raft_group_id: u32,
379        snapshot_key: Option<String>,
380    }
381
382    /// Bytes live in an opendal-managed S3 bucket under `{prefix}/group-{gid}/`.
383    pub struct S3SnapshotStore {
384        operator: Operator,
385        prefix: String,
386        references: Option<SnapshotReferenceConfig>,
387        gc_grace: Duration,
388    }
389
390    impl std::fmt::Debug for S3SnapshotStore {
391        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
392            f.debug_struct("S3SnapshotStore")
393                .field("prefix", &self.prefix)
394                .field("references", &self.references)
395                .field("gc_grace", &self.gc_grace)
396                .finish_non_exhaustive()
397        }
398    }
399
400    impl S3SnapshotStore {
401        pub fn new(operator: Operator, prefix: impl Into<String>) -> Self {
402            let mut prefix = prefix.into();
403            while prefix.ends_with('/') {
404                prefix.pop();
405            }
406            Self {
407                operator,
408                prefix,
409                references: None,
410                gc_grace: S3_SNAPSHOT_GC_GRACE,
411            }
412        }
413
414        pub fn with_references(mut self, references: SnapshotReferenceConfig) -> Self {
415            self.references = Some(references);
416            self
417        }
418
419        #[cfg(test)]
420        pub(crate) fn with_gc_grace_for_tests(mut self, gc_grace: Duration) -> Self {
421            self.gc_grace = gc_grace;
422            self
423        }
424
425        /// In-memory opendal operator under `prefix`, for tests.
426        pub fn memory_for_tests(prefix: impl Into<String>) -> Result<Self, SnapshotStoreError> {
427            let operator = Operator::via_iter(Scheme::Memory, [])
428                .map_err(|err| SnapshotStoreError::Backend(err.to_string()))?;
429            Ok(Self::new(operator, prefix))
430        }
431
432        #[cfg(test)]
433        pub(crate) async fn write_raw_for_tests(
434            &self,
435            key: &str,
436            bytes: Vec<u8>,
437        ) -> Result<(), SnapshotStoreError> {
438            self.operator
439                .write(key, bytes)
440                .await
441                .map_err(|err| SnapshotStoreError::Backend(err.to_string()))
442        }
443
444        #[cfg(test)]
445        pub(crate) async fn delete_raw_for_tests(
446            &self,
447            key: &str,
448        ) -> Result<(), SnapshotStoreError> {
449            self.operator
450                .delete(key)
451                .await
452                .map_err(|err| SnapshotStoreError::Backend(err.to_string()))
453        }
454
455        /// Build an S3 snapshot store from a [`ColdConfig`].
456        /// Snapshot blobs share the cold bucket/credentials and use `prefix`
457        /// (defaults to `snapshots`) for separation.
458        pub fn try_new(
459            config: &crate::ColdConfig,
460            prefix: impl Into<String>,
461        ) -> Result<Self, SnapshotStoreError> {
462            let s3 = config.s3.as_ref().ok_or_else(|| {
463                SnapshotStoreError::Backend("S3 config is required for snapshot s3 backend".into())
464            })?;
465            let bucket = s3.bucket.as_deref().ok_or_else(|| {
466                SnapshotStoreError::Backend("S3 bucket is required for snapshot s3 backend".into())
467            })?;
468            if bucket.trim().is_empty() {
469                return Err(SnapshotStoreError::Backend(
470                    "snapshot s3 bucket must not be empty".into(),
471                ));
472            }
473            let mut builder = opendal::services::S3::default().bucket(bucket);
474            if let Some(root) = config.root.as_deref()
475                && !root.trim().is_empty()
476            {
477                builder = builder.root(root);
478            }
479            if let Some(region) = s3.region.as_deref()
480                && !region.trim().is_empty()
481            {
482                builder = builder.region(region);
483            }
484            if let Some(endpoint) = s3.endpoint.as_deref()
485                && !endpoint.trim().is_empty()
486            {
487                builder = builder.endpoint(endpoint);
488            }
489            if let Some(access) = s3.access_key_id.as_deref()
490                && !access.trim().is_empty()
491            {
492                builder = builder.access_key_id(access);
493            }
494            if let Some(secret) = s3.secret_access_key.as_deref()
495                && !secret.trim().is_empty()
496            {
497                builder = builder.secret_access_key(secret);
498            }
499            if let Some(token) = s3.session_token.as_deref()
500                && !token.trim().is_empty()
501            {
502                builder = builder.session_token(token);
503            }
504            // Backup/snapshot objects inherit the cold tier's encryption
505            // posture (#149).
506            let (builder, _encryption) = crate::cold_store::apply_s3_encryption(builder, s3)
507                .map_err(|err| SnapshotStoreError::Backend(err.to_string()))?;
508            let operator = crate::cold_store::with_s3_resilience(
509                Operator::new(builder)
510                    .map_err(|err| SnapshotStoreError::Backend(err.to_string()))?
511                    .finish(),
512                s3.timeout.as_duration(),
513                s3.max_retries,
514            );
515            Ok(Self::new(operator, prefix))
516        }
517
518        fn object_key(&self, key: &SnapshotKey, digest: blake3::Hash) -> String {
519            format!(
520                "{}/group-{}/objects/{}.snap",
521                self.prefix,
522                key.raft_group_id,
523                digest.to_hex(),
524            )
525        }
526
527        fn group_prefix(&self, raft_group_id: u32) -> String {
528            format!("{}/group-{raft_group_id}/", self.prefix)
529        }
530
531        fn reference_key(&self, raft_group_id: u32, node_id: u64) -> String {
532            format!(
533                "{}references/node-{node_id}.json",
534                self.group_prefix(raft_group_id)
535            )
536        }
537
538        async fn write_content_once(
539            &self,
540            object_key: &str,
541            stored_bytes: Vec<u8>,
542        ) -> Result<u64, SnapshotStoreError> {
543            let stored_size_bytes = stored_bytes.len() as u64;
544            if self
545                .operator
546                .info()
547                .full_capability()
548                .write_with_if_not_exists
549            {
550                match self
551                    .operator
552                    .write_with(object_key, stored_bytes)
553                    .if_not_exists(true)
554                    .await
555                {
556                    Ok(_) => {}
557                    Err(err)
558                        if matches!(
559                            err.kind(),
560                            ErrorKind::AlreadyExists | ErrorKind::ConditionNotMatch
561                        ) =>
562                    {
563                        let metadata =
564                            self.operator.stat(object_key).await.map_err(|stat_error| {
565                                SnapshotStoreError::Backend(format!(
566                                    "stat shared s3 snapshot after create race: {stat_error}"
567                                ))
568                            })?;
569                        if metadata.content_length() != stored_size_bytes {
570                            return Err(SnapshotStoreError::Integrity(format!(
571                                "shared s3 snapshot {object_key} size {} != expected {stored_size_bytes}",
572                                metadata.content_length()
573                            )));
574                        }
575                    }
576                    Err(err) => return Err(SnapshotStoreError::Backend(err.to_string())),
577                }
578            } else {
579                // The production S3 backend supports conditional creation. This
580                // fallback keeps capability-limited test backends useful.
581                match self.operator.stat(object_key).await {
582                    Ok(metadata) => {
583                        if metadata.content_length() != stored_size_bytes {
584                            return Err(SnapshotStoreError::Integrity(format!(
585                                "shared snapshot {object_key} size {} != expected {stored_size_bytes}",
586                                metadata.content_length()
587                            )));
588                        }
589                    }
590                    Err(err) if matches!(err.kind(), ErrorKind::NotFound) => {
591                        self.operator
592                            .write(object_key, stored_bytes)
593                            .await
594                            .map_err(|write_error| {
595                                SnapshotStoreError::Backend(write_error.to_string())
596                            })?;
597                    }
598                    Err(err) => return Err(SnapshotStoreError::Backend(err.to_string())),
599                }
600            }
601            Ok(stored_size_bytes)
602        }
603    }
604
605    fn compress_snapshot_chunks(
606        chunks: SnapshotBytesIterator,
607    ) -> Result<(Vec<u8>, u64, blake3::Hash), SnapshotStoreError> {
608        let mut size_bytes = 0u64;
609        let mut encoder = zstd::stream::write::Encoder::new(Vec::new(), S3_SNAPSHOT_ZSTD_LEVEL)
610            .map_err(|err| SnapshotStoreError::Backend(format!("compress s3 snapshot: {err}")))?;
611        for chunk in chunks {
612            let chunk = chunk?;
613            size_bytes = size_bytes.checked_add(chunk.len() as u64).ok_or_else(|| {
614                SnapshotStoreError::Integrity("s3 snapshot size overflows u64".to_owned())
615            })?;
616            encoder.write_all(&chunk).map_err(|err| {
617                SnapshotStoreError::Backend(format!("compress s3 snapshot: {err}"))
618            })?;
619        }
620        let stored_bytes = encoder.finish().map_err(|err| {
621            SnapshotStoreError::Backend(format!("finish s3 snapshot compression: {err}"))
622        })?;
623        let digest = blake3::hash(&stored_bytes);
624        Ok((stored_bytes, size_bytes, digest))
625    }
626
627    impl SnapshotStore for S3SnapshotStore {
628        fn upload<'a>(
629            &'a self,
630            key: SnapshotKey,
631            bytes: Bytes,
632        ) -> SnapshotStoreFuture<'a, SnapshotLocation> {
633            Box::pin(async move {
634                let size_bytes = bytes.len() as u64;
635                let stored_bytes =
636                    zstd::bulk::compress(&bytes, S3_SNAPSHOT_ZSTD_LEVEL).map_err(|err| {
637                        SnapshotStoreError::Backend(format!("compress s3 snapshot: {err}"))
638                    })?;
639                let object_key = self.object_key(&key, blake3::hash(&stored_bytes));
640                let stored_size_bytes = self.write_content_once(&object_key, stored_bytes).await?;
641                Ok(SnapshotLocation::S3 {
642                    key: object_key,
643                    size_bytes,
644                    stored_size_bytes: Some(stored_size_bytes),
645                    compression: SnapshotCompression::Zstd,
646                    shared_object: true,
647                })
648            })
649        }
650
651        fn upload_iter<'a>(
652            &'a self,
653            key: SnapshotKey,
654            chunks: SnapshotBytesIterator,
655        ) -> SnapshotStoreFuture<'a, SnapshotLocation> {
656            Box::pin(async move {
657                let encoded = tokio::task::spawn_blocking(move || compress_snapshot_chunks(chunks))
658                    .await
659                    .map_err(|err| {
660                        SnapshotStoreError::Io(io::Error::other(format!(
661                            "join s3 snapshot encoder: {err}"
662                        )))
663                    })??;
664                let (stored_bytes, size_bytes, digest) = encoded;
665                let object_key = self.object_key(&key, digest);
666                let stored_size_bytes = self.write_content_once(&object_key, stored_bytes).await?;
667                Ok(SnapshotLocation::S3 {
668                    key: object_key,
669                    size_bytes,
670                    stored_size_bytes: Some(stored_size_bytes),
671                    compression: SnapshotCompression::Zstd,
672                    shared_object: true,
673                })
674            })
675        }
676
677        fn download<'a>(
678            &'a self,
679            location: &'a SnapshotLocation,
680        ) -> SnapshotStoreFuture<'a, Vec<u8>> {
681            Box::pin(async move {
682                let SnapshotLocation::S3 {
683                    key, size_bytes, ..
684                } = location
685                else {
686                    return Err(SnapshotStoreError::Backend(format!(
687                        "s3 snapshot store cannot download {location:?}"
688                    )));
689                };
690                let buf = self.operator.read(key).await.map_err(|err| {
691                    if matches!(err.kind(), opendal::ErrorKind::NotFound) {
692                        SnapshotStoreError::NotFound(format!("s3 snapshot missing at {key}"))
693                    } else {
694                        SnapshotStoreError::Backend(err.to_string())
695                    }
696                })?;
697                let stored_bytes = buf.to_vec();
698                let expected_stored_size = location.stored_size_hint();
699                if stored_bytes.len() as u64 != expected_stored_size {
700                    return Err(SnapshotStoreError::Integrity(format!(
701                        "s3 snapshot {key} stored size {} != expected {}",
702                        stored_bytes.len(),
703                        expected_stored_size
704                    )));
705                }
706                let bytes = match location.compression() {
707                    SnapshotCompression::None => stored_bytes,
708                    SnapshotCompression::Zstd => zstd::bulk::decompress(
709                        &stored_bytes,
710                        usize::try_from(*size_bytes).map_err(|_| {
711                            SnapshotStoreError::Integrity(format!(
712                                "s3 snapshot {key} logical size {size_bytes} does not fit usize"
713                            ))
714                        })?,
715                    )
716                    .map_err(|err| {
717                        SnapshotStoreError::Integrity(format!(
718                            "decompress s3 snapshot {key}: {err}"
719                        ))
720                    })?,
721                };
722                if bytes.len() as u64 != *size_bytes {
723                    return Err(SnapshotStoreError::Integrity(format!(
724                        "s3 snapshot {key} logical size {} != expected {}",
725                        bytes.len(),
726                        size_bytes
727                    )));
728                }
729                Ok(bytes)
730            })
731        }
732
733        fn delete<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()> {
734            Box::pin(async move {
735                let SnapshotLocation::S3 {
736                    key, shared_object, ..
737                } = location
738                else {
739                    return Ok(());
740                };
741                if *shared_object {
742                    return Ok(());
743                }
744                match self.operator.delete(key).await {
745                    Ok(()) => Ok(()),
746                    Err(err) if matches!(err.kind(), opendal::ErrorKind::NotFound) => Ok(()),
747                    Err(err) => Err(SnapshotStoreError::Backend(err.to_string())),
748                }
749            })
750        }
751
752        fn prune_retired<'a>(
753            &'a self,
754            raft_group_id: u32,
755            current: &'a SnapshotLocation,
756            retain_latest: usize,
757        ) -> SnapshotStoreFuture<'a, ()> {
758            Box::pin(async move {
759                let SnapshotLocation::S3 {
760                    key: current_key, ..
761                } = current
762                else {
763                    return Ok(());
764                };
765                let Some(references) = &self.references else {
766                    return Ok(());
767                };
768                let expected_voters = references.voters_for(raft_group_id);
769                if expected_voters.is_empty() {
770                    return Ok(());
771                }
772                let group_prefix = self.group_prefix(raft_group_id);
773                let mut retained = HashSet::from([current_key.clone()]);
774                for node_id in expected_voters {
775                    let reference_key = self.reference_key(raft_group_id, *node_id);
776                    let reference_bytes = match self.operator.read(&reference_key).await {
777                        Ok(bytes) => bytes,
778                        Err(error) if matches!(error.kind(), opendal::ErrorKind::NotFound) => {
779                            tracing::debug!(
780                                raft_group_id,
781                                node_id,
782                                "deferring S3 snapshot pruning until every voter publishes a reference"
783                            );
784                            return Ok(());
785                        }
786                        Err(error) => {
787                            return Err(SnapshotStoreError::Backend(error.to_string()));
788                        }
789                    };
790                    let reference: SnapshotReference =
791                        serde_json::from_slice(&reference_bytes.to_vec())
792                            .map_err(|error| SnapshotStoreError::Deserialize(error.to_string()))?;
793                    if reference.version != SNAPSHOT_REFERENCE_VERSION
794                        || reference.node_id != *node_id
795                        || reference.raft_group_id != raft_group_id
796                    {
797                        return Err(SnapshotStoreError::Integrity(format!(
798                            "invalid S3 snapshot reference {reference_key}"
799                        )));
800                    }
801                    if let Some(key) = reference.snapshot_key {
802                        if !key.starts_with(&group_prefix) || !key.ends_with(".snap") {
803                            return Err(SnapshotStoreError::Integrity(format!(
804                                "S3 snapshot reference {reference_key} points outside group namespace"
805                            )));
806                        }
807                        retained.insert(key);
808                    }
809                }
810                let cutoff = SystemTime::now()
811                    .checked_sub(self.gc_grace)
812                    .unwrap_or(SystemTime::UNIX_EPOCH);
813                let entries = self
814                    .operator
815                    .list_with(&group_prefix)
816                    .recursive(true)
817                    .await
818                    .map_err(|error| SnapshotStoreError::Backend(error.to_string()))?;
819                for entry in &entries {
820                    if !entry.metadata().mode().is_file()
821                        || !entry
822                            .path()
823                            .starts_with(&format!("{group_prefix}references/"))
824                        || !entry.path().ends_with(".json")
825                    {
826                        continue;
827                    }
828                    let bytes = self
829                        .operator
830                        .read(entry.path())
831                        .await
832                        .map_err(|error| SnapshotStoreError::Backend(error.to_string()))?;
833                    let reference: SnapshotReference = serde_json::from_slice(&bytes.to_vec())
834                        .map_err(|error| SnapshotStoreError::Deserialize(error.to_string()))?;
835                    if reference.version != SNAPSHOT_REFERENCE_VERSION
836                        || reference.raft_group_id != raft_group_id
837                    {
838                        return Err(SnapshotStoreError::Integrity(format!(
839                            "invalid S3 snapshot reference {}",
840                            entry.path()
841                        )));
842                    }
843                    if let Some(key) = reference.snapshot_key {
844                        if !key.starts_with(&group_prefix) || !key.ends_with(".snap") {
845                            return Err(SnapshotStoreError::Integrity(format!(
846                                "S3 snapshot reference {} points outside group namespace",
847                                entry.path()
848                            )));
849                        }
850                        retained.insert(key);
851                    }
852                }
853                let mut retired = Vec::new();
854                for entry in entries {
855                    if !entry.metadata().mode().is_file()
856                        || !entry.path().ends_with(".snap")
857                        || retained.contains(entry.path())
858                    {
859                        continue;
860                    }
861                    let modified = match entry.metadata().last_modified() {
862                        Some(modified) => Some(modified.into()),
863                        None => self
864                            .operator
865                            .stat(entry.path())
866                            .await
867                            .map_err(|error| SnapshotStoreError::Backend(error.to_string()))?
868                            .last_modified()
869                            .map(Into::into)
870                            .or_else(|| self.gc_grace.is_zero().then_some(SystemTime::UNIX_EPOCH)),
871                    };
872                    if let Some(modified) = modified
873                        && modified <= cutoff
874                    {
875                        retired.push((modified, entry.path().to_owned()));
876                    }
877                }
878                retired.sort_unstable_by(|left, right| right.cmp(left));
879                let mut deleted = 0_usize;
880                for (_modified, key) in retired.into_iter().skip(retain_latest) {
881                    self.operator
882                        .delete(&key)
883                        .await
884                        .map_err(|error| SnapshotStoreError::Backend(error.to_string()))?;
885                    deleted = deleted.saturating_add(1);
886                }
887                if deleted > 0 {
888                    tracing::info!(
889                        raft_group_id,
890                        deleted,
891                        retained = retained.len(),
892                        "pruned unreachable S3 snapshot objects"
893                    );
894                }
895                Ok(())
896            })
897        }
898
899        fn publish_reference<'a>(
900            &'a self,
901            raft_group_id: u32,
902            location: &'a SnapshotLocation,
903        ) -> SnapshotStoreFuture<'a, ()> {
904            Box::pin(async move {
905                let Some(references) = &self.references else {
906                    return Ok(());
907                };
908                let snapshot_key = match location {
909                    SnapshotLocation::S3 { key, .. } => {
910                        let group_prefix = self.group_prefix(raft_group_id);
911                        if !key.starts_with(&group_prefix) || !key.ends_with(".snap") {
912                            return Err(SnapshotStoreError::Integrity(format!(
913                                "S3 snapshot key {key} is outside group {raft_group_id} namespace"
914                            )));
915                        }
916                        Some(key.clone())
917                    }
918                    SnapshotLocation::Inline { .. } | SnapshotLocation::Local { .. } => None,
919                };
920                let reference = serde_json::to_vec(&SnapshotReference {
921                    version: SNAPSHOT_REFERENCE_VERSION,
922                    node_id: references.node_id,
923                    raft_group_id,
924                    snapshot_key,
925                })
926                .map_err(|error| SnapshotStoreError::Serialize(error.to_string()))?;
927                self.operator
928                    .write(
929                        &self.reference_key(raft_group_id, references.node_id),
930                        reference,
931                    )
932                    .await
933                    .map_err(|error| SnapshotStoreError::Backend(error.to_string()))
934            })
935        }
936
937        fn verify_uploaded<'a>(
938            &'a self,
939            location: &'a SnapshotLocation,
940        ) -> SnapshotStoreFuture<'a, ()> {
941            Box::pin(async move {
942                let SnapshotLocation::S3 { key, .. } = location else {
943                    return Ok(());
944                };
945                let meta = self.operator.stat(key).await.map_err(|err| {
946                    if matches!(err.kind(), opendal::ErrorKind::NotFound) {
947                        SnapshotStoreError::NotFound(format!(
948                            "s3 snapshot upload verification failed: {key} not present after upload"
949                        ))
950                    } else {
951                        SnapshotStoreError::Backend(err.to_string())
952                    }
953                })?;
954                let actual = meta.content_length();
955                let expected = location.stored_size_hint();
956                if actual != expected {
957                    return Err(SnapshotStoreError::Integrity(format!(
958                        "s3 snapshot {key} stored size mismatch post-upload: stat={actual} expected={expected}"
959                    )));
960                }
961                Ok(())
962            })
963        }
964
965        fn health_check(&self) -> SnapshotStoreFuture<'_, ()> {
966            Box::pin(async move {
967                // A `stat` on a probe key is a single cheap round-trip that goes
968                // through the same TimeoutLayer/RetryLayer as real writes, so it
969                // reports unreachable S3 (timeout / connection error) without
970                // building a snapshot. `NotFound` means S3 answered — healthy.
971                let probe = format!("{}/.health-probe", self.prefix);
972                match self.operator.stat(&probe).await {
973                    Ok(_) => Ok(()),
974                    Err(err) if matches!(err.kind(), opendal::ErrorKind::NotFound) => Ok(()),
975                    Err(err) => Err(SnapshotStoreError::Backend(err.to_string())),
976                }
977            })
978        }
979    }
980}
981
982#[cfg(not(madsim))]
983pub use s3::S3SnapshotStore;
984
985/// Pick a snapshot store from a typed `ursula_config::RaftSnapshotConfig`. Returns `None`
986/// when the backend is "inline" (the default) so callers can fall back to
987/// [`default_snapshot_store`] without instantiating anything.
988pub fn snapshot_store_from_config(
989    cfg: &ursula_config::RaftSnapshotConfig,
990    cold_cfg: &crate::ColdConfig,
991    references: SnapshotReferenceConfig,
992) -> Result<Option<SharedSnapshotStore>, SnapshotStoreError> {
993    match cfg.backend {
994        ursula_config::RaftSnapshotBackend::Inline => Ok(None),
995        #[cfg(not(madsim))]
996        ursula_config::RaftSnapshotBackend::S3 => {
997            // `try_new` configures the OpenDAL operator with `cold_cfg.root`, so
998            // this namespace must stay relative to that root.
999            let prefix = snapshot_namespace(cfg);
1000            Ok(Some(Arc::new(
1001                S3SnapshotStore::try_new(cold_cfg, &prefix)?.with_references(references),
1002            )))
1003        }
1004        #[cfg(madsim)]
1005        ursula_config::RaftSnapshotBackend::S3 => Err(SnapshotStoreError::Backend(format!(
1006            "snapshot backend {:?} has no I/O under madsim; use 'inline'",
1007            cfg.backend
1008        ))),
1009    }
1010}
1011
1012#[cfg(not(madsim))]
1013fn snapshot_namespace(cfg: &ursula_config::RaftSnapshotConfig) -> String {
1014    cfg.s3_prefix
1015        .as_deref()
1016        .unwrap_or("snapshots")
1017        .trim_matches('/')
1018        .to_owned()
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023    use super::*;
1024
1025    fn test_key(raft_group_id: u32, snapshot_id: &str) -> SnapshotKey {
1026        SnapshotKey {
1027            raft_group_id,
1028            snapshot_id: snapshot_id.to_owned(),
1029        }
1030    }
1031
1032    #[cfg(not(madsim))]
1033    #[test]
1034    fn snapshot_namespace_stays_relative_to_the_cold_root() {
1035        let config = ursula_config::RaftSnapshotConfig {
1036            backend: ursula_config::RaftSnapshotBackend::S3,
1037            s3_prefix: Some("/snapshots/".to_owned()),
1038            ..Default::default()
1039        };
1040
1041        assert_eq!(snapshot_namespace(&config), "snapshots");
1042    }
1043
1044    #[tokio::test]
1045    async fn inline_roundtrip() {
1046        let store = InlineSnapshotStore;
1047        let key = test_key(0, "group-0-T1-N1-100");
1048        let loc = store
1049            .upload(key, b"hello world".to_vec().into())
1050            .await
1051            .unwrap();
1052        assert!(matches!(loc, SnapshotLocation::Inline { .. }));
1053        let bytes = store.download(&loc).await.unwrap();
1054        assert_eq!(bytes, b"hello world");
1055        store.delete(&loc).await.unwrap();
1056    }
1057
1058    #[cfg(not(madsim))]
1059    #[tokio::test]
1060    async fn inline_iterator_is_consumed_off_the_async_worker() {
1061        let store = InlineSnapshotStore;
1062        let caller = std::thread::current().id();
1063        let (thread_tx, thread_rx) = std::sync::mpsc::channel();
1064        let chunks = Box::new(std::iter::once_with(move || {
1065            thread_tx.send(std::thread::current().id()).unwrap();
1066            Ok(Bytes::from_static(b"snapshot"))
1067        }));
1068
1069        let location = store
1070            .upload_iter(test_key(0, "offloaded"), chunks)
1071            .await
1072            .unwrap();
1073
1074        assert_ne!(thread_rx.recv().unwrap(), caller);
1075        assert_eq!(location, SnapshotLocation::Inline {
1076            bytes: b"snapshot".to_vec()
1077        });
1078    }
1079
1080    #[tokio::test]
1081    async fn inline_rejects_other_location() {
1082        let store = InlineSnapshotStore;
1083        let loc = SnapshotLocation::Local {
1084            path: PathBuf::from("/tmp/nope"),
1085            size_bytes: 4,
1086        };
1087        assert!(matches!(
1088            store.download(&loc).await,
1089            Err(SnapshotStoreError::Backend(_))
1090        ));
1091    }
1092
1093    #[test]
1094    fn pointer_encode_decode_inline() {
1095        let pointer = SnapshotPointer {
1096            snapshot_id: "group-0-1-100".into(),
1097            location: SnapshotLocation::Inline {
1098                bytes: vec![1, 2, 3, 4],
1099            },
1100        };
1101        let bytes = pointer.encode().unwrap();
1102        let back = SnapshotPointer::decode(&bytes).unwrap();
1103        assert_eq!(back.snapshot_id, pointer.snapshot_id);
1104        match back.location {
1105            SnapshotLocation::Inline { bytes } => assert_eq!(bytes, vec![1, 2, 3, 4]),
1106            other => panic!("unexpected location: {other:?}"),
1107        }
1108    }
1109
1110    #[test]
1111    fn pointer_encode_decode_local() {
1112        let pointer = SnapshotPointer {
1113            snapshot_id: "group-7-2-500".into(),
1114            location: SnapshotLocation::Local {
1115                path: PathBuf::from("/var/snap/group-7-term-2-log-500.snap"),
1116                size_bytes: 12345,
1117            },
1118        };
1119        let bytes = pointer.encode().unwrap();
1120        let back = SnapshotPointer::decode(&bytes).unwrap();
1121        assert_eq!(back.snapshot_id, pointer.snapshot_id);
1122        assert_eq!(back.location.size_hint(), 12345);
1123    }
1124
1125    #[test]
1126    fn pointer_decode_defaults_legacy_s3_objects_to_unshared() {
1127        let bytes = br#"{
1128            "snapshot_id":"group-7-2-500",
1129            "location":{
1130                "kind":"s3",
1131                "key":"snapshots/group-7/legacy.snap",
1132                "size_bytes":123,
1133                "compression":"none"
1134            }
1135        }"#;
1136        let pointer = SnapshotPointer::decode(bytes).unwrap();
1137        assert!(matches!(pointer.location, SnapshotLocation::S3 {
1138            shared_object: false,
1139            ..
1140        }));
1141    }
1142
1143    #[cfg(not(madsim))]
1144    #[tokio::test]
1145    async fn s3_memory_roundtrip() {
1146        let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
1147        let key = test_key(3, "group-3-T5-N2-9876");
1148        let payload = b"raw snapshot bytes".repeat(64);
1149        let loc = store.upload(key, payload.clone().into()).await.unwrap();
1150        match &loc {
1151            SnapshotLocation::S3 {
1152                key,
1153                size_bytes,
1154                stored_size_bytes,
1155                compression,
1156                shared_object,
1157            } => {
1158                assert!(key.starts_with("snapshots/group-3/objects/"));
1159                assert_eq!(*size_bytes, payload.len() as u64);
1160                assert_eq!(*compression, SnapshotCompression::Zstd);
1161                assert!(*shared_object);
1162                assert!(stored_size_bytes.is_some());
1163                assert!(stored_size_bytes.unwrap() < *size_bytes);
1164            }
1165            other => panic!("expected S3 location, got {other:?}"),
1166        }
1167        let bytes = store.download(&loc).await.unwrap();
1168        assert_eq!(bytes, payload);
1169        // A content-addressed object may already be referenced by another
1170        // replica. Eager local cleanup must not remove it.
1171        store.delete(&loc).await.unwrap();
1172        assert_eq!(store.download(&loc).await.unwrap(), payload);
1173        store.delete(&loc).await.unwrap();
1174    }
1175
1176    #[cfg(not(madsim))]
1177    #[tokio::test]
1178    async fn s3_iterator_upload_is_compressed_and_offloaded() {
1179        let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
1180        let caller = std::thread::current().id();
1181        let (thread_tx, thread_rx) = std::sync::mpsc::channel();
1182        let chunks = Box::new(std::iter::once_with(move || {
1183            thread_tx.send(std::thread::current().id()).unwrap();
1184            Ok(Bytes::from(vec![b'x'; 64 * 1024]))
1185        }));
1186
1187        let location = store
1188            .upload_iter(test_key(9, "group-9-T1-N1-1"), chunks)
1189            .await
1190            .unwrap();
1191
1192        assert_ne!(thread_rx.recv().unwrap(), caller);
1193        assert_eq!(location.compression(), SnapshotCompression::Zstd);
1194        assert!(location.stored_size_hint() < location.size_hint());
1195        assert_eq!(store.download(&location).await.unwrap(), vec![
1196            b'x';
1197            64 * 1024
1198        ]);
1199    }
1200
1201    #[cfg(not(madsim))]
1202    #[tokio::test]
1203    async fn s3_download_accepts_legacy_uncompressed_pointer() {
1204        let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
1205        let key = test_key(5, "group-5-T1-N1-10");
1206        let loc = store
1207            .upload(key, b"legacy body".to_vec().into())
1208            .await
1209            .unwrap();
1210        let SnapshotLocation::S3 { key, .. } = loc else {
1211            panic!("expected s3 location")
1212        };
1213        store
1214            .write_raw_for_tests(&key, b"legacy body".to_vec())
1215            .await
1216            .unwrap();
1217        let legacy = SnapshotLocation::S3 {
1218            key,
1219            size_bytes: b"legacy body".len() as u64,
1220            stored_size_bytes: None,
1221            compression: SnapshotCompression::None,
1222            shared_object: false,
1223        };
1224        assert_eq!(store.download(&legacy).await.unwrap(), b"legacy body");
1225    }
1226
1227    #[cfg(not(madsim))]
1228    #[tokio::test]
1229    async fn s3_snapshot_keys_are_content_addressed() {
1230        let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
1231        let key1 = test_key(4, "group-4-T18-N3-264150");
1232        let key2 = test_key(4, "group-4-T18-N3-264150");
1233        let key3 = test_key(4, "group-4-T18-N3-264151");
1234        let loc1 = store.upload(key1, b"body1".to_vec().into()).await.unwrap();
1235        let loc2 = store.upload(key2, b"body2".to_vec().into()).await.unwrap();
1236        let loc3 = store.upload(key3, b"body1".to_vec().into()).await.unwrap();
1237        let (k1, k2, k3) = match (&loc1, &loc2, &loc3) {
1238            (
1239                SnapshotLocation::S3 { key: k1, .. },
1240                SnapshotLocation::S3 { key: k2, .. },
1241                SnapshotLocation::S3 { key: k3, .. },
1242            ) => (k1.clone(), k2.clone(), k3.clone()),
1243            _ => panic!("expected S3 locations"),
1244        };
1245        assert_ne!(k1, k2, "different bytes must never alias");
1246        assert_eq!(k1, k3, "identical bytes in one group must share an object");
1247        assert_eq!(store.download(&loc1).await.unwrap(), b"body1");
1248        assert_eq!(store.download(&loc2).await.unwrap(), b"body2");
1249        assert_eq!(store.download(&loc3).await.unwrap(), b"body1");
1250        store.delete(&loc1).await.unwrap();
1251        assert_eq!(store.download(&loc3).await.unwrap(), b"body1");
1252        assert_eq!(store.download(&loc2).await.unwrap(), b"body2");
1253    }
1254
1255    #[cfg(not(madsim))]
1256    #[tokio::test]
1257    async fn s3_verify_uploaded_catches_missing_object() {
1258        let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
1259        let key = test_key(2, "group-2-T1-N1-7");
1260        let loc = store.upload(key, b"payload".to_vec().into()).await.unwrap();
1261        // Round-trip after a real upload: must succeed.
1262        store.verify_uploaded(&loc).await.unwrap();
1263        // Same location, after an out-of-band delete: must report missing so
1264        // the snapshot build path can fail fast instead of publishing a
1265        // pointer to a 404.
1266        let SnapshotLocation::S3 { key, .. } = &loc else {
1267            panic!("expected S3 location")
1268        };
1269        store.delete_raw_for_tests(key).await.unwrap();
1270        let err = store.verify_uploaded(&loc).await.unwrap_err();
1271        assert!(
1272            matches!(err, SnapshotStoreError::NotFound(_)),
1273            "expected NotFound after delete, got {err:?}"
1274        );
1275    }
1276
1277    #[cfg(not(madsim))]
1278    #[tokio::test]
1279    async fn s3_pruning_waits_for_every_voter_and_preserves_their_references() {
1280        use std::collections::BTreeMap;
1281        use std::collections::BTreeSet;
1282        use std::time::Duration;
1283
1284        let references = SnapshotReferenceConfig {
1285            node_id: 1,
1286            default_voters: BTreeSet::from([1, 2, 3]),
1287            per_group_voters: BTreeMap::new(),
1288        };
1289        let store = S3SnapshotStore::memory_for_tests("snapshots")
1290            .unwrap()
1291            .with_references(references)
1292            .with_gc_grace_for_tests(Duration::ZERO);
1293        let retired = store
1294            .upload(test_key(7, "retired"), b"retired".to_vec().into())
1295            .await
1296            .unwrap();
1297        let node_two = store
1298            .upload(test_key(7, "node-two"), b"node-two".to_vec().into())
1299            .await
1300            .unwrap();
1301        let node_three = store
1302            .upload(test_key(7, "node-three"), b"node-three".to_vec().into())
1303            .await
1304            .unwrap();
1305        let current = store
1306            .upload(test_key(7, "current"), b"current".to_vec().into())
1307            .await
1308            .unwrap();
1309        store.publish_reference(7, &current).await.unwrap();
1310
1311        store.prune_retired(7, &current, 0).await.unwrap();
1312        assert_eq!(store.download(&retired).await.unwrap(), b"retired");
1313
1314        for (node_id, location) in [(2, &node_two), (3, &node_three)] {
1315            let SnapshotLocation::S3 { key, .. } = location else {
1316                panic!("expected S3 location")
1317            };
1318            store
1319                .write_raw_for_tests(
1320                    &format!("snapshots/group-7/references/node-{node_id}.json"),
1321                    serde_json::to_vec(&serde_json::json!({
1322                        "version": 1,
1323                        "node_id": node_id,
1324                        "raft_group_id": 7,
1325                        "snapshot_key": key,
1326                    }))
1327                    .unwrap(),
1328                )
1329                .await
1330                .unwrap();
1331        }
1332        store.prune_retired(7, &current, 0).await.unwrap();
1333        assert!(matches!(
1334            store.download(&retired).await,
1335            Err(SnapshotStoreError::NotFound(_))
1336        ));
1337        assert_eq!(store.download(&node_two).await.unwrap(), b"node-two");
1338        assert_eq!(store.download(&node_three).await.unwrap(), b"node-three");
1339        assert_eq!(store.download(&current).await.unwrap(), b"current");
1340    }
1341}