Phase 4a: pin-aware LRU eviction
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 10s

A `claw-cargo pin` used to be silently vulnerable to the size-cap
eviction ticker — the tag existed but the underlying blob could get
LRU'd out, leaving a dangling reference. Now tags act as
retention markers: any blob referenced by any tag (stamped or
legacy) is protected from `evict_to_size_cap`.

* `BlobStore::evict_to_size_cap_with_pins(max_bytes, pinned_set)` —
  same LRU-by-mtime pass, but pinned blob IDs skip the eviction
  loop. Existing `evict_to_size_cap` is now a thin wrapper with an
  empty pin set (100% backward compat).
* `TagStore::pinned_blob_values()` — unions every 32-byte value
  referenced by any tag across `tags/` (legacy) and `tags-v2/`
  (Phase 3c stamped). Dedupes naturally.
* Auto-GC ticker in `ClusterServices` now collects the pin set on
  every eviction pass and passes it in. Log fields include
  `pinned_blobs = N` so operators can see the retention set size.
* `claw-store cluster-gc --evict-to-gb N` CLI opens the tag store
  the same way, prints `pinned blobs: N` in the report.

+3 tests:
- evict_with_pins_protects_pinned_blobs_from_eviction — 3 blobs
  ordered oldest→newest, pin the oldest; without pins LRU would
  evict it; with pins the next-oldest goes instead. Guards the
  main semantic.
- evict_with_pins_stops_when_pinned_footprint_dominates —
  everything pinned + cap = 0 → no-op. Guards the "operator asked
  for the impossible" case.
- pinned_blob_values_unions_both_stores — legacy tag with value V1,
  stamped tag with value V2, second stamped tag also referencing
  V1 → set contains {V1, V2}. Dedupe check.

283 tests pass (baseline +3). Pre-existing macOS failure unchanged.
This commit is contained in:
Omar Sobh
2026-07-13 13:54:47 -07:00
parent 2c3cd2ab38
commit 5be11a11b0
4 changed files with 235 additions and 5 deletions
+93
View File
@@ -583,6 +583,31 @@ impl BlobStore {
/// `chunks_scanned` is 0 (this function doesn't do a full scan;
/// call [`gc_orphan_chunks`] separately for that).
pub async fn evict_to_size_cap(&self, max_bytes: u64) -> Result<GcReport> {
// No pins → every manifest is a candidate. Delegate.
self.evict_to_size_cap_with_pins(
max_bytes,
&std::collections::HashSet::new(),
)
.await
}
/// Phase 4 (2026-07-13): pin-aware LRU eviction. `pinned_blobs`
/// is the set of blob IDs the caller considers protected from
/// eviction — typically the set of every blob referenced by a
/// live tag. Pinned manifests are skipped entirely; their chunks
/// stay in the referenced set so shared chunks with evicted
/// blobs also survive.
///
/// The eviction pass may go under cap earlier than a pin-free
/// pass would — pinned blobs count against `max_bytes` but can't
/// be evicted to make room, so if the pinned footprint alone
/// exceeds the cap, we return without doing anything (the caller
/// is expected to raise `blob_max_gb` or drop pins).
pub async fn evict_to_size_cap_with_pins(
&self,
max_bytes: u64,
pinned_blobs: &std::collections::HashSet<BlobId>,
) -> Result<GcReport> {
// 1. Compute per-manifest chunk sets + total live size.
let manifest_summaries = self.collect_manifest_summaries().await?;
let mut referenced: std::collections::HashMap<ChunkHash, u32> =
@@ -604,12 +629,17 @@ impl BlobStore {
let mut bytes_reclaimed = 0u64;
// 2. Sort oldest-first + evict manifests until under cap.
// Skip pinned blobs entirely — their chunks stay in
// `referenced` so any shared chunks also stay put.
let mut summaries = manifest_summaries;
summaries.sort_by_key(|s| s.manifest_mtime);
for summary in summaries {
if current_size <= max_bytes {
break;
}
if pinned_blobs.contains(&summary.blob_id) {
continue;
}
self.delete_manifest(&summary.blob_id).await?;
// For each chunk this manifest used: decrement refcount;
// if it hits zero, delete the chunk file + free its bytes.
@@ -1152,6 +1182,69 @@ mod tests {
assert_eq!(report.bytes_reclaimed, 0);
}
#[tokio::test]
async fn evict_with_pins_protects_pinned_blobs_from_eviction() {
// Phase 4: 3 blobs, mtime-ordered a < b < c. Pin the OLDEST
// (a) — normal LRU would evict a first. With pins, a survives
// and b (the next-oldest) is evicted instead.
let (_tmp, store) = open_store();
let a_bytes = vec![0u8; 100_000];
let b_bytes = vec![1u8; 100_000];
let c_bytes = vec![2u8; 100_000];
let id_a = store.put_bytes(&a_bytes).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
let id_b = store.put_bytes(&b_bytes).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
let id_c = store.put_bytes(&c_bytes).await.unwrap();
let mut pinned = std::collections::HashSet::new();
pinned.insert(id_a);
// Cap at ~2 blobs. Without pins, a would be evicted; with
// the pin, b goes instead.
let report = store
.evict_to_size_cap_with_pins(250_000, &pinned)
.await
.unwrap();
assert!(report.chunks_removed >= 1);
assert!(
store.load_manifest(&id_a).await.unwrap().is_some(),
"pinned blob a must survive"
);
assert!(
store.load_manifest(&id_b).await.unwrap().is_none(),
"next-oldest unpinned b was evicted"
);
assert!(
store.load_manifest(&id_c).await.unwrap().is_some(),
"newest c stays"
);
}
#[tokio::test]
async fn evict_with_pins_stops_when_pinned_footprint_dominates() {
// Every blob is pinned → nothing to evict → eviction is a
// no-op regardless of `max_bytes`.
let (_tmp, store) = open_store();
let id_a = store.put_bytes(&vec![9u8; 100_000]).await.unwrap();
let id_b = store.put_bytes(&vec![8u8; 100_000]).await.unwrap();
let mut pinned = std::collections::HashSet::new();
pinned.insert(id_a);
pinned.insert(id_b);
let report = store
.evict_to_size_cap_with_pins(0, &pinned)
.await
.unwrap();
assert_eq!(report.chunks_removed, 0);
assert!(store.load_manifest(&id_a).await.unwrap().is_some());
assert!(store.load_manifest(&id_b).await.unwrap().is_some());
}
#[tokio::test]
async fn gc_on_empty_store_reports_zero() {
let (_tmp, store) = open_store();