Phase 7d follow-on: snapshots pin blobs against LRU eviction #58
@@ -302,6 +302,14 @@ impl ClusterServices {
|
|||||||
(Some(store), Some(hours)) if hours > 0 => {
|
(Some(store), Some(hours)) if hours > 0 => {
|
||||||
let store = store.clone();
|
let store = store.clone();
|
||||||
let tag_store_for_gc = tag_store.clone();
|
let tag_store_for_gc = tag_store.clone();
|
||||||
|
// Phase 7d follow-on: snapshot store is under the
|
||||||
|
// same root as the blob store. Open once here so the
|
||||||
|
// ticker doesn't pay the fs setup cost every tick.
|
||||||
|
let snapshot_store_for_gc = blob_store_root
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|root| {
|
||||||
|
crate::cluster::snapshot::SnapshotStore::open(root.clone()).ok()
|
||||||
|
});
|
||||||
let interval = Duration::from_secs(hours * 3600);
|
let interval = Duration::from_secs(hours * 3600);
|
||||||
let max_gb = cluster.blob_max_gb;
|
let max_gb = cluster.blob_max_gb;
|
||||||
Some(tokio::spawn(async move {
|
Some(tokio::spawn(async move {
|
||||||
@@ -339,7 +347,7 @@ impl ClusterServices {
|
|||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.map(|d| d.as_secs())
|
.map(|d| d.as_secs())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let pinned = match &tag_store_for_gc {
|
let mut pinned = match &tag_store_for_gc {
|
||||||
Some(ts) => {
|
Some(ts) => {
|
||||||
// Phase 4b: prune expired pins first so
|
// Phase 4b: prune expired pins first so
|
||||||
// the pin set reflects the wall-clock
|
// the pin set reflects the wall-clock
|
||||||
@@ -357,6 +365,17 @@ impl ClusterServices {
|
|||||||
}
|
}
|
||||||
None => std::collections::HashSet::new(),
|
None => std::collections::HashSet::new(),
|
||||||
};
|
};
|
||||||
|
// Phase 7d follow-on: snapshots pin their
|
||||||
|
// referenced blobs. Union in every blob_id
|
||||||
|
// captured by any snapshot; the eviction
|
||||||
|
// routine sees the merged set.
|
||||||
|
let mut snapshot_pin_count = 0usize;
|
||||||
|
if let Some(ss) = &snapshot_store_for_gc {
|
||||||
|
if let Ok(snaps) = ss.pinned_blob_ids().await {
|
||||||
|
snapshot_pin_count = snaps.len();
|
||||||
|
pinned.extend(snaps);
|
||||||
|
}
|
||||||
|
}
|
||||||
match store
|
match store
|
||||||
.evict_to_size_cap_with_pins(cap, &pinned)
|
.evict_to_size_cap_with_pins(cap, &pinned)
|
||||||
.await
|
.await
|
||||||
@@ -366,6 +385,7 @@ impl ClusterServices {
|
|||||||
bytes_reclaimed = r.bytes_reclaimed,
|
bytes_reclaimed = r.bytes_reclaimed,
|
||||||
max_gb = gb,
|
max_gb = gb,
|
||||||
pinned_blobs = pinned.len(),
|
pinned_blobs = pinned.len(),
|
||||||
|
snapshot_pins = snapshot_pin_count,
|
||||||
"auto-GC evicted LRU blobs to hit size cap"
|
"auto-GC evicted LRU blobs to hit size cap"
|
||||||
),
|
),
|
||||||
Ok(_) => {} // under cap already; keep quiet
|
Ok(_) => {} // under cap already; keep quiet
|
||||||
|
|||||||
@@ -183,6 +183,28 @@ impl SnapshotStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 7d follow-on: union of every `blob_id` referenced by ANY
|
||||||
|
/// snapshot. Feeds pin-aware eviction — every blob captured by a
|
||||||
|
/// live snapshot survives the LRU cap. Semantically "snapshots
|
||||||
|
/// act as immortal retention pins until the operator deletes
|
||||||
|
/// them".
|
||||||
|
///
|
||||||
|
/// Cheap: one file read + JSON parse per snapshot. A fleet with
|
||||||
|
/// 100 snapshots × 10k blobs each is ~1 MB of JSON I/O.
|
||||||
|
pub async fn pinned_blob_ids(
|
||||||
|
&self,
|
||||||
|
) -> Result<std::collections::HashSet<BlobId>> {
|
||||||
|
let mut out = std::collections::HashSet::new();
|
||||||
|
for summary in self.list().await? {
|
||||||
|
if let Some(m) = self.get(&summary.name).await? {
|
||||||
|
for id in m.blob_ids {
|
||||||
|
out.insert(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
fn snapshot_path(&self, name: &str) -> PathBuf {
|
fn snapshot_path(&self, name: &str) -> PathBuf {
|
||||||
// Names are validated: no slashes, printable only. Safe to
|
// Names are validated: no slashes, printable only. Safe to
|
||||||
// use directly as a filename fragment. We still normalize
|
// use directly as a filename fragment. We still normalize
|
||||||
@@ -347,6 +369,32 @@ mod tests {
|
|||||||
assert!(validate_name("v2026.07.14-pre-release").is_ok());
|
assert!(validate_name("v2026.07.14-pre-release").is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pinned_blob_ids_unions_all_snapshots() {
|
||||||
|
// Two snapshots, some overlap. Union must dedupe.
|
||||||
|
let (_tmp, blob, snap) = open();
|
||||||
|
let a = blob.put_bytes(b"pin-alpha").await.unwrap();
|
||||||
|
let b = blob.put_bytes(b"pin-beta").await.unwrap();
|
||||||
|
let c = blob.put_bytes(b"pin-gamma").await.unwrap();
|
||||||
|
// Snapshot 1: captures a, b, c
|
||||||
|
snap.create("s1", &blob, 1).await.unwrap();
|
||||||
|
// Snapshot 2 (later): same content, still captures a, b, c
|
||||||
|
snap.create("s2", &blob, 2).await.unwrap();
|
||||||
|
|
||||||
|
let pins = snap.pinned_blob_ids().await.unwrap();
|
||||||
|
assert_eq!(pins.len(), 3);
|
||||||
|
assert!(pins.contains(&a));
|
||||||
|
assert!(pins.contains(&b));
|
||||||
|
assert!(pins.contains(&c));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pinned_blob_ids_empty_when_no_snapshots() {
|
||||||
|
let (_tmp, blob, snap) = open();
|
||||||
|
blob.put_bytes(b"blob-with-no-snapshot").await.unwrap();
|
||||||
|
assert!(snap.pinned_blob_ids().await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn round_trip_preserves_blob_ids_sorted() {
|
async fn round_trip_preserves_blob_ids_sorted() {
|
||||||
// list_blob_ids order is filesystem-dependent. Snapshot
|
// list_blob_ids order is filesystem-dependent. Snapshot
|
||||||
|
|||||||
+14
-2
@@ -341,7 +341,7 @@ async fn cmd_cluster_gc(cfg: &Config, evict_to_gb: Option<u64>) -> Result<()> {
|
|||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.map(|d| d.as_secs())
|
.map(|d| d.as_secs())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let (pinned_blobs, expired_pruned) = if tags_dir.is_dir() {
|
let (mut pinned_blobs, expired_pruned) = if tags_dir.is_dir() {
|
||||||
let ts = TagStore::open(tags_dir.clone())
|
let ts = TagStore::open(tags_dir.clone())
|
||||||
.with_context(|| format!("opening tag store at {}", tags_dir.display()))?;
|
.with_context(|| format!("opening tag store at {}", tags_dir.display()))?;
|
||||||
// Phase 4b: prune first so the set we hand to the evictor is
|
// Phase 4b: prune first so the set we hand to the evictor is
|
||||||
@@ -361,6 +361,18 @@ async fn cmd_cluster_gc(cfg: &Config, evict_to_gb: Option<u64>) -> Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
(std::collections::HashSet::new(), 0)
|
(std::collections::HashSet::new(), 0)
|
||||||
};
|
};
|
||||||
|
// Phase 7d follow-on: snapshot references act as immortal pins.
|
||||||
|
// Any blob captured by ANY snapshot survives the LRU cap so
|
||||||
|
// operators can guarantee retention windows via snapshots alone,
|
||||||
|
// without hand-managing per-blob tags.
|
||||||
|
let snapshot_store = cluster::snapshot::SnapshotStore::open(root.clone())
|
||||||
|
.context("opening snapshot store")?;
|
||||||
|
let snapshot_pins = snapshot_store
|
||||||
|
.pinned_blob_ids()
|
||||||
|
.await
|
||||||
|
.context("collecting snapshot pins")?;
|
||||||
|
let snapshot_pin_count = snapshot_pins.len();
|
||||||
|
pinned_blobs.extend(snapshot_pins);
|
||||||
|
|
||||||
// Phase 2 (optional): pin-aware LRU eviction to hit a size cap.
|
// Phase 2 (optional): pin-aware LRU eviction to hit a size cap.
|
||||||
let evict = if let Some(gb) = evict_to_gb {
|
let evict = if let Some(gb) = evict_to_gb {
|
||||||
@@ -386,7 +398,7 @@ async fn cmd_cluster_gc(cfg: &Config, evict_to_gb: Option<u64>) -> Result<()> {
|
|||||||
println!("lru eviction (cap {} GiB):", evict_to_gb.unwrap_or(0));
|
println!("lru eviction (cap {} GiB):", evict_to_gb.unwrap_or(0));
|
||||||
println!(" chunks removed: {}", evict.chunks_removed);
|
println!(" chunks removed: {}", evict.chunks_removed);
|
||||||
println!(" bytes reclaimed: {}", evict.bytes_reclaimed);
|
println!(" bytes reclaimed: {}", evict.bytes_reclaimed);
|
||||||
println!(" pinned blobs: {}", pinned_blobs.len());
|
println!(" pinned blobs: {} ({} from snapshots)", pinned_blobs.len(), snapshot_pin_count);
|
||||||
println!(" expired pins pruned: {}", expired_pruned);
|
println!(" expired pins pruned: {}", expired_pruned);
|
||||||
}
|
}
|
||||||
println!("total elapsed: {:?}", elapsed);
|
println!("total elapsed: {:?}", elapsed);
|
||||||
|
|||||||
Reference in New Issue
Block a user