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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user