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
+39
View File
@@ -884,6 +884,45 @@ impl BlobStore {
Ok(referenced)
}
/// Phase 7d (2026-07-14): enumerate every blob currently in the
/// store. Cheap — reads only manifests, not chunk bodies. Used
/// by [`crate::cluster::snapshot::SnapshotStore::create`] to
/// build a point-in-time reference set. Order is filesystem walk
/// order — callers that need determinism must sort.
pub async fn list_blob_ids(&self) -> Result<Vec<BlobId>> {
let blobs_root = self.root.join("blobs");
let mut out = Vec::new();
let mut top = match tokio::fs::read_dir(&blobs_root).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(bucket) = top.next_entry().await? {
if !bucket.file_type().await?.is_dir() {
continue;
}
let mut inner = tokio::fs::read_dir(bucket.path()).await?;
while let Some(entry) = inner.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,
};
let hex = match name_str.strip_suffix(".manifest.json") {
Some(h) => h,
None => continue,
};
if let Ok(id) = BlobId::from_hex(hex) {
out.push(id);
}
}
}
Ok(out)
}
/// Phase 7a (2026-07-14): read-only fsck for the blob store.
///
/// For every `.manifest.json`, for every chunk it references: