use std::fmt::Debug;
use std::future::Future;
use std::io;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use bytes::Bytes;
use crossbeam_utils::CachePadded;
use serde::Deserialize;
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SnapshotKey {
pub raft_group_id: u32,
pub snapshot_id: String,
}
fn unique_snapshot_leaf(snapshot_id: &str) -> String {
static COUNTER: CachePadded<AtomicU64> = CachePadded::new(AtomicU64::new(0));
let nonce_nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let nonce_seq = COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{snapshot_id}-{nonce_nanos:032}-{nonce_seq:020}.snap")
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SnapshotLocation {
Inline {
#[serde(with = "serde_bytes_vec")]
bytes: Vec<u8>,
},
Local { path: PathBuf, size_bytes: u64 },
S3 {
key: String,
size_bytes: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
stored_size_bytes: Option<u64>,
#[serde(default)]
compression: SnapshotCompression,
},
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SnapshotCompression {
#[default]
None,
Zstd,
}
impl SnapshotLocation {
pub fn size_hint(&self) -> u64 {
match self {
Self::Inline { bytes } => bytes.len() as u64,
Self::Local { size_bytes, .. } => *size_bytes,
Self::S3 { size_bytes, .. } => *size_bytes,
}
}
pub fn stored_size_hint(&self) -> u64 {
match self {
Self::Inline { bytes } => bytes.len() as u64,
Self::Local { size_bytes, .. } => *size_bytes,
Self::S3 {
size_bytes,
stored_size_bytes,
..
} => stored_size_bytes.unwrap_or(*size_bytes),
}
}
pub fn compression(&self) -> SnapshotCompression {
match self {
Self::S3 { compression, .. } => *compression,
Self::Inline { .. } | Self::Local { .. } => SnapshotCompression::None,
}
}
}
mod serde_bytes_vec {
use serde::Deserialize;
use serde::Deserializer;
use serde::Serializer;
pub fn serialize<S: Serializer>(bytes: &[u8], ser: S) -> Result<S::Ok, S::Error> {
ser.serialize_bytes(bytes)
}
pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Vec<u8>, D::Error> {
Vec::<u8>::deserialize(de)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotPointer {
pub snapshot_id: String,
pub location: SnapshotLocation,
}
impl SnapshotPointer {
pub fn encode(&self) -> Result<Vec<u8>, SnapshotStoreError> {
serde_json::to_vec(self).map_err(|err| SnapshotStoreError::Serialize(err.to_string()))
}
pub fn decode(bytes: &[u8]) -> Result<Self, SnapshotStoreError> {
serde_json::from_slice(bytes)
.map_err(|err| SnapshotStoreError::Deserialize(err.to_string()))
}
}
#[derive(Debug)]
pub enum SnapshotStoreError {
Backend(String),
NotFound(String),
Integrity(String),
Serialize(String),
Deserialize(String),
Io(io::Error),
}
impl std::fmt::Display for SnapshotStoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Backend(m) => write!(f, "snapshot store backend: {m}"),
Self::NotFound(m) => write!(f, "snapshot not found: {m}"),
Self::Integrity(m) => write!(f, "snapshot integrity: {m}"),
Self::Serialize(m) => write!(f, "snapshot serialize: {m}"),
Self::Deserialize(m) => write!(f, "snapshot deserialize: {m}"),
Self::Io(err) => write!(f, "snapshot io: {err}"),
}
}
}
impl std::error::Error for SnapshotStoreError {}
impl From<io::Error> for SnapshotStoreError {
fn from(err: io::Error) -> Self {
Self::Io(err)
}
}
impl SnapshotStoreError {
pub fn into_io(self) -> io::Error {
match self {
Self::Io(err) => err,
other => io::Error::other(other.to_string()),
}
}
}
pub type SnapshotStoreFuture<'a, T> =
Pin<Box<dyn Future<Output = Result<T, SnapshotStoreError>> + Send + 'a>>;
pub type SnapshotBytesIterator = Box<dyn Iterator<Item = Result<Bytes, SnapshotStoreError>> + Send>;
pub trait SnapshotStore: Send + Sync + Debug {
fn upload<'a>(
&'a self,
key: SnapshotKey,
bytes: Bytes,
) -> SnapshotStoreFuture<'a, SnapshotLocation>;
fn upload_iter<'a>(
&'a self,
key: SnapshotKey,
chunks: SnapshotBytesIterator,
) -> SnapshotStoreFuture<'a, SnapshotLocation> {
Box::pin(async move {
let mut bytes = Vec::new();
for chunk in chunks {
bytes.extend_from_slice(chunk?.as_ref());
}
self.upload(key, Bytes::from(bytes)).await
})
}
fn download<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, Vec<u8>>;
fn delete<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()>;
fn prune_retired<'a>(
&'a self,
_raft_group_id: u32,
_current: &'a SnapshotLocation,
_retain_latest: usize,
) -> SnapshotStoreFuture<'a, ()> {
Box::pin(async move { Ok(()) })
}
fn health_check(&self) -> SnapshotStoreFuture<'_, ()> {
Box::pin(async move { Ok(()) })
}
fn verify_uploaded<'a>(
&'a self,
_location: &'a SnapshotLocation,
) -> SnapshotStoreFuture<'a, ()> {
Box::pin(async move { Ok(()) })
}
}
pub type SharedSnapshotStore = Arc<dyn SnapshotStore>;
pub fn default_snapshot_store() -> SharedSnapshotStore {
Arc::new(InlineSnapshotStore)
}
#[derive(Debug, Default, Clone, Copy)]
pub struct InlineSnapshotStore;
impl SnapshotStore for InlineSnapshotStore {
fn upload<'a>(
&'a self,
_key: SnapshotKey,
bytes: Bytes,
) -> SnapshotStoreFuture<'a, SnapshotLocation> {
Box::pin(async move {
Ok(SnapshotLocation::Inline {
bytes: bytes.to_vec(),
})
})
}
fn upload_iter<'a>(
&'a self,
_key: SnapshotKey,
chunks: SnapshotBytesIterator,
) -> SnapshotStoreFuture<'a, SnapshotLocation> {
Box::pin(async move {
let mut bytes = Vec::new();
for chunk in chunks {
bytes.extend_from_slice(chunk?.as_ref());
}
Ok(SnapshotLocation::Inline { bytes })
})
}
fn download<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, Vec<u8>> {
Box::pin(async move {
match location {
SnapshotLocation::Inline { bytes } => Ok(bytes.clone()),
other => Err(SnapshotStoreError::Backend(format!(
"inline snapshot store cannot download {other:?}"
))),
}
})
}
fn delete<'a>(&'a self, _location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()> {
Box::pin(async move { Ok(()) })
}
}
#[cfg(not(madsim))]
mod s3 {
use bytes::Bytes;
use opendal::Operator;
use opendal::Scheme;
use super::SnapshotBytesIterator;
use super::SnapshotCompression;
use super::SnapshotKey;
use super::SnapshotLocation;
use super::SnapshotStore;
use super::SnapshotStoreError;
use super::SnapshotStoreFuture;
use super::unique_snapshot_leaf;
const S3_SNAPSHOT_ZSTD_LEVEL: i32 = 3;
pub struct S3SnapshotStore {
operator: Operator,
prefix: String,
}
impl std::fmt::Debug for S3SnapshotStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("S3SnapshotStore")
.field("prefix", &self.prefix)
.finish_non_exhaustive()
}
}
impl S3SnapshotStore {
pub fn new(operator: Operator, prefix: impl Into<String>) -> Self {
let mut prefix = prefix.into();
while prefix.ends_with('/') {
prefix.pop();
}
Self { operator, prefix }
}
pub fn memory_for_tests(prefix: impl Into<String>) -> Result<Self, SnapshotStoreError> {
let operator = Operator::via_iter(Scheme::Memory, [])
.map_err(|err| SnapshotStoreError::Backend(err.to_string()))?;
Ok(Self::new(operator, prefix))
}
#[cfg(test)]
pub(crate) async fn write_raw_for_tests(
&self,
key: &str,
bytes: Vec<u8>,
) -> Result<(), SnapshotStoreError> {
self.operator
.write(key, bytes)
.await
.map_err(|err| SnapshotStoreError::Backend(err.to_string()))
}
pub fn try_new(
config: &crate::ColdConfig,
prefix: impl Into<String>,
) -> Result<Self, SnapshotStoreError> {
let s3 = config.s3.as_ref().ok_or_else(|| {
SnapshotStoreError::Backend("S3 config is required for snapshot s3 backend".into())
})?;
let bucket = s3.bucket.as_deref().ok_or_else(|| {
SnapshotStoreError::Backend("S3 bucket is required for snapshot s3 backend".into())
})?;
if bucket.trim().is_empty() {
return Err(SnapshotStoreError::Backend(
"snapshot s3 bucket must not be empty".into(),
));
}
let mut builder = opendal::services::S3::default().bucket(bucket);
if let Some(root) = config.root.as_deref()
&& !root.trim().is_empty()
{
builder = builder.root(root);
}
if let Some(region) = s3.region.as_deref()
&& !region.trim().is_empty()
{
builder = builder.region(region);
}
if let Some(endpoint) = s3.endpoint.as_deref()
&& !endpoint.trim().is_empty()
{
builder = builder.endpoint(endpoint);
}
if let Some(access) = s3.access_key_id.as_deref()
&& !access.trim().is_empty()
{
builder = builder.access_key_id(access);
}
if let Some(secret) = s3.secret_access_key.as_deref()
&& !secret.trim().is_empty()
{
builder = builder.secret_access_key(secret);
}
if let Some(token) = s3.session_token.as_deref()
&& !token.trim().is_empty()
{
builder = builder.session_token(token);
}
let operator = crate::cold_store::with_s3_resilience(
Operator::new(builder)
.map_err(|err| SnapshotStoreError::Backend(err.to_string()))?
.finish(),
s3.timeout.as_duration(),
s3.max_retries,
);
Ok(Self::new(operator, prefix))
}
fn object_key(&self, key: &SnapshotKey) -> String {
format!(
"{}/group-{}/{}",
self.prefix,
key.raft_group_id,
unique_snapshot_leaf(&key.snapshot_id),
)
}
}
impl SnapshotStore for S3SnapshotStore {
fn upload<'a>(
&'a self,
key: SnapshotKey,
bytes: Bytes,
) -> SnapshotStoreFuture<'a, SnapshotLocation> {
Box::pin(async move {
let object_key = self.object_key(&key);
let size_bytes = bytes.len() as u64;
let stored_bytes =
zstd::bulk::compress(&bytes, S3_SNAPSHOT_ZSTD_LEVEL).map_err(|err| {
SnapshotStoreError::Backend(format!("compress s3 snapshot: {err}"))
})?;
let stored_size_bytes = stored_bytes.len() as u64;
self.operator
.write(&object_key, stored_bytes)
.await
.map_err(|err| SnapshotStoreError::Backend(err.to_string()))?;
Ok(SnapshotLocation::S3 {
key: object_key,
size_bytes,
stored_size_bytes: Some(stored_size_bytes),
compression: SnapshotCompression::Zstd,
})
})
}
fn upload_iter<'a>(
&'a self,
key: SnapshotKey,
chunks: SnapshotBytesIterator,
) -> SnapshotStoreFuture<'a, SnapshotLocation> {
Box::pin(async move {
let object_key = self.object_key(&key);
let mut size_bytes = 0u64;
let mut writer = self
.operator
.writer(&object_key)
.await
.map_err(|err| SnapshotStoreError::Backend(err.to_string()))?;
for chunk in chunks {
let chunk = chunk?;
size_bytes = size_bytes.checked_add(chunk.len() as u64).ok_or_else(|| {
SnapshotStoreError::Integrity(format!(
"s3 snapshot {object_key} size overflows u64"
))
})?;
writer
.write(chunk)
.await
.map_err(|err| SnapshotStoreError::Backend(err.to_string()))?;
}
writer
.close()
.await
.map_err(|err| SnapshotStoreError::Backend(err.to_string()))?;
Ok(SnapshotLocation::S3 {
key: object_key,
size_bytes,
stored_size_bytes: Some(size_bytes),
compression: SnapshotCompression::None,
})
})
}
fn download<'a>(
&'a self,
location: &'a SnapshotLocation,
) -> SnapshotStoreFuture<'a, Vec<u8>> {
Box::pin(async move {
let SnapshotLocation::S3 {
key, size_bytes, ..
} = location
else {
return Err(SnapshotStoreError::Backend(format!(
"s3 snapshot store cannot download {location:?}"
)));
};
let buf = self.operator.read(key).await.map_err(|err| {
if matches!(err.kind(), opendal::ErrorKind::NotFound) {
SnapshotStoreError::NotFound(format!("s3 snapshot missing at {key}"))
} else {
SnapshotStoreError::Backend(err.to_string())
}
})?;
let stored_bytes = buf.to_vec();
let expected_stored_size = location.stored_size_hint();
if stored_bytes.len() as u64 != expected_stored_size {
return Err(SnapshotStoreError::Integrity(format!(
"s3 snapshot {key} stored size {} != expected {}",
stored_bytes.len(),
expected_stored_size
)));
}
let bytes = match location.compression() {
SnapshotCompression::None => stored_bytes,
SnapshotCompression::Zstd => zstd::bulk::decompress(
&stored_bytes,
usize::try_from(*size_bytes).map_err(|_| {
SnapshotStoreError::Integrity(format!(
"s3 snapshot {key} logical size {size_bytes} does not fit usize"
))
})?,
)
.map_err(|err| {
SnapshotStoreError::Integrity(format!(
"decompress s3 snapshot {key}: {err}"
))
})?,
};
if bytes.len() as u64 != *size_bytes {
return Err(SnapshotStoreError::Integrity(format!(
"s3 snapshot {key} logical size {} != expected {}",
bytes.len(),
size_bytes
)));
}
Ok(bytes)
})
}
fn delete<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()> {
Box::pin(async move {
let SnapshotLocation::S3 { key, .. } = location else {
return Ok(());
};
match self.operator.delete(key).await {
Ok(()) => Ok(()),
Err(err) if matches!(err.kind(), opendal::ErrorKind::NotFound) => Ok(()),
Err(err) => Err(SnapshotStoreError::Backend(err.to_string())),
}
})
}
fn prune_retired<'a>(
&'a self,
raft_group_id: u32,
current: &'a SnapshotLocation,
retain_latest: usize,
) -> SnapshotStoreFuture<'a, ()> {
Box::pin(async move {
let SnapshotLocation::S3 {
key: current_key, ..
} = current
else {
return Ok(());
};
tracing::debug!(
raft_group_id,
current_key,
retain_latest,
"skipping S3 snapshot pruning until published OpenRaft pointers can be proven unreachable"
);
Ok(())
})
}
fn verify_uploaded<'a>(
&'a self,
location: &'a SnapshotLocation,
) -> SnapshotStoreFuture<'a, ()> {
Box::pin(async move {
let SnapshotLocation::S3 { key, .. } = location else {
return Ok(());
};
let meta = self.operator.stat(key).await.map_err(|err| {
if matches!(err.kind(), opendal::ErrorKind::NotFound) {
SnapshotStoreError::NotFound(format!(
"s3 snapshot upload verification failed: {key} not present after upload"
))
} else {
SnapshotStoreError::Backend(err.to_string())
}
})?;
let actual = meta.content_length();
let expected = location.stored_size_hint();
if actual != expected {
return Err(SnapshotStoreError::Integrity(format!(
"s3 snapshot {key} stored size mismatch post-upload: stat={actual} expected={expected}"
)));
}
Ok(())
})
}
fn health_check(&self) -> SnapshotStoreFuture<'_, ()> {
Box::pin(async move {
let probe = format!("{}/.health-probe", self.prefix);
match self.operator.stat(&probe).await {
Ok(_) => Ok(()),
Err(err) if matches!(err.kind(), opendal::ErrorKind::NotFound) => Ok(()),
Err(err) => Err(SnapshotStoreError::Backend(err.to_string())),
}
})
}
}
}
#[cfg(not(madsim))]
pub use s3::S3SnapshotStore;
pub fn snapshot_store_from_config(
cfg: &ursula_config::RaftSnapshotConfig,
cold_cfg: &crate::ColdConfig,
) -> Result<Option<SharedSnapshotStore>, SnapshotStoreError> {
let _ = cold_cfg;
match cfg.backend {
ursula_config::RaftSnapshotBackend::Inline => Ok(None),
#[cfg(not(madsim))]
ursula_config::RaftSnapshotBackend::Local => {
let root = cfg.local_root.as_ref().ok_or_else(|| {
SnapshotStoreError::Backend("snapshot local_root required for local backend".into())
})?;
let root_str = root.to_string_lossy();
if root_str.trim().is_empty() {
return Err(SnapshotStoreError::Backend(
"snapshot local_root must not be empty".into(),
));
}
Ok(Some(Arc::new(LocalSnapshotStore::new(root))))
}
#[cfg(not(madsim))]
ursula_config::RaftSnapshotBackend::S3 => {
let prefix = cfg.s3_prefix.as_deref().unwrap_or("snapshots");
Ok(Some(Arc::new(S3SnapshotStore::try_new(cold_cfg, prefix)?)))
}
#[cfg(madsim)]
ursula_config::RaftSnapshotBackend::Local | ursula_config::RaftSnapshotBackend::S3 => {
Err(SnapshotStoreError::Backend(format!(
"snapshot backend {:?} has no I/O under madsim; use 'inline'",
cfg.backend
)))
}
}
}
#[cfg(not(madsim))]
mod local {
use std::io;
use std::path::PathBuf;
use bytes::Bytes;
use tokio::io::AsyncWriteExt;
use super::SnapshotBytesIterator;
use super::SnapshotKey;
use super::SnapshotLocation;
use super::SnapshotStore;
use super::SnapshotStoreError;
use super::SnapshotStoreFuture;
use super::unique_snapshot_leaf;
#[derive(Debug, Clone)]
pub struct LocalSnapshotStore {
root: PathBuf,
}
impl LocalSnapshotStore {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
fn path_for(&self, key: SnapshotKey) -> PathBuf {
self.root
.join(format!("group-{}", key.raft_group_id))
.join(unique_snapshot_leaf(&key.snapshot_id))
}
}
impl SnapshotStore for LocalSnapshotStore {
fn upload<'a>(
&'a self,
key: SnapshotKey,
bytes: Bytes,
) -> SnapshotStoreFuture<'a, SnapshotLocation> {
Box::pin(async move {
let path = self.path_for(key);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let size_bytes = bytes.len() as u64;
tokio::fs::write(&path, bytes.as_ref()).await?;
Ok(SnapshotLocation::Local { path, size_bytes })
})
}
fn upload_iter<'a>(
&'a self,
key: SnapshotKey,
chunks: SnapshotBytesIterator,
) -> SnapshotStoreFuture<'a, SnapshotLocation> {
Box::pin(async move {
let path = self.path_for(key);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let mut size_bytes = 0u64;
let mut file = tokio::fs::File::create(&path).await?;
for chunk in chunks {
let chunk = chunk?;
size_bytes = size_bytes.checked_add(chunk.len() as u64).ok_or_else(|| {
SnapshotStoreError::Integrity(format!(
"local snapshot at {} size overflows u64",
path.display()
))
})?;
file.write_all(chunk.as_ref()).await?;
}
file.sync_all().await?;
Ok(SnapshotLocation::Local { path, size_bytes })
})
}
fn download<'a>(
&'a self,
location: &'a SnapshotLocation,
) -> SnapshotStoreFuture<'a, Vec<u8>> {
Box::pin(async move {
let SnapshotLocation::Local { path, size_bytes } = location else {
return Err(SnapshotStoreError::Backend(format!(
"local snapshot store cannot download {location:?}"
)));
};
let bytes = tokio::fs::read(path).await.map_err(|err| {
if err.kind() == io::ErrorKind::NotFound {
SnapshotStoreError::NotFound(format!(
"local snapshot missing at {}",
path.display()
))
} else {
SnapshotStoreError::Io(err)
}
})?;
if bytes.len() as u64 != *size_bytes {
return Err(SnapshotStoreError::Integrity(format!(
"local snapshot at {} size {} != expected {}",
path.display(),
bytes.len(),
size_bytes
)));
}
Ok(bytes)
})
}
fn delete<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()> {
Box::pin(async move {
let SnapshotLocation::Local { path, .. } = location else {
return Ok(());
};
match tokio::fs::remove_file(path).await {
Ok(()) => Ok(()),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(SnapshotStoreError::Io(err)),
}
})
}
fn prune_retired<'a>(
&'a self,
raft_group_id: u32,
current: &'a SnapshotLocation,
retain_latest: usize,
) -> SnapshotStoreFuture<'a, ()> {
Box::pin(async move {
let SnapshotLocation::Local {
path: current_path, ..
} = current
else {
return Ok(());
};
tracing::debug!(
raft_group_id,
current_path = %current_path.display(),
retain_latest,
"skipping local snapshot pruning until published OpenRaft pointers can be proven unreachable"
);
Ok(())
})
}
}
}
#[cfg(not(madsim))]
pub use local::LocalSnapshotStore;
#[cfg(test)]
mod tests {
use super::*;
fn test_key(raft_group_id: u32, snapshot_id: &str) -> SnapshotKey {
SnapshotKey {
raft_group_id,
snapshot_id: snapshot_id.to_owned(),
}
}
#[tokio::test]
async fn inline_roundtrip() {
let store = InlineSnapshotStore;
let key = test_key(0, "group-0-T1-N1-100");
let loc = store
.upload(key, b"hello world".to_vec().into())
.await
.unwrap();
assert!(matches!(loc, SnapshotLocation::Inline { .. }));
let bytes = store.download(&loc).await.unwrap();
assert_eq!(bytes, b"hello world");
store.delete(&loc).await.unwrap();
}
#[tokio::test]
async fn inline_rejects_other_location() {
let store = InlineSnapshotStore;
let loc = SnapshotLocation::Local {
path: PathBuf::from("/tmp/nope"),
size_bytes: 4,
};
assert!(matches!(
store.download(&loc).await,
Err(SnapshotStoreError::Backend(_))
));
}
#[test]
fn pointer_encode_decode_inline() {
let pointer = SnapshotPointer {
snapshot_id: "group-0-1-100".into(),
location: SnapshotLocation::Inline {
bytes: vec![1, 2, 3, 4],
},
};
let bytes = pointer.encode().unwrap();
let back = SnapshotPointer::decode(&bytes).unwrap();
assert_eq!(back.snapshot_id, pointer.snapshot_id);
match back.location {
SnapshotLocation::Inline { bytes } => assert_eq!(bytes, vec![1, 2, 3, 4]),
other => panic!("unexpected location: {other:?}"),
}
}
#[test]
fn pointer_encode_decode_local() {
let pointer = SnapshotPointer {
snapshot_id: "group-7-2-500".into(),
location: SnapshotLocation::Local {
path: PathBuf::from("/var/snap/group-7-term-2-log-500.snap"),
size_bytes: 12345,
},
};
let bytes = pointer.encode().unwrap();
let back = SnapshotPointer::decode(&bytes).unwrap();
assert_eq!(back.snapshot_id, pointer.snapshot_id);
assert_eq!(back.location.size_hint(), 12345);
}
#[cfg(not(madsim))]
#[tokio::test]
async fn local_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let store = LocalSnapshotStore::new(dir.path());
let key = test_key(7, "group-7-T2-N1-500");
let loc = store
.upload(key, b"some snapshot bytes".to_vec().into())
.await
.unwrap();
let bytes = store.download(&loc).await.unwrap();
assert_eq!(bytes, b"some snapshot bytes");
store.delete(&loc).await.unwrap();
let again = store.download(&loc).await;
assert!(matches!(again, Err(SnapshotStoreError::NotFound(_))));
store.delete(&loc).await.unwrap();
}
#[cfg(not(madsim))]
#[tokio::test]
async fn local_two_uploads_with_same_snapshot_id_get_different_paths() {
let dir = tempfile::tempdir().unwrap();
let store = LocalSnapshotStore::new(dir.path());
let key1 = test_key(4, "group-4-T18-N3-264150");
let key2 = test_key(4, "group-4-T18-N3-264150");
let loc1 = store.upload(key1, b"body1".to_vec().into()).await.unwrap();
let loc2 = store.upload(key2, b"body2".to_vec().into()).await.unwrap();
let (path1, path2) = match (&loc1, &loc2) {
(
SnapshotLocation::Local { path: path1, .. },
SnapshotLocation::Local { path: path2, .. },
) => (path1.clone(), path2.clone()),
_ => panic!("expected local locations"),
};
assert_ne!(
path1, path2,
"same snapshot_id must yield distinct local paths"
);
assert_eq!(store.download(&loc1).await.unwrap(), b"body1");
assert_eq!(store.download(&loc2).await.unwrap(), b"body2");
}
#[cfg(not(madsim))]
#[tokio::test]
async fn local_prune_retired_keeps_published_snapshot_locations_readable() {
let dir = tempfile::tempdir().unwrap();
let store = LocalSnapshotStore::new(dir.path());
let loc1 = store
.upload(test_key(7, "group-7-T1-N1-1"), b"one".to_vec().into())
.await
.unwrap();
let loc2 = store
.upload(test_key(7, "group-7-T1-N1-2"), b"two".to_vec().into())
.await
.unwrap();
let loc3 = store
.upload(test_key(7, "group-7-T1-N1-3"), b"three".to_vec().into())
.await
.unwrap();
store.prune_retired(7, &loc3, 1).await.unwrap();
assert_eq!(store.download(&loc1).await.unwrap(), b"one");
assert_eq!(store.download(&loc2).await.unwrap(), b"two");
assert_eq!(store.download(&loc3).await.unwrap(), b"three");
}
#[cfg(not(madsim))]
#[tokio::test]
async fn s3_memory_roundtrip() {
let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
let key = test_key(3, "group-3-T5-N2-9876");
let payload = b"raw snapshot bytes".repeat(64);
let loc = store.upload(key, payload.clone().into()).await.unwrap();
match &loc {
SnapshotLocation::S3 {
key,
size_bytes,
stored_size_bytes,
compression,
} => {
assert!(key.starts_with("snapshots/group-3/"));
assert_eq!(*size_bytes, payload.len() as u64);
assert_eq!(*compression, SnapshotCompression::Zstd);
assert!(stored_size_bytes.is_some());
assert!(stored_size_bytes.unwrap() < *size_bytes);
}
other => panic!("expected S3 location, got {other:?}"),
}
let bytes = store.download(&loc).await.unwrap();
assert_eq!(bytes, payload);
store.delete(&loc).await.unwrap();
assert!(matches!(
store.download(&loc).await,
Err(SnapshotStoreError::NotFound(_))
));
store.delete(&loc).await.unwrap();
}
#[cfg(not(madsim))]
#[tokio::test]
async fn s3_download_accepts_legacy_uncompressed_pointer() {
let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
let key = test_key(5, "group-5-T1-N1-10");
let loc = store
.upload(key, b"legacy body".to_vec().into())
.await
.unwrap();
let SnapshotLocation::S3 { key, .. } = loc else {
panic!("expected s3 location")
};
store
.write_raw_for_tests(&key, b"legacy body".to_vec())
.await
.unwrap();
let legacy = SnapshotLocation::S3 {
key,
size_bytes: b"legacy body".len() as u64,
stored_size_bytes: None,
compression: SnapshotCompression::None,
};
assert_eq!(store.download(&legacy).await.unwrap(), b"legacy body");
}
#[cfg(not(madsim))]
#[tokio::test]
async fn s3_two_uploads_with_same_snapshot_id_get_different_keys() {
let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
let key1 = test_key(4, "group-4-T18-N3-264150");
let key2 = test_key(4, "group-4-T18-N3-264150");
let loc1 = store.upload(key1, b"body1".to_vec().into()).await.unwrap();
let loc2 = store.upload(key2, b"body2".to_vec().into()).await.unwrap();
let (k1, k2) = match (&loc1, &loc2) {
(SnapshotLocation::S3 { key: k1, .. }, SnapshotLocation::S3 { key: k2, .. }) => {
(k1.clone(), k2.clone())
}
_ => panic!("expected S3 locations"),
};
assert_ne!(k1, k2, "same snapshot_id must yield distinct S3 keys");
assert_eq!(store.download(&loc1).await.unwrap(), b"body1");
assert_eq!(store.download(&loc2).await.unwrap(), b"body2");
store.delete(&loc1).await.unwrap();
assert!(matches!(
store.download(&loc1).await,
Err(SnapshotStoreError::NotFound(_))
));
assert_eq!(store.download(&loc2).await.unwrap(), b"body2");
}
#[cfg(not(madsim))]
#[tokio::test]
async fn s3_verify_uploaded_catches_missing_object() {
let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
let key = test_key(2, "group-2-T1-N1-7");
let loc = store.upload(key, b"payload".to_vec().into()).await.unwrap();
store.verify_uploaded(&loc).await.unwrap();
store.delete(&loc).await.unwrap();
let err = store.verify_uploaded(&loc).await.unwrap_err();
assert!(
matches!(err, SnapshotStoreError::NotFound(_)),
"expected NotFound after delete, got {err:?}"
);
}
#[cfg(not(madsim))]
#[tokio::test]
async fn local_integrity_detects_size_mismatch() {
let dir = tempfile::tempdir().unwrap();
let store = LocalSnapshotStore::new(dir.path());
let key = test_key(1, "group-1-T1-N1-1");
let loc = store.upload(key, b"abcd".to_vec().into()).await.unwrap();
let SnapshotLocation::Local { path, .. } = &loc else {
unreachable!()
};
tokio::fs::write(path, b"abcde").await.unwrap();
let result = store.download(&loc).await;
assert!(matches!(result, Err(SnapshotStoreError::Integrity(_))));
}
}