use crate::error::{Result, StorageError};
use crate::kv::{KvStore, RocksDbStore, WriteOp, CF_SNAPSHOTS};
use flate2::read::{GzDecoder, GzEncoder};
use flate2::Compression;
use parking_lot::RwLock;
use rocksdb::checkpoint::Checkpoint;
use serde::{Deserialize, Serialize};
use std::io::Read;
use std::path::Path;
use std::sync::Arc;
use tenzro_types::{BlockHeight, Hash, Timestamp};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Snapshot {
pub height: BlockHeight,
pub state_root: Hash,
pub block_hash: Hash,
pub timestamp: Timestamp,
pub metadata: SnapshotMetadata,
pub state_data: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotMetadata {
pub account_count: u64,
pub state_size: u64,
pub compressed_size: u64,
pub compression: CompressionType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CompressionType {
None,
Lz4,
Zstd,
Gzip,
}
fn compress_gzip(data: &[u8]) -> Result<Vec<u8>> {
let mut encoder = GzEncoder::new(data, Compression::default());
let mut compressed = Vec::new();
encoder.read_to_end(&mut compressed).map_err(|e| {
StorageError::InvalidSnapshot(format!("gzip compression failed: {}", e))
})?;
Ok(compressed)
}
fn decompress_gzip(data: &[u8]) -> Result<Vec<u8>> {
let mut decoder = GzDecoder::new(data);
let mut decompressed = Vec::new();
decoder.read_to_end(&mut decompressed).map_err(|e| {
StorageError::InvalidSnapshot(format!("gzip decompression failed: {}", e))
})?;
Ok(decompressed)
}
pub struct SnapshotManager<K: KvStore> {
kv_store: Arc<K>,
retention_count: Arc<RwLock<u64>>,
state_lock: Arc<RwLock<()>>,
}
impl<K: KvStore> SnapshotManager<K> {
pub fn new(kv_store: Arc<K>, retention_count: u64) -> Self {
Self {
kv_store,
retention_count: Arc::new(RwLock::new(retention_count)),
state_lock: Arc::new(RwLock::new(())),
}
}
pub fn state_lock(&self) -> Arc<RwLock<()>> {
self.state_lock.clone()
}
#[allow(clippy::await_holding_lock)]
pub async fn create_consistent_snapshot<F>(
&self,
height: BlockHeight,
state_root: Hash,
block_hash: Hash,
collect_state: F,
) -> Result<Snapshot>
where
F: FnOnce() -> Vec<u8>,
{
let _guard = self.state_lock.write();
let state_data = collect_state();
let state_size = state_data.len() as u64;
let compressed_data = compress_gzip(&state_data)?;
let compressed_size = compressed_data.len() as u64;
let snapshot = Snapshot {
height,
state_root,
block_hash,
timestamp: Timestamp::now(),
metadata: SnapshotMetadata {
account_count: 0,
state_size,
compressed_size,
compression: CompressionType::Gzip,
},
state_data: compressed_data,
};
self.store_snapshot(&snapshot).await?;
self.prune_snapshots(height).await?;
Ok(snapshot)
}
#[allow(clippy::await_holding_lock)]
pub async fn create_snapshot(
&self,
height: BlockHeight,
state_root: Hash,
block_hash: Hash,
state_data: Vec<u8>,
) -> Result<Snapshot> {
let _guard = self.state_lock.read();
let state_size = state_data.len() as u64;
let compressed_data = compress_gzip(&state_data)?;
let compressed_size = compressed_data.len() as u64;
let snapshot = Snapshot {
height,
state_root,
block_hash,
timestamp: Timestamp::now(),
metadata: SnapshotMetadata {
account_count: 0, state_size,
compressed_size,
compression: CompressionType::Gzip,
},
state_data: compressed_data,
};
self.store_snapshot(&snapshot).await?;
self.prune_snapshots(height).await?;
Ok(snapshot)
}
async fn store_snapshot(&self, snapshot: &Snapshot) -> Result<()> {
let key = Self::snapshot_key(snapshot.height);
let data = bincode::serialize(snapshot)?;
self.kv_store.put(CF_SNAPSHOTS, &key, &data)?;
let index_key = Self::snapshot_index_key();
let mut index = self.load_snapshot_index().await?;
index.push(snapshot.height);
index.sort_by_key(|h| h.0);
let index_data = bincode::serialize(&index)?;
self.kv_store.put(CF_SNAPSHOTS, &index_key, &index_data)?;
Ok(())
}
pub async fn load_snapshot(&self, height: BlockHeight) -> Result<Option<Snapshot>> {
let key = Self::snapshot_key(height);
if let Some(data) = self.kv_store.get(CF_SNAPSHOTS, &key)? {
let snapshot: Snapshot = bincode::deserialize(&data)?;
Ok(Some(snapshot))
} else {
Ok(None)
}
}
pub async fn delete_snapshot(&self, height: BlockHeight) -> Result<()> {
let key = Self::snapshot_key(height);
self.kv_store.delete(CF_SNAPSHOTS, &key)?;
let index_key = Self::snapshot_index_key();
let mut index = self.load_snapshot_index().await?;
index.retain(|h| *h != height);
let index_data = bincode::serialize(&index)?;
self.kv_store.put(CF_SNAPSHOTS, &index_key, &index_data)?;
Ok(())
}
pub async fn list_snapshots(&self) -> Result<Vec<BlockHeight>> {
self.load_snapshot_index().await
}
pub async fn latest_snapshot(&self) -> Result<Option<Snapshot>> {
let index = self.load_snapshot_index().await?;
if let Some(height) = index.last() {
self.load_snapshot(*height).await
} else {
Ok(None)
}
}
async fn prune_snapshots(&self, _current_height: BlockHeight) -> Result<()> {
let retention = *self.retention_count.read();
let index = self.load_snapshot_index().await?;
if index.len() as u64 <= retention {
return Ok(());
}
let to_delete = index.len() as u64 - retention;
let mut ops = Vec::new();
for height in index.iter().take(to_delete as usize) {
let key = Self::snapshot_key(*height);
ops.push(WriteOp::Delete {
cf: CF_SNAPSHOTS.to_string(),
key,
});
}
let new_index: Vec<BlockHeight> = index.into_iter().skip(to_delete as usize).collect();
let index_key = Self::snapshot_index_key();
let index_data = bincode::serialize(&new_index)?;
ops.push(WriteOp::Put {
cf: CF_SNAPSHOTS.to_string(),
key: index_key,
value: index_data,
});
self.kv_store.write_batch(ops)?;
Ok(())
}
async fn load_snapshot_index(&self) -> Result<Vec<BlockHeight>> {
let key = Self::snapshot_index_key();
if let Some(data) = self.kv_store.get(CF_SNAPSHOTS, &key)? {
let index: Vec<BlockHeight> = bincode::deserialize(&data)?;
Ok(index)
} else {
Ok(Vec::new())
}
}
fn snapshot_key(height: BlockHeight) -> Vec<u8> {
let mut key = b"snapshot:".to_vec();
key.extend_from_slice(&height.0.to_be_bytes());
key
}
fn snapshot_index_key() -> Vec<u8> {
b"snapshot_index".to_vec()
}
pub fn set_retention_count(&self, count: u64) {
*self.retention_count.write() = count;
}
pub fn retention_count(&self) -> u64 {
*self.retention_count.read()
}
}
impl SnapshotManager<RocksDbStore> {
pub async fn create_checkpoint_snapshot(
&self,
height: BlockHeight,
state_root: Hash,
block_hash: Hash,
checkpoint_path: &Path,
) -> Result<Snapshot> {
let db = self.kv_store.db();
let checkpoint = Checkpoint::new(db)
.map_err(|e| StorageError::InvalidSnapshot(format!("Failed to create checkpoint: {}", e)))?;
checkpoint.create_checkpoint(checkpoint_path)
.map_err(|e| StorageError::InvalidSnapshot(format!("Failed to create checkpoint at path: {}", e)))?;
let checkpoint_size = calculate_directory_size(checkpoint_path)?;
let snapshot = Snapshot {
height,
state_root,
block_hash,
timestamp: Timestamp::now(),
metadata: SnapshotMetadata {
account_count: 0, state_size: checkpoint_size,
compressed_size: checkpoint_size,
compression: CompressionType::None,
},
state_data: checkpoint_path.to_string_lossy().as_bytes().to_vec(),
};
let key = Self::snapshot_key(snapshot.height);
let data = bincode::serialize(&snapshot)?;
self.kv_store.put(CF_SNAPSHOTS, &key, &data)?;
let index_key = Self::snapshot_index_key();
let index_data_opt = self.kv_store.get(CF_SNAPSHOTS, &index_key)?;
let mut index: Vec<BlockHeight> = if let Some(data) = index_data_opt {
bincode::deserialize(&data)?
} else {
Vec::new()
};
index.push(snapshot.height);
index.sort_by_key(|h| h.0);
let index_data = bincode::serialize(&index)?;
self.kv_store.put(CF_SNAPSHOTS, &index_key, &index_data)?;
let retention = *self.retention_count.read();
if index.len() as u64 > retention {
let to_delete = index.len() as u64 - retention;
let mut ops = Vec::new();
for height in index.iter().take(to_delete as usize) {
let key = Self::snapshot_key(*height);
ops.push(WriteOp::Delete {
cf: CF_SNAPSHOTS.to_string(),
key,
});
}
let new_index: Vec<BlockHeight> = index.into_iter().skip(to_delete as usize).collect();
let index_data = bincode::serialize(&new_index)?;
ops.push(WriteOp::Put {
cf: CF_SNAPSHOTS.to_string(),
key: index_key,
value: index_data,
});
self.kv_store.write_batch(ops)?;
}
Ok(snapshot)
}
}
fn calculate_directory_size(path: &Path) -> Result<u64> {
let mut total_size = 0u64;
if path.is_file() {
return Ok(std::fs::metadata(path)?.len());
}
if path.is_dir() {
for entry in std::fs::read_dir(path)? {
let entry = entry?;
let metadata = entry.metadata()?;
if metadata.is_file() {
total_size += metadata.len();
} else if metadata.is_dir() {
total_size += calculate_directory_size(&entry.path())?;
}
}
}
Ok(total_size)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotEntry {
pub cf: String,
pub key: Vec<u8>,
pub value: Vec<u8>,
}
pub fn serialize_snapshot_entries(entries: &[SnapshotEntry]) -> Result<Vec<u8>> {
bincode::serialize(entries).map_err(|e| {
StorageError::SerializationError(format!("Failed to serialize snapshot entries: {}", e))
})
}
pub fn compute_state_root(entries: &[SnapshotEntry]) -> Hash {
use sha2::{Digest, Sha256};
const DOMAIN: &[u8] = b"tenzro/snapshot/state-root/v1";
let mut sorted: Vec<&SnapshotEntry> = entries.iter().collect();
sorted.sort_by(|a, b| {
a.cf.as_bytes()
.cmp(b.cf.as_bytes())
.then_with(|| a.key.cmp(&b.key))
});
let mut hasher = Sha256::new();
hasher.update(DOMAIN);
for e in sorted {
let cf = e.cf.as_bytes();
hasher.update((cf.len() as u32).to_le_bytes());
hasher.update(cf);
hasher.update((e.key.len() as u32).to_le_bytes());
hasher.update(&e.key);
hasher.update((e.value.len() as u32).to_le_bytes());
hasher.update(&e.value);
}
let digest = hasher.finalize();
let mut out = [0u8; 32];
out.copy_from_slice(&digest);
Hash::new(out)
}
pub struct SnapshotRestorer;
impl SnapshotRestorer {
pub async fn restore_from_snapshot(
snapshot: &Snapshot,
store: &dyn KvStore,
) -> Result<RestoredState> {
if snapshot.state_data.is_empty() {
return Err(StorageError::InvalidSnapshot(
"snapshot contains no state data".to_string(),
));
}
let raw_data = match snapshot.metadata.compression {
CompressionType::Gzip => decompress_gzip(&snapshot.state_data)?,
CompressionType::None => snapshot.state_data.clone(),
other => {
return Err(StorageError::InvalidSnapshot(format!(
"unsupported compression type: {:?}",
other
)));
}
};
let entries: Vec<SnapshotEntry> =
bincode::deserialize(&raw_data).map_err(|e| {
StorageError::InvalidSnapshot(format!(
"state_data is not a valid SnapshotEntry list \
(is this a RocksDB checkpoint snapshot?): {}",
e
))
})?;
let entry_count = entries.len();
tracing::info!(
"Restoring snapshot at height {} with {} entries",
snapshot.height.0,
entry_count
);
let ops: Vec<WriteOp> = entries
.iter()
.map(|e| WriteOp::Put {
cf: e.cf.clone(),
key: e.key.clone(),
value: e.value.clone(),
})
.collect();
if !ops.is_empty() {
store.write_batch_sync(ops)?;
}
tracing::info!(
"Snapshot at height {} restored successfully ({} entries)",
snapshot.height.0,
entry_count
);
Ok(RestoredState {
height: snapshot.height,
state_root: snapshot.state_root,
account_count: snapshot.metadata.account_count,
entries_restored: entry_count as u64,
})
}
pub fn validate_snapshot(snapshot: &Snapshot) -> Result<bool> {
if snapshot.state_data.is_empty() {
return Ok(false);
}
if snapshot.metadata.compressed_size != snapshot.state_data.len() as u64 {
return Ok(false);
}
if snapshot.state_root != Hash::zero() {
let raw = match snapshot.metadata.compression {
CompressionType::Gzip => match decompress_gzip(&snapshot.state_data) {
Ok(d) => d,
Err(_) => return Ok(true), },
CompressionType::None => snapshot.state_data.clone(),
_ => return Ok(true),
};
if let Ok(entries) = bincode::deserialize::<Vec<SnapshotEntry>>(&raw) {
let recomputed = compute_state_root(&entries);
if recomputed != snapshot.state_root {
tracing::warn!(
height = snapshot.height.0,
recorded = %hex::encode(snapshot.state_root.as_bytes()),
recomputed = %hex::encode(recomputed.as_bytes()),
"snapshot state_root mismatch"
);
return Ok(false);
}
}
}
Ok(true)
}
}
#[derive(Debug, Clone)]
pub struct RestoredState {
pub height: BlockHeight,
pub state_root: Hash,
pub account_count: u64,
pub entries_restored: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::kv::MemoryStore;
#[tokio::test]
async fn test_snapshot_creation() {
let kv_store = Arc::new(MemoryStore::new());
let manager = SnapshotManager::new(kv_store, 5);
let state_data = vec![1, 2, 3, 4, 5];
let snapshot = manager
.create_snapshot(
BlockHeight::new(100),
Hash::zero(),
Hash::zero(),
state_data,
)
.await
.unwrap();
assert_eq!(snapshot.height, BlockHeight::new(100));
assert_eq!(snapshot.metadata.state_size, 5);
}
#[tokio::test]
async fn test_snapshot_load() {
let kv_store = Arc::new(MemoryStore::new());
let manager = SnapshotManager::new(kv_store, 5);
let state_data = vec![1, 2, 3, 4, 5];
manager
.create_snapshot(
BlockHeight::new(100),
Hash::zero(),
Hash::zero(),
state_data,
)
.await
.unwrap();
let loaded = manager
.load_snapshot(BlockHeight::new(100))
.await
.unwrap();
assert!(loaded.is_some());
assert_eq!(loaded.unwrap().height, BlockHeight::new(100));
}
#[tokio::test]
async fn test_snapshot_pruning() {
let kv_store = Arc::new(MemoryStore::new());
let manager = SnapshotManager::new(kv_store, 3);
for i in 1..=5 {
manager
.create_snapshot(
BlockHeight::new(i * 100),
Hash::zero(),
Hash::zero(),
vec![i as u8],
)
.await
.unwrap();
}
let snapshots = manager.list_snapshots().await.unwrap();
assert_eq!(snapshots.len(), 3);
assert_eq!(snapshots[0], BlockHeight::new(300));
assert_eq!(snapshots[1], BlockHeight::new(400));
assert_eq!(snapshots[2], BlockHeight::new(500));
}
#[tokio::test]
async fn test_latest_snapshot() {
let kv_store = Arc::new(MemoryStore::new());
let manager = SnapshotManager::new(kv_store, 5);
manager
.create_snapshot(
BlockHeight::new(100),
Hash::zero(),
Hash::zero(),
vec![1],
)
.await
.unwrap();
manager
.create_snapshot(
BlockHeight::new(200),
Hash::zero(),
Hash::zero(),
vec![2],
)
.await
.unwrap();
let latest = manager.latest_snapshot().await.unwrap();
assert!(latest.is_some());
assert_eq!(latest.unwrap().height, BlockHeight::new(200));
}
#[tokio::test]
async fn test_snapshot_deletion() {
let kv_store = Arc::new(MemoryStore::new());
let manager = SnapshotManager::new(kv_store, 5);
manager
.create_snapshot(
BlockHeight::new(100),
Hash::zero(),
Hash::zero(),
vec![1],
)
.await
.unwrap();
manager.delete_snapshot(BlockHeight::new(100)).await.unwrap();
let loaded = manager.load_snapshot(BlockHeight::new(100)).await.unwrap();
assert!(loaded.is_none());
}
#[tokio::test]
async fn test_restore_from_snapshot_writes_entries() {
use crate::kv::{MemoryStore, CF_STATE, CF_ACCOUNTS};
let entries = vec![
SnapshotEntry {
cf: CF_STATE.to_string(),
key: b"balance:0xabc".to_vec(),
value: 1234u128.to_le_bytes().to_vec(),
},
SnapshotEntry {
cf: CF_ACCOUNTS.to_string(),
key: b"account:0xabc".to_vec(),
value: b"account_data".to_vec(),
},
];
let state_data = serialize_snapshot_entries(&entries).unwrap();
let restore_store = Arc::new(MemoryStore::new());
let manager = SnapshotManager::new(restore_store.clone(), 5);
let snapshot = manager
.create_snapshot(
BlockHeight::new(42),
Hash::zero(),
Hash::zero(),
state_data,
)
.await
.unwrap();
let target_store = Arc::new(MemoryStore::new());
let restored = SnapshotRestorer::restore_from_snapshot(&snapshot, target_store.as_ref())
.await
.unwrap();
assert_eq!(restored.height, BlockHeight::new(42));
assert_eq!(restored.entries_restored, 2);
let balance_bytes = target_store
.get(CF_STATE, b"balance:0xabc")
.unwrap()
.expect("balance entry should be present");
assert_eq!(u128::from_le_bytes(balance_bytes.try_into().unwrap()), 1234u128);
let account_bytes = target_store
.get(CF_ACCOUNTS, b"account:0xabc")
.unwrap()
.expect("account entry should be present");
assert_eq!(account_bytes, b"account_data");
}
#[tokio::test]
async fn test_restore_from_snapshot_empty_data_returns_error() {
let kv_store = Arc::new(MemoryStore::new());
let target_store = Arc::new(MemoryStore::new());
let snapshot = Snapshot {
height: BlockHeight::new(1),
state_root: Hash::zero(),
block_hash: Hash::zero(),
timestamp: Timestamp::now(),
metadata: SnapshotMetadata {
account_count: 0,
state_size: 0,
compressed_size: 0,
compression: CompressionType::None,
},
state_data: vec![],
};
let result = SnapshotRestorer::restore_from_snapshot(&snapshot, target_store.as_ref()).await;
assert!(result.is_err(), "empty state_data must return an error");
let _ = kv_store;
}
#[tokio::test]
async fn test_restore_roundtrip_via_snapshot_manager() {
use crate::kv::{MemoryStore, CF_STATE};
let source_entries = (0u8..5).map(|i| SnapshotEntry {
cf: CF_STATE.to_string(),
key: format!("key:{}", i).into_bytes(),
value: vec![i * 10],
}).collect::<Vec<_>>();
let state_data = serialize_snapshot_entries(&source_entries).unwrap();
let snap_store = Arc::new(MemoryStore::new());
let manager = SnapshotManager::new(snap_store, 10);
let snapshot = manager
.create_snapshot(BlockHeight::new(77), Hash::zero(), Hash::zero(), state_data)
.await
.unwrap();
assert!(SnapshotRestorer::validate_snapshot(&snapshot).unwrap());
let target = Arc::new(MemoryStore::new());
let result = SnapshotRestorer::restore_from_snapshot(&snapshot, target.as_ref())
.await
.unwrap();
assert_eq!(result.entries_restored, 5);
for i in 0u8..5 {
let key = format!("key:{}", i).into_bytes();
let val = target.get(CF_STATE, &key).unwrap().expect("entry missing");
assert_eq!(val, vec![i * 10]);
}
}
#[test]
fn compute_state_root_is_deterministic() {
use crate::kv::CF_STATE;
let entries_a = vec![
SnapshotEntry {
cf: CF_STATE.to_string(),
key: b"k1".to_vec(),
value: b"v1".to_vec(),
},
SnapshotEntry {
cf: CF_STATE.to_string(),
key: b"k2".to_vec(),
value: b"v2".to_vec(),
},
];
let entries_b = vec![
SnapshotEntry {
cf: CF_STATE.to_string(),
key: b"k2".to_vec(),
value: b"v2".to_vec(),
},
SnapshotEntry {
cf: CF_STATE.to_string(),
key: b"k1".to_vec(),
value: b"v1".to_vec(),
},
];
let root_a = compute_state_root(&entries_a);
let root_b = compute_state_root(&entries_b);
assert_eq!(root_a, root_b, "root must be order-independent");
assert_ne!(root_a, Hash::zero(), "root must be non-zero for non-empty payload");
}
#[test]
fn compute_state_root_changes_with_value() {
use crate::kv::CF_STATE;
let entries_a = vec![SnapshotEntry {
cf: CF_STATE.to_string(),
key: b"k".to_vec(),
value: b"v1".to_vec(),
}];
let entries_b = vec![SnapshotEntry {
cf: CF_STATE.to_string(),
key: b"k".to_vec(),
value: b"v2".to_vec(),
}];
assert_ne!(
compute_state_root(&entries_a),
compute_state_root(&entries_b)
);
}
#[tokio::test]
async fn validate_snapshot_accepts_correct_root() {
use crate::kv::CF_STATE;
let kv_store = Arc::new(MemoryStore::new());
let manager = SnapshotManager::new(kv_store, 5);
let entries = vec![
SnapshotEntry {
cf: CF_STATE.to_string(),
key: b"alpha".to_vec(),
value: b"1".to_vec(),
},
SnapshotEntry {
cf: CF_STATE.to_string(),
key: b"beta".to_vec(),
value: b"2".to_vec(),
},
];
let real_root = compute_state_root(&entries);
let payload = serialize_snapshot_entries(&entries).unwrap();
let snapshot = manager
.create_snapshot(BlockHeight::new(42), real_root, Hash::zero(), payload)
.await
.unwrap();
assert!(SnapshotRestorer::validate_snapshot(&snapshot).unwrap());
}
#[tokio::test]
async fn validate_snapshot_rejects_wrong_root() {
use crate::kv::CF_STATE;
let kv_store = Arc::new(MemoryStore::new());
let manager = SnapshotManager::new(kv_store, 5);
let entries = vec![SnapshotEntry {
cf: CF_STATE.to_string(),
key: b"alpha".to_vec(),
value: b"1".to_vec(),
}];
let mut wrong_root = [0u8; 32];
wrong_root[0] = 0x42; let payload = serialize_snapshot_entries(&entries).unwrap();
let snapshot = manager
.create_snapshot(
BlockHeight::new(43),
Hash::new(wrong_root),
Hash::zero(),
payload,
)
.await
.unwrap();
assert!(!SnapshotRestorer::validate_snapshot(&snapshot).unwrap());
}
}