1use 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#[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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
50pub struct SnapshotKey {
51 pub raft_group_id: u32,
52 pub snapshot_id: String,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(tag = "kind", rename_all = "snake_case")]
58pub enum SnapshotLocation {
59 Inline {
62 #[serde(with = "serde_bytes_vec")]
63 bytes: Vec<u8>,
64 },
65 Local { path: PathBuf, size_bytes: u64 },
67 S3 {
69 key: String,
70 size_bytes: u64,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
75 stored_size_bytes: Option<u64>,
76 #[serde(default)]
78 compression: SnapshotCompression,
79 #[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 Vec::<u8>::deserialize(de)
137 }
138}
139
140#[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 fn upload<'a>(
192 &'a self,
193 key: SnapshotKey,
194 bytes: Bytes,
195 ) -> SnapshotStoreFuture<'a, SnapshotLocation>;
196
197 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 fn download<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, Vec<u8>>;
214
215 fn delete<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()>;
217
218 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 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 fn health_check(&self) -> SnapshotStoreFuture<'_, ()> {
249 Box::pin(async move { Ok(()) })
250 }
251
252 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
271pub fn default_snapshot_store() -> SharedSnapshotStore {
273 Arc::new(InlineSnapshotStore)
274}
275
276#[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 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 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 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 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 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 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
985pub 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 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 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 store.verify_uploaded(&loc).await.unwrap();
1263 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, ¤t).await.unwrap();
1310
1311 store.prune_retired(7, ¤t, 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, ¤t, 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(¤t).await.unwrap(), b"current");
1340 }
1341}