1use std::fmt::Debug;
15use std::future::Future;
16use std::io;
17use std::path::PathBuf;
18use std::pin::Pin;
19use std::sync::Arc;
20use std::sync::atomic::AtomicU64;
21use std::sync::atomic::Ordering;
22
23use bytes::Bytes;
24use crossbeam_utils::CachePadded;
25use serde::Deserialize;
26use serde::Serialize;
27
28#[derive(Debug, Clone, PartialEq, Eq, Hash)]
33pub struct SnapshotKey {
34 pub raft_group_id: u32,
35 pub snapshot_id: String,
36}
37
38fn unique_snapshot_leaf(snapshot_id: &str) -> String {
39 static COUNTER: CachePadded<AtomicU64> = CachePadded::new(AtomicU64::new(0));
40 let nonce_nanos = std::time::SystemTime::now()
41 .duration_since(std::time::UNIX_EPOCH)
42 .map(|d| d.as_nanos())
43 .unwrap_or(0);
44 let nonce_seq = COUNTER.fetch_add(1, Ordering::Relaxed);
45 format!("{snapshot_id}-{nonce_nanos:032}-{nonce_seq:020}.snap")
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(tag = "kind", rename_all = "snake_case")]
51pub enum SnapshotLocation {
52 Inline {
55 #[serde(with = "serde_bytes_vec")]
56 bytes: Vec<u8>,
57 },
58 Local { path: PathBuf, size_bytes: u64 },
60 S3 {
62 key: String,
63 size_bytes: u64,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
68 stored_size_bytes: Option<u64>,
69 #[serde(default)]
71 compression: SnapshotCompression,
72 },
73}
74
75#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum SnapshotCompression {
78 #[default]
79 None,
80 Zstd,
81}
82
83impl SnapshotLocation {
84 pub fn size_hint(&self) -> u64 {
85 match self {
86 Self::Inline { bytes } => bytes.len() as u64,
87 Self::Local { size_bytes, .. } => *size_bytes,
88 Self::S3 { size_bytes, .. } => *size_bytes,
89 }
90 }
91
92 pub fn stored_size_hint(&self) -> u64 {
93 match self {
94 Self::Inline { bytes } => bytes.len() as u64,
95 Self::Local { size_bytes, .. } => *size_bytes,
96 Self::S3 {
97 size_bytes,
98 stored_size_bytes,
99 ..
100 } => stored_size_bytes.unwrap_or(*size_bytes),
101 }
102 }
103
104 pub fn compression(&self) -> SnapshotCompression {
105 match self {
106 Self::S3 { compression, .. } => *compression,
107 Self::Inline { .. } | Self::Local { .. } => SnapshotCompression::None,
108 }
109 }
110}
111
112mod serde_bytes_vec {
113 use serde::Deserialize;
114 use serde::Deserializer;
115 use serde::Serializer;
116
117 pub fn serialize<S: Serializer>(bytes: &[u8], ser: S) -> Result<S::Ok, S::Error> {
118 ser.serialize_bytes(bytes)
119 }
120
121 pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Vec<u8>, D::Error> {
122 Vec::<u8>::deserialize(de)
125 }
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct SnapshotPointer {
132 pub snapshot_id: String,
133 pub location: SnapshotLocation,
134}
135
136impl SnapshotPointer {
137 pub fn encode(&self) -> Result<Vec<u8>, SnapshotStoreError> {
138 serde_json::to_vec(self).map_err(|err| SnapshotStoreError::Serialize(err.to_string()))
139 }
140
141 pub fn decode(bytes: &[u8]) -> Result<Self, SnapshotStoreError> {
142 serde_json::from_slice(bytes)
143 .map_err(|err| SnapshotStoreError::Deserialize(err.to_string()))
144 }
145}
146
147#[derive(Debug)]
148pub enum SnapshotStoreError {
149 Backend(String),
150 NotFound(String),
151 Integrity(String),
152 Serialize(String),
153 Deserialize(String),
154 Io(io::Error),
155}
156
157impl std::fmt::Display for SnapshotStoreError {
158 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159 match self {
160 Self::Backend(m) => write!(f, "snapshot store backend: {m}"),
161 Self::NotFound(m) => write!(f, "snapshot not found: {m}"),
162 Self::Integrity(m) => write!(f, "snapshot integrity: {m}"),
163 Self::Serialize(m) => write!(f, "snapshot serialize: {m}"),
164 Self::Deserialize(m) => write!(f, "snapshot deserialize: {m}"),
165 Self::Io(err) => write!(f, "snapshot io: {err}"),
166 }
167 }
168}
169
170impl std::error::Error for SnapshotStoreError {}
171
172impl From<io::Error> for SnapshotStoreError {
173 fn from(err: io::Error) -> Self {
174 Self::Io(err)
175 }
176}
177
178impl SnapshotStoreError {
179 pub fn into_io(self) -> io::Error {
180 match self {
181 Self::Io(err) => err,
182 other => io::Error::other(other.to_string()),
183 }
184 }
185}
186
187pub type SnapshotStoreFuture<'a, T> =
188 Pin<Box<dyn Future<Output = Result<T, SnapshotStoreError>> + Send + 'a>>;
189pub type SnapshotBytesIterator = Box<dyn Iterator<Item = Result<Bytes, SnapshotStoreError>> + Send>;
190
191pub trait SnapshotStore: Send + Sync + Debug {
192 fn upload<'a>(
195 &'a self,
196 key: SnapshotKey,
197 bytes: Bytes,
198 ) -> SnapshotStoreFuture<'a, SnapshotLocation>;
199
200 fn upload_iter<'a>(
202 &'a self,
203 key: SnapshotKey,
204 chunks: SnapshotBytesIterator,
205 ) -> SnapshotStoreFuture<'a, SnapshotLocation> {
206 Box::pin(async move {
207 let mut bytes = Vec::new();
208 for chunk in chunks {
209 bytes.extend_from_slice(chunk?.as_ref());
210 }
211 self.upload(key, Bytes::from(bytes)).await
212 })
213 }
214
215 fn download<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, Vec<u8>>;
217
218 fn delete<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()>;
220
221 fn prune_retired<'a>(
226 &'a self,
227 _raft_group_id: u32,
228 _current: &'a SnapshotLocation,
229 _retain_latest: usize,
230 ) -> SnapshotStoreFuture<'a, ()> {
231 Box::pin(async move { Ok(()) })
232 }
233
234 fn health_check(&self) -> SnapshotStoreFuture<'_, ()> {
240 Box::pin(async move { Ok(()) })
241 }
242
243 fn verify_uploaded<'a>(
253 &'a self,
254 _location: &'a SnapshotLocation,
255 ) -> SnapshotStoreFuture<'a, ()> {
256 Box::pin(async move { Ok(()) })
257 }
258}
259
260pub type SharedSnapshotStore = Arc<dyn SnapshotStore>;
261
262pub fn default_snapshot_store() -> SharedSnapshotStore {
264 Arc::new(InlineSnapshotStore)
265}
266
267#[derive(Debug, Default, Clone, Copy)]
269pub struct InlineSnapshotStore;
270
271impl SnapshotStore for InlineSnapshotStore {
272 fn upload<'a>(
273 &'a self,
274 _key: SnapshotKey,
275 bytes: Bytes,
276 ) -> SnapshotStoreFuture<'a, SnapshotLocation> {
277 Box::pin(async move {
278 Ok(SnapshotLocation::Inline {
279 bytes: bytes.to_vec(),
280 })
281 })
282 }
283
284 fn upload_iter<'a>(
285 &'a self,
286 _key: SnapshotKey,
287 chunks: SnapshotBytesIterator,
288 ) -> SnapshotStoreFuture<'a, SnapshotLocation> {
289 Box::pin(async move {
290 let mut bytes = Vec::new();
291 for chunk in chunks {
292 bytes.extend_from_slice(chunk?.as_ref());
293 }
294 Ok(SnapshotLocation::Inline { bytes })
295 })
296 }
297
298 fn download<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, Vec<u8>> {
299 Box::pin(async move {
300 match location {
301 SnapshotLocation::Inline { bytes } => Ok(bytes.clone()),
302 other => Err(SnapshotStoreError::Backend(format!(
303 "inline snapshot store cannot download {other:?}"
304 ))),
305 }
306 })
307 }
308
309 fn delete<'a>(&'a self, _location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()> {
310 Box::pin(async move { Ok(()) })
311 }
312}
313
314#[cfg(not(madsim))]
315mod s3 {
316 use bytes::Bytes;
317 use opendal::Operator;
318 use opendal::Scheme;
319
320 use super::SnapshotBytesIterator;
321 use super::SnapshotCompression;
322 use super::SnapshotKey;
323 use super::SnapshotLocation;
324 use super::SnapshotStore;
325 use super::SnapshotStoreError;
326 use super::SnapshotStoreFuture;
327 use super::unique_snapshot_leaf;
328
329 const S3_SNAPSHOT_ZSTD_LEVEL: i32 = 3;
330
331 pub struct S3SnapshotStore {
333 operator: Operator,
334 prefix: String,
335 }
336
337 impl std::fmt::Debug for S3SnapshotStore {
338 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
339 f.debug_struct("S3SnapshotStore")
340 .field("prefix", &self.prefix)
341 .finish_non_exhaustive()
342 }
343 }
344
345 impl S3SnapshotStore {
346 pub fn new(operator: Operator, prefix: impl Into<String>) -> Self {
347 let mut prefix = prefix.into();
348 while prefix.ends_with('/') {
349 prefix.pop();
350 }
351 Self { operator, prefix }
352 }
353
354 pub fn memory_for_tests(prefix: impl Into<String>) -> Result<Self, SnapshotStoreError> {
356 let operator = Operator::via_iter(Scheme::Memory, [])
357 .map_err(|err| SnapshotStoreError::Backend(err.to_string()))?;
358 Ok(Self::new(operator, prefix))
359 }
360
361 #[cfg(test)]
362 pub(crate) async fn write_raw_for_tests(
363 &self,
364 key: &str,
365 bytes: Vec<u8>,
366 ) -> Result<(), SnapshotStoreError> {
367 self.operator
368 .write(key, bytes)
369 .await
370 .map_err(|err| SnapshotStoreError::Backend(err.to_string()))
371 }
372
373 pub fn try_new(
377 config: &crate::ColdConfig,
378 prefix: impl Into<String>,
379 ) -> Result<Self, SnapshotStoreError> {
380 let s3 = config.s3.as_ref().ok_or_else(|| {
381 SnapshotStoreError::Backend("S3 config is required for snapshot s3 backend".into())
382 })?;
383 let bucket = s3.bucket.as_deref().ok_or_else(|| {
384 SnapshotStoreError::Backend("S3 bucket is required for snapshot s3 backend".into())
385 })?;
386 if bucket.trim().is_empty() {
387 return Err(SnapshotStoreError::Backend(
388 "snapshot s3 bucket must not be empty".into(),
389 ));
390 }
391 let mut builder = opendal::services::S3::default().bucket(bucket);
392 if let Some(root) = config.root.as_deref()
393 && !root.trim().is_empty()
394 {
395 builder = builder.root(root);
396 }
397 if let Some(region) = s3.region.as_deref()
398 && !region.trim().is_empty()
399 {
400 builder = builder.region(region);
401 }
402 if let Some(endpoint) = s3.endpoint.as_deref()
403 && !endpoint.trim().is_empty()
404 {
405 builder = builder.endpoint(endpoint);
406 }
407 if let Some(access) = s3.access_key_id.as_deref()
408 && !access.trim().is_empty()
409 {
410 builder = builder.access_key_id(access);
411 }
412 if let Some(secret) = s3.secret_access_key.as_deref()
413 && !secret.trim().is_empty()
414 {
415 builder = builder.secret_access_key(secret);
416 }
417 if let Some(token) = s3.session_token.as_deref()
418 && !token.trim().is_empty()
419 {
420 builder = builder.session_token(token);
421 }
422 let operator = crate::cold_store::with_s3_resilience(
423 Operator::new(builder)
424 .map_err(|err| SnapshotStoreError::Backend(err.to_string()))?
425 .finish(),
426 s3.timeout.as_duration(),
427 s3.max_retries,
428 );
429 Ok(Self::new(operator, prefix))
430 }
431
432 fn object_key(&self, key: &SnapshotKey) -> String {
440 format!(
441 "{}/group-{}/{}",
442 self.prefix,
443 key.raft_group_id,
444 unique_snapshot_leaf(&key.snapshot_id),
445 )
446 }
447 }
448
449 impl SnapshotStore for S3SnapshotStore {
450 fn upload<'a>(
451 &'a self,
452 key: SnapshotKey,
453 bytes: Bytes,
454 ) -> SnapshotStoreFuture<'a, SnapshotLocation> {
455 Box::pin(async move {
456 let object_key = self.object_key(&key);
457 let size_bytes = bytes.len() as u64;
458 let stored_bytes =
459 zstd::bulk::compress(&bytes, S3_SNAPSHOT_ZSTD_LEVEL).map_err(|err| {
460 SnapshotStoreError::Backend(format!("compress s3 snapshot: {err}"))
461 })?;
462 let stored_size_bytes = stored_bytes.len() as u64;
463 self.operator
464 .write(&object_key, stored_bytes)
465 .await
466 .map_err(|err| SnapshotStoreError::Backend(err.to_string()))?;
467 Ok(SnapshotLocation::S3 {
468 key: object_key,
469 size_bytes,
470 stored_size_bytes: Some(stored_size_bytes),
471 compression: SnapshotCompression::Zstd,
472 })
473 })
474 }
475
476 fn upload_iter<'a>(
477 &'a self,
478 key: SnapshotKey,
479 chunks: SnapshotBytesIterator,
480 ) -> SnapshotStoreFuture<'a, SnapshotLocation> {
481 Box::pin(async move {
482 let object_key = self.object_key(&key);
483 let mut size_bytes = 0u64;
484 let mut writer = self
485 .operator
486 .writer(&object_key)
487 .await
488 .map_err(|err| SnapshotStoreError::Backend(err.to_string()))?;
489 for chunk in chunks {
490 let chunk = chunk?;
491 size_bytes = size_bytes.checked_add(chunk.len() as u64).ok_or_else(|| {
492 SnapshotStoreError::Integrity(format!(
493 "s3 snapshot {object_key} size overflows u64"
494 ))
495 })?;
496 writer
497 .write(chunk)
498 .await
499 .map_err(|err| SnapshotStoreError::Backend(err.to_string()))?;
500 }
501 writer
502 .close()
503 .await
504 .map_err(|err| SnapshotStoreError::Backend(err.to_string()))?;
505 Ok(SnapshotLocation::S3 {
506 key: object_key,
507 size_bytes,
508 stored_size_bytes: Some(size_bytes),
509 compression: SnapshotCompression::None,
510 })
511 })
512 }
513
514 fn download<'a>(
515 &'a self,
516 location: &'a SnapshotLocation,
517 ) -> SnapshotStoreFuture<'a, Vec<u8>> {
518 Box::pin(async move {
519 let SnapshotLocation::S3 {
520 key, size_bytes, ..
521 } = location
522 else {
523 return Err(SnapshotStoreError::Backend(format!(
524 "s3 snapshot store cannot download {location:?}"
525 )));
526 };
527 let buf = self.operator.read(key).await.map_err(|err| {
528 if matches!(err.kind(), opendal::ErrorKind::NotFound) {
529 SnapshotStoreError::NotFound(format!("s3 snapshot missing at {key}"))
530 } else {
531 SnapshotStoreError::Backend(err.to_string())
532 }
533 })?;
534 let stored_bytes = buf.to_vec();
535 let expected_stored_size = location.stored_size_hint();
536 if stored_bytes.len() as u64 != expected_stored_size {
537 return Err(SnapshotStoreError::Integrity(format!(
538 "s3 snapshot {key} stored size {} != expected {}",
539 stored_bytes.len(),
540 expected_stored_size
541 )));
542 }
543 let bytes = match location.compression() {
544 SnapshotCompression::None => stored_bytes,
545 SnapshotCompression::Zstd => zstd::bulk::decompress(
546 &stored_bytes,
547 usize::try_from(*size_bytes).map_err(|_| {
548 SnapshotStoreError::Integrity(format!(
549 "s3 snapshot {key} logical size {size_bytes} does not fit usize"
550 ))
551 })?,
552 )
553 .map_err(|err| {
554 SnapshotStoreError::Integrity(format!(
555 "decompress s3 snapshot {key}: {err}"
556 ))
557 })?,
558 };
559 if bytes.len() as u64 != *size_bytes {
560 return Err(SnapshotStoreError::Integrity(format!(
561 "s3 snapshot {key} logical size {} != expected {}",
562 bytes.len(),
563 size_bytes
564 )));
565 }
566 Ok(bytes)
567 })
568 }
569
570 fn delete<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()> {
571 Box::pin(async move {
572 let SnapshotLocation::S3 { key, .. } = location else {
573 return Ok(());
574 };
575 match self.operator.delete(key).await {
576 Ok(()) => Ok(()),
577 Err(err) if matches!(err.kind(), opendal::ErrorKind::NotFound) => Ok(()),
578 Err(err) => Err(SnapshotStoreError::Backend(err.to_string())),
579 }
580 })
581 }
582
583 fn prune_retired<'a>(
584 &'a self,
585 raft_group_id: u32,
586 current: &'a SnapshotLocation,
587 retain_latest: usize,
588 ) -> SnapshotStoreFuture<'a, ()> {
589 Box::pin(async move {
590 let SnapshotLocation::S3 {
591 key: current_key, ..
592 } = current
593 else {
594 return Ok(());
595 };
596 tracing::debug!(
597 raft_group_id,
598 current_key,
599 retain_latest,
600 "skipping S3 snapshot pruning until published OpenRaft pointers can be proven unreachable"
601 );
602 Ok(())
603 })
604 }
605
606 fn verify_uploaded<'a>(
607 &'a self,
608 location: &'a SnapshotLocation,
609 ) -> SnapshotStoreFuture<'a, ()> {
610 Box::pin(async move {
611 let SnapshotLocation::S3 { key, .. } = location else {
612 return Ok(());
613 };
614 let meta = self.operator.stat(key).await.map_err(|err| {
615 if matches!(err.kind(), opendal::ErrorKind::NotFound) {
616 SnapshotStoreError::NotFound(format!(
617 "s3 snapshot upload verification failed: {key} not present after upload"
618 ))
619 } else {
620 SnapshotStoreError::Backend(err.to_string())
621 }
622 })?;
623 let actual = meta.content_length();
624 let expected = location.stored_size_hint();
625 if actual != expected {
626 return Err(SnapshotStoreError::Integrity(format!(
627 "s3 snapshot {key} stored size mismatch post-upload: stat={actual} expected={expected}"
628 )));
629 }
630 Ok(())
631 })
632 }
633
634 fn health_check(&self) -> SnapshotStoreFuture<'_, ()> {
635 Box::pin(async move {
636 let probe = format!("{}/.health-probe", self.prefix);
641 match self.operator.stat(&probe).await {
642 Ok(_) => Ok(()),
643 Err(err) if matches!(err.kind(), opendal::ErrorKind::NotFound) => Ok(()),
644 Err(err) => Err(SnapshotStoreError::Backend(err.to_string())),
645 }
646 })
647 }
648 }
649}
650
651#[cfg(not(madsim))]
652pub use s3::S3SnapshotStore;
653
654pub fn snapshot_store_from_config(
658 cfg: &ursula_config::RaftSnapshotConfig,
659 cold_cfg: &crate::ColdConfig,
660) -> Result<Option<SharedSnapshotStore>, SnapshotStoreError> {
661 let _ = cold_cfg;
662 match cfg.backend {
663 ursula_config::RaftSnapshotBackend::Inline => Ok(None),
664 #[cfg(not(madsim))]
665 ursula_config::RaftSnapshotBackend::Local => {
666 let root = cfg.local_root.as_ref().ok_or_else(|| {
667 SnapshotStoreError::Backend("snapshot local_root required for local backend".into())
668 })?;
669 let root_str = root.to_string_lossy();
670 if root_str.trim().is_empty() {
671 return Err(SnapshotStoreError::Backend(
672 "snapshot local_root must not be empty".into(),
673 ));
674 }
675 Ok(Some(Arc::new(LocalSnapshotStore::new(root))))
676 }
677 #[cfg(not(madsim))]
678 ursula_config::RaftSnapshotBackend::S3 => {
679 let prefix = cfg.s3_prefix.as_deref().unwrap_or("snapshots");
680 Ok(Some(Arc::new(S3SnapshotStore::try_new(cold_cfg, prefix)?)))
681 }
682 #[cfg(madsim)]
683 ursula_config::RaftSnapshotBackend::Local | ursula_config::RaftSnapshotBackend::S3 => {
684 Err(SnapshotStoreError::Backend(format!(
685 "snapshot backend {:?} has no I/O under madsim; use 'inline'",
686 cfg.backend
687 )))
688 }
689 }
690}
691
692#[cfg(not(madsim))]
693mod local {
694 use std::io;
695 use std::path::PathBuf;
696
697 use bytes::Bytes;
698 use tokio::io::AsyncWriteExt;
699
700 use super::SnapshotBytesIterator;
701 use super::SnapshotKey;
702 use super::SnapshotLocation;
703 use super::SnapshotStore;
704 use super::SnapshotStoreError;
705 use super::SnapshotStoreFuture;
706 use super::unique_snapshot_leaf;
707
708 #[derive(Debug, Clone)]
710 pub struct LocalSnapshotStore {
711 root: PathBuf,
712 }
713
714 impl LocalSnapshotStore {
715 pub fn new(root: impl Into<PathBuf>) -> Self {
716 Self { root: root.into() }
717 }
718
719 fn path_for(&self, key: SnapshotKey) -> PathBuf {
720 self.root
721 .join(format!("group-{}", key.raft_group_id))
722 .join(unique_snapshot_leaf(&key.snapshot_id))
723 }
724 }
725
726 impl SnapshotStore for LocalSnapshotStore {
727 fn upload<'a>(
728 &'a self,
729 key: SnapshotKey,
730 bytes: Bytes,
731 ) -> SnapshotStoreFuture<'a, SnapshotLocation> {
732 Box::pin(async move {
733 let path = self.path_for(key);
734 if let Some(parent) = path.parent() {
735 tokio::fs::create_dir_all(parent).await?;
736 }
737 let size_bytes = bytes.len() as u64;
738 tokio::fs::write(&path, bytes.as_ref()).await?;
739 Ok(SnapshotLocation::Local { path, size_bytes })
740 })
741 }
742
743 fn upload_iter<'a>(
744 &'a self,
745 key: SnapshotKey,
746 chunks: SnapshotBytesIterator,
747 ) -> SnapshotStoreFuture<'a, SnapshotLocation> {
748 Box::pin(async move {
749 let path = self.path_for(key);
750 if let Some(parent) = path.parent() {
751 tokio::fs::create_dir_all(parent).await?;
752 }
753 let mut size_bytes = 0u64;
754 let mut file = tokio::fs::File::create(&path).await?;
755 for chunk in chunks {
756 let chunk = chunk?;
757 size_bytes = size_bytes.checked_add(chunk.len() as u64).ok_or_else(|| {
758 SnapshotStoreError::Integrity(format!(
759 "local snapshot at {} size overflows u64",
760 path.display()
761 ))
762 })?;
763 file.write_all(chunk.as_ref()).await?;
764 }
765 file.sync_all().await?;
766 Ok(SnapshotLocation::Local { path, size_bytes })
767 })
768 }
769
770 fn download<'a>(
771 &'a self,
772 location: &'a SnapshotLocation,
773 ) -> SnapshotStoreFuture<'a, Vec<u8>> {
774 Box::pin(async move {
775 let SnapshotLocation::Local { path, size_bytes } = location else {
776 return Err(SnapshotStoreError::Backend(format!(
777 "local snapshot store cannot download {location:?}"
778 )));
779 };
780 let bytes = tokio::fs::read(path).await.map_err(|err| {
781 if err.kind() == io::ErrorKind::NotFound {
782 SnapshotStoreError::NotFound(format!(
783 "local snapshot missing at {}",
784 path.display()
785 ))
786 } else {
787 SnapshotStoreError::Io(err)
788 }
789 })?;
790 if bytes.len() as u64 != *size_bytes {
791 return Err(SnapshotStoreError::Integrity(format!(
792 "local snapshot at {} size {} != expected {}",
793 path.display(),
794 bytes.len(),
795 size_bytes
796 )));
797 }
798 Ok(bytes)
799 })
800 }
801
802 fn delete<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()> {
803 Box::pin(async move {
804 let SnapshotLocation::Local { path, .. } = location else {
805 return Ok(());
806 };
807 match tokio::fs::remove_file(path).await {
808 Ok(()) => Ok(()),
809 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
810 Err(err) => Err(SnapshotStoreError::Io(err)),
811 }
812 })
813 }
814
815 fn prune_retired<'a>(
816 &'a self,
817 raft_group_id: u32,
818 current: &'a SnapshotLocation,
819 retain_latest: usize,
820 ) -> SnapshotStoreFuture<'a, ()> {
821 Box::pin(async move {
822 let SnapshotLocation::Local {
823 path: current_path, ..
824 } = current
825 else {
826 return Ok(());
827 };
828 tracing::debug!(
829 raft_group_id,
830 current_path = %current_path.display(),
831 retain_latest,
832 "skipping local snapshot pruning until published OpenRaft pointers can be proven unreachable"
833 );
834 Ok(())
835 })
836 }
837 }
838}
839
840#[cfg(not(madsim))]
841pub use local::LocalSnapshotStore;
842
843#[cfg(test)]
844mod tests {
845 use super::*;
846
847 fn test_key(raft_group_id: u32, snapshot_id: &str) -> SnapshotKey {
848 SnapshotKey {
849 raft_group_id,
850 snapshot_id: snapshot_id.to_owned(),
851 }
852 }
853
854 #[tokio::test]
855 async fn inline_roundtrip() {
856 let store = InlineSnapshotStore;
857 let key = test_key(0, "group-0-T1-N1-100");
858 let loc = store
859 .upload(key, b"hello world".to_vec().into())
860 .await
861 .unwrap();
862 assert!(matches!(loc, SnapshotLocation::Inline { .. }));
863 let bytes = store.download(&loc).await.unwrap();
864 assert_eq!(bytes, b"hello world");
865 store.delete(&loc).await.unwrap();
866 }
867
868 #[tokio::test]
869 async fn inline_rejects_other_location() {
870 let store = InlineSnapshotStore;
871 let loc = SnapshotLocation::Local {
872 path: PathBuf::from("/tmp/nope"),
873 size_bytes: 4,
874 };
875 assert!(matches!(
876 store.download(&loc).await,
877 Err(SnapshotStoreError::Backend(_))
878 ));
879 }
880
881 #[test]
882 fn pointer_encode_decode_inline() {
883 let pointer = SnapshotPointer {
884 snapshot_id: "group-0-1-100".into(),
885 location: SnapshotLocation::Inline {
886 bytes: vec![1, 2, 3, 4],
887 },
888 };
889 let bytes = pointer.encode().unwrap();
890 let back = SnapshotPointer::decode(&bytes).unwrap();
891 assert_eq!(back.snapshot_id, pointer.snapshot_id);
892 match back.location {
893 SnapshotLocation::Inline { bytes } => assert_eq!(bytes, vec![1, 2, 3, 4]),
894 other => panic!("unexpected location: {other:?}"),
895 }
896 }
897
898 #[test]
899 fn pointer_encode_decode_local() {
900 let pointer = SnapshotPointer {
901 snapshot_id: "group-7-2-500".into(),
902 location: SnapshotLocation::Local {
903 path: PathBuf::from("/var/snap/group-7-term-2-log-500.snap"),
904 size_bytes: 12345,
905 },
906 };
907 let bytes = pointer.encode().unwrap();
908 let back = SnapshotPointer::decode(&bytes).unwrap();
909 assert_eq!(back.snapshot_id, pointer.snapshot_id);
910 assert_eq!(back.location.size_hint(), 12345);
911 }
912
913 #[cfg(not(madsim))]
914 #[tokio::test]
915 async fn local_roundtrip() {
916 let dir = tempfile::tempdir().unwrap();
917 let store = LocalSnapshotStore::new(dir.path());
918 let key = test_key(7, "group-7-T2-N1-500");
919 let loc = store
920 .upload(key, b"some snapshot bytes".to_vec().into())
921 .await
922 .unwrap();
923 let bytes = store.download(&loc).await.unwrap();
924 assert_eq!(bytes, b"some snapshot bytes");
925 store.delete(&loc).await.unwrap();
926 let again = store.download(&loc).await;
927 assert!(matches!(again, Err(SnapshotStoreError::NotFound(_))));
928 store.delete(&loc).await.unwrap();
930 }
931
932 #[cfg(not(madsim))]
933 #[tokio::test]
934 async fn local_two_uploads_with_same_snapshot_id_get_different_paths() {
935 let dir = tempfile::tempdir().unwrap();
936 let store = LocalSnapshotStore::new(dir.path());
937 let key1 = test_key(4, "group-4-T18-N3-264150");
938 let key2 = test_key(4, "group-4-T18-N3-264150");
939 let loc1 = store.upload(key1, b"body1".to_vec().into()).await.unwrap();
940 let loc2 = store.upload(key2, b"body2".to_vec().into()).await.unwrap();
941 let (path1, path2) = match (&loc1, &loc2) {
942 (
943 SnapshotLocation::Local { path: path1, .. },
944 SnapshotLocation::Local { path: path2, .. },
945 ) => (path1.clone(), path2.clone()),
946 _ => panic!("expected local locations"),
947 };
948 assert_ne!(
949 path1, path2,
950 "same snapshot_id must yield distinct local paths"
951 );
952 assert_eq!(store.download(&loc1).await.unwrap(), b"body1");
953 assert_eq!(store.download(&loc2).await.unwrap(), b"body2");
954 }
955
956 #[cfg(not(madsim))]
957 #[tokio::test]
958 async fn local_prune_retired_keeps_published_snapshot_locations_readable() {
959 let dir = tempfile::tempdir().unwrap();
960 let store = LocalSnapshotStore::new(dir.path());
961 let loc1 = store
962 .upload(test_key(7, "group-7-T1-N1-1"), b"one".to_vec().into())
963 .await
964 .unwrap();
965 let loc2 = store
966 .upload(test_key(7, "group-7-T1-N1-2"), b"two".to_vec().into())
967 .await
968 .unwrap();
969 let loc3 = store
970 .upload(test_key(7, "group-7-T1-N1-3"), b"three".to_vec().into())
971 .await
972 .unwrap();
973
974 store.prune_retired(7, &loc3, 1).await.unwrap();
975
976 assert_eq!(store.download(&loc1).await.unwrap(), b"one");
977 assert_eq!(store.download(&loc2).await.unwrap(), b"two");
978 assert_eq!(store.download(&loc3).await.unwrap(), b"three");
979 }
980
981 #[cfg(not(madsim))]
982 #[tokio::test]
983 async fn s3_memory_roundtrip() {
984 let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
985 let key = test_key(3, "group-3-T5-N2-9876");
986 let payload = b"raw snapshot bytes".repeat(64);
987 let loc = store.upload(key, payload.clone().into()).await.unwrap();
988 match &loc {
989 SnapshotLocation::S3 {
990 key,
991 size_bytes,
992 stored_size_bytes,
993 compression,
994 } => {
995 assert!(key.starts_with("snapshots/group-3/"));
996 assert_eq!(*size_bytes, payload.len() as u64);
997 assert_eq!(*compression, SnapshotCompression::Zstd);
998 assert!(stored_size_bytes.is_some());
999 assert!(stored_size_bytes.unwrap() < *size_bytes);
1000 }
1001 other => panic!("expected S3 location, got {other:?}"),
1002 }
1003 let bytes = store.download(&loc).await.unwrap();
1004 assert_eq!(bytes, payload);
1005 store.delete(&loc).await.unwrap();
1006 assert!(matches!(
1007 store.download(&loc).await,
1008 Err(SnapshotStoreError::NotFound(_))
1009 ));
1010 store.delete(&loc).await.unwrap();
1012 }
1013
1014 #[cfg(not(madsim))]
1015 #[tokio::test]
1016 async fn s3_download_accepts_legacy_uncompressed_pointer() {
1017 let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
1018 let key = test_key(5, "group-5-T1-N1-10");
1019 let loc = store
1020 .upload(key, b"legacy body".to_vec().into())
1021 .await
1022 .unwrap();
1023 let SnapshotLocation::S3 { key, .. } = loc else {
1024 panic!("expected s3 location")
1025 };
1026 store
1027 .write_raw_for_tests(&key, b"legacy body".to_vec())
1028 .await
1029 .unwrap();
1030 let legacy = SnapshotLocation::S3 {
1031 key,
1032 size_bytes: b"legacy body".len() as u64,
1033 stored_size_bytes: None,
1034 compression: SnapshotCompression::None,
1035 };
1036 assert_eq!(store.download(&legacy).await.unwrap(), b"legacy body");
1037 }
1038
1039 #[cfg(not(madsim))]
1040 #[tokio::test]
1041 async fn s3_two_uploads_with_same_snapshot_id_get_different_keys() {
1042 let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
1047 let key1 = test_key(4, "group-4-T18-N3-264150");
1048 let key2 = test_key(4, "group-4-T18-N3-264150");
1049 let loc1 = store.upload(key1, b"body1".to_vec().into()).await.unwrap();
1050 let loc2 = store.upload(key2, b"body2".to_vec().into()).await.unwrap();
1051 let (k1, k2) = match (&loc1, &loc2) {
1052 (SnapshotLocation::S3 { key: k1, .. }, SnapshotLocation::S3 { key: k2, .. }) => {
1053 (k1.clone(), k2.clone())
1054 }
1055 _ => panic!("expected S3 locations"),
1056 };
1057 assert_ne!(k1, k2, "same snapshot_id must yield distinct S3 keys");
1058 assert_eq!(store.download(&loc1).await.unwrap(), b"body1");
1061 assert_eq!(store.download(&loc2).await.unwrap(), b"body2");
1062 store.delete(&loc1).await.unwrap();
1063 assert!(matches!(
1064 store.download(&loc1).await,
1065 Err(SnapshotStoreError::NotFound(_))
1066 ));
1067 assert_eq!(store.download(&loc2).await.unwrap(), b"body2");
1068 }
1069
1070 #[cfg(not(madsim))]
1071 #[tokio::test]
1072 async fn s3_verify_uploaded_catches_missing_object() {
1073 let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
1074 let key = test_key(2, "group-2-T1-N1-7");
1075 let loc = store.upload(key, b"payload".to_vec().into()).await.unwrap();
1076 store.verify_uploaded(&loc).await.unwrap();
1078 store.delete(&loc).await.unwrap();
1082 let err = store.verify_uploaded(&loc).await.unwrap_err();
1083 assert!(
1084 matches!(err, SnapshotStoreError::NotFound(_)),
1085 "expected NotFound after delete, got {err:?}"
1086 );
1087 }
1088
1089 #[cfg(not(madsim))]
1090 #[tokio::test]
1091 async fn local_integrity_detects_size_mismatch() {
1092 let dir = tempfile::tempdir().unwrap();
1093 let store = LocalSnapshotStore::new(dir.path());
1094 let key = test_key(1, "group-1-T1-N1-1");
1095 let loc = store.upload(key, b"abcd".to_vec().into()).await.unwrap();
1096 let SnapshotLocation::Local { path, .. } = &loc else {
1097 unreachable!()
1098 };
1099 tokio::fs::write(path, b"abcde").await.unwrap();
1100 let result = store.download(&loc).await;
1101 assert!(matches!(result, Err(SnapshotStoreError::Integrity(_))));
1102 }
1103}