1use 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
35pub struct SnapshotKey {
36 pub raft_group_id: u32,
37 pub snapshot_id: String,
38}
39
40#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(tag = "kind", rename_all = "snake_case")]
56pub enum SnapshotLocation {
57 Inline {
60 #[serde(with = "serde_bytes_vec")]
61 bytes: Vec<u8>,
62 },
63 Local { path: PathBuf, size_bytes: u64 },
65 S3 {
67 key: String,
68 size_bytes: u64,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
73 stored_size_bytes: Option<u64>,
74 #[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 Vec::<u8>::deserialize(de)
130 }
131}
132
133#[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 fn upload<'a>(
185 &'a self,
186 key: SnapshotKey,
187 bytes: Bytes,
188 ) -> SnapshotStoreFuture<'a, SnapshotLocation>;
189
190 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 fn download<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, Vec<u8>>;
207
208 fn delete<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()>;
210
211 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 fn health_check(&self) -> SnapshotStoreFuture<'_, ()> {
230 Box::pin(async move { Ok(()) })
231 }
232
233 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
252pub fn default_snapshot_store() -> SharedSnapshotStore {
254 Arc::new(InlineSnapshotStore)
255}
256
257#[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 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 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 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 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 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
644pub 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 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 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 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 store.verify_uploaded(&loc).await.unwrap();
834 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}