Skip to main content

reddb_server/storage/cache/blob/
cache.rs

1//! Byte-oriented Blob Cache.
2//!
3//! This is the first internal tracer for RedDB's exact-key blob cache. It is
4//! intentionally L1-only: a sharded, byte-bounded, in-process cache with SIEVE
5//! eviction, namespace caps, and opaque content metadata. Durable L2 storage,
6//! dependency invalidation, and public APIs land in follow-up slices.
7
8use super::config::{BlobCacheConfig, L2Compression, L2PromotionPolicy};
9use super::entry::{effective_expires_at_unix_ms, jitter_seed, Entry};
10use super::l2::BlobCacheL2;
11use super::shard::{InsertOutcome, Lookup, Shard};
12
13use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
14use std::hash::{Hash, Hasher};
15#[cfg(test)]
16use std::path::{Path, PathBuf};
17use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
18use std::sync::{Arc, OnceLock, Weak};
19use std::time::{SystemTime, UNIX_EPOCH};
20
21use indexmap::Equivalent;
22use parking_lot::RwLock;
23
24use super::super::compressor::{CompressOpts, Compressed, L2BlobCompressor};
25use super::super::extended_ttl::ExtendedTtlPolicy;
26use super::super::promotion_pool::{
27    AsyncPromotionPool, PoolOpts, PromotionExecutor, PromotionRequest,
28};
29
30// Test-only thread-local counter of how many times
31// `EffectiveExpiry::compute` is invoked from `Shard::get`. Thread-local
32// (rather than a global atomic) so the off-fast-path test does not race
33// with other tests in the harness's parallel executor.
34#[cfg(test)]
35thread_local! {
36    pub(super) static EFFECTIVE_EXPIRY_COMPUTE_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum CacheError {
41    BlobTooLarge {
42        size: usize,
43        max: usize,
44    },
45    MetadataTooLarge {
46        keys: usize,
47        bytes: usize,
48        max_keys: usize,
49        max_bytes: usize,
50    },
51    TooManyNamespaces {
52        max: usize,
53    },
54    VersionMismatch {
55        existing: u64,
56        attempted: u64,
57    },
58    L2Full {
59        size: u64,
60        max: u64,
61    },
62    L2Io(String),
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Hash)]
66pub(super) struct BlobCacheKey {
67    pub(super) namespace: String,
68    pub(super) key: String,
69}
70
71impl BlobCacheKey {
72    pub(super) fn new(namespace: impl Into<String>, key: impl Into<String>) -> Self {
73        Self {
74            namespace: namespace.into(),
75            key: key.into(),
76        }
77    }
78
79    pub(super) fn borrowed<'a>(namespace: &'a str, key: &'a str) -> BlobCacheKeyRef<'a> {
80        BlobCacheKeyRef { namespace, key }
81    }
82}
83
84pub(super) struct BlobCacheKeyRef<'a> {
85    pub(super) namespace: &'a str,
86    pub(super) key: &'a str,
87}
88
89impl Hash for BlobCacheKeyRef<'_> {
90    fn hash<H: Hasher>(&self, state: &mut H) {
91        self.namespace.hash(state);
92        self.key.hash(state);
93    }
94}
95
96impl Equivalent<BlobCacheKey> for BlobCacheKeyRef<'_> {
97    fn equivalent(&self, key: &BlobCacheKey) -> bool {
98        self.namespace == key.namespace && self.key == key.key
99    }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Hash)]
103struct ScopedLabel {
104    namespace: String,
105    label: String,
106}
107
108impl ScopedLabel {
109    fn new(namespace: impl Into<String>, label: impl Into<String>) -> Self {
110        Self {
111            namespace: namespace.into(),
112            label: label.into(),
113        }
114    }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct BlobCacheHit {
119    pub(super) bytes: Arc<[u8]>,
120    pub(super) content_metadata: Option<Arc<BTreeMap<String, String>>>,
121    pub(super) version: Option<u64>,
122    /// `Some(remaining_ms)` when the hit came from the stale-while-revalidate
123    /// window of an `ExtendedTtlPolicy`; `None` when the entry was fresh.
124    /// Boolean staleness is just `.is_some()`.
125    pub(super) stale_window_remaining_ms: Option<u64>,
126}
127
128impl BlobCacheHit {
129    pub(crate) fn new(
130        bytes: Arc<[u8]>,
131        content_metadata: Option<Arc<BTreeMap<String, String>>>,
132        version: Option<u64>,
133    ) -> Self {
134        Self {
135            bytes,
136            content_metadata,
137            version,
138            stale_window_remaining_ms: None,
139        }
140    }
141
142    pub(crate) fn new_stale(
143        bytes: Arc<[u8]>,
144        content_metadata: Option<Arc<BTreeMap<String, String>>>,
145        version: Option<u64>,
146        window_remaining_ms: u64,
147    ) -> Self {
148        Self {
149            bytes,
150            content_metadata,
151            version,
152            stale_window_remaining_ms: Some(window_remaining_ms),
153        }
154    }
155
156    /// Cached payload, refcounted so duplicate readers share the buffer.
157    pub fn bytes(&self) -> &Arc<[u8]> {
158        &self.bytes
159    }
160
161    /// Convenience accessor returning a `&[u8]` view into [`bytes`](Self::bytes).
162    pub fn value(&self) -> &[u8] {
163        &self.bytes
164    }
165
166    /// Opaque content metadata captured on `put`.
167    pub fn content_metadata(&self) -> &BTreeMap<String, String> {
168        static EMPTY: BTreeMap<String, String> = BTreeMap::new();
169        self.content_metadata.as_deref().unwrap_or(&EMPTY)
170    }
171
172    /// Optional CAS / freshness version stamped on `put`.
173    pub fn version(&self) -> Option<u64> {
174        self.version
175    }
176
177    /// `true` when the hit was served from the stale-while-revalidate window
178    /// of an `ExtendedTtlPolicy`. Always `false` when the extended policy is
179    /// `off()` or the entry was within its hard expiry.
180    pub fn is_stale(&self) -> bool {
181        self.stale_window_remaining_ms.is_some()
182    }
183
184    /// Remaining stale-window milliseconds when [`is_stale`](Self::is_stale)
185    /// is `true`; `None` when the hit was fresh.
186    pub fn stale_window_remaining_ms(&self) -> Option<u64> {
187        self.stale_window_remaining_ms
188    }
189}
190
191#[derive(Debug, Clone, Default, PartialEq, Eq)]
192pub struct BlobCachePut {
193    pub bytes: Vec<u8>,
194    pub content_metadata: BTreeMap<String, String>,
195    pub tags: BTreeSet<String>,
196    pub dependencies: BTreeSet<String>,
197    pub policy: BlobCachePolicy,
198}
199
200impl BlobCachePut {
201    pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
202        Self {
203            bytes: bytes.into(),
204            content_metadata: BTreeMap::new(),
205            tags: BTreeSet::new(),
206            dependencies: BTreeSet::new(),
207            policy: BlobCachePolicy::default(),
208        }
209    }
210
211    pub fn with_content_metadata(mut self, content_metadata: BTreeMap<String, String>) -> Self {
212        self.content_metadata = content_metadata;
213        self
214    }
215
216    pub fn with_tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
217        self.tags = tags.into_iter().map(Into::into).collect();
218        self
219    }
220
221    pub fn with_dependencies(
222        mut self,
223        dependencies: impl IntoIterator<Item = impl Into<String>>,
224    ) -> Self {
225        self.dependencies = dependencies.into_iter().map(Into::into).collect();
226        self
227    }
228
229    pub fn with_policy(mut self, policy: BlobCachePolicy) -> Self {
230        self.policy = policy;
231        self
232    }
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236pub enum L1Admission {
237    Always,
238    Auto,
239    Never,
240}
241
242/// Three-valued answer for [`BlobCache::exists`].
243///
244/// Today the implementation always returns [`Present`](Self::Present) or
245/// [`Absent`](Self::Absent) — it tracks the answer authoritatively. The
246/// [`MaybePresent`](Self::MaybePresent) variant exists in the type so the
247/// upcoming Bloom synopsis (#146) can answer "probably yes" without forcing
248/// a metadata read, all without breaking the `exists` contract.
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub enum CachePresence {
251    /// The cache holds a live entry for this key.
252    Present,
253    /// The cache definitely does not hold this key (negative cache hit).
254    Absent,
255    /// A probabilistic synopsis cannot rule the key out without a deeper
256    /// lookup. Treat as a hit prospect: the caller should fetch.
257    MaybePresent,
258}
259
260impl From<bool> for CachePresence {
261    fn from(present: bool) -> Self {
262        if present {
263            CachePresence::Present
264        } else {
265            CachePresence::Absent
266        }
267    }
268}
269
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub struct BlobCachePolicy {
272    ttl_ms: Option<u64>,
273    expires_at_unix_ms: Option<u64>,
274    max_blob_bytes: Option<usize>,
275    l1_admission: L1Admission,
276    priority: u8,
277    version: Option<u64>,
278    /// Extended TTL knobs (idle / stale-while-revalidate / jitter).
279    /// Defaults to [`ExtendedTtlPolicy::off`] so existing call sites and
280    /// stored entries continue to behave with hard-expiry-only semantics.
281    /// Wired into [`BlobCache::get`] behind the
282    /// `cache.blob.policy.extended` config knob (#194).
283    extended: ExtendedTtlPolicy,
284}
285
286impl Default for BlobCachePolicy {
287    fn default() -> Self {
288        Self {
289            ttl_ms: None,
290            expires_at_unix_ms: None,
291            max_blob_bytes: None,
292            l1_admission: L1Admission::Auto,
293            priority: 128,
294            version: None,
295            extended: ExtendedTtlPolicy::off(),
296        }
297    }
298}
299
300impl BlobCachePolicy {
301    // ----- builder-style setters (consuming) -----------------------------
302
303    pub fn ttl_ms(mut self, ttl_ms: u64) -> Self {
304        self.ttl_ms = Some(ttl_ms);
305        self
306    }
307
308    pub fn expires_at_unix_ms(mut self, expires_at_unix_ms: u64) -> Self {
309        self.expires_at_unix_ms = Some(expires_at_unix_ms);
310        self
311    }
312
313    pub fn max_blob_bytes(mut self, max_blob_bytes: usize) -> Self {
314        self.max_blob_bytes = Some(max_blob_bytes);
315        self
316    }
317
318    pub fn l1_admission(mut self, l1_admission: L1Admission) -> Self {
319        self.l1_admission = l1_admission;
320        self
321    }
322
323    pub fn priority(mut self, priority: u8) -> Self {
324        self.priority = priority;
325        self
326    }
327
328    pub fn version(mut self, version: u64) -> Self {
329        self.version = Some(version);
330        self
331    }
332
333    /// Replace the extended TTL knobs in one chainable call. Defaults to
334    /// [`ExtendedTtlPolicy::off`]; setting an active policy turns on the
335    /// idle / stale-serve / jitter behaviours in [`BlobCache::get`] and
336    /// [`BlobCache::put`] for entries written with this policy.
337    pub fn extended(mut self, extended: ExtendedTtlPolicy) -> Self {
338        self.extended = extended;
339        self
340    }
341
342    // ----- read-back accessors -------------------------------------------
343    //
344    // Setter methods consume `self` and return `Self`, so they cannot share
345    // a name with `&self` getters. The `*_value` suffix keeps both surfaces
346    // available without renaming the public builder API.
347
348    pub fn ttl_ms_value(&self) -> Option<u64> {
349        self.ttl_ms
350    }
351
352    pub fn expires_at_unix_ms_value(&self) -> Option<u64> {
353        self.expires_at_unix_ms
354    }
355
356    pub fn max_blob_bytes_value(&self) -> Option<usize> {
357        self.max_blob_bytes
358    }
359
360    pub fn l1_admission_value(&self) -> L1Admission {
361        self.l1_admission
362    }
363
364    pub fn priority_value(&self) -> u8 {
365        self.priority
366    }
367
368    pub fn version_value(&self) -> Option<u64> {
369        self.version
370    }
371
372    /// Read-back accessor for the extended TTL knobs. Mirrors the
373    /// `*_value` getter pattern used by every other [`BlobCachePolicy`]
374    /// field (#151 — fields are private; readers go through getters).
375    pub fn extended_value(&self) -> ExtendedTtlPolicy {
376        self.extended
377    }
378}
379
380#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
381pub struct BlobCacheStats {
382    pub(super) hits: u64,
383    pub(super) misses: u64,
384    pub(super) insertions: u64,
385    pub(super) evictions: u64,
386    pub(super) expirations: u64,
387    pub(super) invalidations: u64,
388    pub(super) namespace_flushes: u64,
389    pub(super) version_mismatches: u64,
390    pub(super) entries: usize,
391    pub(super) bytes_in_use: usize,
392    pub(super) l1_bytes_max: usize,
393    pub(super) l2_bytes_in_use: u64,
394    pub(super) l2_bytes_max: u64,
395    pub(super) l2_full_rejections: u64,
396    pub(super) l2_metadata_reads: u64,
397    pub(super) l2_negative_skips: u64,
398    /// Times the per-namespace Bloom synopsis answered `MaybePresent` but the
399    /// authoritative L2 metadata B+ tree said `Absent` (the false-positive
400    /// cost of the probabilistic synopsis).
401    pub(super) synopsis_metadata_reads: u64,
402    /// Total bytes used by all per-namespace Bloom synopsis filters.
403    pub(super) synopsis_bytes: u64,
404    pub(super) namespaces: usize,
405    pub(super) max_namespaces: usize,
406    /// Async promotion pool counters (issue #193). All zero when the
407    /// pool is not enabled (default — `cache.blob.async_promotion = "off"`).
408    pub(super) promotion_queued: u64,
409    pub(super) promotion_dropped: u64,
410    pub(super) promotion_completed: u64,
411    pub(super) promotion_queue_depth: usize,
412    /// Numerator of the L2 compression ratio: sum of `original_len` over
413    /// entries that actually compressed (#192). Stored as the ratio's
414    /// component so [`BlobCacheStats`] stays `Eq` (avoids `f64` fields).
415    pub(super) l2_compression_original_bytes: u64,
416    /// Denominator of the L2 compression ratio: sum of `stored_len` over
417    /// entries that actually compressed.
418    pub(super) l2_compression_stored_bytes: u64,
419    /// Counter of L2 entries the compressor returned as `Raw` (any reason).
420    pub(super) l2_compression_skipped_total: u64,
421    /// Cumulative `(original_len - stored_len)` across compressed entries.
422    pub(super) l2_bytes_saved_total: u64,
423    /// Counter — L1 hits that served a stale entry from the SWR window of
424    /// an `ExtendedTtlPolicy` (#194). Stays 0 when extended is off.
425    pub(super) l1_stale_serves_total: u64,
426    /// Counter — L1 entries evicted by the idle-TTL gate of an
427    /// `ExtendedTtlPolicy` (#194). Stays 0 when extended is off.
428    pub(super) l1_idle_evicts_total: u64,
429}
430
431impl BlobCacheStats {
432    /// Number of `get`/`exists` calls that resolved to `Present` /
433    /// `MaybePresent`. Both count as hit prospects.
434    pub fn hits(&self) -> u64 {
435        self.hits
436    }
437
438    /// Number of `get`/`exists` calls that resolved to `Absent`.
439    pub fn misses(&self) -> u64 {
440        self.misses
441    }
442
443    pub fn insertions(&self) -> u64 {
444        self.insertions
445    }
446
447    pub fn evictions(&self) -> u64 {
448        self.evictions
449    }
450
451    pub fn expirations(&self) -> u64 {
452        self.expirations
453    }
454
455    pub fn invalidations(&self) -> u64 {
456        self.invalidations
457    }
458
459    pub fn namespace_flushes(&self) -> u64 {
460        self.namespace_flushes
461    }
462
463    pub fn version_mismatches(&self) -> u64 {
464        self.version_mismatches
465    }
466
467    pub fn entries(&self) -> usize {
468        self.entries
469    }
470
471    /// Bytes resident in L1. Returned as `u64` for symmetry with
472    /// [`l2_bytes_in_use`](Self::l2_bytes_in_use); upcast is lossless.
473    pub fn bytes_in_use(&self) -> u64 {
474        self.bytes_in_use as u64
475    }
476
477    pub fn l1_bytes_max(&self) -> usize {
478        self.l1_bytes_max
479    }
480
481    pub fn l2_bytes_in_use(&self) -> u64 {
482        self.l2_bytes_in_use
483    }
484
485    pub fn l2_bytes_max(&self) -> u64 {
486        self.l2_bytes_max
487    }
488
489    pub fn l2_full_rejections(&self) -> u64 {
490        self.l2_full_rejections
491    }
492
493    pub fn l2_metadata_reads(&self) -> u64 {
494        self.l2_metadata_reads
495    }
496
497    pub fn l2_negative_skips(&self) -> u64 {
498        self.l2_negative_skips
499    }
500
501    /// Times the Bloom synopsis answered `MaybePresent` but the authoritative
502    /// L2 metadata B+ tree said `Absent`. This is the cost of the
503    /// probabilistic synopsis: a counter for the false-positive rate in
504    /// production. Negative answers from the filter never trigger a metadata
505    /// read (see [`l2_negative_skips`](Self::l2_negative_skips)).
506    pub fn synopsis_metadata_reads(&self) -> u64 {
507        self.synopsis_metadata_reads
508    }
509
510    /// Total bytes used by all per-namespace Bloom synopsis filters.
511    pub fn synopsis_bytes(&self) -> u64 {
512        self.synopsis_bytes
513    }
514
515    pub fn namespaces(&self) -> usize {
516        self.namespaces
517    }
518
519    pub fn max_namespaces(&self) -> usize {
520        self.max_namespaces
521    }
522
523    /// Total promotion requests successfully enqueued by `get` since boot.
524    /// `0` when async promotion is disabled.
525    pub fn promotion_queued(&self) -> u64 {
526        self.promotion_queued
527    }
528
529    /// Total promotion requests dropped on queue saturation since boot.
530    /// `0` when async promotion is disabled.
531    pub fn promotion_dropped(&self) -> u64 {
532        self.promotion_dropped
533    }
534
535    /// Total promotion requests executed by workers since boot.
536    /// `0` when async promotion is disabled.
537    pub fn promotion_completed(&self) -> u64 {
538        self.promotion_completed
539    }
540
541    /// Snapshot of pending requests in the promotion queue.
542    /// `0` when async promotion is disabled.
543    pub fn promotion_queue_depth(&self) -> usize {
544        self.promotion_queue_depth
545    }
546
547    /// Running average of `original_len / stored_len` for L2 entries that
548    /// the compressor actually shrank (#192). Returns `1.0` when no
549    /// compressed entry has been observed yet, regardless of how many
550    /// `Raw` entries have passed through (callers should pair this with
551    /// [`l2_compression_skipped_total`](Self::l2_compression_skipped_total)
552    /// to interpret).
553    pub fn l2_compression_ratio_observed(&self) -> f64 {
554        if self.l2_compression_stored_bytes == 0 {
555            return 1.0;
556        }
557        self.l2_compression_original_bytes as f64 / self.l2_compression_stored_bytes as f64
558    }
559
560    /// Number of L2 entries the compressor returned as `Raw` since boot —
561    /// any reason: payload below `min_bytes`, content type already
562    /// compressed, ratio gate fired, or `cache.blob.l2_compression = "off"`.
563    pub fn l2_compression_skipped_total(&self) -> u64 {
564        self.l2_compression_skipped_total
565    }
566
567    /// Cumulative `(original_len - stored_len)` across all L2 entries the
568    /// compressor shrank. Operators read this to size the L2 budget
569    /// multiplier from real workloads.
570    pub fn l2_bytes_saved_total(&self) -> u64 {
571        self.l2_bytes_saved_total
572    }
573
574    /// Counter — L1 hits served as stale by the SWR window of an
575    /// `ExtendedTtlPolicy` (#194). `0` when no entry was written with an
576    /// active extended policy.
577    pub fn l1_stale_serves_total(&self) -> u64 {
578        self.l1_stale_serves_total
579    }
580
581    /// Counter — L1 entries evicted by the idle-TTL gate of an
582    /// `ExtendedTtlPolicy` (#194). `0` when no entry was written with an
583    /// active extended policy.
584    pub fn l1_idle_evicts_total(&self) -> u64 {
585        self.l1_idle_evicts_total
586    }
587}
588
589#[derive(Clone, Copy)]
590enum IndexedKind {
591    Tag,
592    Dependency,
593}
594
595#[derive(Debug)]
596struct AtomicStats {
597    hits: AtomicU64,
598    misses: AtomicU64,
599    insertions: AtomicU64,
600    evictions: AtomicU64,
601    expirations: AtomicU64,
602    invalidations: AtomicU64,
603    namespace_flushes: AtomicU64,
604    version_mismatches: AtomicU64,
605    l2_full_rejections: AtomicU64,
606    /// Counter incremented every time `BlobCache::get` returns a stale
607    /// entry from the SWR window of an `ExtendedTtlPolicy`. Stays at 0
608    /// when extended is `off()` for every entry.
609    l1_stale_serves: AtomicU64,
610    /// Counter incremented every time the idle-TTL gate of an
611    /// `ExtendedTtlPolicy` evicts an L1 entry. Stays at 0 when extended
612    /// is `off()` for every entry.
613    l1_idle_evicts: AtomicU64,
614}
615
616impl AtomicStats {
617    fn new() -> Self {
618        Self {
619            hits: AtomicU64::new(0),
620            misses: AtomicU64::new(0),
621            insertions: AtomicU64::new(0),
622            evictions: AtomicU64::new(0),
623            expirations: AtomicU64::new(0),
624            invalidations: AtomicU64::new(0),
625            namespace_flushes: AtomicU64::new(0),
626            version_mismatches: AtomicU64::new(0),
627            l2_full_rejections: AtomicU64::new(0),
628            l1_stale_serves: AtomicU64::new(0),
629            l1_idle_evicts: AtomicU64::new(0),
630        }
631    }
632}
633
634/// Sharded, byte-bounded blob cache with optional durable L2 backing.
635///
636/// # Concurrency
637///
638/// `BlobCache` is `Send + Sync`. All public methods are safe to call from
639/// multiple threads concurrently. Internal sharding ensures disjoint-key
640/// contention does not serialize: independent keys land on independent
641/// `RwLock<Shard>` instances, and the global indexes (namespace set, tag /
642/// dependency maps) are read-mostly behind their own `RwLock`s.
643///
644/// `BlobCache` is **not** `Clone` — share ownership via `Arc<BlobCache>`.
645///
646/// # Blocking
647///
648/// All methods are synchronous. `put` may perform L2 disk I/O on the
649/// calling thread when an L2 path is configured; tokio callers should wrap
650/// `put` in `spawn_blocking`. `get`, `exists`, and the `invalidate_*`
651/// family touch L2 only on rehydrate / delete paths.
652pub struct BlobCache {
653    config: BlobCacheConfig,
654    shards: Vec<RwLock<Shard>>,
655    namespaces: RwLock<HashSet<String>>,
656    namespace_generations: RwLock<HashMap<String, u64>>,
657    tag_index: RwLock<HashMap<ScopedLabel, HashSet<BlobCacheKey>>>,
658    dependency_index: RwLock<HashMap<ScopedLabel, HashSet<BlobCacheKey>>>,
659    l2: Option<Arc<BlobCacheL2>>,
660    bytes_in_use: AtomicUsize,
661    stats: AtomicStats,
662    /// Optional async L2->L1 promotion pool (issue #193). When `None`,
663    /// `get` performs the L1 promotion synchronously on the read path.
664    /// When set via `enable_async_promotion`, L2 hits return bytes to
665    /// the caller immediately and the L1 install runs on a worker.
666    promotion_pool: OnceLock<Arc<AsyncPromotionPool>>,
667}
668
669// Compile-time guarantee that the documented `Send + Sync` contract above
670// stays in lockstep with the struct's interior. If this ever fails to
671// compile, the docstring is lying — fix the field that broke it, do not
672// remove this assertion.
673const _: fn() = || {
674    fn assert_send_sync<T: Send + Sync>() {}
675    assert_send_sync::<BlobCache>();
676};
677
678impl BlobCache {
679    /// Infallible constructor. Panics if `config.l2_path` is set and the L2
680    /// file cannot be opened — use [`BlobCache::open_with_l2`] instead for
681    /// configs that include an L2 path so boot errors are handled gracefully.
682    pub fn new(config: BlobCacheConfig) -> Self {
683        Self::try_new(config).expect("open blob-cache L2")
684    }
685
686    /// Fallible constructor for configs that include an L2 path.
687    /// Returns `Err(CacheError::L2Io(...))` on invalid path, corrupt control
688    /// sidecar, or any other recoverable I/O failure — the process stays alive.
689    pub fn open_with_l2(config: BlobCacheConfig) -> Result<Self, CacheError> {
690        Self::try_new(config)
691    }
692
693    fn try_new(config: BlobCacheConfig) -> Result<Self, CacheError> {
694        let config = BlobCacheConfig {
695            shard_count: config.shard_count.max(1),
696            ..config
697        };
698        let l2 = config
699            .l2_path
700            .clone()
701            .map(|path| BlobCacheL2::open(path, config.l2_bytes_max))
702            .transpose()?;
703        let shards = (0..config.shard_count)
704            .map(|_| RwLock::new(Shard::new()))
705            .collect();
706        Ok(Self {
707            config,
708            shards,
709            namespaces: RwLock::new(HashSet::new()),
710            namespace_generations: RwLock::new(HashMap::new()),
711            tag_index: RwLock::new(HashMap::new()),
712            dependency_index: RwLock::new(HashMap::new()),
713            l2: l2.map(Arc::new),
714            bytes_in_use: AtomicUsize::new(0),
715            stats: AtomicStats::new(),
716            promotion_pool: OnceLock::new(),
717        })
718    }
719
720    pub fn with_defaults() -> Self {
721        Self::new(BlobCacheConfig::default())
722    }
723
724    /// Path to the L2 metadata B+ tree directory, when L2 is enabled.
725    ///
726    /// Used by the backup orchestrator (`include_blob_cache=true`) so it
727    /// can locate the on-disk L2 tree for tarball / per-file upload, and
728    /// by the runbook procedures in
729    /// `docs/operations/blob-cache-backup-restore.md` §2 / §3 to confirm
730    /// where on disk the cache lives.
731    pub fn l2_path(&self) -> Option<&std::path::Path> {
732        self.config.l2_path.as_deref()
733    }
734
735    pub fn put(
736        &self,
737        namespace: impl Into<String>,
738        key: impl Into<String>,
739        input: BlobCachePut,
740    ) -> Result<(), CacheError> {
741        self.put_at(namespace, key, input, unix_now_ms())
742    }
743
744    fn put_at(
745        &self,
746        namespace: impl Into<String>,
747        key: impl Into<String>,
748        input: BlobCachePut,
749        now_ms: u64,
750    ) -> Result<(), CacheError> {
751        let namespace = namespace.into();
752        let key = BlobCacheKey::new(namespace.clone(), key);
753        self.validate_blob_size(input.bytes.len(), input.policy)?;
754        self.validate_metadata(&input.content_metadata)?;
755        self.ensure_namespace(&namespace)?;
756        let namespace_generation = self.current_generation(&namespace);
757        let tags = input.tags.clone();
758        let dependencies = input.dependencies.clone();
759
760        let shard_idx = self.shard_index(&key);
761        let mut shard = self.shards[shard_idx].write();
762        shard.clear_l2_hit_marker(&key);
763        self.check_version(
764            &shard,
765            &key,
766            input.policy.version_value(),
767            namespace_generation,
768        )?;
769        let entry = Entry::new(
770            input.bytes,
771            input.content_metadata,
772            input.tags,
773            input.dependencies,
774            input.policy,
775            namespace_generation,
776            now_ms,
777            &namespace,
778            &key.key,
779        );
780        let entry_size = entry.size;
781        if let Some(l2) = &self.l2 {
782            let old_l2_size = l2.record_size(&key);
783            // Compression decision happens in the foreground put — the
784            // outcome (`Compressed::Raw` or `Compressed::Zstd`) is what
785            // gets framed and written to the chain (#192). When the knob
786            // is `Off`, skip the compressor entirely (CPU savings) and
787            // emit a `Raw` variant directly so the on-disk format stays
788            // uniform.
789            let compressed = match self.config.l2_compression {
790                L2Compression::Off => Compressed::Raw(entry.bytes.as_ref().to_vec()),
791                L2Compression::On => {
792                    let content_type = entry
793                        .content_metadata
794                        .as_ref()
795                        .and_then(|metadata| metadata.get("content-type"))
796                        .map(String::as_str);
797                    L2BlobCompressor::compress(
798                        entry.bytes.as_ref(),
799                        content_type,
800                        &CompressOpts::default(),
801                    )
802                    .map_err(|err| CacheError::L2Io(err.to_string()))?
803                }
804            };
805            match l2.put(&key, &entry, old_l2_size, compressed) {
806                Ok(()) => {}
807                Err(err @ CacheError::L2Full { .. }) => {
808                    self.stats
809                        .l2_full_rejections
810                        .fetch_add(1, Ordering::Relaxed);
811                    return Err(err);
812                }
813                Err(err) => return Err(err),
814            }
815        }
816        let outcome = if matches!(input.policy.l1_admission_value(), L1Admission::Never) {
817            let old_entry = shard.remove(&key);
818            InsertOutcome {
819                old_entry,
820                admitted: false,
821            }
822        } else {
823            shard.insert(key.clone(), entry)
824        };
825        drop(shard);
826
827        if let Some(old_entry) = outcome.old_entry.as_ref() {
828            self.deindex_entry(&key, old_entry);
829        }
830        if outcome.admitted {
831            self.index_entry(&key, &tags, &dependencies);
832        }
833
834        let old_size = outcome.old_entry.as_ref().map_or(0, |entry| entry.size);
835        let new_size = if outcome.admitted { entry_size } else { 0 };
836        if new_size >= old_size {
837            self.bytes_in_use
838                .fetch_add(new_size - old_size, Ordering::Relaxed);
839        } else {
840            self.bytes_in_use
841                .fetch_sub(old_size - new_size, Ordering::Relaxed);
842        }
843        self.stats.insertions.fetch_add(1, Ordering::Relaxed);
844        if outcome.admitted {
845            self.evict_until_within_budget(shard_idx);
846        }
847        Ok(())
848    }
849
850    pub fn get(&self, namespace: &str, key: &str) -> Option<BlobCacheHit> {
851        self.get_at(namespace, key, unix_now_ms())
852    }
853
854    fn get_at(&self, namespace: &str, key: &str, now_ms: u64) -> Option<BlobCacheHit> {
855        let namespace_generation = self.current_generation(namespace);
856        let shard_idx = self.shard_index_parts(namespace, key);
857        let mut shard = self.shards[shard_idx].write();
858        match shard.get_by_parts(namespace, key, now_ms, namespace_generation) {
859            Lookup::Hit(hit) => {
860                self.stats.hits.fetch_add(1, Ordering::Relaxed);
861                if hit.is_stale() {
862                    self.stats.l1_stale_serves.fetch_add(1, Ordering::Relaxed);
863                }
864                Some(hit)
865            }
866            Lookup::Expired(entry) => {
867                drop(shard);
868                let cache_key = BlobCacheKey::new(namespace, key);
869                self.record_removed_entry(&cache_key, &entry);
870                if let Some(l2) = &self.l2 {
871                    l2.delete_key(&cache_key);
872                }
873                self.stats.expirations.fetch_add(1, Ordering::Relaxed);
874                self.stats.misses.fetch_add(1, Ordering::Relaxed);
875                None
876            }
877            Lookup::IdleEvicted(entry) => {
878                drop(shard);
879                let cache_key = BlobCacheKey::new(namespace, key);
880                self.record_removed_entry(&cache_key, &entry);
881                if let Some(l2) = &self.l2 {
882                    l2.delete_key(&cache_key);
883                }
884                self.stats.expirations.fetch_add(1, Ordering::Relaxed);
885                self.stats.l1_idle_evicts.fetch_add(1, Ordering::Relaxed);
886                self.stats.misses.fetch_add(1, Ordering::Relaxed);
887                None
888            }
889            Lookup::Stale(entry) => {
890                drop(shard);
891                let cache_key = BlobCacheKey::new(namespace, key);
892                self.record_removed_entry(&cache_key, &entry);
893                self.stats.misses.fetch_add(1, Ordering::Relaxed);
894                None
895            }
896            Lookup::Miss => {
897                drop(shard);
898                let cache_key = BlobCacheKey::new(namespace, key);
899                if let Some(pool) = self.promotion_pool.get() {
900                    // Async path: do the L2 read (we owe the bytes to the
901                    // caller right now) but defer the L1 install onto the
902                    // worker pool. Caller does not pay promotion bookkeeping.
903                    if let Some(l2) = self.l2.as_ref() {
904                        if let Some(entry) = l2.get(&cache_key, now_ms, namespace_generation) {
905                            let hit = entry.hit();
906                            if self.should_promote_l2_hit(&cache_key, shard_idx) {
907                                // Drop the freshly-fetched Entry — the worker will
908                                // re-fetch it. Cost: one extra L2 metadata read +
909                                // blob read per promoted L2 hit while async mode is on.
910                                // Acceptable trade-off for opt-in mode.
911                                drop(entry);
912                                let request = PromotionRequest {
913                                    namespace: cache_key.namespace.clone(),
914                                    key: cache_key.key.clone(),
915                                    bytes: Arc::clone(hit.bytes()),
916                                    policy: BlobCachePolicy::default(),
917                                };
918                                let _ = pool.schedule(request);
919                            }
920                            self.stats.hits.fetch_add(1, Ordering::Relaxed);
921                            return Some(hit);
922                        }
923                    }
924                    self.stats.misses.fetch_add(1, Ordering::Relaxed);
925                    return None;
926                }
927                if let Some(hit) =
928                    self.rehydrate_l2_entry(&cache_key, now_ms, namespace_generation, shard_idx)
929                {
930                    self.stats.hits.fetch_add(1, Ordering::Relaxed);
931                    return Some(hit);
932                }
933                self.stats.misses.fetch_add(1, Ordering::Relaxed);
934                None
935            }
936            Lookup::Present => unreachable!("get cannot return presence-only lookup"),
937        }
938    }
939
940    /// Probe whether `(namespace, key)` is cached.
941    ///
942    /// Returns a three-valued [`CachePresence`]:
943    ///
944    /// - `Present` when an L1-resident entry is held for the key.
945    /// - `Absent` when the cache can authoritatively rule the key out: either
946    ///   no L2 is configured, or the per-namespace Bloom synopsis
947    ///   (no-false-negatives) says the key was never inserted into L2.
948    /// - `MaybePresent` when L1 missed but the Bloom synopsis cannot rule the
949    ///   key out. Callers that need an exact answer must follow up with
950    ///   [`get`](Self::get), which performs the authoritative metadata read
951    ///   and either rehydrates a hit or surfaces a genuine miss.
952    ///
953    /// `exists` deliberately does NOT touch the L2 metadata B+ tree on a
954    /// `MaybePresent` answer — that is the whole reason the synopsis exists
955    /// (#146). The probabilistic answer is the cheap fast path; pay the
956    /// metadata-read cost only when you actually need the bytes.
957    pub fn exists(&self, namespace: &str, key: &str) -> CachePresence {
958        self.exists_at(namespace, key, unix_now_ms())
959    }
960
961    fn exists_at(&self, namespace: &str, key: &str, now_ms: u64) -> CachePresence {
962        let namespace_generation = self.current_generation(namespace);
963        let shard_idx = self.shard_index_parts(namespace, key);
964        let mut shard = self.shards[shard_idx].write();
965        match shard.contains_by_parts(namespace, key, now_ms, namespace_generation) {
966            Lookup::Present => {
967                self.stats.hits.fetch_add(1, Ordering::Relaxed);
968                CachePresence::Present
969            }
970            Lookup::Expired(entry) => {
971                drop(shard);
972                let cache_key = BlobCacheKey::new(namespace, key);
973                self.record_removed_entry(&cache_key, &entry);
974                if let Some(l2) = &self.l2 {
975                    l2.delete_key(&cache_key);
976                }
977                self.stats.expirations.fetch_add(1, Ordering::Relaxed);
978                self.stats.misses.fetch_add(1, Ordering::Relaxed);
979                CachePresence::Absent
980            }
981            Lookup::IdleEvicted(entry) => {
982                drop(shard);
983                let cache_key = BlobCacheKey::new(namespace, key);
984                self.record_removed_entry(&cache_key, &entry);
985                if let Some(l2) = &self.l2 {
986                    l2.delete_key(&cache_key);
987                }
988                self.stats.expirations.fetch_add(1, Ordering::Relaxed);
989                self.stats.l1_idle_evicts.fetch_add(1, Ordering::Relaxed);
990                self.stats.misses.fetch_add(1, Ordering::Relaxed);
991                CachePresence::Absent
992            }
993            Lookup::Stale(entry) => {
994                drop(shard);
995                let cache_key = BlobCacheKey::new(namespace, key);
996                self.record_removed_entry(&cache_key, &entry);
997                self.stats.misses.fetch_add(1, Ordering::Relaxed);
998                CachePresence::Absent
999            }
1000            Lookup::Miss => {
1001                drop(shard);
1002                let Some(l2) = self.l2.as_ref() else {
1003                    self.stats.misses.fetch_add(1, Ordering::Relaxed);
1004                    return CachePresence::Absent;
1005                };
1006                if l2.synopsis_may_contain(namespace, key) {
1007                    // Filter says maybe — the cheap fast path defers the
1008                    // authoritative read to `get`. Count as a hit prospect.
1009                    self.stats.hits.fetch_add(1, Ordering::Relaxed);
1010                    CachePresence::MaybePresent
1011                } else {
1012                    // Filter says no — definitively absent (no
1013                    // false-negatives).
1014                    self.stats.misses.fetch_add(1, Ordering::Relaxed);
1015                    CachePresence::Absent
1016                }
1017            }
1018            Lookup::Hit(_) => unreachable!("exists cannot return a hit payload"),
1019        }
1020    }
1021
1022    /// Node-local invalidation for one exact cache key.
1023    ///
1024    /// This does not propagate to replicas. Cluster-wide invalidation is a
1025    /// future contract; callers that need cross-node coherence must rely on the
1026    /// underlying write reaching each node and triggering local eviction there.
1027    pub fn invalidate_key(&self, namespace: &str, key: &str) -> usize {
1028        if !self.namespace_exists(namespace) {
1029            return 0;
1030        }
1031        let cache_key = BlobCacheKey::new(namespace, key);
1032        let shard_idx = self.shard_index(&cache_key);
1033        let mut shard = self.shards[shard_idx].write();
1034        let removed = shard.remove(&cache_key);
1035        drop(shard);
1036
1037        if let Some(entry) = removed {
1038            self.record_invalidated_entry(&cache_key, &entry);
1039            1
1040        } else {
1041            self.l2
1042                .as_ref()
1043                .and_then(|l2| l2.delete_key(&cache_key))
1044                .map(|_| {
1045                    self.stats.invalidations.fetch_add(1, Ordering::Relaxed);
1046                    1
1047                })
1048                .unwrap_or(0)
1049        }
1050    }
1051
1052    /// Node-local invalidation for keys with a namespace-local prefix.
1053    pub fn invalidate_prefix(&self, namespace: &str, prefix: &str) -> usize {
1054        if !self.namespace_exists(namespace) {
1055            return 0;
1056        }
1057
1058        let mut removed = Vec::new();
1059        for shard in &self.shards {
1060            let mut shard = shard.write();
1061            let keys = shard
1062                .keys_matching(|key| key.namespace == namespace && key.key.starts_with(prefix));
1063            for key in keys {
1064                if let Some(entry) = shard.remove(&key) {
1065                    removed.push((key, entry));
1066                }
1067            }
1068        }
1069
1070        let count = removed.len();
1071        for (key, entry) in removed {
1072            self.record_invalidated_entry(&key, &entry);
1073        }
1074        let l2_count = self
1075            .l2
1076            .as_ref()
1077            .map_or(0, |l2| l2.delete_prefix(namespace, prefix));
1078        if l2_count > count {
1079            self.stats
1080                .invalidations
1081                .fetch_add((l2_count - count) as u64, Ordering::Relaxed);
1082        }
1083        count.max(l2_count)
1084    }
1085
1086    /// Node-local batched invalidation for all entries carrying any of `tags`.
1087    ///
1088    /// Locks each affected shard once per call, so a batched invalidation
1089    /// from a downstream adapter (#143) does not multiply lock acquisitions
1090    /// the way N singular calls would.
1091    pub fn invalidate_tags(&self, namespace: &str, tags: &[&str]) -> usize {
1092        self.invalidate_indexed_many(namespace, tags, IndexedKind::Tag)
1093    }
1094
1095    /// Node-local batched invalidation for all entries carrying any of `dependencies`.
1096    pub fn invalidate_dependencies(&self, namespace: &str, dependencies: &[&str]) -> usize {
1097        self.invalidate_indexed_many(namespace, dependencies, IndexedKind::Dependency)
1098    }
1099
1100    /// Node-local invalidation for all entries carrying `tag`.
1101    #[deprecated(
1102        since = "0.1.0",
1103        note = "use `invalidate_tags(namespace, &[tag])` for batched callers"
1104    )]
1105    pub fn invalidate_tag(&self, namespace: &str, tag: &str) -> usize {
1106        self.invalidate_indexed_many(namespace, &[tag], IndexedKind::Tag)
1107    }
1108
1109    /// Node-local invalidation for all entries carrying `dependency`.
1110    #[deprecated(
1111        since = "0.1.0",
1112        note = "use `invalidate_dependencies(namespace, &[dependency])` for batched callers"
1113    )]
1114    pub fn invalidate_dependency(&self, namespace: &str, dependency: &str) -> usize {
1115        self.invalidate_indexed_many(namespace, &[dependency], IndexedKind::Dependency)
1116    }
1117
1118    /// O(1) foreground namespace flush.
1119    ///
1120    /// The foreground path only bumps a namespace generation. Old entries become
1121    /// invisible immediately and are physically removed by later cache access or
1122    /// a future sweeper.
1123    pub fn invalidate_namespace(&self, namespace: &str) -> bool {
1124        if !self.namespace_exists(namespace) {
1125            return false;
1126        }
1127        let mut generations = self.namespace_generations.write();
1128        let generation = generations.entry(namespace.to_string()).or_insert(0);
1129        *generation = generation.saturating_add(1);
1130        if let Some(l2) = &self.l2 {
1131            l2.delete_namespace(namespace);
1132        }
1133        self.stats.namespace_flushes.fetch_add(1, Ordering::Relaxed);
1134        true
1135    }
1136
1137    /// RAM this cache occupies right now (ADR 0073 §2, the `blob_cache_l1`
1138    /// pool): L1 entry payloads plus L2's resident metadata — its B+ tree
1139    /// pages and Bloom synopsis filters. L2's *disk* extent is not memory and
1140    /// is excluded; `BlobCacheStats::l2_bytes_in_use` reports it separately.
1141    pub fn ram_bytes_in_use(&self) -> u64 {
1142        let l1 = self.bytes_in_use.load(Ordering::Relaxed) as u64;
1143        let l2 = self
1144            .l2
1145            .as_ref()
1146            .map_or(0, |l2| l2.resident_metadata_bytes());
1147        l1.saturating_add(l2)
1148    }
1149
1150    pub fn stats(&self) -> BlobCacheStats {
1151        BlobCacheStats {
1152            hits: self.stats.hits.load(Ordering::Relaxed),
1153            misses: self.stats.misses.load(Ordering::Relaxed),
1154            insertions: self.stats.insertions.load(Ordering::Relaxed),
1155            evictions: self.stats.evictions.load(Ordering::Relaxed),
1156            expirations: self.stats.expirations.load(Ordering::Relaxed),
1157            invalidations: self.stats.invalidations.load(Ordering::Relaxed),
1158            namespace_flushes: self.stats.namespace_flushes.load(Ordering::Relaxed),
1159            version_mismatches: self.stats.version_mismatches.load(Ordering::Relaxed),
1160            entries: self.shards.iter().map(|shard| shard.read().len()).sum(),
1161            bytes_in_use: self.bytes_in_use.load(Ordering::Relaxed),
1162            l1_bytes_max: self.config.l1_bytes_max,
1163            l2_bytes_in_use: self.l2.as_ref().map_or(0, |l2| l2.stats_bytes_in_use()),
1164            l2_bytes_max: self.config.l2_bytes_max,
1165            l2_full_rejections: self.stats.l2_full_rejections.load(Ordering::Relaxed),
1166            l2_metadata_reads: self.l2.as_ref().map_or(0, |l2| l2.stats_metadata_reads()),
1167            l2_negative_skips: self.l2.as_ref().map_or(0, |l2| l2.stats_negative_skips()),
1168            synopsis_metadata_reads: self
1169                .l2
1170                .as_ref()
1171                .map_or(0, |l2| l2.stats_synopsis_metadata_reads()),
1172            synopsis_bytes: self.l2.as_ref().map_or(0, |l2| l2.stats_synopsis_bytes()),
1173            namespaces: self.namespaces.read().len(),
1174            max_namespaces: self.config.max_namespaces,
1175            promotion_queued: self
1176                .promotion_pool
1177                .get()
1178                .map_or(0, |p| p.metrics().queued_total),
1179            promotion_dropped: self
1180                .promotion_pool
1181                .get()
1182                .map_or(0, |p| p.metrics().dropped_total),
1183            promotion_completed: self
1184                .promotion_pool
1185                .get()
1186                .map_or(0, |p| p.metrics().completed_total),
1187            promotion_queue_depth: self
1188                .promotion_pool
1189                .get()
1190                .map_or(0, |p| p.metrics().queue_depth),
1191            l2_compression_original_bytes: self
1192                .l2
1193                .as_ref()
1194                .map_or(0, |l2| l2.stats_compression_original_bytes()),
1195            l2_compression_stored_bytes: self
1196                .l2
1197                .as_ref()
1198                .map_or(0, |l2| l2.stats_compression_stored_bytes()),
1199            l2_compression_skipped_total: self
1200                .l2
1201                .as_ref()
1202                .map_or(0, |l2| l2.stats_compression_skipped_total()),
1203            l2_bytes_saved_total: self
1204                .l2
1205                .as_ref()
1206                .map_or(0, |l2| l2.stats_bytes_saved_total()),
1207            l1_stale_serves_total: self.stats.l1_stale_serves.load(Ordering::Relaxed),
1208            l1_idle_evicts_total: self.stats.l1_idle_evicts.load(Ordering::Relaxed),
1209        }
1210    }
1211
1212    // -- Async promotion (issue #193) ---------------------------------------
1213
1214    /// Initialize the async L2->L1 promotion pool. Must be called on an
1215    /// `Arc<Self>` so the executor closure can hold a `Weak<Self>` (no
1216    /// reference cycle).
1217    ///
1218    /// Idempotent on first call only — `OnceLock` semantics: a second call
1219    /// returns the previously-installed pool unchanged. The returned `Arc`
1220    /// can be used by callers that want to inspect metrics directly; most
1221    /// callers should ignore it and read metrics via `stats()`.
1222    pub fn enable_async_promotion(self: &Arc<Self>, opts: PoolOpts) -> Arc<AsyncPromotionPool> {
1223        let weak: Weak<Self> = Arc::downgrade(self);
1224        let executor: PromotionExecutor = Arc::new(move |req| {
1225            // Upgrade only at execution time. If the cache has been
1226            // dropped, the worker silently no-ops (executor never holds
1227            // a strong ref between calls).
1228            let Some(cache) = weak.upgrade() else {
1229                return Ok(());
1230            };
1231            cache.promote_from_l2(&req)
1232        });
1233        let pool = AsyncPromotionPool::new_with_executor(opts, executor);
1234        match self.promotion_pool.set(Arc::clone(&pool)) {
1235            Ok(()) => pool,
1236            // Race: another caller already initialized. Drain ours and
1237            // return the winner. The losing pool's workers are spawned;
1238            // shutdown drains them out gracefully.
1239            Err(losing_pool) => {
1240                losing_pool.shutdown();
1241                Arc::clone(
1242                    self.promotion_pool
1243                        .get()
1244                        .expect("OnceLock set+get inconsistency"),
1245                )
1246            }
1247        }
1248    }
1249
1250    /// Drain and stop the async promotion pool, if enabled. Safe to call
1251    /// from `Drop` impls / test teardown — no-op when the pool was never
1252    /// initialized.
1253    pub fn shutdown_async_promotion(&self) {
1254        if let Some(pool) = self.promotion_pool.get() {
1255            Arc::clone(pool).shutdown();
1256        }
1257    }
1258
1259    /// Test-only escape hatch: schedule outcome of the most recent attempt
1260    /// is internal; tests assert on `stats()` counters instead.
1261    #[cfg(test)]
1262    fn promotion_pool_handle(&self) -> Option<Arc<AsyncPromotionPool>> {
1263        self.promotion_pool.get().cloned()
1264    }
1265
1266    /// Test-only: install a custom executor (e.g. one that sleeps to
1267    /// expose the hot-path / worker-path latency split). Used by the
1268    /// async-promotion wiring tests in this file.
1269    #[cfg(test)]
1270    fn enable_async_promotion_with_executor(
1271        self: &Arc<Self>,
1272        opts: PoolOpts,
1273        executor: PromotionExecutor,
1274    ) -> Arc<AsyncPromotionPool> {
1275        let pool = AsyncPromotionPool::new_with_executor(opts, executor);
1276        let _ = self.promotion_pool.set(Arc::clone(&pool));
1277        pool
1278    }
1279
1280    pub fn config(&self) -> &BlobCacheConfig {
1281        &self.config
1282    }
1283
1284    #[cfg(test)]
1285    fn inject_l2_fault_after_blob_write_once(&self) {
1286        self.l2
1287            .as_ref()
1288            .expect("L2 enabled")
1289            .inject_fault_after_blob_write_once();
1290    }
1291
1292    #[cfg(test)]
1293    fn inject_l2_synopsis_maybe_present(&self, namespace: &str, key: &str) {
1294        self.l2
1295            .as_ref()
1296            .expect("L2 enabled")
1297            .inject_synopsis_maybe_present(namespace, key);
1298    }
1299
1300    /// Test-only escape hatch (#192 lane 2/5): synthesise a legacy
1301    /// `V1Raw` L2 entry on disk so the forward-compat read test can
1302    /// verify pre-compression entries still rehydrate.
1303    #[cfg(test)]
1304    fn inject_l2_v1_entry(
1305        &self,
1306        namespace: &str,
1307        key: &str,
1308        payload: &[u8],
1309    ) -> Result<(), CacheError> {
1310        let l2 = self.l2.as_ref().expect("L2 enabled");
1311        let cache_key = BlobCacheKey::new(namespace, key);
1312        l2.inject_v1_entry(&cache_key, payload)
1313    }
1314
1315    fn validate_blob_size(&self, size: usize, policy: BlobCachePolicy) -> Result<(), CacheError> {
1316        let max = policy
1317            .max_blob_bytes_value()
1318            .unwrap_or(self.config.l1_bytes_max);
1319        if size > max {
1320            Err(CacheError::BlobTooLarge { size, max })
1321        } else {
1322            Ok(())
1323        }
1324    }
1325
1326    fn validate_metadata(&self, metadata: &BTreeMap<String, String>) -> Result<(), CacheError> {
1327        let keys = metadata.len();
1328        let bytes = metadata
1329            .iter()
1330            .map(|(key, value)| key.len() + value.len())
1331            .sum::<usize>();
1332        if keys > self.config.content_metadata_keys_max
1333            || bytes > self.config.content_metadata_bytes_max
1334        {
1335            Err(CacheError::MetadataTooLarge {
1336                keys,
1337                bytes,
1338                max_keys: self.config.content_metadata_keys_max,
1339                max_bytes: self.config.content_metadata_bytes_max,
1340            })
1341        } else {
1342            Ok(())
1343        }
1344    }
1345
1346    fn rehydrate_l2_entry(
1347        &self,
1348        key: &BlobCacheKey,
1349        now_ms: u64,
1350        namespace_generation: u64,
1351        shard_idx: usize,
1352    ) -> Option<BlobCacheHit> {
1353        let l2 = self.l2.as_ref()?;
1354        let entry = l2.get(key, now_ms, namespace_generation)?;
1355        let hit = entry.hit();
1356        if self.should_promote_l2_hit(key, shard_idx) {
1357            self.do_l1_promotion_sync(key, entry, shard_idx);
1358        }
1359        Some(hit)
1360    }
1361
1362    fn should_promote_l2_hit(&self, key: &BlobCacheKey, shard_idx: usize) -> bool {
1363        match self.config.l2_promotion_policy {
1364            L2PromotionPolicy::Immediate => true,
1365            L2PromotionPolicy::OnSecondHit => self.shards[shard_idx]
1366                .write()
1367                .l2_hit_should_promote_on_second_hit(key),
1368        }
1369    }
1370
1371    /// Pure L1 install bookkeeping: shard write-lock, byte accounting,
1372    /// eviction loop. Extracted so the async promotion pool can call it
1373    /// from a worker (issue #193, lane 1/5).
1374    ///
1375    /// This is intentionally side-effect-only — it does not touch hit/miss
1376    /// stats (the caller already counted the hit) and does not return the
1377    /// `BlobCacheHit` (the caller already handed bytes to the user).
1378    fn do_l1_promotion_sync(&self, key: &BlobCacheKey, entry: Entry, shard_idx: usize) {
1379        let entry_size = entry.size;
1380        let mut shard = self.shards[shard_idx].write();
1381        let outcome = shard.insert(key.clone(), entry);
1382        drop(shard);
1383        let old_size = outcome.old_entry.as_ref().map_or(0, |entry| entry.size);
1384        if entry_size >= old_size {
1385            self.bytes_in_use
1386                .fetch_add(entry_size - old_size, Ordering::Relaxed);
1387        } else {
1388            self.bytes_in_use
1389                .fetch_sub(old_size - entry_size, Ordering::Relaxed);
1390        }
1391        self.evict_until_within_budget(shard_idx);
1392    }
1393
1394    /// Worker-side promotion path: re-fetch the entry from L2 and run the
1395    /// L1 install bookkeeping. Idempotent — re-promoting a key that the
1396    /// hot path already promoted (race with another reader) is harmless.
1397    /// Returns `Err` only when L2 is unavailable or the key is no longer
1398    /// present at L2 (silently treated as a no-op upstream).
1399    fn promote_from_l2(&self, req: &PromotionRequest) -> Result<(), String> {
1400        let l2 = self
1401            .l2
1402            .as_ref()
1403            .ok_or_else(|| "promotion executor invoked without L2 configured".to_string())?;
1404        let cache_key = BlobCacheKey::new(req.namespace.as_str(), req.key.as_str());
1405        let now_ms = unix_now_ms();
1406        let namespace_generation = self.current_generation(req.namespace.as_str());
1407        if let Some(entry) = l2.get(&cache_key, now_ms, namespace_generation) {
1408            let shard_idx = self.shard_index(&cache_key);
1409            self.do_l1_promotion_sync(&cache_key, entry, shard_idx);
1410        }
1411        Ok(())
1412    }
1413
1414    fn ensure_namespace(&self, namespace: &str) -> Result<(), CacheError> {
1415        {
1416            let namespaces = self.namespaces.read();
1417            if namespaces.contains(namespace) {
1418                return Ok(());
1419            }
1420        }
1421        let mut namespaces = self.namespaces.write();
1422        if namespaces.contains(namespace) {
1423            return Ok(());
1424        }
1425        if namespaces.len() >= self.config.max_namespaces {
1426            return Err(CacheError::TooManyNamespaces {
1427                max: self.config.max_namespaces,
1428            });
1429        }
1430        namespaces.insert(namespace.to_string());
1431        self.namespace_generations
1432            .write()
1433            .entry(namespace.to_string())
1434            .or_insert(0);
1435        Ok(())
1436    }
1437
1438    fn namespace_exists(&self, namespace: &str) -> bool {
1439        self.namespaces.read().contains(namespace)
1440            || self
1441                .l2
1442                .as_ref()
1443                .is_some_and(|l2| l2.has_namespace(namespace))
1444    }
1445
1446    fn current_generation(&self, namespace: &str) -> u64 {
1447        self.namespace_generations
1448            .read()
1449            .get(namespace)
1450            .copied()
1451            .unwrap_or(0)
1452    }
1453
1454    fn index_entry(
1455        &self,
1456        key: &BlobCacheKey,
1457        tags: &BTreeSet<String>,
1458        dependencies: &BTreeSet<String>,
1459    ) {
1460        if !tags.is_empty() {
1461            let mut index = self.tag_index.write();
1462            for tag in tags {
1463                index
1464                    .entry(ScopedLabel::new(key.namespace.as_str(), tag.as_str()))
1465                    .or_default()
1466                    .insert(key.clone());
1467            }
1468        }
1469        if !dependencies.is_empty() {
1470            let mut index = self.dependency_index.write();
1471            for dependency in dependencies {
1472                index
1473                    .entry(ScopedLabel::new(
1474                        key.namespace.as_str(),
1475                        dependency.as_str(),
1476                    ))
1477                    .or_default()
1478                    .insert(key.clone());
1479            }
1480        }
1481    }
1482
1483    fn deindex_entry(&self, key: &BlobCacheKey, entry: &Entry) {
1484        Self::remove_indexed_labels(&self.tag_index, key, &entry.tags);
1485        Self::remove_indexed_labels(&self.dependency_index, key, &entry.dependencies);
1486    }
1487
1488    fn remove_indexed_labels(
1489        index: &RwLock<HashMap<ScopedLabel, HashSet<BlobCacheKey>>>,
1490        key: &BlobCacheKey,
1491        labels: &BTreeSet<String>,
1492    ) {
1493        if labels.is_empty() {
1494            return;
1495        }
1496        let mut index = index.write();
1497        for label in labels {
1498            let scoped = ScopedLabel::new(key.namespace.as_str(), label.as_str());
1499            let should_remove = if let Some(keys) = index.get_mut(&scoped) {
1500                keys.remove(key);
1501                keys.is_empty()
1502            } else {
1503                false
1504            };
1505            if should_remove {
1506                index.remove(&scoped);
1507            }
1508        }
1509    }
1510
1511    fn record_removed_entry(&self, key: &BlobCacheKey, entry: &Entry) {
1512        self.bytes_in_use.fetch_sub(entry.size, Ordering::Relaxed);
1513        self.deindex_entry(key, entry);
1514    }
1515
1516    fn record_invalidated_entry(&self, key: &BlobCacheKey, entry: &Entry) {
1517        self.record_removed_entry(key, entry);
1518        if let Some(l2) = &self.l2 {
1519            l2.delete_key(key);
1520        }
1521        self.stats.invalidations.fetch_add(1, Ordering::Relaxed);
1522    }
1523
1524    fn invalidate_indexed_many(
1525        &self,
1526        namespace: &str,
1527        labels: &[&str],
1528        kind: IndexedKind,
1529    ) -> usize {
1530        if labels.is_empty() || !self.namespace_exists(namespace) {
1531            return 0;
1532        }
1533
1534        // Snapshot the candidate keys for every label up front so the
1535        // shard-locking pass below sees a stable set. We deduplicate by
1536        // BlobCacheKey so a key tagged with multiple invalidated labels is
1537        // still removed (and counted) exactly once.
1538        let mut candidates: HashMap<BlobCacheKey, HashSet<String>> = HashMap::new();
1539        {
1540            let index = match kind {
1541                IndexedKind::Tag => self.tag_index.read(),
1542                IndexedKind::Dependency => self.dependency_index.read(),
1543            };
1544            for label in labels {
1545                let scoped = ScopedLabel::new(namespace, *label);
1546                if let Some(keys) = index.get(&scoped) {
1547                    for key in keys {
1548                        candidates
1549                            .entry(key.clone())
1550                            .or_default()
1551                            .insert((*label).to_string());
1552                    }
1553                }
1554            }
1555        }
1556
1557        if candidates.is_empty() {
1558            return 0;
1559        }
1560
1561        // Group candidates by shard so each shard lock is taken at most
1562        // once per call.
1563        let mut by_shard: HashMap<usize, Vec<(BlobCacheKey, HashSet<String>)>> = HashMap::new();
1564        for (key, matched_labels) in candidates {
1565            let shard_idx = self.shard_index(&key);
1566            by_shard
1567                .entry(shard_idx)
1568                .or_default()
1569                .push((key, matched_labels));
1570        }
1571
1572        let mut removed = Vec::new();
1573        for (shard_idx, keys) in by_shard {
1574            let mut shard = self.shards[shard_idx].write();
1575            for (key, matched_labels) in keys {
1576                let still_matches = match kind {
1577                    IndexedKind::Tag => shard.entry_has_any_tag(&key, &matched_labels),
1578                    IndexedKind::Dependency => {
1579                        shard.entry_has_any_dependency(&key, &matched_labels)
1580                    }
1581                };
1582                if still_matches {
1583                    if let Some(entry) = shard.remove(&key) {
1584                        removed.push((key, entry));
1585                    }
1586                }
1587            }
1588        }
1589
1590        let count = removed.len();
1591        for (key, entry) in removed {
1592            self.record_invalidated_entry(&key, &entry);
1593        }
1594        count
1595    }
1596
1597    fn shard_index(&self, key: &BlobCacheKey) -> usize {
1598        self.shard_index_parts(&key.namespace, &key.key)
1599    }
1600
1601    fn shard_index_parts(&self, namespace: &str, key: &str) -> usize {
1602        let mut hasher = std::collections::hash_map::DefaultHasher::new();
1603        namespace.hash(&mut hasher);
1604        key.hash(&mut hasher);
1605        (hasher.finish() as usize) % self.shards.len()
1606    }
1607
1608    fn check_version(
1609        &self,
1610        shard: &Shard,
1611        key: &BlobCacheKey,
1612        attempted: Option<u64>,
1613        namespace_generation: u64,
1614    ) -> Result<(), CacheError> {
1615        let Some(attempted) = attempted else {
1616            return Ok(());
1617        };
1618        let Some(existing) = shard.existing_version(key, namespace_generation) else {
1619            return Ok(());
1620        };
1621        if existing >= attempted {
1622            self.stats
1623                .version_mismatches
1624                .fetch_add(1, Ordering::Relaxed);
1625            Err(CacheError::VersionMismatch {
1626                existing,
1627                attempted,
1628            })
1629        } else {
1630            Ok(())
1631        }
1632    }
1633
1634    fn evict_until_within_budget(&self, preferred_start: usize) {
1635        while self.bytes_in_use.load(Ordering::Relaxed) > self.config.l1_bytes_max {
1636            let mut evicted = false;
1637            for offset in 0..self.shards.len() {
1638                let idx = (preferred_start + offset) % self.shards.len();
1639                let mut shard = self.shards[idx].write();
1640                if let Some((key, entry)) = shard.evict_one() {
1641                    self.bytes_in_use.fetch_sub(entry.size, Ordering::Relaxed);
1642                    self.stats.evictions.fetch_add(1, Ordering::Relaxed);
1643                    evicted = true;
1644                    drop(shard);
1645                    self.deindex_entry(&key, &entry);
1646                    break;
1647                }
1648            }
1649            if !evicted {
1650                break;
1651            }
1652        }
1653    }
1654}
1655
1656fn unix_now_ms() -> u64 {
1657    SystemTime::now()
1658        .duration_since(UNIX_EPOCH)
1659        .map(|duration| duration.as_millis() as u64)
1660        .unwrap_or(0)
1661}
1662
1663impl Default for BlobCache {
1664    fn default() -> Self {
1665        Self::with_defaults()
1666    }
1667}
1668
1669#[cfg(test)]
1670mod tests;