Phase 4a: pin-aware LRU eviction
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 `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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user