Phase 7d: snapshot primitives + CLI
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:
Omar Sobh
2026-07-14 09:26:47 -07:00
parent da198c0903
commit e564b0ce89
4 changed files with 519 additions and 0 deletions
+366
View File
@@ -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);
}
}