#![allow(dead_code)]
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuditEventKind {
SnapshotSaved,
SnapshotLoaded,
SnapshotsPruned,
SnapshotsRetained,
Custom(String),
}
impl std::fmt::Display for AuditEventKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::SnapshotSaved => write!(f, "SNAPSHOT_SAVED"),
Self::SnapshotLoaded => write!(f, "SNAPSHOT_LOADED"),
Self::SnapshotsPruned => write!(f, "SNAPSHOTS_PRUNED"),
Self::SnapshotsRetained => write!(f, "SNAPSHOTS_RETAINED"),
Self::Custom(s) => write!(f, "CUSTOM:{s}"),
}
}
}
#[derive(Debug, Clone)]
pub struct AuditEntry {
pub recorded_at: Instant,
pub kind: AuditEventKind,
pub message: String,
pub snapshot_id: Option<SnapshotId>,
}
impl AuditEntry {
fn new(
kind: AuditEventKind,
message: impl Into<String>,
snapshot_id: Option<SnapshotId>,
) -> Self {
Self {
recorded_at: Instant::now(),
kind,
message: message.into(),
snapshot_id,
}
}
}
#[derive(Debug, Default)]
pub struct AuditLog {
entries: Vec<AuditEntry>,
}
impl AuditLog {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn append(&mut self, entry: AuditEntry) {
self.entries.push(entry);
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[must_use]
pub fn entries(&self) -> &[AuditEntry] {
&self.entries
}
#[must_use]
pub fn entries_of_kind(&self, kind: &AuditEventKind) -> Vec<&AuditEntry> {
self.entries.iter().filter(|e| &e.kind == kind).collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SnapshotId {
pub term: u64,
pub index: u64,
}
impl SnapshotId {
#[must_use]
pub fn new(term: u64, index: u64) -> Self {
Self { term, index }
}
#[must_use]
pub fn is_valid(&self) -> bool {
self.term > 0 && self.index > 0
}
#[must_use]
pub fn is_newer_than(&self, other: &SnapshotId) -> bool {
self > other
}
}
impl std::fmt::Display for SnapshotId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "T{}:I{}", self.term, self.index)
}
}
#[derive(Debug, Clone)]
pub struct Snapshot {
pub id: SnapshotId,
pub data: Vec<u8>,
pub captured_at: Instant,
pub source_node: String,
pub checksum: u64,
}
impl Snapshot {
#[must_use]
pub fn new(id: SnapshotId, data: Vec<u8>, source_node: impl Into<String>) -> Self {
let checksum = data.iter().map(|&b| u64::from(b)).sum();
Self {
id,
data,
captured_at: Instant::now(),
source_node: source_node.into(),
checksum,
}
}
#[must_use]
pub fn size_bytes(&self) -> usize {
self.data.len()
}
#[must_use]
pub fn is_stale_at(&self, now: Instant, max_age: Duration) -> bool {
now.saturating_duration_since(self.captured_at) >= max_age
}
#[must_use]
pub fn verify_checksum(&self) -> bool {
let expected: u64 = self.data.iter().map(|&b| u64::from(b)).sum();
expected == self.checksum
}
}
#[derive(Debug, Default)]
pub struct SnapshotStore {
snapshots: BTreeMap<SnapshotId, Snapshot>,
total_bytes: usize,
audit_log: AuditLog,
}
impl SnapshotStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn save(&mut self, snapshot: Snapshot) {
let id = snapshot.id;
let is_replace = self.snapshots.contains_key(&id);
if let Some(old) = self.snapshots.get(&id) {
self.total_bytes -= old.size_bytes();
}
let size = snapshot.size_bytes();
self.total_bytes += size;
self.snapshots.insert(id, snapshot);
let msg = if is_replace {
format!("Replaced snapshot {id} ({size} bytes)")
} else {
format!("Saved snapshot {id} ({size} bytes)")
};
self.audit_log.append(AuditEntry::new(
AuditEventKind::SnapshotSaved,
msg,
Some(id),
));
}
#[must_use]
pub fn load(&mut self, id: &SnapshotId) -> Option<&Snapshot> {
if self.snapshots.contains_key(id) {
let msg = format!("Loaded snapshot {id}");
self.audit_log.append(AuditEntry::new(
AuditEventKind::SnapshotLoaded,
msg,
Some(*id),
));
}
self.snapshots.get(id)
}
#[must_use]
pub fn get(&self, id: &SnapshotId) -> Option<&Snapshot> {
self.snapshots.get(id)
}
#[must_use]
pub fn count(&self) -> usize {
self.snapshots.len()
}
#[must_use]
pub fn total_bytes(&self) -> usize {
self.total_bytes
}
#[must_use]
pub fn latest(&self) -> Option<&Snapshot> {
self.snapshots.values().next_back()
}
pub fn prune_old(&mut self, now: Instant, max_age: Duration) -> usize {
let stale_ids: Vec<SnapshotId> = self
.snapshots
.values()
.filter(|s| s.is_stale_at(now, max_age))
.map(|s| s.id)
.collect();
let removed = stale_ids.len();
for id in &stale_ids {
if let Some(s) = self.snapshots.remove(id) {
self.total_bytes -= s.size_bytes();
}
}
if removed > 0 {
let msg = format!("Pruned {removed} stale snapshot(s)");
self.audit_log
.append(AuditEntry::new(AuditEventKind::SnapshotsPruned, msg, None));
}
removed
}
pub fn retain_latest(&mut self, keep_count: usize) -> usize {
if self.snapshots.len() <= keep_count {
return 0;
}
let to_remove = self.snapshots.len() - keep_count;
let old_ids: Vec<SnapshotId> = self.snapshots.keys().take(to_remove).copied().collect();
for id in &old_ids {
if let Some(s) = self.snapshots.remove(id) {
self.total_bytes -= s.size_bytes();
}
}
let removed = old_ids.len();
if removed > 0 {
let msg = format!("Retained latest {keep_count} snapshot(s), removed {removed}");
self.audit_log.append(AuditEntry::new(
AuditEventKind::SnapshotsRetained,
msg,
None,
));
}
removed
}
#[must_use]
pub fn all_ids(&self) -> Vec<SnapshotId> {
self.snapshots.keys().copied().collect()
}
#[must_use]
pub fn audit_log(&self) -> &AuditLog {
&self.audit_log
}
pub fn audit_custom(&mut self, event: impl Into<String>, message: impl Into<String>) {
self.audit_log.append(AuditEntry::new(
AuditEventKind::Custom(event.into()),
message,
None,
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn snap(term: u64, index: u64, data: Vec<u8>) -> Snapshot {
Snapshot::new(SnapshotId::new(term, index), data, "node1")
}
#[test]
fn test_snapshot_id_is_valid() {
assert!(SnapshotId::new(1, 1).is_valid());
assert!(!SnapshotId::new(0, 1).is_valid());
assert!(!SnapshotId::new(1, 0).is_valid());
assert!(!SnapshotId::new(0, 0).is_valid());
}
#[test]
fn test_snapshot_id_ordering() {
let a = SnapshotId::new(1, 5);
let b = SnapshotId::new(2, 1);
let c = SnapshotId::new(1, 10);
assert!(b > a);
assert!(c > a);
assert!(b > c);
}
#[test]
fn test_snapshot_id_is_newer_than() {
let older = SnapshotId::new(1, 5);
let newer = SnapshotId::new(2, 1);
assert!(newer.is_newer_than(&older));
assert!(!older.is_newer_than(&newer));
}
#[test]
fn test_snapshot_id_display() {
let id = SnapshotId::new(3, 42);
assert_eq!(format!("{id}"), "T3:I42");
}
#[test]
fn test_snapshot_checksum() {
let s = snap(1, 1, vec![1u8, 2, 3]);
assert!(s.verify_checksum());
assert_eq!(s.checksum, 6);
}
#[test]
fn test_snapshot_size_bytes() {
let s = snap(1, 1, vec![0u8; 512]);
assert_eq!(s.size_bytes(), 512);
}
#[test]
fn test_snapshot_is_stale_at() {
let mut s = snap(1, 1, vec![0]);
s.captured_at = Instant::now() - Duration::from_secs(20);
assert!(s.is_stale_at(Instant::now(), Duration::from_secs(10)));
assert!(!s.is_stale_at(Instant::now(), Duration::from_secs(30)));
}
#[test]
fn test_store_save_and_load() {
let mut store = SnapshotStore::new();
let id = SnapshotId::new(1, 10);
store.save(snap(1, 10, vec![7, 8, 9]));
let loaded = store.load(&id).expect("loading should succeed");
assert_eq!(loaded.checksum, 7 + 8 + 9);
}
#[test]
fn test_store_count() {
let mut store = SnapshotStore::new();
assert_eq!(store.count(), 0);
store.save(snap(1, 1, vec![1]));
store.save(snap(1, 2, vec![2]));
assert_eq!(store.count(), 2);
}
#[test]
fn test_store_total_bytes() {
let mut store = SnapshotStore::new();
store.save(snap(1, 1, vec![0u8; 100]));
store.save(snap(1, 2, vec![0u8; 200]));
assert_eq!(store.total_bytes(), 300);
}
#[test]
fn test_store_replace_updates_bytes() {
let mut store = SnapshotStore::new();
store.save(snap(1, 1, vec![0u8; 100]));
store.save(snap(1, 1, vec![0u8; 50])); assert_eq!(store.count(), 1);
assert_eq!(store.total_bytes(), 50);
}
#[test]
fn test_store_latest() {
let mut store = SnapshotStore::new();
store.save(snap(1, 5, vec![1]));
store.save(snap(2, 1, vec![2]));
store.save(snap(1, 10, vec![3]));
assert_eq!(
store.latest().expect("latest should exist").id,
SnapshotId::new(2, 1)
);
}
#[test]
fn test_store_prune_old() {
let mut store = SnapshotStore::new();
let mut s_old = snap(1, 1, vec![1]);
s_old.captured_at = Instant::now() - Duration::from_secs(20);
store.save(s_old);
store.save(snap(1, 2, vec![2]));
let removed = store.prune_old(Instant::now(), Duration::from_secs(10));
assert_eq!(removed, 1);
assert_eq!(store.count(), 1);
}
#[test]
fn test_store_retain_latest() {
let mut store = SnapshotStore::new();
store.save(snap(1, 1, vec![1]));
store.save(snap(1, 2, vec![2]));
store.save(snap(1, 3, vec![3]));
store.save(snap(2, 1, vec![4]));
let removed = store.retain_latest(2);
assert_eq!(removed, 2);
assert_eq!(store.count(), 2);
}
#[test]
fn test_store_all_ids_ordered() {
let mut store = SnapshotStore::new();
store.save(snap(2, 5, vec![]));
store.save(snap(1, 1, vec![]));
store.save(snap(1, 10, vec![]));
let ids = store.all_ids();
assert!(ids.windows(2).all(|w| w[0] < w[1]));
}
}