Phase 7d: snapshot primitives + CLI
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 10s
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 10s
A snapshot is a named, immutable point-in-time record of every blob
live in the store. It's NOT a data copy — blobs are content-addressed
and already live under blobs/. A snapshot is a JSON reference set at
<root>/snapshots/<name>.json.
Why:
* Rollback anchor before risky migrations.
* Retention pin: combined with the Phase 4a pin-aware LRU eviction,
operators can guarantee "these blobs stay on disk N days".
* Audit: "which blobs existed at release time?"
New module cluster::snapshot:
* SnapshotStore::create(name, blob_store, created_at)
* SnapshotStore::get(name) / list() / delete(name)
* SnapshotManifest { name, created_at_unix, blob_ids }
* SnapshotSummary for cheap list rendering (no blob-list slurp).
BlobStore gains list_blob_ids() — walks blobs/**/*.manifest.json
and returns the blob id set. Manifests only, no chunk reads.
New CLI commands:
* claw-store cluster-snapshot-create --name <>
* claw-store cluster-snapshot-list
* claw-store cluster-snapshot-show --name <>
* claw-store cluster-snapshot-delete --name <>
Semantics:
* Snapshots are immutable: create with existing name errors, does
not clobber. Delete-then-create if you really want to overwrite.
* delete() removes only the reference file. Never touches blob
data — protects against operators nuking live data by pruning
snapshots.
* list() sorts by created_at_unix ascending — oldest first so
triage picks pruning candidates quickly.
* blob_ids are sorted at write time so the same content on two
nodes yields byte-identical snapshot files.
* Names validated: no /, \\, NUL, control chars; max 512 bytes.
+8 tests covering create+capture, immutability, get-missing,
list-ordering, delete truth-values, delete-doesn't-touch-blobs,
name-validation, and sorted round-trip.
353 tests pass (+8). Pre-existing macOS
hot::tests::test_project_target_size_bytes failure unchanged.
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
//! Snapshot store (Phase 7d).
|
||||
//!
|
||||
//! A **snapshot** is a named, immutable record of every blob live in
|
||||
//! the store at the moment it was created. It is *not* a copy of the
|
||||
//! data — blobs are content-addressed and already live under
|
||||
//! `blobs/`. A snapshot is a list of `blob_id`s + wall-clock creation
|
||||
//! time, stored as JSON at
|
||||
//! `<root>/snapshots/<name>.json`.
|
||||
//!
|
||||
//! Why this exists:
|
||||
//! * **Rollback anchor** — before a risky migration, snapshot the
|
||||
//! current graph; if things go sideways the snapshot lists exactly
|
||||
//! which blobs must survive.
|
||||
//! * **Retention pin** — combined with the pin-aware LRU eviction
|
||||
//! from Phase 4a, the operator can guarantee "these blobs stay on
|
||||
//! disk for the next N days" without hand-listing them.
|
||||
//! * **Audit** — "which blobs existed at release time?"
|
||||
//!
|
||||
//! Layout:
|
||||
//!
|
||||
//! ```text
|
||||
//! <root>/snapshots/<name>.json # SnapshotManifest, JSON
|
||||
//! ```
|
||||
//!
|
||||
//! Snapshot names are operator-supplied strings; the same
|
||||
//! `validate_name` rules that guard `TagStore` apply — printable
|
||||
//! non-slash characters, bounded length.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::cluster::blob::{BlobId, BlobStore};
|
||||
|
||||
/// Longest snapshot name we accept. Same rationale as
|
||||
/// [`crate::cluster::tags::MAX_TAG_KEY_BYTES`] but tighter — names
|
||||
/// end up in filesystem paths.
|
||||
pub const MAX_SNAPSHOT_NAME_BYTES: usize = 512;
|
||||
|
||||
/// On-disk JSON representation of a snapshot.
|
||||
///
|
||||
/// `created_at_unix` is wall-clock seconds at the moment the snapshot
|
||||
/// was written. Stored explicitly so listing doesn't have to `stat`
|
||||
/// files, and so restore/report tools can render "created 3 days ago"
|
||||
/// without a filesystem call.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SnapshotManifest {
|
||||
pub name: String,
|
||||
pub created_at_unix: u64,
|
||||
/// Blob ids that were live in the store at snapshot time.
|
||||
/// Duplicates deduped by construction. Order is arbitrary.
|
||||
pub blob_ids: Vec<BlobId>,
|
||||
}
|
||||
|
||||
/// Compact form returned by [`SnapshotStore::list`]. Enough to render
|
||||
/// a table without slurping every snapshot's full blob list into
|
||||
/// memory.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SnapshotSummary {
|
||||
pub name: String,
|
||||
pub created_at_unix: u64,
|
||||
pub blob_count: usize,
|
||||
pub file_bytes: u64,
|
||||
}
|
||||
|
||||
/// Filesystem-backed snapshot store rooted at a directory.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SnapshotStore {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl SnapshotStore {
|
||||
/// Open (create if missing) the snapshot store under `root`.
|
||||
/// `root` is typically the same directory the blob store lives
|
||||
/// under — snapshots land in `<root>/snapshots/`.
|
||||
pub fn open(root: PathBuf) -> Result<Self> {
|
||||
std::fs::create_dir_all(root.join("snapshots"))
|
||||
.with_context(|| format!("creating snapshots dir under {}", root.display()))?;
|
||||
Ok(Self { root })
|
||||
}
|
||||
|
||||
/// Point-in-time snapshot of every blob currently in `store`.
|
||||
/// Reads the live manifest set via `BlobStore::list_blob_ids`
|
||||
/// and writes a single JSON file. Fails if a snapshot with the
|
||||
/// same name already exists — snapshots are meant to be
|
||||
/// immutable checkpoints, not mutable pointers. Use `delete`
|
||||
/// then `create` if you really want to overwrite.
|
||||
pub async fn create(
|
||||
&self,
|
||||
name: &str,
|
||||
store: &BlobStore,
|
||||
created_at_unix: u64,
|
||||
) -> Result<SnapshotManifest> {
|
||||
validate_name(name)?;
|
||||
let path = self.snapshot_path(name);
|
||||
if tokio::fs::metadata(&path).await.is_ok() {
|
||||
bail!("snapshot {:?} already exists at {}", name, path.display());
|
||||
}
|
||||
let mut blob_ids = store.list_blob_ids().await?;
|
||||
// Stable order = stable JSON. Blob_ids from list_blob_ids come
|
||||
// in filesystem walk order, which is not portable.
|
||||
blob_ids.sort();
|
||||
blob_ids.dedup();
|
||||
let manifest = SnapshotManifest {
|
||||
name: name.to_string(),
|
||||
created_at_unix,
|
||||
blob_ids,
|
||||
};
|
||||
let bytes = serde_json::to_vec_pretty(&manifest)
|
||||
.context("serializing snapshot manifest")?;
|
||||
atomic_write(&path, &bytes).await?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// Read a snapshot back. `None` if unknown; `Err` if the file is
|
||||
/// present but malformed (visibility over silent skip).
|
||||
pub async fn get(&self, name: &str) -> Result<Option<SnapshotManifest>> {
|
||||
validate_name(name)?;
|
||||
let path = self.snapshot_path(name);
|
||||
let bytes = match tokio::fs::read(&path).await {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => return Err(anyhow::Error::from(e)),
|
||||
};
|
||||
let m: SnapshotManifest = serde_json::from_slice(&bytes)
|
||||
.with_context(|| format!("decoding {}", path.display()))?;
|
||||
Ok(Some(m))
|
||||
}
|
||||
|
||||
/// Enumerate every snapshot. Cheap: one file read per snapshot
|
||||
/// (the summary field is 3 numbers + a name). Returns entries
|
||||
/// sorted by `created_at_unix` ascending — oldest first — so an
|
||||
/// operator scanning a long list can find the oldest to prune.
|
||||
pub async fn list(&self) -> Result<Vec<SnapshotSummary>> {
|
||||
let dir = self.root.join("snapshots");
|
||||
let mut out = Vec::new();
|
||||
let mut entries = match tokio::fs::read_dir(&dir).await {
|
||||
Ok(e) => e,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
|
||||
Err(e) => return Err(anyhow::Error::from(e)),
|
||||
};
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
if !entry.file_type().await?.is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name();
|
||||
let name_str = match name.to_str() {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
if !name_str.ends_with(".json") {
|
||||
continue;
|
||||
}
|
||||
let bytes = match tokio::fs::read(entry.path()).await {
|
||||
Ok(b) => b,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let file_bytes = bytes.len() as u64;
|
||||
if let Ok(m) = serde_json::from_slice::<SnapshotManifest>(&bytes) {
|
||||
out.push(SnapshotSummary {
|
||||
name: m.name,
|
||||
created_at_unix: m.created_at_unix,
|
||||
blob_count: m.blob_ids.len(),
|
||||
file_bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|s| s.created_at_unix);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Remove a snapshot. Returns `true` if a file was removed,
|
||||
/// `false` if none existed. Never touches blob storage —
|
||||
/// deleting a snapshot only forgets the reference set, not the
|
||||
/// blobs themselves.
|
||||
pub async fn delete(&self, name: &str) -> Result<bool> {
|
||||
validate_name(name)?;
|
||||
let path = self.snapshot_path(name);
|
||||
match tokio::fs::remove_file(&path).await {
|
||||
Ok(()) => Ok(true),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(e) => Err(anyhow::Error::from(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_path(&self, name: &str) -> PathBuf {
|
||||
// Names are validated: no slashes, printable only. Safe to
|
||||
// use directly as a filename fragment. We still normalize
|
||||
// via hex-of-name if the operator ever passes something
|
||||
// exotic like a unicode dash — but that's a future concern.
|
||||
self.root
|
||||
.join("snapshots")
|
||||
.join(format!("{name}.json"))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_name(name: &str) -> Result<()> {
|
||||
if name.is_empty() {
|
||||
bail!("snapshot name cannot be empty");
|
||||
}
|
||||
if name.len() > MAX_SNAPSHOT_NAME_BYTES {
|
||||
bail!(
|
||||
"snapshot name length {} exceeds cap {}",
|
||||
name.len(),
|
||||
MAX_SNAPSHOT_NAME_BYTES
|
||||
);
|
||||
}
|
||||
for c in name.chars() {
|
||||
if c.is_control() {
|
||||
bail!("snapshot name has control character");
|
||||
}
|
||||
if c == '/' || c == '\\' || c == '\0' {
|
||||
bail!("snapshot name may not contain / \\ or NUL");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
static RANDOM_SUFFIX: std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
async fn atomic_write(final_path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let parent = final_path
|
||||
.parent()
|
||||
.context("snapshot path had no parent")?;
|
||||
let tmp_name = format!(
|
||||
".tmp.{}.{}",
|
||||
std::process::id(),
|
||||
RANDOM_SUFFIX.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
);
|
||||
let tmp_path = parent.join(tmp_name);
|
||||
{
|
||||
let mut f = tokio::fs::File::create(&tmp_path)
|
||||
.await
|
||||
.with_context(|| format!("creating tmp snapshot {}", tmp_path.display()))?;
|
||||
f.write_all(bytes).await?;
|
||||
f.sync_all().await?;
|
||||
}
|
||||
tokio::fs::rename(&tmp_path, final_path)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"renaming {} → {}",
|
||||
tmp_path.display(),
|
||||
final_path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn open() -> (TempDir, BlobStore, SnapshotStore) {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let blob = BlobStore::open(tmp.path().to_path_buf()).unwrap();
|
||||
let snap = SnapshotStore::open(tmp.path().to_path_buf()).unwrap();
|
||||
(tmp, blob, snap)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_captures_all_live_blob_ids() {
|
||||
// Three blobs in, snapshot should reference all three.
|
||||
let (_tmp, blob, snap) = open();
|
||||
let a = blob.put_bytes(b"alpha").await.unwrap();
|
||||
let b = blob.put_bytes(b"beta").await.unwrap();
|
||||
let c = blob.put_bytes(b"gamma").await.unwrap();
|
||||
let m = snap.create("v1", &blob, 1_700_000_000).await.unwrap();
|
||||
assert_eq!(m.name, "v1");
|
||||
assert_eq!(m.created_at_unix, 1_700_000_000);
|
||||
assert_eq!(m.blob_ids.len(), 3);
|
||||
let ids: std::collections::HashSet<_> = m.blob_ids.iter().collect();
|
||||
assert!(ids.contains(&a));
|
||||
assert!(ids.contains(&b));
|
||||
assert!(ids.contains(&c));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_is_immutable_second_call_errors() {
|
||||
// Snapshots are meant to be pin-in-time. Silent overwrite
|
||||
// would be a footgun. Second create with same name must
|
||||
// error, not clobber.
|
||||
let (_tmp, blob, snap) = open();
|
||||
blob.put_bytes(b"x").await.unwrap();
|
||||
snap.create("v1", &blob, 1).await.unwrap();
|
||||
let err = snap.create("v1", &blob, 2).await.unwrap_err();
|
||||
assert!(err.to_string().contains("already exists"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_returns_none_for_missing() {
|
||||
let (_tmp, _blob, snap) = open();
|
||||
assert!(snap.get("nope").await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_sorts_by_creation_time_ascending() {
|
||||
// Oldest first — operator triaging a growing list wants
|
||||
// the pruning candidate at the top.
|
||||
let (_tmp, blob, snap) = open();
|
||||
blob.put_bytes(b"seed").await.unwrap();
|
||||
snap.create("newer", &blob, 2000).await.unwrap();
|
||||
snap.create("older", &blob, 1000).await.unwrap();
|
||||
snap.create("newest", &blob, 3000).await.unwrap();
|
||||
let entries = snap.list().await.unwrap();
|
||||
let names: Vec<_> = entries.iter().map(|s| s.name.as_str()).collect();
|
||||
assert_eq!(names, ["older", "newer", "newest"]);
|
||||
assert_eq!(entries[0].blob_count, 1);
|
||||
assert!(entries[0].file_bytes > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_true_when_present_false_when_not() {
|
||||
let (_tmp, blob, snap) = open();
|
||||
blob.put_bytes(b"seed").await.unwrap();
|
||||
snap.create("v1", &blob, 1).await.unwrap();
|
||||
assert!(snap.delete("v1").await.unwrap());
|
||||
assert!(!snap.delete("v1").await.unwrap());
|
||||
assert!(snap.get("v1").await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_snapshot_does_not_touch_blobs() {
|
||||
// A snapshot is a reference set, not a copy. Deleting one
|
||||
// must not affect blob data — otherwise operators could
|
||||
// accidentally nuke live data by pruning snapshots.
|
||||
let (_tmp, blob, snap) = open();
|
||||
let id = blob.put_bytes(b"survivor").await.unwrap();
|
||||
snap.create("temp", &blob, 1).await.unwrap();
|
||||
snap.delete("temp").await.unwrap();
|
||||
// Blob still readable.
|
||||
let back = blob.get_bytes(&id).await.unwrap();
|
||||
assert_eq!(back.as_deref(), Some(&b"survivor"[..]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_name_rejects_slash_and_control() {
|
||||
assert!(validate_name("with/slash").is_err());
|
||||
assert!(validate_name("with\\backslash").is_err());
|
||||
assert!(validate_name("with\0null").is_err());
|
||||
assert!(validate_name("with\x01ctrl").is_err());
|
||||
assert!(validate_name("").is_err());
|
||||
assert!(validate_name("ok-name").is_ok());
|
||||
assert!(validate_name("v2026.07.14-pre-release").is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn round_trip_preserves_blob_ids_sorted() {
|
||||
// list_blob_ids order is filesystem-dependent. Snapshot
|
||||
// must sort before writing so different nodes taking a
|
||||
// snapshot of the same content get byte-identical files.
|
||||
let (_tmp, blob, snap) = open();
|
||||
for word in ["one", "two", "three", "four", "five"] {
|
||||
blob.put_bytes(word.as_bytes()).await.unwrap();
|
||||
}
|
||||
let m = snap.create("v1", &blob, 42).await.unwrap();
|
||||
let mut sorted = m.blob_ids.clone();
|
||||
sorted.sort();
|
||||
assert_eq!(m.blob_ids, sorted, "blob_ids must be sorted on write");
|
||||
let m2 = snap.get("v1").await.unwrap().unwrap();
|
||||
assert_eq!(m2.blob_ids, m.blob_ids);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user