Skip to main content

decdn_cache/
engine.rs

1//! Cache engine: local iroh-blobs store fronted by an [`Origin`] for misses.
2
3use std::collections::{HashMap, HashSet};
4use std::fs::OpenOptions;
5use std::io::Write;
6use std::num::NonZeroU64;
7use std::path::{Path, PathBuf};
8use std::pin::Pin;
9use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
10use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError, Weak};
11use std::time::{Duration, Instant};
12
13use arc_swap::ArcSwap;
14use bao_tree::ChunkRanges;
15use bao_tree::io::BaoContentItem;
16use bao_tree::io::fsm::{ResponseDecoder, ResponseDecoderNext};
17use bytes::{Bytes, BytesMut};
18use dashmap::DashMap;
19use futures_util::StreamExt;
20use iroh_blobs::api::blobs::EncodedItem;
21use iroh_blobs::store::fs::FsStore;
22use iroh_blobs::store::fs::options::Options as FsStoreOptions;
23use iroh_blobs::store::{GcConfig, ProtectOutcome};
24use iroh_blobs::{Hash, HashAndFormat};
25use iroh_io::AsyncStreamReader;
26use tokio::sync::{Notify, broadcast};
27
28use decdn_config_types::{CircuitBreakerPolicy, DeniedHashes, PinDiff, PinnedHashes, RetryPolicy};
29
30use crate::CHUNK_GROUP_BYTES;
31use crate::circuit_breaker::{
32    Admission, Clock, OriginBreaker, OriginOutcome, SystemClock, TrialGuard,
33};
34use crate::error::{CacheError, CacheResult, OriginPullError};
35use crate::fill_session::{FillClaim, FillRegistry, FillSession};
36use crate::metrics::CacheMetrics;
37use crate::origin::{Origin, OriginKind, OutboardFetch};
38use crate::origin_probe::{OriginProbeMemo, OriginProbePolicy, Presence};
39use crate::origin_range::{
40    MAX_CONCURRENT_RANGE_PULLS, OriginRangeCursor, OriginRangeWire, OriginReadBudget,
41    RANGE_PULL_PERMIT_WARN_AFTER, within_origin_timeout,
42};
43use crate::outboard_cache::{OUTBOARD_CACHE_BYTES, OUTBOARD_CACHE_ENTRIES, OutboardCache};
44use crate::probe_hold::ProbeHoldOutcome;
45use crate::range_pull::{AlignedRange, align_range, align_range_clamped};
46use crate::retry::{
47    TerminalFailure, classify_io_error, drain_to_bytes, run_with_retry_classified, should_buffer,
48};
49use crate::{from_store_hash, to_store_hash};
50
51/// Rescan slot: no pass running, and none requested.
52const RESCAN_IDLE: u8 = 0;
53/// A pass owns the slot; nothing queued behind it.
54const RESCAN_RUNNING: u8 = 1;
55/// A pass owns the slot and a further pass is queued behind it.
56const RESCAN_QUEUED: u8 = 2;
57
58/// Releases the rescan slot if a pass leaves without a clean release CAS —
59/// today, only by panicking. Without it the slot stays claimed and every later
60/// rescan returns immediately, so the announce set freezes at whatever the
61/// panicking pass had last published.
62struct RescanSlotGuard<'a> {
63    slot: &'a AtomicU8,
64    armed: bool,
65}
66
67impl Drop for RescanSlotGuard<'_> {
68    fn drop(&mut self) {
69        if self.armed {
70            self.slot.store(RESCAN_IDLE, Ordering::Release);
71        }
72    }
73}
74
75/// Per-candidate ceiling for an origin rescan's existence probes.
76///
77/// Deliberately not `cache.origin_probe_timeout_ms`: that knob is sized so a
78/// slow origin cannot stall the probe serve path, and a rescan is a bulk walk
79/// with nothing waiting on it. Borrowing the tighter budget would classify a
80/// merely slow origin as faulting on every candidate, which on a cold boot —
81/// where nothing can be carried forward — leaves the announce set empty for the
82/// process lifetime.
83const RESCAN_PROBE_TIMEOUT: Duration = Duration::from_secs(30);
84
85/// How far before [`CacheEngine::open`] the open-time recency seed sits.
86///
87/// Every blob the open-time store walk finds enters `access_times` at
88/// `open - COLD_SEED_AGE`. Every access after open records `Instant::now()`,
89/// which is later. So both eviction policies see an unaccessed pre-open blob as
90/// older than anything touched since open: [`crate::policy::LruEviction`]
91/// releases it first, and [`crate::policy::TinyLfuEviction`] releases it first
92/// among blobs of equal frequency. The pre-open blobs tie on recency, and both
93/// policies break that tie largest first.
94const COLD_SEED_AGE: Duration = Duration::from_secs(1);
95
96/// The origin-held index and what the rescan that built it could not resolve.
97///
98/// One `ArcSwap` payload rather than an index plus separate counters: a reader
99/// that saw a fault-truncated index alongside a later rescan's zero fault count
100/// would report a short announce set as healthy, which is the failure the counts
101/// exist to expose. Published together, read together.
102#[derive(Debug, Default)]
103struct OriginHeldIndex {
104    /// Hash -> total byte size, for everything a configured origin can serve.
105    held: HashMap<Hash, u64>,
106    /// Candidates whose size probe faulted rather than answering. Each either
107    /// kept a size carried from the previous index or is missing from `held`.
108    probe_faults: u64,
109    /// Origins whose `enumerate` failed. Their listings contributed nothing to
110    /// this pass, so every hash discoverable only through one of them is absent
111    /// from `held` — with no per-hash fault to count, since none was probed.
112    enumerate_failures: u64,
113}
114
115/// What one [`CacheEngine::rescan_origins`] probe pass resolved.
116#[derive(Debug, Default)]
117struct RescanResolution {
118    /// The rebuilt index: hash -> total byte size.
119    held: HashMap<Hash, u64>,
120    /// Candidates whose probe faulted rather than answering.
121    faults: u64,
122    /// Faulted candidates that kept an entry from the previous index. Always
123    /// `<= faults`, hence the same width.
124    carried: u64,
125}
126
127/// The origin-held announce set plus what the rescan behind it could not
128/// resolve, read as one consistent view — see
129/// [`CacheEngine::origin_held_snapshot`].
130#[derive(Debug, Default)]
131pub struct OriginHeldReport {
132    /// Hashes a configured origin can serve, minus anything currently refused.
133    pub hashes: HashSet<Hash>,
134    /// Size probes that faulted on the rescan that built this set.
135    pub probe_faults: u64,
136    /// Origins whose listing failed on that rescan, contributing nothing.
137    pub enumerate_failures: u64,
138}
139
140/// A live origin-existence answer (#1766), distinguishing a genuine negative
141/// from a backend fault. It is the target of the two-way collapse in
142/// `crate::origin_probe::Presence`: `Present`/`Absent` map straight across,
143/// and `Fault` is folded into "don't advertise" — see
144/// [`CacheEngine::origin_probe_size`].
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum OriginPresence {
147    /// A configured origin holds the blob; carries the total byte size.
148    Present(u64),
149    /// No configured origin holds the blob: a genuine `HEAD`/`HeadObject`
150    /// 404, or no origin is configured at all ([`CacheError::NoOrigin`] — a
151    /// node with nothing configured genuinely holds nothing; that is not a
152    /// transient condition).
153    Absent,
154    /// The probe could not get an authoritative answer: a transport error, or
155    /// the live `HEAD` overran `cache.origin_probe_timeout_ms`. Distinct from
156    /// `Absent` on purpose — a caller that would otherwise sign an
157    /// authoritative `NotFound` must not do so on a fault; see the
158    /// origin-only serve gate (`dispatch.rs`).
159    Fault,
160}
161
162impl From<Presence> for OriginPresence {
163    fn from(presence: Presence) -> Self {
164        match presence {
165            Presence::Present(size) => OriginPresence::Present(size),
166            Presence::Absent => OriginPresence::Absent,
167            Presence::Fault => OriginPresence::Fault,
168        }
169    }
170}
171
172/// Engine bundling a filesystem-backed iroh-blobs store with an optional
173/// origin backend. Lookups hit the store first; on miss and when an origin is
174/// configured, bytes are pulled and BLAKE3-verified. Insert-before-return is a
175/// property of the buffered path ([`Self::get`] / [`Self::populate`]), not of
176/// this type: [`Self::admit_bao`] commits only a verified sub-range.
177#[derive(Debug, Clone)]
178pub struct CacheEngine {
179    inner: Arc<Inner>,
180}
181
182#[derive(Debug)]
183struct Inner {
184    store: FsStore,
185    /// Ordered list of origin backends consulted on cache misses
186    /// (#284). Empty vec => no pull-through configured; `pull_through`
187    /// short-circuits to `CacheError::NoOrigin`. A single-element vec
188    /// is the single-origin case. Entries are tried in operator-supplied
189    /// order; the next entry is consulted on `NotFound`, permanent error,
190    /// or retry-budget exhaustion.
191    /// Deterministic per-origin failures (`HashMismatch`,
192    /// `BlobTooLarge`, and local-store errors classified as
193    /// `Store`) deliberately do not fall back — they indicate a
194    /// misbehaving backend or a degraded local store that must
195    /// surface, not be masked by trying a different mirror.
196    origins: Vec<Arc<dyn Origin>>,
197    /// Per-origin circuit-breakers (#963), parallel to `origins` by
198    /// index. `breakers[i]` fronts `origins[i]`'s pull-through retry
199    /// loop so a sustained outage on one backend fast-fails its misses
200    /// without burning retry/backoff, while the chain still advances to
201    /// the next backend. Always the same length as `origins` (built
202    /// together in `open_full`); an empty `origins` yields an empty
203    /// `breakers` and the `NoOrigin` short-circuit never reaches them.
204    breakers: Vec<OriginBreaker>,
205    max_blob_bytes: u64,
206    /// Per-hash recency for eviction ordering: the instant of the last access,
207    /// or the open-time seed.
208    ///
209    /// Holds every blob the open-time store walk finds, seeded at
210    /// [`Inner::cold_seed_at`], plus every access since open at the instant of
211    /// that access. The seed makes a
212    /// blob from before a restart an eviction candidate without traffic.
213    ///
214    /// Sharded. A serve completion's [`CacheEngine::record_access`] takes one
215    /// shard's lock, so its cost is independent of the cached-blob count and it
216    /// never queues behind a full-map scan. The scans — `eviction_candidates`
217    /// and `access_times_snapshot` — walk shard by shard and hold one shard at a
218    /// time, so a completion contends only when it lands on the shard the scan
219    /// is inside at that moment.
220    ///
221    /// A scan therefore reads a shard-by-shard view rather than one instant: a
222    /// record that lands while the walk is in progress may or may not appear in
223    /// its result. Recency is advisory input to eviction ordering, and a hash
224    /// missed by one sweep is seen by the next.
225    access_times: DashMap<Hash, Instant>,
226    /// The single instant the open-time seed writes into `access_times`
227    /// ([`COLD_SEED_AGE`] before open). An entry that still holds this value has
228    /// had no access since open, so [`CacheEngine::last_accessed`] reports none
229    /// for it.
230    cold_seed_at: Instant,
231    /// Hashes whose `decdn-partial-` protecting tag this process has already
232    /// written and not observed dropped. A partial's protection only needs the
233    /// tag to EXIST, and the name is deterministic per hash, so re-admitting more
234    /// ranges of the same blob need not re-issue the store write — one fill can
235    /// admit hundreds of ranges. [`CacheEngine::protect_partial`] short-circuits on
236    /// a present entry; [`CacheEngine::drop_named_tags_for`] removes the entry
237    /// before deleting the tag, so the next admit re-protects. Lock-free
238    /// (`DashMap`) so it never serializes the admit hot path. Starts empty each
239    /// process, so the first admit after restart harmlessly re-sets the tag once.
240    partial_protected: DashMap<Hash, ()>,
241    /// Present-byte count of each partial blob, shared by
242    /// [`CacheEngine::size_snapshot`] and the GC callback's walk. See
243    /// [`PARTIAL_SIZE_TTL`] for why the walks do not re-observe every
244    /// partial every time.
245    partial_sizes: Arc<PartialSizeMemo>,
246    /// In-flight pull-through requests. When a pull is in progress for a hash,
247    /// subsequent callers wait on the [`Notify`] rather than issuing a
248    /// duplicate origin fetch (coalescing, fixes #305).
249    ///
250    /// Production code locks it only through [`Inner::lock_inflight`] —
251    /// never `.lock()` directly. See that method for why poison must be
252    /// recovered here rather than skipped. (Tests below reach for `.lock()`
253    /// deliberately, to poison it and to read its length through
254    /// `PoisonError::into_inner`.)
255    inflight: Mutex<HashMap<Hash, Arc<Notify>>>,
256    /// One-shot latch for the [`Inner::lock_inflight`] poison log (#1517).
257    /// The counter carries the true count of poisonings; this bounds the
258    /// log to one line even under a pathological panic loop.
259    inflight_poison_logged: AtomicBool,
260    /// Operator-pinned blob hashes (#276). Pinned hashes are excluded from
261    /// the eviction-candidates snapshot and therefore survive any LRU
262    /// pressure. Held in [`ArcSwap`] so SIGHUP reloads can swap in a new
263    /// set atomically without rebuilding the engine — the pattern mirrors
264    /// the `Arc<AtomicU64>` used for `payment.rate_per_mb` (commit
265    /// 166ae41); pinning sets aren't `Copy`, so `ArcSwap` is the
266    /// non-blocking equivalent for `HashSet<Hash>`.
267    ///
268    /// Pinning interacts with the `evicted` field in one direction only:
269    /// pinning prevents *LRU* eviction (issue #276) but does not protect
270    /// against an explicit operator [`CacheEngine::evict`] (#279) — an
271    /// operator running a DMCA takedown on a pinned hash gets the
272    /// takedown, full stop. The pin just keeps the hash off the LRU
273    /// candidate list.
274    pinned: ArcSwap<HashSet<Hash>>,
275    /// Origin-held discovery index (#1130): hashes this node can serve from a
276    /// configured origin *before* any pull-through, each mapped to its total
277    /// byte size. Rebuilt wholesale by [`CacheEngine::rescan_origins`] (fs
278    /// directory enumeration ∪ present operator pins) and swapped atomically,
279    /// mirroring `pinned`. Probe and DHT-announce read it so cold origin
280    /// content is discoverable on the first request rather than only after a
281    /// warm pulls it into the store. Never includes refused/denied hashes.
282    origin_held: ArcSwap<OriginHeldIndex>,
283    /// Single-flight slot for [`CacheEngine::rescan_origins`], with one queued
284    /// rerun. See `RESCAN_IDLE` / `RESCAN_RUNNING` / `RESCAN_QUEUED`.
285    ///
286    /// A rescan reads the current index (to carry a faulted candidate forward),
287    /// probes every candidate, and only then publishes — a read-modify-write
288    /// spanning the whole walk. Both production triggers fire detached, and a
289    /// walk gets slower exactly when the origin is faulting, so overlapping
290    /// passes would let the slower one publish a payload derived from a
291    /// pre-empted index and drop whatever the fresher pass found.
292    ///
293    /// Excluding is not enough on its own: queueing every trigger behind a lock
294    /// would pile up one waiter per tick for as long as a walk outruns the
295    /// cadence, then run that backlog of obsolete passes back to back. The slot
296    /// collapses any number of triggers into a single rerun, which is all a
297    /// rerun can be worth — the next pass re-derives everything from scratch.
298    rescan_slot: AtomicU8,
299    /// Live-origin probe memo (#1130 pt3). The `origin_held` index only covers
300    /// fs enumeration ∪ pins — http/s3 do not list, so a non-pinned bucket
301    /// object is absent from it. [`CacheEngine::origin_probe_size`] falls back to
302    /// a live `HEAD`/`HeadObject` for such hashes and memoises the answer here
303    /// (positive AND negative) under `cache.origin_probe_ttl_sec`, so a probe
304    /// flood costs at most one origin round-trip per hash per TTL window rather
305    /// than one per probe. Restart-configured like `max_probe_holds`; swapped in
306    /// wholesale by [`CacheEngine::set_origin_probe_config`] at bring-up.
307    origin_probe_memo: Mutex<OriginProbeMemo>,
308    /// Hashes this node refuses to serve, announce, or acquire because the
309    /// operator's own `[content] denied_hashes` names them, and which a later
310    /// config reload can UN-refuse (ADR 011 § Local Denylist). The governance
311    /// half lives in [`Self::chain_denied`].
312    ///
313    /// Distinct from [`Self::evicted`] on exactly one axis: reversibility.
314    /// Eviction is a durable, sticky operator act recorded in `evicted.log`;
315    /// this set is a live policy view swapped wholesale from the current
316    /// denylist. A wrongful takedown must be reversible without editing a log
317    /// file and restarting, and ADR 011 § One-hour removal orders puts this
318    /// mechanism on a statutory clock in both directions.
319    ///
320    /// It lives HERE rather than beside the origin deny-set in `decdn-node`
321    /// because "will this node serve/announce/acquire `hash`" has five
322    /// consumers — the serve path, `try_probe_hold`, the DHT republisher,
323    /// `populate`, and the per-MB in-flight re-check that terminates a stream
324    /// already running when a takedown lands — and the ADR requires one answer
325    /// for all of them. Keeping it next to `evicted` is what lets
326    /// [`CacheEngine::refuses`] be the single predicate they all call, so the
327    /// next consumer inherits the check instead of forgetting it. The in-flight
328    /// one is exactly that: it was added later and needed no wiring of its own.
329    denied: ArcSwap<HashSet<Hash>>,
330    /// Hashes refused because *governance* blacklisted them — the blacklist
331    /// watcher's live projection of `ContentBlacklist`, kept in its own slot
332    /// beside [`Self::denied`] for the same reason the node keeps local and
333    /// on-chain origins apart: the two have independent lifecycles, and a config
334    /// reload calling [`CacheEngine::set_denied`] must not clobber what the
335    /// chain watcher learned (nor the reverse).
336    ///
337    /// Membership is not what stops the serving — the watcher also
338    /// [`CacheEngine::evict`]s, which is sticky and durable. What this set adds
339    /// is the *reason*, and the reason picks the refusal reason the node
340    /// meters: a denied hash rather than `EvictedSinceProbe`, the reason the
341    /// eviction arm reports without this slot. On the wire every one of these
342    /// refusals is `Declined`, so a governance takedown and this operator's own
343    /// denylist stay indistinguishable (ADR 011 §`StreamRequest` Response).
344    ///
345    /// It is also the earlier of the two gates: the watcher denies *before* it
346    /// evicts, so a takedown whose durable eviction fails still stops serving on
347    /// the same tick rather than waiting for the retry.
348    chain_denied: ArcSwap<HashSet<Hash>>,
349    /// Hashes the operator has explicitly evicted via [`CacheEngine::evict`]
350    /// (issue #279). Membership is honored by [`CacheEngine::has`] and
351    /// [`CacheEngine::get`] so an evicted blob is not served, even though
352    /// the underlying iroh-blobs store may still hold the bytes —
353    /// `Blobs::delete` is `pub(crate)` in iroh-blobs and reserved for the
354    /// GC task. [`CacheEngine::evict`] deletes the blob's protecting named
355    /// tag(s) (#860), so reclaim of disk bytes then happens on the next
356    /// iroh-blobs GC sweep, configured via `cache.gc_interval_sec` (#518).
357    /// Reclaim is therefore best-effort: it requires GC to be enabled *and*
358    /// the tag deletion to have succeeded — a failed delete leaves the bytes
359    /// GC-protected and is surfaced via `tag_drop_failures` (serving is
360    /// blocked regardless). On-demand (synchronous) reclamation is tracked
361    /// under #520, blocked on upstream exposing the sweep API.
362    ///
363    /// Persisted alongside the iroh-blobs store at `<cache_dir>/evicted.log`
364    /// on every successful [`CacheEngine::evict`] call so DMCA takedowns and
365    /// other operator evicts survive a process restart — an
366    /// in-memory-only set would silently let evicted content resume serving
367    /// after `decdn run` is restarted, which is exactly the failure mode
368    /// #279 needs to prevent.
369    ///
370    /// Append-only, so the serve-path membership check in
371    /// [`CacheEngine::is_evicted`] is a lock-free load (#1789 item 5) and
372    /// readers never block on the rarer writer. Unlike [`Self::denied`] and
373    /// [`Self::chain_denied`], which are replaced wholesale on a config reload,
374    /// this set may only grow: an operator eviction is a takedown, and a hash
375    /// that stopped being served must not start again. [`MonotoneHashSet`]
376    /// carries that difference.
377    evicted: MonotoneHashSet,
378    /// Held hashes whose own serve export failed bao validation against the
379    /// content root: the stored bytes or outboard diverged after admission
380    /// (disk rot or tampering). [`CacheEngine::refuses`] honors membership, so
381    /// the node stops serving, announcing, and re-acquiring the hash while the
382    /// corrupt entry is on disk. The quarantine drops the protecting tags, so
383    /// the next GC sweep reclaims the entry. The membership lifts on the next
384    /// lookup or origin rescan that finds the store no longer holds the hash
385    /// (see [`CacheEngine::is_quarantined`]). A later pull-through then admits
386    /// a freshly verified copy.
387    ///
388    /// In memory only. A restart before the sweep forgets the entry, and the
389    /// next serve of the corrupt bytes trips the validation and quarantines it
390    /// again. A durable record is unnecessary: the validated export aborts
391    /// every serve at the first mismatching chunk group, so no buyer pays for
392    /// a corrupt group in the window.
393    quarantined: DashMap<Hash, ()>,
394    /// Probe-triggered eviction holds (#318, ADR 005 §Probe-triggered
395    /// eviction hold). Maps a held hash to its hold *expiry* instant; a
396    /// held hash is invisible to [`CacheEngine::eviction_candidates`] until
397    /// expiry, composing *above* the LRU layer. Holds are per-blob, not
398    /// per-probe: many peers probing the same hash share (and refresh) one
399    /// entry, so the slot count is bounded by distinct held blobs, not
400    /// probe volume. Expired entries are swept lazily on every hold
401    /// admission, every `eviction_candidates` call, and every
402    /// [`CacheEngine::probe_hold_slots_used`] read (no background task).
403    /// Expiries run on the tokio clock so a paused test clock drives them.
404    ///
405    /// Like [`Self::pinned`] this only blocks *LRU* eviction — an explicit
406    /// operator [`CacheEngine::evict`] still wins (ADR 040 §Pinning, durable
407    /// operator-evict, and the probe-hold stay engine-enforced: DMCA always
408    /// wins), enforced
409    /// because [`CacheEngine::try_probe_hold`] gates on [`CacheEngine::has`]
410    /// which already
411    /// honors the evicted set.
412    probe_holds: Mutex<HashMap<Hash, tokio::time::Instant>>,
413    /// Hold-budget cap (ADR 005 §Hold budget). `0` disables `has_blob: true`
414    /// entirely. Set once from `cache.max_probe_holds` via
415    /// [`CacheEngine::set_max_probe_holds`] at runtime bring-up — `cache.*`
416    /// is restart-required (not hot-reloaded), so an atomic written once is
417    /// sufficient and avoids threading the value through every `open_*`
418    /// constructor and its many test call sites.
419    max_probe_holds: AtomicUsize,
420    /// Optional shared frequency signal (ADR 040). `None` for the `lru`/`always`
421    /// default — the observe call is skipped, so recency-only pays nothing.
422    frequency: ArcSwap<Option<Arc<dyn crate::policy::FrequencyEstimator>>>,
423    /// Admission policy consulted at store-time (ADR 040). Always present —
424    /// defaults to [`crate::policy::AlwaysAdmit`] (store to
425    /// [`crate::policy::Segment::Main`]), so behavior is unchanged until an
426    /// operator selects a different policy. `ArcSwap<Arc<dyn Trait>>` rather
427    /// than `ArcSwap<dyn Trait>`: the latter needs `RefCnt: Sized`, which
428    /// `arc-swap` 1.9.2 does not give a `dyn` trait object.
429    admission: ArcSwap<Arc<dyn crate::policy::AdmissionPolicy>>,
430    /// Generic `hash -> Segment` membership the engine tracks with no meaning
431    /// attached (ADR 040 §1). Admission stores the label it chose; the eviction
432    /// policy reads and moves it at the sweep. Pure in-memory metadata — the
433    /// blob's normal commit tag still provides GC protection, so no tag I/O is
434    /// tied to it. Only non-default (`Probation`) entries are stored; an absent
435    /// hash reads back as [`crate::policy::Segment::Main`], so under the default
436    /// `AlwaysAdmit` (always `Main`) the map stays empty and inert.
437    segments: Mutex<HashMap<Hash, crate::policy::Segment>>,
438    /// Append-only file holding lowercase-hex evicted hashes, one per line.
439    /// Loaded on [`CacheEngine::open`]; appended to (with `fsync`) on every
440    /// successful [`CacheEngine::evict`]. Lives at `<cache_dir>/evicted.log`.
441    /// The format is intentionally trivial so operators can grep / inspect /
442    /// hand-edit it during incident response; duplicate lines are tolerated
443    /// (loading deduplicates via the `HashSet`).
444    evicted_log_path: PathBuf,
445    /// Origin pull-through retry policy (#285). Set once at construction;
446    /// changes require a restart. `RetryPolicy: Copy` so the per-fetch
447    /// read is a single struct copy.
448    retry_policy: RetryPolicy,
449    /// Optional handle to the cache-side `OpenMetrics` counters (#285).
450    /// The engine bumps `origin_fetches` once per pull-through and the
451    /// retry loop bumps `origin_retry_exhausted` on terminal exhaustion.
452    /// `None` in tests / non-metrics builds — bumps short-circuit.
453    metrics: Option<Arc<CacheMetrics>>,
454    /// Broadcast channel announcing successful blob commits to interested
455    /// subscribers (DHT republish per ADR 022 §STORE Flow). Producers (the
456    /// `pull_through` success arm) call `send` and ignore the
457    /// "no-active-receivers" error — broadcast is fire-and-forget. The
458    /// channel is bounded; lagged receivers see [`broadcast::error::RecvError::Lagged`]
459    /// on the next recv and decide for themselves whether to backfill —
460    /// the DHT-side subscriber treats it as "force a republish sweep on
461    /// the next tick", so a brief stall in the consumer doesn't lose
462    /// blobs from the republish set.
463    inserts_tx: broadcast::Sender<Hash>,
464    /// Strong reference to the GC callback's late-bound `FsStore`
465    /// handle (#518). `Some` when `gc_interval > 0` was passed to
466    /// [`CacheEngine::open_full`], `None` when GC is disabled.
467    ///
468    /// **Why this lives here:** iroh-blobs spawns its GC loop on its
469    /// internal runtime when `Options.gc` is set. The loop's cb captures
470    /// a [`Weak<OnceLock<FsStore>>`] rather than a strong `Arc`, so the
471    /// cb itself contributes no strong refcount to the iroh-blobs
472    /// `FsStore` handle. When this `Inner` drops, the strong `Arc` here
473    /// drops with it; subsequent cb fires see `Weak::upgrade -> None`
474    /// and silently no-op (no metric writes, no work) — the cb is no
475    /// longer wedged in a cycle that keeps it alive.
476    ///
477    /// **What this does NOT do:** the iroh-blobs GC loop itself
478    /// (`run_gc(store: Store, ...)`) owns its own `Store` clone for the
479    /// lifetime of its `loop`, separate from anything in `Inner`. So
480    /// `Inner.drop()` does not shut iroh-blobs' internal runtime down;
481    /// the runtime sits resident-but-idle until the surrounding process
482    /// exits (or until `gc_run_once` errors and breaks the loop). For
483    /// the current single-engine, process-lifetime model this is
484    /// benign. #520 tracks driving the loop ourselves once iroh-blobs
485    /// exposes `gc_run_once`, at which point engine drop will be able
486    /// to abort the loop directly.
487    ///
488    /// **Invariant:** no `Arc::clone` of this field may escape `Inner`.
489    /// Cloning the strong `Arc` into a longer-lived owner would re-
490    /// introduce a cb-side strong ref via the round-trip and defeat
491    /// the cycle-break, leaving the cb running with a populated
492    /// `Weak` past engine drop.
493    ///
494    /// `dead_code` is allowed because nothing *reads* this field —
495    /// its only purpose is keeping the `Arc` strong-ref alive for the
496    /// lifetime of `Inner`. The `Weak` captured into the cb is the
497    /// reading party.
498    #[allow(dead_code)]
499    gc_store_handle: Option<Arc<OnceLock<FsStore>>>,
500    /// Range-aware in-flight fill registry (ADR 038). Coalesces concurrent
501    /// serve-misses for the same hash: a request whose range an in-flight pull
502    /// already covers attaches an observer instead of opening a duplicate pull,
503    /// and a partial overlap opens a pull for only its remainder. Also holds the
504    /// per-hash captured outboard every serve leg reads. Purely synchronous range
505    /// math; holds no blob bytes. Driven through [`CacheEngine::claim_fill`].
506    fill_registry: Arc<FillRegistry>,
507    /// Bounds the [`CacheEngine::origin_range_wire`] encodes that run at once
508    /// (#2065); each holds a permit for its whole span and `O(window + outboard)`
509    /// bytes, so the bound caps their sum.
510    own_origin_range_pulls: Arc<tokio::sync::Semaphore>,
511    /// Per configured origin (same index as `origins`): whether the origin has
512    /// served a clean first range window. Set on the first such window, by the
513    /// serviceability probe ([`CacheEngine::origin_range_serviceable`]) or a
514    /// draw, and never cleared, so the probe's range half reads nothing for a
515    /// confirmed origin. An origin that later stops serving ranges fails its
516    /// draw like any other mid-life origin fault.
517    origin_range_confirmed: Box<[AtomicBool]>,
518    /// Origin `{H}.obao4` outboards already read, keyed by hash, tagged with
519    /// the serving origin, and bounded by bytes. The serviceability probe's
520    /// outboard half ([`CacheEngine::origin_fetch_outboard_bytes`]) and every
521    /// [`CacheEngine::origin_range_wire`] draw read it and fill it. Each
522    /// [`OriginRangeWire`] holds a handle and evicts the copy it used when the
523    /// range fails bao verification.
524    outboards: OutboardCache,
525    /// One async lock per hash whose outboard an origin read is fetching, so
526    /// concurrent cold misses of one hash wait for the first read and reuse its
527    /// cached copy instead of each downloading it. Held as `Weak`: an entry
528    /// lives while a fetch holds its lock, and dead entries are pruned on the
529    /// next lookup.
530    outboard_flights: Mutex<HashMap<Hash, Weak<tokio::sync::Mutex<()>>>>,
531    /// Head start, in milliseconds, of the time budget for one origin read of
532    /// the range-pull path. `0` means no budget. Set once at bring-up by
533    /// [`CacheEngine::set_origin_read_budget`].
534    origin_read_head_start_ms: AtomicU64,
535    /// Throughput floor, in bytes per second, that the rest of that budget
536    /// scales with. `0` means no budget.
537    origin_read_min_bps: AtomicU64,
538}
539
540impl Inner {
541    /// The only way to lock [`Inner::inflight`] (#1517).
542    ///
543    /// **Poison is recovered and cleared, not skipped.** This mutex guards
544    /// the fill-coalescing map, which is what stops N concurrent requests
545    /// for one missing blob from opening N origin pulls. Skipping the
546    /// critical section — what `.lock().ok()` did before #1517 — loses
547    /// coalescing, and because nothing cleared the poison, it lost it for
548    /// the rest of the process. On a metered `http`/`s3` origin that is an
549    /// unbounded multiplier on the egress bill; on the `Peer` origin
550    /// reached via `populate` it is a double-spend of USDC vouchers to
551    /// upstream nodes — the hazard the in-flight coalescing map exists to prevent.
552    /// Recovering the guard keeps that invariant intact, and matches the
553    /// reasoning already written down for `evicted` (see
554    /// [`CacheEngine::evict`] and [`CacheEngine::is_evicted`]).
555    ///
556    /// The map is only ever read, inserted into, and removed from under
557    /// this lock — no user code runs inside the critical section, at any of
558    /// the six call sites — so a recovered guard cannot observe a torn
559    /// `HashMap`. Having established that, [`Mutex::clear_poison`] (stable
560    /// since 1.77) returns the mutex to a healthy state. That
561    /// is what makes the counter below mean *"how many tasks panicked in
562    /// here"* rather than *"how many times we locked since one did"* — the
563    /// latter climbs at request rate forever and reads on a `rate()` panel
564    /// as a raging ongoing incident long after a single panic.
565    ///
566    /// **It is still reported.** The anti-panic policy makes poison close
567    /// to unreachable, so a firing here is a genuine bug: it bumps
568    /// `decdn_cache_inflight_mutex_poisoned_total` once per poisoning, and
569    /// logs on the first one (latched via `inflight_poison_logged` — with
570    /// the clear above, a repeat means a *new* panic rather than an echo of
571    /// the old one, but the latch still bounds a pathological panic loop).
572    ///
573    /// Note the coalescing wait this guards (`notify.notified().await` in
574    /// [`CacheEngine::get`]) has no deadline of its own; callers impose
575    /// their own (the node wraps `populate` in `tokio::time::timeout`).
576    fn lock_inflight(&self) -> MutexGuard<'_, HashMap<Hash, Arc<Notify>>> {
577        match self.inflight.lock() {
578            Ok(guard) => guard,
579            Err(poisoned) => {
580                if let Some(m) = &self.metrics {
581                    m.inflight_mutex_poisoned.inc();
582                }
583                if !self.inflight_poison_logged.swap(true, Ordering::Relaxed) {
584                    tracing::error!(
585                        "inflight coalescing mutex poisoned; recovering inner state and \
586                         clearing the poison. A task panicked while holding it — this is a \
587                         bug, not an operational condition. Coalescing is preserved and no \
588                         restart is needed; any further poisonings are counted by \
589                         decdn_cache_inflight_mutex_poisoned_total but not re-logged."
590                    );
591                }
592                self.inflight.clear_poison();
593                poisoned.into_inner()
594            }
595        }
596    }
597}
598
599// `PinnedHashes` and `PinDiff` moved to the `decdn-config-types` leaf
600// crate (#578) and are imported above. The engine holds its pinned set
601// internally as `HashSet<Hash>` (the iroh-blobs store hash) and converts
602// at the public boundary via `to_store_hash` / `from_store_hash`.
603
604/// Append-only set of hashes with a lock-free read path.
605///
606/// Every published snapshot is a superset of its predecessor. That is the
607/// property [`CacheEngine::is_evicted`] relies on: a takedown observed once is
608/// observed by every later reader, so a lock-free read can never answer
609/// not-evicted for a hash the operator has already evicted.
610///
611/// Writers serialize on `writer`, so the publish is a single uncontended
612/// `Arc` clone-and-swap rather than a CAS retry loop that re-clones the whole
613/// set on every lost race. The clone itself is O(n) in the set's size, which is
614/// the cost this shape accepts to keep the read side free — writes are operator
615/// takedowns and blacklist enforcement, reads are on every serve.
616#[derive(Debug)]
617struct MonotoneHashSet {
618    snapshot: ArcSwap<HashSet<Hash>>,
619    /// Serializes writers so the read-modify-write below is atomic: without it
620    /// the cap check and the "was it already present" answer are both racy.
621    writer: std::sync::Mutex<()>,
622}
623
624impl MonotoneHashSet {
625    fn new(initial: HashSet<Hash>) -> Self {
626        Self {
627            snapshot: ArcSwap::from(Arc::new(initial)),
628            writer: std::sync::Mutex::new(()),
629        }
630    }
631
632    fn contains(&self, hash: Hash) -> bool {
633        self.snapshot.load().contains(&hash)
634    }
635
636    /// The current snapshot, for a caller that needs several questions answered
637    /// against one consistent view.
638    fn snapshot(&self) -> arc_swap::Guard<Arc<HashSet<Hash>>> {
639        self.snapshot.load()
640    }
641
642    /// Add `hash` unless the set already holds it or is at `cap`. Returns
643    /// `false` only when `cap` would be exceeded — an already-present hash is
644    /// a success, since the set already says what the caller wants it to say.
645    ///
646    /// Atomic with respect to other writers, so two concurrent inserts cannot
647    /// both read a set one below `cap` and both land.
648    fn insert_if_absent(&self, hash: Hash, cap: usize) -> bool {
649        let _writer = self.writer.lock().unwrap_or_else(PoisonError::into_inner);
650        let current = self.snapshot.load();
651        if current.contains(&hash) {
652            return true;
653        }
654        if current.len() >= cap {
655            return false;
656        }
657        let mut next = HashSet::clone(&current);
658        next.insert(hash);
659        drop(current);
660        self.snapshot.store(Arc::new(next));
661        true
662    }
663}
664
665/// Serve-path verdict for one hash, from a single store `status()` call.
666///
667/// Answers in one store contact what the delivery path asks in two —
668/// [`CacheEngine::has`] for presence and [`CacheEngine::inspect`] for size
669/// (#1789 item 7 part B). The variants are the delivery path's own branches,
670/// so a size is reachable only where it is serveable and a refusal can never
671/// be paired with a wire size.
672#[derive(Debug, Clone, PartialEq, Eq)]
673pub enum ServeAudit {
674    /// The store reports the blob complete and no gate refuses it (denied /
675    /// chain-denied / evicted) — the same condition [`CacheEngine::has`]
676    /// reports. `size` is the whole-blob wire size, and `0` means a genuinely
677    /// empty blob.
678    Serveable {
679        /// Whole-blob byte size to advertise on the wire.
680        size: u64,
681    },
682    /// Nothing serveable from the local store: the blob is absent, partial, or
683    /// refused by a gate.
684    Unavailable {
685        /// Whether this node withdrew the hash on its own authority: an
686        /// operator eviction ([`CacheEngine::is_evicted`], #279) or a
687        /// stored-corruption quarantine ([`CacheEngine::is_quarantined`]). The
688        /// miss path must not fill a withdrawn hash: a fill of an evicted hash
689        /// would undo the takedown, and a fill of a quarantined hash would land
690        /// on the corrupt entry and pay for bytes it can never serve. A
691        /// quarantined hash becomes fillable again only after GC reclaims the
692        /// entry and the quarantine lifts; an evicted hash never does.
693        withdrawn: bool,
694    },
695}
696
697impl ServeAudit {
698    /// Wire size for a warm hit; `None` when the delivery path must fill and
699    /// then size the blob itself.
700    #[must_use]
701    pub const fn hit_size(&self) -> Option<u64> {
702        match self {
703            Self::Serveable { size } => Some(*size),
704            Self::Unavailable { .. } => None,
705        }
706    }
707
708    /// Whether the blob can be served from the local store right now.
709    #[must_use]
710    pub const fn is_serveable(&self) -> bool {
711        matches!(self, Self::Serveable { .. })
712    }
713
714    /// Whether this node withdrew the hash: evicted or quarantined. `false`
715    /// for a serveable hash, since both are gates that refuse a serve.
716    #[must_use]
717    pub const fn is_withdrawn(&self) -> bool {
718        matches!(self, Self::Unavailable { withdrawn: true })
719    }
720}
721
722/// Read-only snapshot of a hash's local-cache state, returned by
723/// [`CacheEngine::inspect`]. Backs `decdn node evict --dry-run`
724/// (issue #379): operators running DMCA takedowns want to confirm the blob's size, last-access
725/// time, pin status, and already-evicted flag before mutating state.
726///
727/// All fields reflect the *underlying* cache state — `size_bytes` reads
728/// from the iroh-blobs store directly, so a hash that has already been
729/// logically evicted (and whose bytes are still on disk pending the
730/// follow-up GC sweep in #518) still reports its size here. That keeps
731/// dry-run honest about disk reclaim potential rather than hiding it once
732/// the operator has flipped the evicted flag. For a complete blob the size
733/// is its byte length; for a partial blob it is the validated total (see
734/// [`Self::size_bytes`]), and [`CacheEngine::size_snapshot`] gives the bytes
735/// it holds on disk.
736#[derive(Debug, Default, Clone, PartialEq, Eq)]
737pub struct EvictionPreview {
738    /// Bytes the iroh-blobs store reports for this hash. `None` when the
739    /// blob isn't in the store; matches `BlobStatus::NotFound`. A partial
740    /// blob reports `BlobStatus::Partial { size }` as is: the whole blob's
741    /// size once its last chunk validates, `None` before. That is the blob's
742    /// total, not the bytes it holds on disk — the delivery path signs it as
743    /// the blob's total and the probe path advertises it, and
744    /// [`CacheEngine::size_snapshot`] is the on-disk measure.
745    pub size_bytes: Option<u64>,
746    /// Microseconds elapsed since the last recorded access to the blob: a
747    /// serve, a hit, or a fill. `None` when the blob has had no access since
748    /// open — including a blob on disk at open that nothing has touched since
749    /// (its open-time recency seed is not an access) — or when eviction or
750    /// quarantine has cleared its entry.
751    pub last_accessed_us_ago: Option<u64>,
752    /// Whether the hash is in the operator-pinned set (#276). Pinning
753    /// protects against LRU eviction but **not** against an explicit
754    /// [`CacheEngine::evict`]; surfaced here so a dry-run operator can
755    /// spot the case before running the takedown.
756    pub pinned: bool,
757    /// Whether the hash already lives in `<cache_dir>/evicted.log`.
758    /// `true` means a real [`CacheEngine::evict`] would short-circuit
759    /// at the idempotency guard at the top of `evict()` — the dry-run
760    /// is reporting on a no-op.
761    pub already_evicted: bool,
762    /// Whether [`CacheEngine::has`] would currently return `true` for
763    /// this hash — equivalently, `BlobStatus::Complete` *and* not
764    /// already evicted. Pre-computed inside `inspect()` so the admin
765    /// RPC doesn't need a second `has()` round-trip to fill the
766    /// `was_present` field on its response.
767    pub served: bool,
768    /// Ordered list of backend kinds the engine would consult on a
769    /// post-eviction miss (#439, #284). Empty when no origin is
770    /// configured (cache-only mode); otherwise one [`OriginKind`] per
771    /// entry of the operator-supplied fallback chain, in order.
772    /// Operators running takedowns or LRU sweeps use this to estimate
773    /// the worst-case origin egress cost — re-pulling from a
774    /// `Filesystem` origin is a local read; re-pulling from `Http` or
775    /// `S3` may consume metered bandwidth, and a chain of three S3
776    /// origins multiplies the bill on a deep fallback.
777    pub origin_kinds: Vec<OriginKind>,
778}
779
780/// The chunk ranges of a hash currently present in the store.
781///
782/// This is the presence oracle the range-aware cache API builds on: `read`
783/// serves only present ranges, resume re-fetches only missing ones, and disk
784/// admission accounts present bytes. Absent and logically-evicted hashes report
785/// empty + not-complete, matching [`CacheEngine::has`]'s eviction masking.
786#[derive(Debug, Clone)]
787pub struct PresentRanges {
788    ranges: ChunkRanges,
789    complete: bool,
790    size: u64,
791}
792
793impl PresentRanges {
794    fn absent() -> Self {
795        Self {
796            ranges: ChunkRanges::empty(),
797            complete: false,
798            size: 0,
799        }
800    }
801
802    /// The whole blob is present.
803    #[must_use]
804    pub const fn is_complete(&self) -> bool {
805        self.complete
806    }
807
808    /// No range of this hash is present (absent or evicted).
809    #[must_use]
810    pub fn is_empty(&self) -> bool {
811        self.ranges.is_empty()
812    }
813
814    /// The present ranges, in chunk units.
815    #[must_use]
816    pub const fn chunk_ranges(&self) -> &ChunkRanges {
817        &self.ranges
818    }
819
820    /// The blob's total size in bytes, from the `observe()` bitfield.
821    ///
822    /// Unlike `status()`, which iroh-blobs leaves unknown for a partial blob
823    /// until its LAST chunk validates, the bitfield knows the full size as
824    /// soon as any chunk carries it (a front-prefix partial with no tail
825    /// still reports it here). `0` for an absent/evicted/refused hash.
826    #[must_use]
827    pub const fn size(&self) -> u64 {
828        self.size
829    }
830
831    /// The content bytes of the blob this node holds: the present ranges
832    /// counted against [`Self::size`]. `0` for an absent/evicted/refused hash.
833    #[must_use]
834    pub fn held_bytes(&self) -> u64 {
835        present_byte_count(&self.ranges, self.size)
836    }
837}
838
839/// Bytes of a `size`-byte blob that `ranges` marks present.
840///
841/// `ranges` counts 1 KiB chunks ([`bao_tree::ChunkNum`]). An open span ends at
842/// `size`: iroh-blobs gives a bitfield that holds the blob's last chunk an open
843/// end ([`ChunkRanges::all`] once complete). The `.min(size)` clamp bounds every
844/// other span and counts a short last chunk at its true length. iroh-blobs'
845/// `Bitfield::total_bytes` computes the same sum with unchecked subtraction,
846/// which underflows on a span that starts past `size`.
847fn present_byte_count(ranges: &ChunkRanges, size: u64) -> u64 {
848    const CHUNK_BYTES: u64 = 1024;
849    let byte = |chunk: &bao_tree::ChunkNum| chunk.0.saturating_mul(CHUNK_BYTES).min(size);
850    ranges.boundaries().chunks(2).fold(0u64, |acc, span| {
851        let start = span.first().map_or(size, byte);
852        let end = span.get(1).map_or(size, byte);
853        acc.saturating_add(end.saturating_sub(start))
854    })
855}
856
857/// The chunk span of discovery block `index` of a `size`-byte blob.
858///
859/// One bao leaf chunk is 1024 bytes ([`bao_tree::ChunkNum`]'s unit), so one
860/// 64 MiB [`decdn_protocol::discovery_block_bytes`] block is 65536 chunks. The
861/// last block ends at the blob's last chunk. The block size is the same
862/// accessor [`decdn_protocol::num_blocks`] reads, so the two agree when a test
863/// overrides it.
864fn discovery_block_span(index: u32, size: u64) -> ChunkRanges {
865    const BAO_CHUNK_BYTES: u64 = 1024;
866    let chunks_per_block = decdn_protocol::discovery_block_bytes() / BAO_CHUNK_BYTES;
867    let total_chunks = size.div_ceil(BAO_CHUNK_BYTES);
868    let start = u64::from(index) * chunks_per_block;
869    let end = (start + chunks_per_block).min(total_chunks);
870    ChunkRanges::from(bao_tree::ChunkNum(start)..bao_tree::ChunkNum(end))
871}
872
873/// Indices of the discovery blocks of a `size`-byte blob that `present` fully
874/// covers. A block with any missing chunk is not covered.
875fn covered_blocks(size: u64, present: &ChunkRanges) -> impl Iterator<Item = u32> + '_ {
876    // `span - present` is empty iff `present` fully contains `span`, i.e.
877    // every chunk in this block is on disk.
878    (0..decdn_protocol::num_blocks(size))
879        .filter(move |&i| (discovery_block_span(i, size) - present).is_empty())
880}
881
882/// Bytes of `hash` present on disk, from its `observe()` bitfield.
883///
884/// `status()` cannot size a partial blob: iroh-blobs leaves the size unknown
885/// until the last chunk validates, then reports the whole blob's size.
886/// Awaiting `observe()` returns only its first item, the current bitfield,
887/// which the store sends at once, so the call never waits on a later write. A
888/// hash that GC removes after its `status()` reads as 0 bytes.
889async fn observed_present_bytes(
890    blobs: &iroh_blobs::api::blobs::Blobs,
891    hash: Hash,
892) -> CacheResult<u64> {
893    let bitfield = blobs.observe(hash).await.map_err(|e| {
894        CacheError::Store(anyhow::Error::from(e).context(format!("observe partial {hash}")))
895    })?;
896    Ok(present_byte_count(&bitfield.ranges, bitfield.size()))
897}
898
899/// How long a partial blob's present-byte count in [`PartialSizeMemo`] stays
900/// fresh.
901///
902/// Each `observe()` of an idle partial loads its store entry, and the entry's
903/// idle shutdown fsyncs its data, outboard and sizes files and rewrites its
904/// bitfield file. The eviction driver walks the store every tick (1 s by
905/// default), so the walk re-observes a partial at most once per TTL. A growing
906/// fill reads up to one TTL of progress low.
907const PARTIAL_SIZE_TTL: Duration = Duration::from_secs(30);
908
909/// Present-byte count of each partial blob, with the instant the walk
910/// measured it. [`snapshot_blob_sizes`] reads and refreshes it; the eviction
911/// driver's walk and the GC callback's walk share one memo.
912type PartialSizeMemo = Mutex<HashMap<Hash, (u64, Instant)>>;
913
914/// Snapshot of access times for blobs that are eligible for LRU
915/// eviction — i.e. **pinned hashes are already excluded**. Returned by
916/// [`CacheEngine::eviction_candidates`].
917///
918/// The one carve-out is a deny-listed hash: it stays a candidate even when
919/// pinned ("deny wins over pin"), so the space path reclaims an unservable
920/// blob instead of holding it on disk.
921///
922/// The newtype makes "pinned-already-excluded" a *type-level* property:
923/// any future eviction-policy implementation that takes
924/// `EvictionCandidates` is guaranteed by the compiler not to evict
925/// pinned hashes. With a raw `HashMap<Hash, Instant>` return that
926/// guarantee would live only in a doc comment, and a future caller
927/// could substitute [`CacheEngine::access_times_snapshot`] (which
928/// includes pinned) by mistake.
929#[derive(Debug, Default)]
930pub struct EvictionCandidates(HashMap<Hash, Instant>);
931
932impl EvictionCandidates {
933    /// Number of eviction candidates.
934    #[must_use]
935    pub fn len(&self) -> usize {
936        self.0.len()
937    }
938
939    /// Are there no eviction candidates?
940    #[must_use]
941    pub fn is_empty(&self) -> bool {
942        self.0.is_empty()
943    }
944
945    /// Does `hash` appear in the candidate set?
946    #[must_use]
947    pub fn contains_key(&self, hash: &Hash) -> bool {
948        self.0.contains_key(hash)
949    }
950
951    /// Iterate `(hash, last-access)` pairs.
952    pub fn iter(&self) -> std::collections::hash_map::Iter<'_, Hash, Instant> {
953        self.0.iter()
954    }
955
956    /// Consume into the underlying `HashMap`. Eviction policies that
957    /// need to sort by access time and pop top-K can call this once at
958    /// the start of their loop. The newtype's invariant
959    /// (pinned-already-excluded) is preserved by the time this returns
960    /// — the caller just gets a plain map to work with.
961    #[must_use]
962    pub fn into_inner(self) -> HashMap<Hash, Instant> {
963        self.0
964    }
965
966    /// Wrap a hand-built map so policy tests can construct a candidate set
967    /// without running a real sweep.
968    #[cfg(test)]
969    #[must_use]
970    pub const fn from_map_for_test(map: HashMap<Hash, Instant>) -> Self {
971        Self(map)
972    }
973}
974
975impl<'a> IntoIterator for &'a EvictionCandidates {
976    type Item = (&'a Hash, &'a Instant);
977    type IntoIter = std::collections::hash_map::Iter<'a, Hash, Instant>;
978
979    fn into_iter(self) -> Self::IntoIter {
980        self.0.iter()
981    }
982}
983
984/// RAII cleanup for an inflight pull-through entry. Removing the entry and
985/// waking waiters in `Drop` keeps the coalescing map consistent even if the
986/// owning task is cancelled (or panics) mid-fetch — without this, a cancelled
987/// pull would leave the entry in place and every subsequent request for the
988/// same hash would block forever on a `Notify` that never fires.
989struct InflightGuard<'a> {
990    hash: Hash,
991    inner: &'a Inner,
992    notify: &'a Arc<Notify>,
993}
994
995impl Drop for InflightGuard<'_> {
996    fn drop(&mut self) {
997        // Removal goes through `lock_inflight`, which recovers a poisoned
998        // guard (#1517). Dropping the removal on poison — what the previous
999        // `if let Ok(..)` did — leaked the entry, and since `notify_waiters`
1000        // only wakes *current* waiters, every later request for this hash
1001        // would then park on a `Notify` that never fires again: the exact
1002        // permanent hang this guard exists to prevent.
1003        self.inner.lock_inflight().remove(&self.hash);
1004        self.notify.notify_waiters();
1005    }
1006}
1007
1008/// Hard cap on the number of distinct hashes the in-memory `evicted`
1009/// set may hold. Bounds (a) the resident memory of the set itself and
1010/// (b) the unbounded growth of `<cache_dir>/evicted.log` under e.g. a
1011/// mass-evict automation gone wrong. At ~64 bytes per `HashSet<Hash>`
1012/// entry the cap is ~64 MB resident. Operators legitimately hitting
1013/// this are operating well outside normal DMCA-takedown caseloads and
1014/// should investigate before raising it. Loading from `evicted.log` on
1015/// `open()` is *not* gated by this cap — entries persisted by a previous
1016/// run always replay (silently dropping a persisted DMCA takedown to
1017/// stay under cap is the worst-case the cap was meant to prevent).
1018pub(crate) const MAX_EVICTED_ENTRIES: usize = 1_000_000;
1019
1020/// Read the evicted-hash log into a [`HashSet`]. A missing file is the
1021/// normal "no evictions yet" case and yields an empty set; any other I/O
1022/// or parse error is fatal because silently dropping persisted evictions
1023/// would resume serving DMCA-flagged content (issue #279). Lines that
1024/// don't parse as a 64-character hex hash are skipped with a `warn` log
1025/// — operators may have hand-edited the file during incident response,
1026/// and one corrupt line shouldn't take the whole log down.
1027fn load_evicted_log(path: &Path) -> CacheResult<HashSet<Hash>> {
1028    let contents = match std::fs::read_to_string(path) {
1029        Ok(s) => s,
1030        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(HashSet::new()),
1031        Err(err) => {
1032            return Err(CacheError::Store(anyhow::Error::from(err).context(
1033                format!("failed to read evicted-hash log at {}", path.display()),
1034            )));
1035        }
1036    };
1037    let mut out = HashSet::new();
1038    for raw in contents.lines() {
1039        let line = raw.trim();
1040        if line.is_empty() || line.starts_with('#') {
1041            continue;
1042        }
1043        match parse_hex_hash(line) {
1044            Some(h) => {
1045                out.insert(h);
1046            }
1047            None => {
1048                tracing::warn!(
1049                    line = %line,
1050                    path = %path.display(),
1051                    "skipping malformed entry in evicted-hash log",
1052                );
1053            }
1054        }
1055    }
1056    Ok(out)
1057}
1058
1059/// Parse a 64-char hex BLAKE3 hash. Accepts either case so hand-edited
1060/// log entries (operators pasting a hash from access logs / takedown
1061/// notices, which may use either case) round-trip through the same
1062/// parser the admin RPC accepts. The persisted format is canonically
1063/// lowercase via `Hash::Display`'s `to_hex()`, so this only relaxes the
1064/// read path — writes remain lowercase, and load-then-save normalizes
1065/// silently. Strict on length: a corrupted log line surfaces as `None`
1066/// (logged + skipped at load time) rather than silently turning into
1067/// the wrong hash.
1068fn parse_hex_hash(s: &str) -> Option<Hash> {
1069    if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
1070        return None;
1071    }
1072    let mut bytes = [0u8; 32];
1073    for (i, byte) in bytes.iter_mut().enumerate() {
1074        // First nibble is the high nibble (bits 7..4): hex `ab` decodes
1075        // to `0xab`, not `0xba`. Naming follows that semantic so an
1076        // audit-sensitive DMCA-takedown codepath isn't decoded against
1077        // mis-labeled variables.
1078        let hi = s.as_bytes().get(i * 2)?;
1079        let lo = s.as_bytes().get(i * 2 + 1)?;
1080        *byte = (hex_digit(*hi)? << 4) | hex_digit(*lo)?;
1081    }
1082    Some(Hash::from_bytes(bytes))
1083}
1084
1085const fn hex_digit(c: u8) -> Option<u8> {
1086    match c {
1087        b'0'..=b'9' => Some(c - b'0'),
1088        b'a'..=b'f' => Some(c - b'a' + 10),
1089        b'A'..=b'F' => Some(c - b'A' + 10),
1090        _ => None,
1091    }
1092}
1093
1094/// `add_protected` body for [`CacheEngine::open_full`]'s GC wiring (#518).
1095///
1096/// Runs once per iroh-blobs sweep cycle, before `gc_run_once`. Snapshots
1097/// the current blob set, attributes "what disappeared since the last
1098/// snapshot" to the previous sweep's reclaim, bumps the cache metrics,
1099/// and saves the snapshot for the next cycle's diff. Always returns
1100/// without adding any hashes to `live` — named-tag promotion in
1101/// [`CacheEngine::pull_through`] and `TempTag` lifetimes are what protect
1102/// cached / in-progress blobs; this callback is purely instrumentation.
1103///
1104/// **Why the spawn-and-wait dance:** iroh-blobs requires the
1105/// `ProtectCb` future to be `Send + Sync`, but its own RPC layer
1106/// (`blobs().list().stream()`, `blobs().status(...)`) returns futures
1107/// that are only `Send`. Awaiting those directly here would leak the
1108/// non-Sync constraint into our outer future. Spawning the snapshot
1109/// work on a separate task and awaiting a [`tokio::sync::oneshot`]
1110/// receiver (Send + Sync as long as `T: Send`) gives the outer future
1111/// the auto-trait shape iroh-blobs demands.
1112///
1113/// **Errors are logged and dropped, never propagated as `Abort`.** Any
1114/// future refactor that wants to surface them should keep returning
1115/// [`ProtectOutcome::Continue`] — `Abort` would skip the sweep itself,
1116/// letting the disk-leak threat the GC was added to mitigate keep
1117/// growing. The next cycle's snapshot recovers the count attribution as
1118/// long as the store eventually services the list/status calls.
1119///
1120/// **Engine-drop semantics:** `store_handle` is a [`Weak`] of the
1121/// `Arc<OnceLock<FsStore>>` that lives in `Inner.gc_store_handle`.
1122/// When the engine drops, that strong `Arc` drops with it, the
1123/// `Weak::upgrade` here returns `None`, and the cb returns silently —
1124/// no metric writes, no work, no contribution to the cb's surviving
1125/// strong-ref graph. iroh-blobs' GC loop itself owns its own `Store`
1126/// clone (via `run_gc(store: Store, ...)`) and keeps running for the
1127/// rest of the process lifetime; that's an upstream design constraint
1128/// tracked under #520. What this `Weak` ensures is that the cb body
1129/// no-ops cleanly past engine drop rather than spuriously snapshotting
1130/// or bumping metrics on a drained engine.
1131async fn gc_protect_callback(
1132    store_handle: Weak<OnceLock<FsStore>>,
1133    prev_pre_sweep: Arc<Mutex<HashMap<Hash, u64>>>,
1134    partial_sizes: Arc<PartialSizeMemo>,
1135    metrics: Option<Arc<CacheMetrics>>,
1136) {
1137    let (tx, rx) = tokio::sync::oneshot::channel::<()>();
1138    tokio::spawn(async move {
1139        gc_protect_inner(store_handle, prev_pre_sweep, partial_sizes, metrics).await;
1140        let _ = tx.send(());
1141    });
1142    if let Err(err) = rx.await {
1143        // The spawned task panicked or was dropped before sending.
1144        // Surface it: a panic in `gc_protect_inner` would otherwise be
1145        // invisible (this cb just returns `Continue` either way).
1146        tracing::warn!(error = %err, "gc protect spawn dropped without completing; metrics may have skipped a cycle");
1147    }
1148}
1149
1150async fn gc_protect_inner(
1151    store_handle: Weak<OnceLock<FsStore>>,
1152    prev_pre_sweep: Arc<Mutex<HashMap<Hash, u64>>>,
1153    partial_sizes: Arc<PartialSizeMemo>,
1154    metrics: Option<Arc<CacheMetrics>>,
1155) {
1156    // `Weak::upgrade` returning `None` is the post-engine-drop steady
1157    // state: `Inner.gc_store_handle` (the strong `Arc`) has dropped,
1158    // the iroh-blobs runtime is in the process of being aborted, and
1159    // any further cb fires before the abort lands have nothing to
1160    // measure against. Quietly skip — this is not an error.
1161    let Some(store_arc) = store_handle.upgrade() else {
1162        return;
1163    };
1164    let Some(store) = store_arc.get() else {
1165        // Practically unreachable: iroh-blobs' `run_gc` calls `sleep`
1166        // first, and we `set` the OnceLock immediately after
1167        // `load_with_opts` returns. A `None` here would mean a future
1168        // refactor enabled near-zero intervals or moved the `set`. Log
1169        // and skip rather than panic.
1170        tracing::warn!("gc protect callback fired before store handle was set");
1171        return;
1172    };
1173
1174    let current = match snapshot_blob_sizes(
1175        store,
1176        &partial_sizes,
1177        Instant::now(),
1178        metrics.as_deref(),
1179    )
1180    .await
1181    {
1182        Ok(snap) => snap,
1183        Err(err) => {
1184            tracing::warn!(error = %err.display_chain(), "gc snapshot failed; skipping reclaim attribution this cycle");
1185            return;
1186        }
1187    };
1188
1189    let reclaimed_bytes: u64 = {
1190        // Surface poison: this mutex guards metrics-only state, so a
1191        // poisoned lock means a *prior* invocation of this function
1192        // panicked while holding the guard (i.e., somewhere in the
1193        // diff/fold loop). Recover the inner state to keep metrics
1194        // flowing — the next cycle re-establishes a baseline — but log
1195        // once so the panic doesn't hide.
1196        //
1197        // Poison handling in this file is decided **per lock site**, not
1198        // per mutex, so the honest summary is a spectrum rather than a
1199        // taxonomy:
1200        // - `inflight` recovers, clears the poison, `error!`s once and
1201        //   counts it at all six sites, because losing its invariant costs
1202        //   origin egress and USDC. See [`Inner::lock_inflight`].
1203        // - `evicted` and `probe_holds` recover silently everywhere; for
1204        //   `evicted` that is load-bearing (skipping would resume serving
1205        //   a DMCA-takedown hash) and written up at `evict`/`is_evicted`.
1206        // - `access_times` has no entry here: it is a `DashMap`, so no
1207        //   poisoning decision exists to make. A panic while one of its shards
1208        //   is locked leaves that shard usable, and every reader and writer
1209        //   takes one shard at a time.
1210        // - `origin_probe_memo` recovers silently via `probe_memo_lock`.
1211        // - `partial_sizes` recovers silently: it caches store reads, and the
1212        //   next walk re-observes any entry it lost.
1213        // - `prev_pre_sweep` (here) is the only metrics-only one: recover
1214        //   + `warn!`, since the next cycle re-establishes a baseline.
1215        let mut guard = match prev_pre_sweep.lock() {
1216            Ok(g) => g,
1217            Err(poisoned) => {
1218                tracing::warn!("gc prev_pre_sweep mutex poisoned; recovering inner state");
1219                poisoned.into_inner()
1220            }
1221        };
1222        let bytes = guard
1223            .iter()
1224            .filter(|(hash, _)| !current.contains_key(*hash))
1225            .fold(0u64, |acc, (_, size)| acc.saturating_add(*size));
1226        *guard = current;
1227        bytes
1228    };
1229
1230    if let Some(m) = metrics.as_ref() {
1231        m.gc_runs.inc();
1232        m.gc_bytes_reclaimed.inc_by(reclaimed_bytes);
1233    }
1234}
1235
1236/// Snapshot the iroh-blobs blob set keyed by hash, with each entry's
1237/// size in bytes. Used by [`gc_protect_callback`] to diff sweep cycles
1238/// (#518) and by [`CacheEngine::size_snapshot`]. Hashes that race the
1239/// snapshot (deleted between `list` and `status`) report
1240/// `BlobStatus::NotFound` and are dropped — they cannot have contributed
1241/// bytes either way.
1242///
1243/// A `Partial` blob reports the bytes its bitfield marks present
1244/// ([`observed_present_bytes`]), not `status()`'s size. Including
1245/// partials matters: the threat model that motivated #518 is exactly
1246/// the partial-import case (`add_stream` errored mid-flight, the
1247/// `TempTag` was dropped, but the bytes already on disk are what we
1248/// want GC to reclaim). A partial that GC removes between `status` and
1249/// `observe` stays in the map at 0 bytes.
1250///
1251/// `memo` holds each partial's last count. A count younger than
1252/// [`PARTIAL_SIZE_TTL`] at `now` is reused without an `observe()`. A failed
1253/// `observe()` falls back to the last count, else to `status()`'s size, and
1254/// the fallback is memoized for one TTL so a dead store entry is retried and
1255/// logged once per TTL, not every walk. Each failure ticks
1256/// `partial_size_observe_failures`. Only a `list` or `status` failure, which
1257/// is store-wide, fails the walk. The walk drops memo entries for hashes that
1258/// are no longer partial.
1259async fn snapshot_blob_sizes(
1260    store: &FsStore,
1261    memo: &PartialSizeMemo,
1262    now: Instant,
1263    metrics: Option<&CacheMetrics>,
1264) -> CacheResult<HashMap<Hash, u64>> {
1265    let blobs = store.blobs();
1266    let mut stream = blobs
1267        .list()
1268        .stream()
1269        .await
1270        .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
1271    let mut out = HashMap::new();
1272    let mut partials = HashSet::new();
1273    while let Some(hash) = stream.next().await {
1274        let hash = hash.map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
1275        let status = blobs
1276            .status(hash)
1277            .await
1278            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
1279        let size = match status {
1280            iroh_blobs::api::blobs::BlobStatus::NotFound => continue,
1281            iroh_blobs::api::blobs::BlobStatus::Partial { size } => {
1282                partials.insert(hash);
1283                partial_size(blobs, hash, size, memo, now, metrics).await
1284            }
1285            iroh_blobs::api::blobs::BlobStatus::Complete { size } => size,
1286        };
1287        out.insert(hash, size);
1288    }
1289    memo.lock()
1290        .unwrap_or_else(PoisonError::into_inner)
1291        .retain(|hash, _| partials.contains(hash));
1292    Ok(out)
1293}
1294
1295/// One partial blob's entry in [`snapshot_blob_sizes`]: the memoized count
1296/// while fresh, else a new `observe()`, else a fallback. `status_size` is
1297/// `status()`'s size for the blob. See [`snapshot_blob_sizes`] for the rules.
1298async fn partial_size(
1299    blobs: &iroh_blobs::api::blobs::Blobs,
1300    hash: Hash,
1301    status_size: Option<u64>,
1302    memo: &PartialSizeMemo,
1303    now: Instant,
1304    metrics: Option<&CacheMetrics>,
1305) -> u64 {
1306    let cached = memo
1307        .lock()
1308        .unwrap_or_else(PoisonError::into_inner)
1309        .get(&hash)
1310        .copied();
1311    if let Some((bytes, at)) = cached
1312        && now.saturating_duration_since(at) < PARTIAL_SIZE_TTL
1313    {
1314        return bytes;
1315    }
1316    let bytes = match observed_present_bytes(blobs, hash).await {
1317        Ok(bytes) => bytes,
1318        Err(err) => {
1319            let fallback = cached.map_or_else(|| status_size.unwrap_or(0), |(bytes, _)| bytes);
1320            tracing::warn!(
1321                %hash,
1322                error = %err.display_chain(),
1323                fallback,
1324                "partial blob size: observe failed; using the last count or the status() size"
1325            );
1326            if let Some(m) = metrics {
1327                m.partial_size_observe_failures.inc();
1328            }
1329            fallback
1330        }
1331    };
1332    memo.lock()
1333        .unwrap_or_else(PoisonError::into_inner)
1334        .insert(hash, (bytes, now));
1335    bytes
1336}
1337
1338/// Append `hash` to the evicted log with `fsync` before returning. The
1339/// caller relies on the durability guarantee — a return-without-error
1340/// means a crash now will replay the eviction on the next `open`.
1341fn append_evicted_log(path: &Path, hash: Hash) -> std::io::Result<()> {
1342    if let Some(parent) = path.parent() {
1343        std::fs::create_dir_all(parent)?;
1344    }
1345    let mut file = OpenOptions::new().create(true).append(true).open(path)?;
1346    // POSIX guarantees `write` calls under PIPE_BUF (typically 4 KiB) are
1347    // atomic on append-mode file handles. A 64-char hash + newline is
1348    // 65 bytes, well under the limit, so concurrent writers from a
1349    // misconfigured shared cache_dir won't interleave bytes mid-line.
1350    let line = format!("{hash}\n");
1351    file.write_all(line.as_bytes())?;
1352    file.sync_all()?;
1353    Ok(())
1354}
1355
1356/// One origin's answer to an outboard read, after the exact-length gate.
1357enum GatedOutboard {
1358    /// The origin served an outboard of the exact length.
1359    Found(Bytes),
1360    /// The origin has no outboard for the hash, or does not publish outboards.
1361    Declined,
1362    /// The origin served an outboard of the wrong length, which cannot verify.
1363    WrongLength,
1364}
1365
1366impl CacheEngine {
1367    /// Open or create the store at `cache_dir`. `max_blob_mb` caps the size
1368    /// of any single blob pulled from the origin. Oversize payloads typically
1369    /// surface as [`CacheError::OriginError`] (the HTTP origin trips the cap
1370    /// mid-stream before the engine sees the bytes), or as
1371    /// [`CacheError::BlobTooLarge`] when a custom `Origin` impl returns bytes
1372    /// that exceed the cap without self-enforcement.
1373    pub async fn open(
1374        cache_dir: &Path,
1375        origins: Vec<Arc<dyn Origin>>,
1376        max_blob_mb: u64,
1377    ) -> CacheResult<Self> {
1378        Self::open_with_pinned(cache_dir, origins, max_blob_mb, PinnedHashes::empty()).await
1379    }
1380
1381    /// Open the cache with an initial pinning set. The set is held in an
1382    /// [`ArcSwap`] internally so subsequent SIGHUP reloads can call
1383    /// [`Self::set_pinned`] without rebuilding the engine.
1384    ///
1385    /// Defaults the retry policy to [`RetryPolicy::default`], wires no
1386    /// metrics handle, and disables periodic GC. Use [`Self::open_full`] to
1387    /// override any of those — the node crate plugs in its
1388    /// `Arc<CacheMetrics>`, the operator-configured retry policy, and the
1389    /// `cache.gc_interval_sec` through that path.
1390    pub async fn open_with_pinned(
1391        cache_dir: &Path,
1392        origins: Vec<Arc<dyn Origin>>,
1393        max_blob_mb: u64,
1394        pinned: PinnedHashes,
1395    ) -> CacheResult<Self> {
1396        Self::open_full(
1397            cache_dir,
1398            origins,
1399            max_blob_mb,
1400            pinned,
1401            RetryPolicy::default(),
1402            CircuitBreakerPolicy::default(),
1403            None,
1404            Duration::ZERO,
1405        )
1406        .await
1407    }
1408
1409    /// Open the cache with full control over policy, metrics, and GC wiring.
1410    /// The runtime's `build_cache` uses this directly; tests usually want
1411    /// [`Self::open`] or [`Self::open_with_pinned`] with their defaults.
1412    ///
1413    /// `gc_interval` controls iroh-blobs' built-in GC sweep loop (#518).
1414    /// [`Duration::ZERO`] disables periodic GC; any other value is forwarded
1415    /// to [`iroh_blobs::store::GcConfig`] and iroh-blobs spawns its own GC
1416    /// task on its internal runtime. Reclaimable bytes accumulate between
1417    /// sweeps — set the interval based on how much disk you're willing to
1418    /// lose to a hostile-origin amplification window.
1419    ///
1420    /// **Why iroh-blobs drives the loop instead of us:** the sweep
1421    /// function (`gc::gc_run_once`) lives in iroh-blobs 0.103's private
1422    /// `store::gc` module and is not re-exported, and `Blobs::delete`
1423    /// is `pub(crate)`. The only externally-reachable trigger is
1424    /// `Options::gc`. #520 tracks switching to a runtime-driven loop
1425    /// with manual on-demand GC (`admin_v1_cacheGc` / `decdn node gc`)
1426    /// once upstream exposes the sweep API.
1427    #[allow(clippy::too_many_arguments)]
1428    pub async fn open_full(
1429        cache_dir: &Path,
1430        origins: Vec<Arc<dyn Origin>>,
1431        max_blob_mb: u64,
1432        pinned: PinnedHashes,
1433        retry_policy: RetryPolicy,
1434        circuit_breaker: CircuitBreakerPolicy,
1435        metrics: Option<Arc<CacheMetrics>>,
1436        gc_interval: Duration,
1437    ) -> CacheResult<Self> {
1438        Self::open_full_with_clock(
1439            cache_dir,
1440            origins,
1441            max_blob_mb,
1442            pinned,
1443            retry_policy,
1444            circuit_breaker,
1445            metrics,
1446            gc_interval,
1447            Arc::new(SystemClock::new()),
1448        )
1449        .await
1450    }
1451
1452    /// [`Self::open_full`] with an injectable [`Clock`] driving the
1453    /// per-origin circuit-breaker cooldown (#963). Production goes
1454    /// through `open_full` (which supplies a [`SystemClock`]); tests use
1455    /// this to drive the breaker's cooldown deterministically with a
1456    /// [`crate::ManualClock`].
1457    #[allow(clippy::too_many_arguments)]
1458    pub async fn open_full_with_clock(
1459        cache_dir: &Path,
1460        origins: Vec<Arc<dyn Origin>>,
1461        max_blob_mb: u64,
1462        pinned: PinnedHashes,
1463        retry_policy: RetryPolicy,
1464        circuit_breaker: CircuitBreakerPolicy,
1465        metrics: Option<Arc<CacheMetrics>>,
1466        gc_interval: Duration,
1467        clock: Arc<dyn Clock>,
1468    ) -> CacheResult<Self> {
1469        tokio::fs::create_dir_all(cache_dir)
1470            .await
1471            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
1472
1473        // Late-bound store handle for the GC `add_protected` callback. The
1474        // callback is captured into `Options.gc` *before* `FsStore` exists,
1475        // but the cb needs to query `blobs().list()` to compute the
1476        // pre-sweep snapshot. Solution: build `Arc<OnceLock<FsStore>>` here,
1477        // capture a `Weak` into the cb, and `set` the OnceLock as soon as
1478        // the store handle is in hand. The strong `Arc` is moved into
1479        // `Inner.gc_store_handle` so its lifetime tracks the engine; on
1480        // engine drop the cb's `Weak::upgrade` returns `None`, which is
1481        // exactly what breaks the cb → `FsStore` clone → iroh-blobs actor
1482        // → GC task → cb cycle.
1483        //
1484        // `None` when GC is disabled — no cb is registered, so no late-
1485        // bind handle is needed.
1486        let gc_store_handle: Option<Arc<OnceLock<FsStore>>> = if gc_interval.is_zero() {
1487            None
1488        } else {
1489            Some(Arc::new(OnceLock::new()))
1490        };
1491
1492        // Previous-cycle pre-sweep snapshot. The cb runs once per cycle
1493        // *before* `gc_run_once`, so the diff between the snapshot saved
1494        // last cycle and the snapshot taken this cycle is exactly the
1495        // hash set the previous sweep deleted (the cache crate has no
1496        // other public delete path: `Blobs::delete` is `pub(crate)` in
1497        // iroh-blobs 0.103). Bytes for that diff is what the previous
1498        // sweep reclaimed; we attribute it on the *current* cb fire.
1499        // First-cycle fires bump the runs counter but emit zero on
1500        // the reclaim counter because there is no prior snapshot.
1501        let prev_pre_sweep: Arc<Mutex<HashMap<Hash, u64>>> = Arc::new(Mutex::new(HashMap::new()));
1502        let partial_sizes: Arc<PartialSizeMemo> = Arc::new(Mutex::new(HashMap::new()));
1503
1504        let mut options = FsStoreOptions::new(cache_dir);
1505        if let Some(strong) = gc_store_handle.as_ref() {
1506            let store_weak: Weak<OnceLock<FsStore>> = Arc::downgrade(strong);
1507            let prev_for_cb = Arc::clone(&prev_pre_sweep);
1508            let sizes_for_cb = Arc::clone(&partial_sizes);
1509            let metrics_for_cb = metrics.clone();
1510            options.gc = Some(GcConfig {
1511                interval: gc_interval,
1512                add_protected: Some(Arc::new(move |_live: &mut HashSet<Hash>| {
1513                    let store_weak = store_weak.clone();
1514                    let prev_for_cb = Arc::clone(&prev_for_cb);
1515                    let sizes_for_cb = Arc::clone(&sizes_for_cb);
1516                    let metrics_for_cb = metrics_for_cb.clone();
1517                    Box::pin(async move {
1518                        gc_protect_callback(store_weak, prev_for_cb, sizes_for_cb, metrics_for_cb)
1519                            .await;
1520                        ProtectOutcome::Continue
1521                    })
1522                })),
1523            });
1524        }
1525
1526        let db_path = cache_dir.join("blobs.db");
1527        let store = FsStore::load_with_opts(db_path, options)
1528            .await
1529            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
1530
1531        // Late-bind: the cb's `Weak<OnceLock<FsStore>>` can now upgrade
1532        // and `.get()` to reach the store handle. `set` only fails if
1533        // the OnceLock was already populated, which can't happen on
1534        // this code path (we just constructed it).
1535        if let Some(strong) = gc_store_handle.as_ref() {
1536            let _ = strong.set(store.clone());
1537        }
1538
1539        // Saturate-on-overflow: an operator setting `max_blob_mb = u64::MAX`
1540        // as a de-facto "unlimited" value should still yield a usable byte cap
1541        // rather than overflow-wrap to zero.
1542        let max_blob_bytes = max_blob_mb.saturating_mul(1024 * 1024);
1543
1544        let evicted_log_path = cache_dir.join("evicted.log");
1545        let evicted = load_evicted_log(&evicted_log_path)?;
1546
1547        // One breaker per origin, parallel by index. Each shares the
1548        // single injected clock and the cache metrics handle so trip /
1549        // recovery / short-circuit counters land in the same encoder
1550        // output as the rest of the cache group (#963).
1551        let breakers = origins
1552            .iter()
1553            .map(|_| OriginBreaker::new(circuit_breaker, Arc::clone(&clock), metrics.clone()))
1554            .collect::<Vec<_>>();
1555        let origin_range_confirmed = origins.iter().map(|_| AtomicBool::new(false)).collect();
1556
1557        // `checked_sub` fails only within `COLD_SEED_AGE` of the monotonic
1558        // clock's origin (near boot). Seeding at `opened` then still sorts at or
1559        // before every later access.
1560        let opened = Instant::now();
1561        let cold_seed_at = opened.checked_sub(COLD_SEED_AGE).unwrap_or(opened);
1562
1563        let engine = Self {
1564            inner: Arc::new(Inner {
1565                store,
1566                origins,
1567                breakers,
1568                max_blob_bytes,
1569                access_times: DashMap::new(),
1570                cold_seed_at,
1571                partial_protected: DashMap::new(),
1572                partial_sizes,
1573                inflight: Mutex::new(HashMap::new()),
1574                inflight_poison_logged: AtomicBool::new(false),
1575                pinned: ArcSwap::from(Arc::new(
1576                    pinned
1577                        .iter()
1578                        .map(|h| to_store_hash(*h))
1579                        .collect::<HashSet<Hash>>(),
1580                )),
1581                origin_held: ArcSwap::from(Arc::new(OriginHeldIndex::default())),
1582                rescan_slot: AtomicU8::new(RESCAN_IDLE),
1583                origin_probe_memo: Mutex::new(OriginProbeMemo::default()),
1584                denied: ArcSwap::from(Arc::new(HashSet::new())),
1585                chain_denied: ArcSwap::from(Arc::new(HashSet::new())),
1586                evicted: MonotoneHashSet::new(evicted),
1587                quarantined: DashMap::new(),
1588                probe_holds: Mutex::new(HashMap::new()),
1589                max_probe_holds: AtomicUsize::new(crate::probe_hold::DEFAULT_MAX_PROBE_HOLDS),
1590                frequency: ArcSwap::from_pointee(None),
1591                admission: ArcSwap::from_pointee(
1592                    Arc::new(crate::policy::AlwaysAdmit) as Arc<dyn crate::policy::AdmissionPolicy>
1593                ),
1594                segments: Mutex::new(HashMap::new()),
1595                evicted_log_path,
1596                retry_policy,
1597                fill_registry: Arc::new(FillRegistry::with_metrics(metrics.clone())),
1598                metrics,
1599                // Bounded channel — slow consumers (e.g. a republish
1600                // scheduler under load) lag instead of backpressuring
1601                // the cache hot path. Cap of 1024 matches the dispatch
1602                // limiter's similar in-flight slot count; sized
1603                // generously enough that a steady-state pull rate above
1604                // 1000/s would have to also lose all subscribers for
1605                // the lag to actually fire.
1606                inserts_tx: broadcast::channel(1024).0,
1607                gc_store_handle,
1608                own_origin_range_pulls: Arc::new(tokio::sync::Semaphore::new(
1609                    MAX_CONCURRENT_RANGE_PULLS,
1610                )),
1611                origin_range_confirmed,
1612                outboards: OutboardCache::new(OUTBOARD_CACHE_BYTES, OUTBOARD_CACHE_ENTRIES),
1613                outboard_flights: Mutex::new(HashMap::new()),
1614                origin_read_head_start_ms: AtomicU64::new(0),
1615                origin_read_min_bps: AtomicU64::new(0),
1616            }),
1617        };
1618        engine.seed_access_times_from_store().await;
1619        Ok(engine)
1620    }
1621
1622    /// Enter every blob already on disk into `access_times` at
1623    /// [`Inner::cold_seed_at`], so eviction can release content from before a
1624    /// restart without waiting for traffic to touch it.
1625    ///
1626    /// The seed runs before `open` returns the engine, so no access can land
1627    /// first; `or_insert` is defensive. The walk is [`Self::iter_hashes`], which
1628    /// skips refused hashes. At open only the durable evicted set is populated,
1629    /// so operator-evicted hashes stay out and every other stored blob enters
1630    /// the map. [`Self::eviction_candidates`] then filters pinned and probe-held
1631    /// hashes as for any other entry.
1632    ///
1633    /// A failed walk logs a WARN, increments `recency_seed_failures`, and leaves
1634    /// the map unseeded. Open still succeeds and the cache still serves every
1635    /// blob, but until the next restart eviction sees only blobs accessed after
1636    /// open.
1637    async fn seed_access_times_from_store(&self) {
1638        let hashes = match self.iter_hashes().await {
1639            Ok(hashes) => hashes,
1640            Err(err) => {
1641                if let Some(m) = &self.inner.metrics {
1642                    m.recency_seed_failures.inc();
1643                }
1644                tracing::warn!(
1645                    error = %err.display_chain(),
1646                    "cache open: store walk failed; blobs from before this start are not \
1647                     eviction candidates until accessed, until the next restart \
1648                     (alert on decdn_cache_recency_seed_failures_total)"
1649                );
1650                return;
1651            }
1652        };
1653        let seeded = hashes.len();
1654        let seed = self.inner.cold_seed_at;
1655        for hash in hashes {
1656            self.inner.access_times.entry(hash).or_insert(seed);
1657        }
1658        tracing::info!(seeded, "cache open: seeded eviction recency from the store");
1659    }
1660
1661    /// Peek the in-flight fill registry for a LIVE fill of `hash`, returning its
1662    /// blob `total_bytes` if one runs. ADVISORY: the serve-miss path uses it to skip
1663    /// the upstream header handshake when it will coalesce onto a live pull, but the
1664    /// authoritative own-vs-attach decision stays in [`Self::claim_fill`]. See
1665    /// [`FillRegistry::in_flight_total`].
1666    #[must_use]
1667    pub fn in_flight_total(&self, hash: Hash) -> Option<u64> {
1668        self.inner.fill_registry.in_flight_total(hash)
1669    }
1670
1671    /// Atomically claim a serve-miss of `[offset, offset+len)` (`len == 0` = to end)
1672    /// of the `total`-byte blob `hash`: decide attach/own/mixed AND register any new
1673    /// owner session under one map-lock acquisition (no plan-then-register TOCTOU
1674    /// race). Returns [`FillClaim::Attach`] to coalesce wholly onto a live pull,
1675    /// [`FillClaim::Owner`] with a freshly-registered session covering the whole
1676    /// request, or [`FillClaim::Mixed`] to own a pull for a contiguous remainder while
1677    /// attaching a sibling for the overlap. `make_session` builds the session only on
1678    /// an owning branch (never built-and-dropped on a pure attach). See
1679    /// [`FillRegistry::claim`].
1680    pub fn claim_fill(
1681        &self,
1682        hash: Hash,
1683        offset: u64,
1684        len: u64,
1685        total: u64,
1686        make_session: impl FnOnce() -> Arc<FillSession>,
1687    ) -> FillClaim {
1688        self.inner
1689            .fill_registry
1690            .claim(hash, offset, len, total, make_session)
1691    }
1692
1693    /// Subscribe to a stream of `Hash`es announcing hashes as they become
1694    /// advertisable in the local store: a whole blob that the private
1695    /// `pull_through` committed, or a ranged admit ([`Self::admit_bao`],
1696    /// [`Self::admit_bao_stream`]) that touched a fully present discovery
1697    /// block, whether the admit succeeded or failed partway. Used by the DHT republish scheduler (ADR
1698    /// 022 §STORE Flow — "When a node verifies its first 64 MiB block of blob
1699    /// H") to send the first publish-set to the K+3 closest peers.
1700    ///
1701    /// One hash can arrive more than once — a ranged fill announces once per
1702    /// block it completes — so a consumer dedupes against its own state.
1703    ///
1704    /// The channel is bounded and best-effort: lagged receivers see a
1705    /// [`broadcast::error::RecvError::Lagged`] on the next recv and
1706    /// MUST treat it as "force a full republish sweep" rather than try
1707    /// to backfill — the cache does not retain the missed hashes.
1708    #[must_use]
1709    pub fn subscribe_inserts(&self) -> broadcast::Receiver<Hash> {
1710        self.inner.inserts_tx.subscribe()
1711    }
1712
1713    /// Atomically swap the pinned-hashes set. Called by the runtime's
1714    /// SIGHUP handler when `cache.pinned_hashes` changes — readers (the
1715    /// eviction-candidate snapshot) observe either the old or the new set,
1716    /// never a partial mix. Returns a [`PinDiff`] so callers can log
1717    /// "added X, removed Y" without re-walking either set.
1718    ///
1719    /// Takes `&PinnedHashes` rather than ownership: callers commonly
1720    /// want to compute the diff without giving up their own copy. The
1721    /// leaf [`PinnedHashes`] is converted to the engine-internal
1722    /// `HashSet<Hash>` (iroh-blobs store hash) here — the cold SIGHUP
1723    /// path, so the O(n) conversion is acceptable; the hot
1724    /// `is_pinned`/`eviction_candidates` lookups stay on the store hash.
1725    pub fn set_pinned(&self, new: &PinnedHashes) -> PinDiff {
1726        let new_arc = Arc::new(
1727            new.iter()
1728                .map(|h| to_store_hash(*h))
1729                .collect::<HashSet<Hash>>(),
1730        );
1731        let prev_arc = self.inner.pinned.swap(Arc::clone(&new_arc));
1732        // Same set-difference arithmetic as `PinnedHashes::diff` (in
1733        // `decdn-config-types`), recomputed here over the store-hash
1734        // sets instead of round-tripping back to leaf hashes. The two
1735        // must stay behaviorally identical — `to_store_hash` is a
1736        // bijection on the 32 bytes, so cardinalities and membership are
1737        // preserved; if `PinnedHashes::diff` ever reports more than
1738        // counts (e.g. a sample of changed hashes), update both.
1739        let added = new_arc.iter().filter(|h| !prev_arc.contains(*h)).count();
1740        let removed = prev_arc.iter().filter(|h| !new_arc.contains(*h)).count();
1741        PinDiff { added, removed }
1742    }
1743
1744    /// Borrow a snapshot of the current pinned set, rebuilt as the leaf
1745    /// [`PinnedHashes`] (config-vocabulary hash) from the engine-internal
1746    /// store-hash set.
1747    ///
1748    /// **Cost:** O(n) — this allocates a fresh `HashSet` and converts
1749    /// every hash across the store↔leaf boundary. Call it off the hot
1750    /// path (it backs SIGHUP reload logging and admin snapshots, not
1751    /// per-request lookups).
1752    pub fn pinned_snapshot(&self) -> PinnedHashes {
1753        let set = self
1754            .inner
1755            .pinned
1756            .load()
1757            .iter()
1758            .map(|h| from_store_hash(*h))
1759            .collect::<HashSet<decdn_config_types::Hash>>();
1760        PinnedHashes::new(set)
1761    }
1762
1763    /// Snapshot of the active retry policy. Used by tests and startup
1764    /// logging. The policy is fixed at construction; changes require
1765    /// a restart.
1766    pub fn retry_policy(&self) -> RetryPolicy {
1767        self.inner.retry_policy
1768    }
1769
1770    /// Is `hash` currently pinned? Cheap O(1) lookup against the live set.
1771    pub fn is_pinned(&self, hash: Hash) -> bool {
1772        self.inner.pinned.load().contains(&hash)
1773    }
1774
1775    /// Rebuild the origin-held discovery index (#1130): the hashes this node
1776    /// can serve from a configured origin *before* any pull-through, so probe
1777    /// and DHT-announce can advertise them. The set is the union of
1778    ///
1779    /// - every enumerable origin's [`Origin::enumerate`] listing (the local
1780    ///   filesystem walks its sharded tree; HTTP/S3 enumerate to nothing), and
1781    /// - operator `pinned_hashes` the origin chain actually has (the remote
1782    ///   discovery path — HTTP has no listing, S3's is deliberately not walked,
1783    ///   so the pin list is their advertisement set),
1784    ///
1785    /// each mapped to its total byte size and filtered through [`Self::refuses`]
1786    /// so denied/blacklisted content is never advertised. Rebuilt wholesale and
1787    /// swapped atomically (like `pinned`): a concurrent reader sees the old or
1788    /// the new map, never a partial one.
1789    ///
1790    /// A candidate whose probe *faults* — a transport error, or the walk
1791    /// overrunning the probe timeout — keeps whatever size the previous index
1792    /// held for it instead of being dropped alongside the genuinely-absent. A
1793    /// fault is not an authoritative absence, and dropping on one shrinks the
1794    /// announce set for a whole rescan interval on a throttle window the origin
1795    /// recovers from in seconds. An origin that faults indefinitely therefore
1796    /// keeps a carried entry for as long as it keeps faulting; that is the
1797    /// intended trade, and the serve path answers from the live origin either
1798    /// way. Only a *retry-eligible* fault carries forward — a permanent one (a
1799    /// revoked ACL, a symlink escape) reads the same on every pass, so carrying
1800    /// it would advertise content this node can never serve until an operator
1801    /// intervenes. Faults are counted
1802    /// on `decdn_cache_origin_probe_failures_total` and reported to the DHT seed
1803    /// paths through [`Self::origin_held_snapshot`].
1804    ///
1805    /// An origin whose `enumerate` fails is the coarser version of the same
1806    /// thing: it contributes no candidates, so its hashes leave the index with
1807    /// no per-hash fault and nothing to carry forward. Only operator pins naming
1808    /// them survive. That leg counts on
1809    /// `decdn_cache_origin_enumerate_failures_total` and rides the same
1810    /// snapshot.
1811    ///
1812    /// Every adapter reserves [`Origin::size`]'s `Ok(None)` for an
1813    /// authoritative absence (a genuine 404): an HTTP or S3 5xx/408/429
1814    /// surfaces as a transient fault and carries forward, a permission decline
1815    /// as a permanent one — so no outage is dropped here as an absence.
1816    ///
1817    /// **Cost:** one origin probe per deduped candidate — every listed entry and
1818    /// every pin, including the ones that resolve absent and never enter the
1819    /// index. A local `metadata()` stat per fs entry, one HTTP `HEAD` / S3
1820    /// `HeadObject` per remote one. Runs off the hot path at the configured
1821    /// rescan cadence (startup / interval / reload), never per request.
1822    ///
1823    /// One pass at a time, with any number of triggers arriving during a pass
1824    /// collapsing into a single rerun. A trigger that finds a pass in flight
1825    /// returns immediately rather than awaiting it, so a walk that outruns the
1826    /// cadence cannot accumulate a backlog of waiters — and one rerun covers
1827    /// every trigger it coalesced, because the next pass re-derives everything
1828    /// from scratch.
1829    pub async fn rescan_origins(&self) {
1830        if !self.claim_rescan_slot() {
1831            // A pass owns the slot and will take another one for this request.
1832            return;
1833        }
1834        // RAII: a panic inside the walk would otherwise strand the slot and
1835        // disable every later rescan for the process lifetime.
1836        let mut guard = RescanSlotGuard {
1837            slot: &self.inner.rescan_slot,
1838            armed: true,
1839        };
1840        loop {
1841            self.rescan_once().await;
1842            if self
1843                .inner
1844                .rescan_slot
1845                .compare_exchange(
1846                    RESCAN_RUNNING,
1847                    RESCAN_IDLE,
1848                    Ordering::AcqRel,
1849                    Ordering::Acquire,
1850                )
1851                .is_ok()
1852            {
1853                // Released cleanly with nothing queued. The guard must not store
1854                // again: a trigger may already have claimed the slot.
1855                guard.armed = false;
1856                return;
1857            }
1858            // The CAS can only fail because a trigger arrived, so consume it and
1859            // take another pass.
1860            self.inner
1861                .rescan_slot
1862                .store(RESCAN_RUNNING, Ordering::Release);
1863        }
1864    }
1865
1866    /// Take the rescan slot, or register a rerun behind whoever holds it.
1867    ///
1868    /// Returns whether the caller owns the slot and must do the work. A request
1869    /// is never lost: it either claims the slot or moves it to `RESCAN_QUEUED`,
1870    /// and the running pass's release is a compare-exchange that fails if one
1871    /// landed first.
1872    fn claim_rescan_slot(&self) -> bool {
1873        loop {
1874            match self.inner.rescan_slot.compare_exchange_weak(
1875                RESCAN_IDLE,
1876                RESCAN_RUNNING,
1877                Ordering::AcqRel,
1878                Ordering::Acquire,
1879            ) {
1880                Ok(_) => return true,
1881                Err(RESCAN_RUNNING) => {
1882                    if self
1883                        .inner
1884                        .rescan_slot
1885                        .compare_exchange_weak(
1886                            RESCAN_RUNNING,
1887                            RESCAN_QUEUED,
1888                            Ordering::AcqRel,
1889                            Ordering::Acquire,
1890                        )
1891                        .is_ok()
1892                    {
1893                        return false;
1894                    }
1895                }
1896                // Already queued — that pass has not taken its snapshot yet, so
1897                // it covers this request too.
1898                Err(RESCAN_QUEUED) => return false,
1899                // Spurious failure or a state change under us; re-read and retry.
1900                Err(_) => {}
1901            }
1902        }
1903    }
1904
1905    /// One rescan pass: gather candidates, resolve them, publish the index.
1906    async fn rescan_once(&self) {
1907        self.lift_reclaimed_quarantines().await;
1908        let (candidates, enumerate_failures) = self.rescan_candidates().await;
1909        let RescanResolution {
1910            held,
1911            faults,
1912            carried,
1913        } = self.resolve_candidates(candidates).await;
1914
1915        let count = held.len();
1916        self.inner.origin_held.store(Arc::new(OriginHeldIndex {
1917            held,
1918            probe_faults: faults,
1919            enumerate_failures,
1920        }));
1921        if let Some(m) = &self.inner.metrics {
1922            if faults > 0 {
1923                m.origin_probe_failures.inc_by(faults);
1924            }
1925            if enumerate_failures > 0 {
1926                m.origin_enumerate_failures.inc_by(enumerate_failures);
1927            }
1928        }
1929        if faults > 0 {
1930            tracing::warn!(
1931                faults,
1932                carried,
1933                count,
1934                "rescan_origins: origin size probes faulted; carried the previous \
1935                 index entry forward where there was one. Candidates with no \
1936                 previous entry are absent from the announce set until a rescan \
1937                 resolves them"
1938            );
1939        }
1940        tracing::debug!(count, "rescan_origins: refreshed origin-held index");
1941    }
1942
1943    /// Every hash [`Self::rescan_origins`] considers: each enumerable origin's
1944    /// listing plus the operator pin set.
1945    ///
1946    /// The pin set is snapshotted here rather than read inside the probe loop,
1947    /// so its `ArcSwap` guard is not held across a `size()` await.
1948    ///
1949    /// Also returns how many origins failed to enumerate. Such an origin
1950    /// contributes nothing this pass — its hashes are never probed, so they
1951    /// carry no per-hash fault and simply leave the index. Only the operator
1952    /// pins naming them survive.
1953    async fn rescan_candidates(&self) -> (Vec<Hash>, u64) {
1954        let mut candidates: Vec<Hash> = Vec::new();
1955        let mut enumerate_failures = 0u64;
1956        for origin in &self.inner.origins {
1957            match origin.enumerate().await {
1958                Ok(hashes) => candidates.extend(hashes),
1959                Err(err) => {
1960                    enumerate_failures = enumerate_failures.saturating_add(1);
1961                    tracing::warn!(
1962                        origin = ?origin.kind(),
1963                        error = %format_args!("{err:#}"),
1964                        "rescan_origins: enumerate failed; every hash discoverable \
1965                         only through this origin leaves the announce set until a \
1966                         later rescan lists it"
1967                    );
1968                }
1969            }
1970        }
1971        candidates.extend(self.inner.pinned.load().iter().copied());
1972        (candidates, enumerate_failures)
1973    }
1974
1975    /// Resolve each candidate against the origin chain for
1976    /// [`Self::rescan_origins`], deduping so a hash is probed once.
1977    ///
1978    /// One local stat (fs) or one `HEAD` (pinned remote) per deduped candidate,
1979    /// through [`Self::probe_origin_chain_classified`] rather than
1980    /// [`Self::origin_size`], which folds a transport error into "origin doesn't
1981    /// have it".
1982    ///
1983    /// The probe memo is deliberately bypassed: an enumeration is as large as
1984    /// the origin's listing, and the memo is a bounded map that evicts an
1985    /// arbitrary live entry once full, so walking a rescan through it would push
1986    /// out the serve path's warm answers. The store walk in the DHT seed does go
1987    /// through the memo, because it asks the same question the serve gate asks
1988    /// about the same hashes moments later.
1989    ///
1990    /// Each candidate is bounded by [`RESCAN_PROBE_TIMEOUT`], not by
1991    /// `cache.origin_probe_timeout_ms` — that knob exists to stop a slow origin
1992    /// stalling the serve hot path, and a bulk walk answering nothing on the
1993    /// critical path has no reason to be that impatient. A candidate that
1994    /// overruns is a fault like any other, so borrowing the tighter budget would
1995    /// turn a merely slow origin into an empty index on a cold boot.
1996    async fn resolve_candidates(&self, candidates: Vec<Hash>) -> RescanResolution {
1997        // `load_full` rather than holding the `ArcSwap` guard: the guard would
1998        // be held across every probe await below, which is the long-lived-guard
1999        // case arc-swap tells callers to avoid.
2000        let previous = self.inner.origin_held.load_full();
2001        let mut out = RescanResolution::default();
2002        // Dedupe on a `seen` set, not on `held`: a candidate that resolves
2003        // `Absent`, or faults with nothing to carry forward, never lands in
2004        // `held`, and every origin's listing is concatenated with the pin set —
2005        // so keying off `held` re-probes those hashes once per duplicate and
2006        // counts one fault per probe.
2007        let mut seen: HashSet<Hash> = HashSet::new();
2008        for hash in candidates {
2009            if self.refuses(hash) || !seen.insert(hash) {
2010                continue;
2011            }
2012            match self
2013                .probe_origin_chain_classified(hash, RESCAN_PROBE_TIMEOUT)
2014                .await
2015            {
2016                (OriginPresence::Present(size), _) => {
2017                    out.held.insert(hash, size);
2018                }
2019                (OriginPresence::Absent, _) => {}
2020                (OriginPresence::Fault, transient) => {
2021                    out.faults = out.faults.saturating_add(1);
2022                    // Carry forward only what a later pass might resolve. A
2023                    // permanent fault — a revoked ACL, a symlink escape — will
2024                    // read the same way on every rescan, so carrying it keeps the
2025                    // hash advertised for the process lifetime while the serve
2026                    // path refuses every request for it.
2027                    if !transient {
2028                        continue;
2029                    }
2030                    if let Some(size) = previous.held.get(&hash).copied() {
2031                        out.held.insert(hash, size);
2032                        out.carried = out.carried.saturating_add(1);
2033                    }
2034                }
2035            }
2036        }
2037        out
2038    }
2039
2040    /// The hashes in the origin-held index (#1130) together with what the rescan
2041    /// that built it could not resolve, read from one load of the index.
2042    ///
2043    /// The only way to read the announce set, deliberately: the origin-held half
2044    /// of a DHT seed has no other error channel. A probe fault either carries an
2045    /// older entry forward or leaves the candidate out of the set, a failed
2046    /// enumeration drops a whole origin's listing, and the seed cannot tell
2047    /// either from an origin that genuinely stopped holding the content.
2048    /// Returning the counts with the hashes — from one load, so a truncated set
2049    /// can never be paired with a later healthy rescan's counts — is what stops a
2050    /// caller from reporting a short set as a complete one.
2051    ///
2052    /// `hashes` is filtered through the **live** [`Self::refuses`] set: the index
2053    /// is only a per-rescan snapshot, so a hash blacklisted / evicted / denied
2054    /// *after* the last rescan is still in it — but must never be announced. The
2055    /// live filter closes that window without waiting for the next rescan.
2056    pub fn origin_held_snapshot(&self) -> OriginHeldReport {
2057        let index = self.inner.origin_held.load();
2058        OriginHeldReport {
2059            hashes: index
2060                .held
2061                .keys()
2062                .copied()
2063                .filter(|h| !self.refuses(*h))
2064                .collect(),
2065            probe_faults: index.probe_faults,
2066            enumerate_failures: index.enumerate_failures,
2067        }
2068    }
2069
2070    /// Total byte size of `hash` if this node can serve it from a configured
2071    /// origin (#1130), else `None`. Backs the probe `has_blob` / `total_bytes`
2072    /// answer for origin-held content — `Some` means "advertise and serve" —
2073    /// resolved from the last [`Self::rescan_origins`] with no per-probe I/O.
2074    ///
2075    /// Returns `None` for a [`Self::refuses`]-listed hash even if the snapshot
2076    /// still holds it: the index is rebuilt only on rescan, so a hash
2077    /// blacklisted / evicted / denied since the last rescan would otherwise be
2078    /// advertised (probe `has_blob: true`) for content the serve path would
2079    /// refuse. The live refusal check is the authority.
2080    pub fn origin_held_size(&self, hash: Hash) -> Option<u64> {
2081        if self.refuses(hash) {
2082            return None;
2083        }
2084        self.inner.origin_held.load().held.get(&hash).copied()
2085    }
2086
2087    /// Total byte size of `hash` if a configured origin can serve it, resolved
2088    /// by a **live** `HEAD`/`HeadObject`/stat and memoised (#1130 pt3). This is
2089    /// the per-probe fallback for the http/s3 discovery gap: [`Self::rescan_origins`]
2090    /// can only index what an origin `enumerate`s, and http/s3 enumerate to
2091    /// nothing, so a non-pinned bucket object is absent from
2092    /// [`Self::origin_held_size`]. Callers should consult the in-memory index
2093    /// first (zero I/O) and only fall back here on its miss.
2094    ///
2095    /// A thin wrapper over [`Self::origin_probe_presence`]: `Present(size)`
2096    /// advertises `Some(size)`, and both `Absent` and `Fault` advertise `None`
2097    /// — a probing caller (the `probe` handler's `has_blob`, DHT announce)
2098    /// never distinguishes "genuinely missing" from "backend unreachable
2099    /// right now"; either way it must not advertise the blob.
2100    ///
2101    /// **Never fetches the body** — existence and size only.
2102    pub async fn origin_probe_size(&self, hash: Hash) -> Option<u64> {
2103        match self.origin_probe_presence(hash).await {
2104            OriginPresence::Present(size) => Some(size),
2105            OriginPresence::Absent | OriginPresence::Fault => None,
2106        }
2107    }
2108
2109    /// Walk the origin chain for `hash`, distinguishing a genuine negative from
2110    /// a backend fault, with no memo read or write.
2111    ///
2112    /// Deliberately not routed through [`Self::origin_size`]: that helper's
2113    /// per-origin error handling is swallow-and-advance, so other callers can
2114    /// fall through a dead origin to a live one, and its terminal `Ok(None)`
2115    /// folds a fault into a negative. A fault-aware caller needs the raw
2116    /// per-origin outcomes instead.
2117    ///
2118    /// - ANY origin answering `Ok(Some(size))` is `Present(size)` — the first
2119    ///   such answer wins, same order as [`Self::origin_size`];
2120    /// - failing that, ANY origin answering `Err(_)` (a transport error), or
2121    ///   the whole walk overrunning `timeout`, is `Fault`;
2122    /// - only when every configured origin answered `Ok(None)` — or no origin
2123    ///   is configured at all, a node that genuinely holds nothing — is the
2124    ///   answer `Absent`. This is deliberately the lowest-precedence outcome:
2125    ///   if at least one origin is unreachable and none confirmed the object,
2126    ///   the honest answer is "unknown", never an authoritative `NotFound`.
2127    ///
2128    /// One `timeout` covers the whole chain rather than resetting per origin,
2129    /// so a chain of slow origins cannot add up past the caller's budget.
2130    ///
2131    /// **Never fetches the body** — existence and size only.
2132    async fn probe_origin_chain(&self, hash: Hash, timeout: Duration) -> OriginPresence {
2133        self.probe_origin_chain_classified(hash, timeout).await.0
2134    }
2135
2136    /// [`Self::probe_origin_chain`] plus whether a `Fault` is worth waiting out.
2137    ///
2138    /// The second element is meaningful only for `Fault`, and is `true` when at
2139    /// least one origin failed with a retry-eligible error (or the walk hit its
2140    /// ceiling). A `Fault` built only from
2141    /// [`OriginPullError::Permanent`] — a symlink escape, a permission denied,
2142    /// an HTTP 4xx — will still be there on the next pass and the one after, so
2143    /// a caller that would otherwise hold state open on the strength of "this
2144    /// might come back" must not.
2145    async fn probe_origin_chain_classified(
2146        &self,
2147        hash: Hash,
2148        timeout: Duration,
2149    ) -> (OriginPresence, bool) {
2150        let walk = async {
2151            let mut any_fault = false;
2152            let mut any_transient = false;
2153            for origin in &self.inner.origins {
2154                match origin.size(hash).await {
2155                    Ok(Some(size)) => return (OriginPresence::Present(size), false),
2156                    Ok(None) => {}
2157                    Err(e) => {
2158                        tracing::debug!(
2159                            %hash,
2160                            kind = ?origin.kind(),
2161                            error = %format_args!("{e:#}"),
2162                            transient = e.is_transient(),
2163                            "origin-probe HEAD faulted; checking remaining origins",
2164                        );
2165                        any_fault = true;
2166                        any_transient |= e.is_transient();
2167                    }
2168                }
2169            }
2170            if any_fault {
2171                (OriginPresence::Fault, any_transient)
2172            } else {
2173                (OriginPresence::Absent, false)
2174            }
2175        };
2176        match tokio::time::timeout(timeout, walk).await {
2177            Ok(outcome) => outcome,
2178            // A ceiling the origin overran says nothing about whether it would
2179            // answer given longer, so treat it as the waitable kind.
2180            Err(_) => (OriginPresence::Fault, true),
2181        }
2182    }
2183
2184    /// Live existence probe against the configured origins, distinguishing a
2185    /// genuine negative from a backend fault (#1766). This is the primitive
2186    /// behind [`Self::origin_probe_size`]; callers that must NOT treat a fault
2187    /// as an authoritative absence (the origin-only serve gate) use this
2188    /// directly instead of the size-only wrapper.
2189    ///
2190    /// A memo hit returns with no I/O. On a miss the origin chain is walked
2191    /// under a `cache.origin_probe_timeout_ms` ceiling — deliberately not via
2192    /// [`Self::origin_size`], whose per-origin error handling is
2193    /// swallow-and-advance so other callers can fall through a dead origin to a
2194    /// live one — and this layer decides what to remember:
2195    ///
2196    /// - `Present(size)` is memoised under the positive TTL;
2197    /// - `Fault` is memoised under the fault TTL (#1789 item 6). The memo is
2198    ///   keyed per hash, so this bounds repeat probes OF THE SAME HASH to one
2199    ///   live `HEAD` per fault TTL — the common shape when a client retries a
2200    ///   request against a failing origin. It does not bound namespace-wide
2201    ///   load: during an outage, N distinct hashes still cost N live `HEAD`s
2202    ///   per window. The TTL is graded against its neighbours and the config
2203    ///   resolver enforces `negative <= fault <= positive`: patient enough that
2204    ///   a retried hash is not re-probed as often as an absence, eager enough
2205    ///   that a recovered origin is noticed soon — a memoised fault costs
2206    ///   client-visible availability, since the origin-only serve gate answers
2207    ///   `InternalError` and the probe/DHT paths report this node holds nothing
2208    ///   for as long as it stands.
2209    /// - `Absent` — every configured origin answered `Ok(None)`, or none is
2210    ///   configured at all (a node with nothing configured genuinely holds
2211    ///   nothing, which is not transient) — is memoised under the short
2212    ///   negative TTL. Every adapter reserves `Ok(None)` for an authoritative
2213    ///   absence (a genuine 404) — an outage or a permission decline is an
2214    ///   `Err`, which reaches the `Fault` arm instead — so this arm never
2215    ///   caches a 5xx as an absence.
2216    ///
2217    /// **Never fetches the body** — existence and size only.
2218    pub async fn origin_probe_presence(&self, hash: Hash) -> OriginPresence {
2219        if self.refuses(hash) {
2220            return OriginPresence::Absent;
2221        }
2222        let now = Instant::now();
2223        let timeout = {
2224            let mut memo = self.probe_memo_lock();
2225            if let Some(presence) = memo.get(hash, now) {
2226                return presence.into();
2227            }
2228            memo.timeout()
2229        };
2230        // Live probe off the memo lock (never hold it across the await).
2231        let presence = self.probe_origin_chain(hash, timeout).await;
2232        let memo_presence = match presence {
2233            OriginPresence::Present(size) => Presence::Present(size),
2234            OriginPresence::Absent => Presence::Absent,
2235            OriginPresence::Fault => Presence::Fault,
2236        };
2237        if matches!(presence, OriginPresence::Fault) {
2238            // Warn rather than debug: the memo answers the repeats, so this
2239            // fires at most once per hash per fault TTL — and it is the only
2240            // record
2241            // that this node is about to refuse serves and answer probes with
2242            // "not held" for content it may well hold. The per-origin errors
2243            // behind the fault stay at debug.
2244            tracing::warn!(
2245                %hash,
2246                fault_ttl_secs = self.probe_memo_lock().fault_ttl().as_secs(),
2247                "origin probe faulted; memoising the fault — serves for this hash \
2248                 answer InternalError and probes answer not-held until it expires"
2249            );
2250        }
2251        self.probe_memo_lock().insert(hash, memo_presence, now);
2252        presence
2253    }
2254
2255    /// Lock the origin-probe memo, recovering a poisoned mutex rather than
2256    /// panicking (the anti-panic policy) — a poisoned memo only means a prior
2257    /// holder panicked mid-update, and a best-effort existence cache is safe to
2258    /// keep using.
2259    fn probe_memo_lock(&self) -> std::sync::MutexGuard<'_, OriginProbeMemo> {
2260        self.inner
2261            .origin_probe_memo
2262            .lock()
2263            .unwrap_or_else(PoisonError::into_inner)
2264    }
2265
2266    /// Install the live-origin-probe configuration at runtime bring-up (#1130
2267    /// pt3). `cache.*` is restart-required, so this is called once from the
2268    /// runtime wiring and swaps the memo wholesale (dropping any warm entries) —
2269    /// the same "set once, no threading through every test constructor" pattern
2270    /// as [`Self::set_max_probe_holds`].
2271    pub fn set_origin_probe_config(&self, policy: OriginProbePolicy) {
2272        *self.probe_memo_lock() = OriginProbeMemo::new(policy);
2273    }
2274
2275    /// Swap in the live *local* denied set from `[content] denied_hashes` (ADR
2276    /// 011 § Local Denylist). Returns the delta for the reload log line, same
2277    /// shape as [`Self::set_pinned`].
2278    ///
2279    /// Wholesale replacement, not a merge: removing an entry from the denylist
2280    /// and reloading must un-deny it. Touches only the local slot — a reload
2281    /// must not drop what the blacklist watcher put in [`Self::set_chain_denied`].
2282    pub fn set_denied(&self, new: &DeniedHashes) -> PinDiff {
2283        let new_arc = Arc::new(
2284            new.iter()
2285                .map(|h| to_store_hash(*h))
2286                .collect::<HashSet<Hash>>(),
2287        );
2288        let prev_arc = self.inner.denied.swap(Arc::clone(&new_arc));
2289        let added = new_arc.difference(&prev_arc).count();
2290        let removed = prev_arc.difference(&new_arc).count();
2291        PinDiff { added, removed }
2292    }
2293
2294    /// Is `hash` on the live denied set? Cheap O(1) lookup.
2295    ///
2296    /// Prefer [`Self::refuses`] unless you specifically need to tell a denial
2297    /// apart from an eviction — the serve path does, to pick the right refusal
2298    /// code and metric; nothing else should care.
2299    pub fn is_denied(&self, hash: Hash) -> bool {
2300        self.inner.denied.load().contains(&hash)
2301    }
2302
2303    /// Replace the governance deny-set wholesale.
2304    ///
2305    /// The set records *why* a hash is refused — governance-sourced, which selects
2306    /// the wire refusal code — a fact `evicted.log` (which records only *that* a
2307    /// hash was evicted) cannot express. The blacklist watcher rebuilds it each
2308    /// boot by re-checking every enumerated hash through
2309    /// `isHashBlacklistedForOperator` and writing survivors one at a time via
2310    /// [`Self::set_chain_denied_one`]; this bulk setter is retained for tests and
2311    /// any wholesale reseed.
2312    pub fn set_chain_denied(&self, hashes: HashSet<Hash>) {
2313        self.inner.chain_denied.store(Arc::new(hashes));
2314    }
2315
2316    /// Add or drop one governance-denied hash, returning whether the set
2317    /// actually changed so a caller can skip logging a no-op replay (the watcher
2318    /// re-scans a block range after a restart and re-delivers events it has
2319    /// already applied).
2320    ///
2321    /// Read-modify-write rather than in-place mutation, because [`ArcSwap`] has
2322    /// no such thing. Blacklist events are governance actions and therefore
2323    /// rare, so the clone costs nothing next to keeping the read side — which is
2324    /// on the request hot path — lock-free.
2325    pub fn set_chain_denied_one(&self, hash: Hash, denied: bool) -> bool {
2326        let current = self.inner.chain_denied.load();
2327        if current.contains(&hash) == denied {
2328            return false;
2329        }
2330        let mut next = HashSet::clone(&current);
2331        if denied {
2332            next.insert(hash);
2333        } else {
2334            next.remove(&hash);
2335        }
2336        self.inner.chain_denied.store(Arc::new(next));
2337        true
2338    }
2339
2340    /// Is `hash` on the live governance deny-set? Cheap O(1) lookup.
2341    ///
2342    /// Prefer [`Self::refuses`] unless you specifically need to tell a
2343    /// governance takedown apart from an eviction or a local denylist entry —
2344    /// the serve path does, to pick the operator-side metric; nothing else
2345    /// should care, and the *wire* code deliberately cannot tell this apart from
2346    /// [`Self::is_denied`].
2347    pub fn is_chain_denied(&self, hash: Hash) -> bool {
2348        self.inner.chain_denied.load().contains(&hash)
2349    }
2350
2351    /// Does this node refuse to serve, announce, or acquire `hash`?
2352    ///
2353    /// The single predicate every "should I expose this blob" decision must
2354    /// call. `is_evicted` alone is NOT sufficient and using it directly is the
2355    /// bug this exists to prevent: a denied-but-still-held hash would keep
2356    /// being probe-answered `has_blob: true` and DHT-republished while the
2357    /// serve path refused it — which under ADR 005 § Probe response is exactly
2358    /// the signed evidence pair that makes the operator slashable for a
2359    /// takedown they were discharging.
2360    ///
2361    /// A [quarantined](Self::is_quarantined) hash is refused too. Its stored
2362    /// bytes failed validation, so a serve or an announce only fails every
2363    /// buyer, and a re-acquisition only lands on the corrupt entry until GC
2364    /// reclaims it.
2365    pub fn refuses(&self, hash: Hash) -> bool {
2366        self.is_denied(hash)
2367            || self.is_chain_denied(hash)
2368            || self.is_evicted(hash)
2369            || self.is_quarantined(hash)
2370    }
2371
2372    /// Is this blob already present in the local store?
2373    ///
2374    /// Returns `Ok(false)` when the hash has been logically evicted (issue
2375    /// #279) even if the underlying store still holds the bytes — operators
2376    /// who call `evict` expect the node to stop serving immediately, so
2377    /// `has` reports the blob as absent.
2378    pub async fn has(&self, hash: Hash) -> CacheResult<bool> {
2379        self.lift_reclaimed_quarantine(hash).await;
2380        if self.refuses(hash) {
2381            return Ok(false);
2382        }
2383        self.inner
2384            .store
2385            .blobs()
2386            .has(hash)
2387            .await
2388            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))
2389    }
2390
2391    /// Serve-path presence + size audit for `hash` in ONE store actor call.
2392    ///
2393    /// Answers from a single `BlobStatus` what the delivery path otherwise asks
2394    /// in two hops — [`Self::has`] for presence, then [`Self::inspect`] for size
2395    /// (#1789 item 7, part B) — saving one store round-trip on every cache hit.
2396    /// Presence matches [`Self::has`] exactly: the blob must be `Complete` and
2397    /// no gate may refuse it, so a logically-evicted hash is
2398    /// [`ServeAudit::Unavailable`] even while the store still holds its bytes.
2399    ///
2400    /// Unlike [`Self::inspect`], a partial blob reports no size here. `inspect`
2401    /// exists to tell an operator how much disk a partial pull occupies; this
2402    /// audit answers what may go on the wire, and a partial blob's byte count
2403    /// is not that.
2404    pub async fn serve_audit(&self, hash: Hash) -> CacheResult<ServeAudit> {
2405        self.lift_reclaimed_quarantine(hash).await;
2406        let withdrawn = self.is_evicted(hash) || self.is_quarantined(hash);
2407        let refused = self.refuses(hash);
2408        let status = self
2409            .inner
2410            .store
2411            .blobs()
2412            .status(hash)
2413            .await
2414            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
2415        match status {
2416            iroh_blobs::api::blobs::BlobStatus::Complete { size } if !refused => {
2417                Ok(ServeAudit::Serveable { size })
2418            }
2419            iroh_blobs::api::blobs::BlobStatus::Complete { .. }
2420            | iroh_blobs::api::blobs::BlobStatus::NotFound
2421            | iroh_blobs::api::blobs::BlobStatus::Partial { .. } => {
2422                Ok(ServeAudit::Unavailable { withdrawn })
2423            }
2424        }
2425    }
2426
2427    /// Which chunk ranges of `hash` are present on disk right now.
2428    ///
2429    /// Classifies via `status()` first (so an absent hash returns immediately),
2430    /// then snapshots the current bitfield by awaiting `observe` directly — the
2431    /// FIRST `observe` item is the current state. It deliberately does NOT call
2432    /// `ObserveProgress::await_completion`, which blocks until the blob is
2433    /// *complete* and would hang forever on a partial or absent blob.
2434    /// Logically-evicted hashes report absent, mirroring [`Self::has`]. Pure
2435    /// query: does not touch access times and changes no serving behavior.
2436    pub async fn present_ranges(&self, hash: Hash) -> CacheResult<PresentRanges> {
2437        if self.refuses(hash) {
2438            return Ok(PresentRanges::absent());
2439        }
2440        // Resolve absence without `observe`: a hash the store has never seen has
2441        // no defined current bitfield to await, and a size-0 bitfield reports
2442        // `is_complete() == true` vacuously — both are wrong answers here.
2443        let status = self
2444            .inner
2445            .store
2446            .blobs()
2447            .status(hash)
2448            .await
2449            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
2450        if matches!(status, iroh_blobs::api::blobs::BlobStatus::NotFound) {
2451            return Ok(PresentRanges::absent());
2452        }
2453        // The blob exists (Partial or Complete): its current bitfield is the
2454        // first item `observe` yields, available immediately.
2455        let bitfield = self
2456            .inner
2457            .store
2458            .blobs()
2459            .observe(hash)
2460            .await
2461            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
2462        Ok(PresentRanges {
2463            complete: bitfield.is_complete(),
2464            size: bitfield.size(),
2465            ranges: bitfield.ranges,
2466        })
2467    }
2468
2469    /// A live watch of which chunk ranges of `hash` are present, for progressive
2470    /// serve-while-filling (#1621). Yields the current bitfield's
2471    /// [`bao_tree::ChunkRanges`] first, then further updates as the blob fills.
2472    ///
2473    /// Mirrors [`Self::present_ranges`]'s guards (refuse an evicted/blacklisted
2474    /// hash, gate a never-seen hash via `status()` before observing — a hash
2475    /// with no defined current state has nothing to watch), but stays a live
2476    /// stream instead of a point-in-time snapshot. Deliberately uses
2477    /// `ObserveProgress::stream()`, NEVER `await_completion`, which blocks
2478    /// until the blob is complete and would hang forever on a partial blob.
2479    pub async fn observe_present_ranges(
2480        &self,
2481        hash: Hash,
2482    ) -> CacheResult<Pin<Box<dyn futures_util::Stream<Item = bao_tree::ChunkRanges> + Send>>> {
2483        use futures_util::StreamExt;
2484        // Same guards as `present_ranges`: never observe an evicted/blacklisted
2485        // hash, and gate a never-seen hash (observe has no defined current state).
2486        if self.refuses(hash) {
2487            return Err(CacheError::Store(anyhow::anyhow!(
2488                "observe_present_ranges: hash is evicted/blacklisted"
2489            )));
2490        }
2491        let status = self
2492            .inner
2493            .store
2494            .blobs()
2495            .status(hash)
2496            .await
2497            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
2498        if matches!(status, iroh_blobs::api::blobs::BlobStatus::NotFound) {
2499            return Err(CacheError::Store(anyhow::anyhow!(
2500                "observe_present_ranges: blob not present"
2501            )));
2502        }
2503        // `.stream()` yields the current bitfield first, then updates. NEVER
2504        // `.await_completion()` — it loops until complete and hangs on a partial.
2505        let stream = self
2506            .inner
2507            .store
2508            .blobs()
2509            .observe(hash)
2510            .stream()
2511            .await
2512            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
2513        Ok(Box::pin(stream.map(|bf| bf.ranges)))
2514    }
2515
2516    /// Which [`decdn_protocol::DISCOVERY_BLOCK_BYTES`] discovery blocks of
2517    /// `hash` this node can serve from its own cache right now.
2518    ///
2519    /// Cached blocks only — an origin-backed node that could re-pull the
2520    /// whole blob on demand does NOT get all-ones here; that capability-aware
2521    /// widening is the probe handler's job (it alone knows whether `hash` has
2522    /// a configured origin), not this cache-only derivation's. Absent,
2523    /// evicted, or otherwise refused hashes report [`decdn_protocol::Coverage::empty`],
2524    /// mirroring [`Self::present_ranges`]'s guards.
2525    ///
2526    /// A block is covered iff every chunk in its byte span is present: this
2527    /// reads the blob's size from the `observe()` bitfield (via
2528    /// [`Self::present_ranges`]), then diffs each block's chunk range against
2529    /// the present ranges the same way [`Self::missing_ranges`] diffs a
2530    /// requested range — a block with any missing chunk is not covered. The
2531    /// bitfield knows the size as soon as any chunk carries it, so a
2532    /// front-prefix partial with no validated tail still reports its covered
2533    /// blocks (where `status()` would leave the size unknown until the last
2534    /// chunk). A `NotFound`, evicted, or refused hash reports no blocks.
2535    pub async fn coverage(&self, hash: Hash) -> CacheResult<decdn_protocol::Coverage> {
2536        Ok(self.coverage_sized(hash).await?.0)
2537    }
2538
2539    /// [`Self::coverage`] plus the blob size it was derived from, so a partial
2540    /// holder can advertise its size alongside its blocks. The size is `None`
2541    /// exactly when the coverage is empty for want of a known size: an absent,
2542    /// evicted, or refused hash.
2543    ///
2544    /// The size comes from the same `observe()` bitfield read as the coverage,
2545    /// so the pair costs one read and cannot disagree.
2546    pub async fn coverage_sized(
2547        &self,
2548        hash: Hash,
2549    ) -> CacheResult<(decdn_protocol::Coverage, Option<u64>)> {
2550        if self.refuses(hash) {
2551            return Ok((decdn_protocol::Coverage::empty(), None));
2552        }
2553        // The size comes from the `observe()` bitfield (via `present_ranges`),
2554        // not `status()`: iroh-blobs leaves a partial blob's size unknown
2555        // until its LAST chunk validates, but the bitfield already knows it
2556        // as soon as any chunk carries it — so a front-prefix partial with no
2557        // tail still derives a non-empty coverage here. `present_ranges`
2558        // already handles the absent/evicted guards (size 0 there too).
2559        let present = self.present_ranges(hash).await?;
2560        let size = present.size();
2561        let coverage = decdn_protocol::Coverage::from_block_indices(
2562            decdn_protocol::num_blocks(size),
2563            covered_blocks(size, present.chunk_ranges()),
2564        );
2565        Ok((coverage, (size > 0).then_some(size)))
2566    }
2567
2568    /// Announce `hash` on [`Self::subscribe_inserts`] when the admit of
2569    /// `admitted` completed at least one discovery block — a block that
2570    /// `admitted` touches and that is now fully present (ADR 022 §STORE Flow:
2571    /// a partial holder publishes once it verifies a 64 MiB block).
2572    ///
2573    /// One admit fills a window, not a block, so most admits touch no fully
2574    /// present block and announce nothing. A front-to-back fill announces about
2575    /// once per block, and the republisher publishes eagerly only once.
2576    ///
2577    /// Fails open: when the coverage query faults, it announces anyway. No
2578    /// later event may name this hash — a blob under 64 MiB has one block, and
2579    /// a finished fill admits nothing more — so a dropped announcement would
2580    /// last until the next lag sweep or restart. The consumer reads coverage
2581    /// itself and publishes nothing for a hash that covers no block. With no
2582    /// subscriber (no DHT task) it skips the store query entirely.
2583    async fn announce_completed_blocks(&self, hash: Hash, admitted: &ChunkRanges) {
2584        if self.inner.inserts_tx.receiver_count() == 0 {
2585            return;
2586        }
2587        let present = match self.present_ranges(hash).await {
2588            Ok(present) => present,
2589            Err(err) => {
2590                tracing::warn!(
2591                    %hash,
2592                    error = %err.display_chain(),
2593                    "admit: coverage query failed; announcing the hash so the republisher \
2594                     decides from its own coverage read"
2595                );
2596                let _ = self.inner.inserts_tx.send(hash);
2597                return;
2598            }
2599        };
2600        let size = present.size();
2601        let completed = covered_blocks(size, present.chunk_ranges())
2602            .any(|i| !discovery_block_span(i, size).is_disjoint(admitted));
2603        if completed {
2604            // `Err` only means no subscriber is left; nothing to announce to.
2605            let _ = self.inner.inserts_tx.send(hash);
2606        }
2607    }
2608
2609    /// The chunk-aligned sub-ranges of `[byte_offset, byte_offset + byte_len)`
2610    /// (`byte_len == 0` = to `blob_size`, an end past `blob_size` clamped to
2611    /// it) that are NOT present on disk.
2612    ///
2613    /// Empty ⇒ the requested span is fully present: a completeness-aware read can
2614    /// serve it with no fetch, and a resumed pull is a no-op. `blob_size` is
2615    /// caller-supplied (the signed `total_bytes`), the same contract as
2616    /// [`Self::export_bao_range_stream`].
2617    pub async fn missing_ranges(
2618        &self,
2619        hash: Hash,
2620        byte_offset: u64,
2621        byte_len: u64,
2622        blob_size: u64,
2623    ) -> CacheResult<ChunkRanges> {
2624        // Same align_range_clamped error mapping as `export_bao_range_stream`:
2625        // only a `byte_offset` at or past `blob_size` is an argument error, not an
2626        // origin fault; an end past `blob_size` clamps rather than erroring, so a
2627        // partial holder does not refuse its own already-complete partial hit.
2628        let aligned = align_range_clamped(byte_offset, byte_len, blob_size).map_err(|e| {
2629            CacheError::Store(anyhow::Error::from(e).context("missing_ranges: range alignment"))
2630        })?;
2631        let present = self.present_ranges(hash).await?;
2632        // requested \ present. `ChunkRanges` is a `RangeSet2`, which implements
2633        // `Sub` (`owned - &ref -> owned`).
2634        Ok(aligned.chunk_ranges().clone() - present.chunk_ranges())
2635    }
2636
2637    /// Mark `hash` as evicted: subsequent [`Self::has`] / [`Self::get`] calls
2638    /// behave as if the blob is absent (returning `false` / `NotFound`
2639    /// respectively). The corresponding [`Self::access_times_snapshot`] entry
2640    /// is cleared so future LRU sweeps don't re-surface the hash.
2641    ///
2642    /// Serving stops immediately via the logical-evicted set (`Blobs::delete`
2643    /// is `pub(crate)` in iroh-blobs and reserved for the GC task, so the
2644    /// bytes are not removed synchronously). `evict` deletes the blob's
2645    /// protecting named tag(s) (#860) so the bytes become GC-eligible; the
2646    /// disk is then reclaimed on the next iroh-blobs GC sweep — cadence
2647    /// `cache.gc_interval_sec`, default 5min (#518) — *when periodic GC is
2648    /// enabled*. With GC disabled (`gc_interval_sec == 0`) serving still stops
2649    /// but the bytes stay on disk until a sweep is configured. Reclaim is
2650    /// best-effort in the other direction too: the tag deletion is not
2651    /// allowed to fail the takedown, so if it errors the bytes remain
2652    /// GC-protected (surfaced via `tag_drop_failures`) while serving stays
2653    /// blocked. The operator-visible behavior — the node stops serving the
2654    /// blob immediately, and reclaims its disk on the next sweep when GC is
2655    /// enabled and the tag delete succeeded — is what `decdn node evict`
2656    /// (issue #279) needs for use cases like DMCA takedown. A stored hash
2657    /// mismatch that a serve detects does not come here: it takes the
2658    /// non-durable [quarantine](Self::is_quarantined), because a corrupt
2659    /// local copy must stay re-acquirable.
2660    ///
2661    /// Persisted: the eviction is appended (with `fsync`) to
2662    /// `<cache_dir>/evicted.log` before this call returns successfully, so
2663    /// the takedown survives a process restart. A persistence failure is
2664    /// surfaced as `Err` rather than silently downgrading to in-memory-only —
2665    /// for DMCA-driven evicts the operator must be able to tell whether
2666    /// the takedown is durable, otherwise a node restart could resume
2667    /// serving the content.
2668    ///
2669    /// Async because the durable append runs the blocking `fsync` on a
2670    /// `spawn_blocking` thread rather than on the async runtime (#845), so a
2671    /// caller on a request-serving worker does not stall on disk I/O.
2672    pub async fn evict(&self, hash: Hash) -> CacheResult<()> {
2673        // Lock-free pre-check on a single snapshot: short-circuit on
2674        // already-evicted (a sequential repeat-evict of the same hash returns
2675        // here and never re-appends) and reject on cap (DoS bound on an
2676        // unbounded public-ish surface). Both checks are advisory against
2677        // concurrency — the snapshot is read before the durable append below —
2678        // and `insert_if_absent` re-decides both under the write lock, so the
2679        // cap is exact and a race on the same new hash appends at most one
2680        // duplicate `evicted.log` line (harmless: replay folds the log into a
2681        // `HashSet`).
2682        let evicted = self.inner.evicted.snapshot();
2683        if evicted.contains(&hash) {
2684            return Ok(());
2685        }
2686        if evicted.len() >= MAX_EVICTED_ENTRIES {
2687            return Err(CacheError::EvictionLimitExceeded {
2688                limit: MAX_EVICTED_ENTRIES,
2689            });
2690        }
2691        drop(evicted);
2692
2693        // Persist FIRST, then commit to the in-memory set: a crash between
2694        // these two steps will at worst replay a successful evict on the
2695        // next open, which is idempotent. The opposite ordering would
2696        // briefly stop serving but lose durability if the fsync failed —
2697        // the worst-case scenario for a takedown.
2698        //
2699        // The append's `fsync` is blocking, so run it on a `spawn_blocking`
2700        // thread (#845) to keep it off the async runtime — `evict` is reached
2701        // from the request-serving hash-mismatch path. A panic in the blocking
2702        // task (JoinError) and an I/O failure are both surfaced as `Err` so the
2703        // operator never gets an `Ok` that silently skipped durable persistence.
2704        let log_path = self.inner.evicted_log_path.clone();
2705        tokio::task::spawn_blocking(move || append_evicted_log(&log_path, hash))
2706            .await
2707            .map_err(|join_err| {
2708                CacheError::Store(anyhow::Error::new(join_err).context("persist eviction task"))
2709            })?
2710            .map_err(|err| {
2711                CacheError::Store(anyhow::Error::from(err).context("persist eviction"))
2712            })?;
2713
2714        // Commit to the in-memory set (#1789 item 5). The publish is a single
2715        // atomic swap of a superset, so no reader can observe `Ok(())` here
2716        // with the set still excluding `hash`, and the read side never has a
2717        // lock that could be poisoned into a "still serving" fallback.
2718        if !self
2719            .inner
2720            .evicted
2721            .insert_if_absent(hash, MAX_EVICTED_ENTRIES)
2722        {
2723            return Err(CacheError::EvictionLimitExceeded {
2724                limit: MAX_EVICTED_ENTRIES,
2725            });
2726        }
2727        self.inner.access_times.remove(&hash);
2728        self.inner
2729            .segments
2730            .lock()
2731            .unwrap_or_else(PoisonError::into_inner)
2732            .remove(&hash);
2733
2734        // Disk reclaim (#860): serving has already stopped via the logical
2735        // set above, but a successfully pulled-through blob carries a named
2736        // tag that protects it from the iroh-blobs GC sweep forever. Delete
2737        // that tag so the next sweep can actually reclaim the bytes — without
2738        // this a DMCA evict retains the content on disk indefinitely. Two
2739        // caveats on the reclaim, both intentional: GC must be enabled
2740        // (`cache.gc_interval_sec > 0`); and a pull-through for the same hash
2741        // that races this evict can re-create a protecting tag *after* the
2742        // delete below (the commit path does not re-check `is_evicted`), in
2743        // which case reclaim waits until that tag is itself cleared — serving
2744        // still stays blocked via the logical set either way.
2745        //
2746        // Best-effort: the durable serve-blocking guarantee is already in
2747        // place, so a tag-delete failure only delays reclaim (it can never
2748        // resurrect serving) and must not turn a successful takedown into an
2749        // `Err`. The failure is surfaced via `tag_drop_failures` + a warn so
2750        // an operator can act; note nothing auto-retries it — a re-run of
2751        // `evict()` short-circuits at the `contains` check above, and open()
2752        // replay only reloads the logical set, so recovery is manual.
2753        match self.drop_named_tags_for(hash).await {
2754            Ok(deleted) => tracing::debug!(%hash, deleted, "evict: dropped protecting tags"),
2755            Err(err) => {
2756                if let Some(m) = &self.inner.metrics {
2757                    m.tag_drop_failures.inc();
2758                }
2759                tracing::warn!(
2760                    %hash,
2761                    error = %err.display_chain(),
2762                    "evict: failed to drop protecting tags; bytes stay GC-protected (not auto-retried)",
2763                );
2764            }
2765        }
2766        // Operator-evict (DMCA/corruption) counter — distinct from the LRU
2767        // `evictions` counter the eviction driver bumps (#1173, ADR 040). Bumped after the
2768        // durable append + logical-set commit succeeded above, so the count
2769        // tracks takedowns that actually stopped serving.
2770        if let Some(m) = &self.inner.metrics {
2771            m.evicted_operator.inc();
2772        }
2773        Ok(())
2774    }
2775
2776    /// Protect a partial (range-admitted) blob from GC by giving its raw hash a
2777    /// deterministic named tag. Idempotent — `set` overwrites the same name, so
2778    /// re-admitting more ranges never proliferates tags. The `decdn-partial-`
2779    /// prefix is opaque to iroh-blobs; `drop_named_tags_for` (evict) matches by
2780    /// hash and removes it. A GC sweep in the sub-second window between
2781    /// `import_bao_bytes` and this `set` is bounded by the GC interval and
2782    /// self-heals on the next admit. #1607.
2783    ///
2784    /// The tag only needs to EXIST, so once this process has written it (tracked
2785    /// in `partial_protected`) the store write is skipped — a single fill admits
2786    /// many ranges, and only the first need pay the tag write. The memo is cleared
2787    /// whenever the tag is dropped (`drop_named_tags_for`), so a re-admit after an
2788    /// eviction re-protects.
2789    ///
2790    /// The memo is a best-effort optimization, not a memo↔tag invariant: the
2791    /// `contains_key`/`insert` here and the `remove` in `drop_named_tags_for` are
2792    /// not atomic against the store, so a `protect_partial` racing a concurrent
2793    /// evict of the same hash can leave the memo set while the tag was deleted.
2794    /// That never affects served correctness — served bytes are hash-verified, and
2795    /// an operator takedown blocks serving through the logical evicted set, not
2796    /// through this tag (see `evict`). Its only cost is that such a partial may go
2797    /// unprotected and be GC-reclaimed, which self-corrects on the next pull.
2798    /// Concurrent first-admits merely repeat one idempotent `set`.
2799    ///
2800    /// A [quarantined](Self::is_quarantined) hash gets no tag: the tag would
2801    /// protect the corrupt entry from the GC sweep that ends the quarantine. A
2802    /// fill already past this check when the quarantine starts can still write
2803    /// the tag. The origin rescan drops it again
2804    /// ([`Self::lift_reclaimed_quarantines`]).
2805    async fn protect_partial(&self, hash: Hash) -> CacheResult<()> {
2806        if self.is_quarantined(hash) || self.inner.partial_protected.contains_key(&hash) {
2807            return Ok(());
2808        }
2809        let name = format!("decdn-partial-{hash}");
2810        self.inner
2811            .store
2812            .tags()
2813            .set(name.as_bytes(), HashAndFormat::raw(hash))
2814            .await
2815            .map_err(|e| {
2816                CacheError::Store(anyhow::Error::from(e).context("protect_partial: tags().set"))
2817            })?;
2818        self.inner.partial_protected.insert(hash, ());
2819        Ok(())
2820    }
2821
2822    /// Delete every named tag pointing at `hash`, making the underlying
2823    /// bytes eligible for the iroh-blobs GC sweep.
2824    ///
2825    /// Pull-through promotes each cached blob to a named tag
2826    /// ([`iroh_blobs::api::tags::Tags::create`] on the streaming path,
2827    /// `add_bytes` on the drain path); iroh-blobs GC protects any blob
2828    /// reachable from a tag, so the bytes are never reclaimed while a tag
2829    /// survives. Tag names are opaque and store-assigned, so matches are
2830    /// found by enumerating tags and comparing hashes. A hash can carry more
2831    /// than one tag, so every match is removed (in practice the engine creates
2832    /// one tag per cached hash — `get`/`populate` short-circuit on a hit and
2833    /// in-flight pulls coalesce (#305) — but the loop is robust to a future
2834    /// caller that tags the same hash twice). Returns the number of tags
2835    /// deleted.
2836    ///
2837    /// Cost scales with the *total* tag set: iroh-blobs has no hash-indexed
2838    /// tag lookup, so this lists every tag and filters in Rust. Fine at
2839    /// operator-evict / mismatch rates; not something to call on a hot path.
2840    async fn drop_named_tags_for(&self, hash: Hash) -> CacheResult<u64> {
2841        // Invalidate the partial-protection memo before touching the store, so a
2842        // later admit re-issues the protecting tag rather than trusting a stale
2843        // "already protected" entry for the tag we are removing. Clearing it up
2844        // front (not after the deletes) also means a delete failure mid-loop still
2845        // leaves the memo clear, so a re-admit re-protects. This is best-effort,
2846        // not atomic against a concurrent `protect_partial` of the same hash — the
2847        // race is benign for the reasons documented on `protect_partial`.
2848        self.inner.partial_protected.remove(&hash);
2849        let tags = self.inner.store.tags();
2850        // Collect matching names before deleting so the (immutable) list
2851        // stream is fully drained before any delete call — keeps the two
2852        // store interactions sequential and easy to reason about.
2853        let mut to_delete = Vec::new();
2854        {
2855            let mut stream = tags
2856                .list()
2857                .await
2858                .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
2859            while let Some(info) = stream.next().await {
2860                let info = info.map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
2861                if info.hash == hash {
2862                    to_delete.push(info.name);
2863                }
2864            }
2865        }
2866        let mut deleted = 0u64;
2867        for name in to_delete {
2868            deleted += tags
2869                .delete(name)
2870                .await
2871                .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
2872        }
2873        Ok(deleted)
2874    }
2875
2876    /// Read-only inspection of a hash's local cache state, used by
2877    /// `admin_v1_evict { dry_run: true }` (issue #379) to preview what
2878    /// an [`Self::evict`] call would touch without mutating any state.
2879    ///
2880    /// Performs a single `BlobStatus` query against the underlying
2881    /// store and reuses the cheap in-memory lookups for pin / access /
2882    /// already-evicted flags. The returned [`EvictionPreview`] also
2883    /// pre-computes a `served` bool so admin can derive `was_present`
2884    /// without a follow-up [`Self::has`] call.
2885    ///
2886    /// The three in-memory probes are each an O(1) lookup and none of them
2887    /// blocks: `evicted` and `pinned` are `ArcSwap` loads and `access_times` is
2888    /// one `DashMap` shard. Same pattern as [`Self::eviction_candidates`].
2889    pub async fn inspect(&self, hash: Hash) -> CacheResult<EvictionPreview> {
2890        let status = self
2891            .inner
2892            .store
2893            .blobs()
2894            .status(hash)
2895            .await
2896            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
2897
2898        // Decompose `BlobStatus` once: `complete` feeds `served`,
2899        // `size_bytes` feeds the wire response. Keeping the `match` here
2900        // (rather than splitting into two helpers) makes the mapping
2901        // between iroh-blobs states and our preview fields auditable in
2902        // one place.
2903        let (size_bytes, complete) = match status {
2904            iroh_blobs::api::blobs::BlobStatus::NotFound => (None, false),
2905            iroh_blobs::api::blobs::BlobStatus::Partial { size } => (size, false),
2906            iroh_blobs::api::blobs::BlobStatus::Complete { size } => (Some(size), true),
2907        };
2908
2909        let already_evicted = self.is_evicted(hash);
2910        // `served` mirrors the `has()` semantic: complete AND not
2911        // evicted. Pre-computed here so admin doesn't issue a second
2912        // round-trip to compute `was_present`.
2913        let served = complete && !already_evicted;
2914
2915        let last_accessed_us_ago = self.last_accessed(hash).map(|inst| {
2916            // Saturate-on-overflow rather than panic. `Instant::elapsed`
2917            // can theoretically exceed u64::MAX microseconds on a
2918            // process that's been up for ~580k years; defensive against
2919            // a future test that builds an `Instant` from a stub.
2920            u64::try_from(inst.elapsed().as_micros()).unwrap_or(u64::MAX)
2921        });
2922
2923        let origin_kinds = self.inner.origins.iter().map(|o| o.kind()).collect();
2924
2925        Ok(EvictionPreview {
2926            size_bytes,
2927            last_accessed_us_ago,
2928            pinned: self.is_pinned(hash),
2929            already_evicted,
2930            served,
2931            origin_kinds,
2932        })
2933    }
2934
2935    /// Has this hash been logically evicted via [`Self::evict`]?
2936    ///
2937    /// Lock-free (#1789 item 5): one `ArcSwap` load and a hash-set probe, so
2938    /// the serve-path gate takes no mutex and has no lock-poisoning failure
2939    /// mode that could answer not-evicted for a taken-down hash. The write side
2940    /// only ever publishes a superset, so an evicted hash is observed evicted
2941    /// by every reader that linearizes after the swap.
2942    pub fn is_evicted(&self, hash: Hash) -> bool {
2943        self.inner.evicted.contains(hash)
2944    }
2945
2946    /// Is this hash quarantined because its stored bytes failed validation on
2947    /// a serve export?
2948    ///
2949    /// A hash mismatch or a short read over held content in
2950    /// [`Self::export_bao_range_stream`] or [`Self::outboard_pairs`] quarantines
2951    /// the hash. [`Self::refuses`] then withholds it from serving, announcing,
2952    /// and re-acquisition. The engine drops its protecting tags, even when the
2953    /// hash is pinned: the pin does not keep corrupt bytes from GC. The
2954    /// quarantine lifts on the next [`Self::has`], [`Self::serve_audit`], or
2955    /// origin rescan that finds the store no longer holds the hash. The quarantine is in memory only.
2956    pub fn is_quarantined(&self, hash: Hash) -> bool {
2957        self.inner.quarantined.contains_key(&hash)
2958    }
2959
2960    /// Quarantine a held `hash` whose serve export failed bao validation
2961    /// against the content root.
2962    ///
2963    /// The store bytes or outboard diverged after admission, so every later
2964    /// serve fails the same way. The quarantine makes [`Self::refuses`]
2965    /// withhold the hash from serving, announcing, and re-acquisition. It
2966    /// drops the protecting named tags, so the next GC sweep reclaims the
2967    /// entry, and it forgets the access-time and segment entries. The tags
2968    /// drop for a pinned hash too: a pin protects content, and these bytes
2969    /// are not that content. [`Self::lift_reclaimed_quarantine`] ends the
2970    /// quarantine when the store no longer holds the hash.
2971    ///
2972    /// Idempotent: only the first trip per hash counts
2973    /// `held_corruption_quarantined` and walks the tag store. A tag-drop
2974    /// failure counts `tag_drop_failures` and leaves the bytes GC-protected
2975    /// until [`Self::lift_reclaimed_quarantines`] retries the drop on the next
2976    /// origin rescan. The hash stays refused regardless.
2977    async fn quarantine_corrupt(&self, hash: Hash) {
2978        if self.inner.quarantined.insert(hash, ()).is_some() {
2979            return;
2980        }
2981        if let Some(m) = &self.inner.metrics {
2982            m.held_corruption_quarantined.inc();
2983        }
2984        if self.inner.gc_store_handle.is_some() {
2985            tracing::warn!(
2986                %hash,
2987                "held blob failed bao validation on export; quarantined it and released it to GC"
2988            );
2989        } else {
2990            tracing::warn!(
2991                %hash,
2992                "held blob failed bao validation on export; quarantined it. GC is disabled \
2993                 (cache.gc_interval_sec = 0), so the corrupt bytes stay on disk and the hash \
2994                 stays withdrawn until a restart"
2995            );
2996        }
2997        self.inner.access_times.remove(&hash);
2998        self.inner
2999            .segments
3000            .lock()
3001            .unwrap_or_else(PoisonError::into_inner)
3002            .remove(&hash);
3003        self.drop_quarantined_tags(hash).await;
3004    }
3005
3006    /// Drop the protecting tags of quarantined `hash`. Returns how many tags
3007    /// were dropped; a failure counts `tag_drop_failures`, logs, and returns 0.
3008    async fn drop_quarantined_tags(&self, hash: Hash) -> u64 {
3009        match self.drop_named_tags_for(hash).await {
3010            Ok(dropped) => dropped,
3011            Err(err) => {
3012                if let Some(m) = &self.inner.metrics {
3013                    m.tag_drop_failures.inc();
3014                }
3015                tracing::warn!(
3016                    %hash,
3017                    error = %err.display_chain(),
3018                    "quarantine: dropping the protecting tags failed; the corrupt bytes stay on \
3019                     disk until the next origin rescan retries"
3020                );
3021                0
3022            }
3023        }
3024    }
3025
3026    /// End the quarantine on `hash` when the store no longer holds it. Returns
3027    /// `true` when `hash` is not quarantined after the call.
3028    ///
3029    /// GC reclaims a quarantined entry because [`Self::quarantine_corrupt`]
3030    /// dropped its tags. After the sweep nothing corrupt remains, so a
3031    /// pull-through may admit a freshly verified copy. A store fault keeps the
3032    /// quarantine, and a later call retries.
3033    async fn lift_reclaimed_quarantine(&self, hash: Hash) -> bool {
3034        if !self.is_quarantined(hash) {
3035            return true;
3036        }
3037        match self.inner.store.blobs().status(hash).await {
3038            Ok(iroh_blobs::api::blobs::BlobStatus::NotFound) => {
3039                self.inner.quarantined.remove(&hash);
3040                tracing::info!(%hash, "quarantined blob reclaimed; lifted the quarantine");
3041                true
3042            }
3043            Ok(_) => false,
3044            Err(err) => {
3045                tracing::debug!(
3046                    %hash,
3047                    error = %err,
3048                    "quarantine: store status failed; the quarantine stays"
3049                );
3050                false
3051            }
3052        }
3053    }
3054
3055    /// Lift every quarantine whose entry GC has reclaimed, and drop the tags
3056    /// again for every quarantined entry the store still holds.
3057    ///
3058    /// The periodic origin rescan calls this, so a reclaimed hash rejoins the
3059    /// origin-held announce set even when no request touches it. The second
3060    /// tag drop covers a fill or pull-through that was already past its
3061    /// quarantine check when the quarantine started and then protected the
3062    /// corrupt entry. Without it, that tag would keep the entry from GC and the
3063    /// quarantine would never lift.
3064    async fn lift_reclaimed_quarantines(&self) {
3065        let quarantined: Vec<Hash> = self.inner.quarantined.iter().map(|e| *e.key()).collect();
3066        for hash in quarantined {
3067            if self.lift_reclaimed_quarantine(hash).await {
3068                continue;
3069            }
3070            let dropped = self.drop_quarantined_tags(hash).await;
3071            if dropped > 0 {
3072                tracing::warn!(
3073                    %hash,
3074                    dropped,
3075                    "quarantine: a fill re-protected the corrupt entry; dropped its tags again"
3076                );
3077            }
3078        }
3079    }
3080
3081    /// Quarantine `hash` when the export error `cause` proves that held content
3082    /// diverged from the root.
3083    ///
3084    /// Three errors can prove it:
3085    /// - `LeafHashMismatch` and `ParentHashMismatch` fail the hash check.
3086    /// - `Io` with `UnexpectedEof` means a data or outboard file is shorter
3087    ///   than the size the store recorded.
3088    ///
3089    /// Each counts only over content the store holds. A partial blob's data
3090    /// file is sparse, so an absent range reads back as zeros or ends early,
3091    /// and fails exactly like corruption. A complete blob holds every range,
3092    /// so any of the three errors quarantines it. A partial blob is
3093    /// quarantined only when the failing chunks lie inside its present ranges.
3094    /// A hash mismatch names the failing leaf or node. A short read names no
3095    /// location, so it counts only when the whole `requested` export range is
3096    /// present. Every other error is a store fault or an absent blob, not
3097    /// corruption.
3098    async fn quarantine_on_mismatch(
3099        &self,
3100        hash: Hash,
3101        cause: &bao_tree::io::EncodeError,
3102        requested: &ChunkRanges,
3103    ) {
3104        use bao_tree::ChunkNum;
3105        use bao_tree::io::EncodeError;
3106
3107        let chunk_log = decdn_bao_range::IROH_BLOCK_SIZE.chunk_log();
3108        // Leaf and node ranges are in 1 KiB chunks, the unit of `ChunkRanges`.
3109        let failing = match cause {
3110            EncodeError::LeafHashMismatch(start) => {
3111                ChunkRanges::from(*start..ChunkNum(start.0.saturating_add(1 << chunk_log)))
3112            }
3113            EncodeError::ParentHashMismatch(node) => ChunkRanges::from(node.chunk_range()),
3114            EncodeError::Io(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => {
3115                requested.clone()
3116            }
3117            _ => return,
3118        };
3119        let Ok(present) = self.present_ranges(hash).await else {
3120            return;
3121        };
3122        let held = present.is_complete() || {
3123            // Clip the failing groups to the blob end: the last group is short.
3124            let failing = failing & ChunkRanges::from(..ChunkNum::chunks(present.size()));
3125            !failing.is_empty() && (failing - present.chunk_ranges()).is_empty()
3126        };
3127        if held {
3128            self.quarantine_corrupt(hash).await;
3129        }
3130    }
3131
3132    /// Set the probe-hold budget cap from `cache.max_probe_holds` (ADR 005
3133    /// §Hold budget, #318). Called once by the runtime at bring-up. `0`
3134    /// disables the hold path so [`Self::try_probe_hold`] returns
3135    /// [`ProbeHoldOutcome::HoldsDisabled`] for any present blob (the node then
3136    /// answers `has_blob: false` to every probe).
3137    pub fn set_max_probe_holds(&self, max: usize) {
3138        self.inner.max_probe_holds.store(max, Ordering::Relaxed);
3139    }
3140
3141    /// Set the time budget for each origin read of the range-pull path — one
3142    /// `{H}.obao4` fetch or one data window (ADR 037). Called once by the
3143    /// runtime at bring-up, from `cache.node_pull_stall_window_sec` and
3144    /// `cache.node_pull_min_throughput_bps`. A read of `len` bytes gets
3145    /// `head_start + len / min_bps`, so the budget scales with the read and
3146    /// never caps the blob size: the origin must only sustain `min_bps` on
3147    /// average after the head start. A read past its budget fails as an origin
3148    /// transport fault and bumps `origin_range_timeouts`, so a stuck origin ends
3149    /// the fill instead of parking it until the client gives up.
3150    ///
3151    /// A zero `head_start` or a zero `min_bps` leaves the reads unbounded. A
3152    /// whole-body read has no stream to watch for idle gaps, so there is no
3153    /// idle-only form of the budget. A non-zero `head_start` below one
3154    /// millisecond rounds up to one millisecond.
3155    pub fn set_origin_read_budget(&self, head_start: Duration, min_bps: u64) {
3156        let ms = if head_start.is_zero() {
3157            0
3158        } else {
3159            u64::try_from(head_start.as_millis())
3160                .unwrap_or(u64::MAX)
3161                .max(1)
3162        };
3163        self.inner
3164            .origin_read_head_start_ms
3165            .store(ms, Ordering::Relaxed);
3166        self.inner
3167            .origin_read_min_bps
3168            .store(min_bps, Ordering::Relaxed);
3169    }
3170
3171    /// The budget [`Self::set_origin_read_budget`] set, or `None` for none.
3172    fn origin_read_budget(&self) -> Option<OriginReadBudget> {
3173        let ms = self.inner.origin_read_head_start_ms.load(Ordering::Relaxed);
3174        let min_bps = self.inner.origin_read_min_bps.load(Ordering::Relaxed);
3175        (ms > 0 && min_bps > 0).then(|| OriginReadBudget {
3176            head_start: Duration::from_millis(ms),
3177            min_bps,
3178        })
3179    }
3180
3181    /// The cached outboard for `hash`, if it has the length a `total_bytes`-byte
3182    /// blob needs.
3183    #[cfg(test)]
3184    fn cached_outboard(&self, hash: Hash, total_bytes: u64) -> Option<Bytes> {
3185        self.inner
3186            .outboards
3187            .get(hash, expected_outboard_len(total_bytes))
3188            .map(|c| c.bytes)
3189    }
3190
3191    /// The async lock that serializes origin outboard reads of `hash`
3192    /// ([`Inner::outboard_flights`]). The caller locks it, re-checks the cache,
3193    /// and only then reads from the origin.
3194    fn outboard_flight(&self, hash: Hash) -> Arc<tokio::sync::Mutex<()>> {
3195        let mut flights = self
3196            .inner
3197            .outboard_flights
3198            .lock()
3199            .unwrap_or_else(PoisonError::into_inner);
3200        if let Some(live) = flights.get(&hash).and_then(Weak::upgrade) {
3201            return live;
3202        }
3203        flights.retain(|_, w| w.strong_count() > 0);
3204        let flight = Arc::new(tokio::sync::Mutex::new(()));
3205        flights.insert(hash, Arc::downgrade(&flight));
3206        flight
3207    }
3208
3209    /// The origin read budget as `(head_start, min_bps)`, or `None` when
3210    /// [`Self::set_origin_read_budget`] left the reads unbounded. For the
3211    /// runtime's wiring tests.
3212    #[must_use]
3213    pub fn origin_read_budget_parts(&self) -> Option<(Duration, u64)> {
3214        self.origin_read_budget().map(|b| (b.head_start, b.min_bps))
3215    }
3216
3217    /// [`Self::fetch_gated_outboard`] under `hash`'s flight lock: a concurrent
3218    /// draw that already cached this origin's copy answers from the cache.
3219    async fn flighted_outboard(
3220        &self,
3221        origin_ix: usize,
3222        origin: &Arc<dyn Origin>,
3223        hash: Hash,
3224        expected_len: u64,
3225    ) -> CacheResult<GatedOutboard> {
3226        let flight = self.outboard_flight(hash);
3227        let _reading = flight.lock().await;
3228        match self.inner.outboards.get(hash, expected_len) {
3229            Some(c) if c.origin_ix == origin_ix => Ok(GatedOutboard::Found(c.bytes)),
3230            _ => {
3231                self.fetch_gated_outboard(origin_ix, origin, hash, expected_len)
3232                    .await
3233            }
3234        }
3235    }
3236
3237    /// Read `hash`'s outboard from the origin at `origin_ix` under the read
3238    /// budget and gate it on `expected_len`. A copy of that exact length is
3239    /// cached, tagged with `origin_ix`. Meters every outboard byte the origin
3240    /// sends as `pull_through_bytes`, before the gate, because that egress is
3241    /// paid either way. Logs a transport fault or timeout at `warn`.
3242    ///
3243    /// # Errors
3244    ///
3245    /// [`CacheError::OriginError`] for a transport fault or a read past the
3246    /// budget.
3247    async fn fetch_gated_outboard(
3248        &self,
3249        origin_ix: usize,
3250        origin: &Arc<dyn Origin>,
3251        hash: Hash,
3252        expected_len: u64,
3253    ) -> CacheResult<GatedOutboard> {
3254        let outboard_max = expected_len.saturating_add(64);
3255        let fetched = within_origin_timeout(
3256            self.origin_read_budget(),
3257            expected_len,
3258            hash,
3259            "outboard fetch",
3260            self.inner.metrics.as_deref(),
3261            origin.fetch_outboard(hash, outboard_max),
3262        )
3263        .await
3264        .and_then(|r| {
3265            r.map_err(|e| CacheError::OriginError {
3266                hash,
3267                source: e.into_inner(),
3268            })
3269        });
3270        let ob = match fetched {
3271            Ok(OutboardFetch::Found(ob)) => ob,
3272            Ok(OutboardFetch::NotFound | OutboardFetch::Unsupported) => {
3273                return Ok(GatedOutboard::Declined);
3274            }
3275            Err(e) => {
3276                tracing::warn!(
3277                    %hash,
3278                    kind = ?origin.kind(),
3279                    error = %e.display_chain(),
3280                    "origin outboard fetch failed; trying next origin",
3281                );
3282                return Err(e);
3283            }
3284        };
3285        if let Some(m) = &self.inner.metrics {
3286            m.pull_through_bytes
3287                .inc_by(u64::try_from(ob.len()).unwrap_or(u64::MAX));
3288        }
3289        // Exact-length gate. A wrong-length `{H}.obao4` (a truncated upload, an
3290        // HTML error body under the cap) can never verify against `H`, and a
3291        // broken origin here must not shadow a healthy later one.
3292        if u64::try_from(ob.len()).unwrap_or(u64::MAX) != expected_len {
3293            tracing::warn!(
3294                %hash,
3295                kind = ?origin.kind(),
3296                got = ob.len(),
3297                expected = expected_len,
3298                "origin served a wrong-length outboard; trying next origin",
3299            );
3300            return Ok(GatedOutboard::WrongLength);
3301        }
3302        if !self.inner.outboards.insert(hash, ob.clone(), origin_ix) {
3303            tracing::debug!(
3304                %hash,
3305                len = ob.len(),
3306                budget = OUTBOARD_CACHE_BYTES,
3307                "outboard is larger than the outboard cache; each draw reads it again",
3308            );
3309        }
3310        Ok(GatedOutboard::Found(ob))
3311    }
3312
3313    /// Install the shared frequency estimator. Called once at bring-up when a
3314    /// `tinylfu` policy is selected; absent otherwise.
3315    pub fn set_frequency_estimator(&self, est: Arc<dyn crate::policy::FrequencyEstimator>) {
3316        self.inner.frequency.store(Arc::new(Some(est)));
3317    }
3318
3319    /// Whether a shared frequency estimator is installed (ADR 040 / ADR 041).
3320    /// Introspection for bring-up wiring tests: the estimator is shared by any
3321    /// consumer that needs a heat signal — `tinylfu` eviction/admission and the
3322    /// `margin` serve-economics policy — so this only reports whether *some*
3323    /// consumer requested one, not which.
3324    #[must_use]
3325    pub fn has_frequency_estimator(&self) -> bool {
3326        self.inner.frequency.load().is_some()
3327    }
3328
3329    /// Install the admission policy consulted at store-time (ADR 040). Called
3330    /// once at bring-up when a non-default policy is selected; otherwise the
3331    /// engine keeps [`crate::policy::AlwaysAdmit`].
3332    pub fn set_admission_policy(&self, policy: Arc<dyn crate::policy::AdmissionPolicy>) {
3333        self.inner.admission.store(Arc::new(policy));
3334    }
3335
3336    /// Consult the admission policy for `ctx`, mapping its verdict onto a
3337    /// [`crate::policy::Segment`]. `PassThrough` is reserved: no
3338    /// shipped policy returns it yet, and no pass-through-without-storing leg
3339    /// exists, so it is treated as `Store { Probation }` until one does.
3340    fn admission_segment(&self, ctx: &crate::policy::AdmissionContext) -> crate::policy::Segment {
3341        match self.inner.admission.load().admit(ctx) {
3342            crate::policy::AdmissionDecision::Store { segment } => segment,
3343            crate::policy::AdmissionDecision::PassThrough => crate::policy::Segment::Probation,
3344        }
3345    }
3346
3347    /// `admission_segment` under a test-visible name, so policy wiring can be
3348    /// asserted without reaching through a fill.
3349    #[cfg(test)]
3350    pub fn admission_segment_for_test(
3351        &self,
3352        ctx: &crate::policy::AdmissionContext,
3353    ) -> crate::policy::Segment {
3354        self.admission_segment(ctx)
3355    }
3356
3357    /// Set `hash`'s generic segment membership (ADR 040 §1). Pure in-memory
3358    /// metadata — the blob's commit tag still protects it from GC, so this does
3359    /// no tag I/O. [`crate::policy::Segment::Main`] is the absent default, so
3360    /// setting `Main` removes any entry; under `AlwaysAdmit` (always `Main`)
3361    /// this is a no-op and the map stays empty.
3362    pub fn set_segment(&self, hash: Hash, segment: crate::policy::Segment) {
3363        let mut guard = self
3364            .inner
3365            .segments
3366            .lock()
3367            .unwrap_or_else(PoisonError::into_inner);
3368        match segment {
3369            crate::policy::Segment::Main => {
3370                guard.remove(&hash);
3371            }
3372            crate::policy::Segment::Probation => {
3373                guard.insert(hash, segment);
3374            }
3375        }
3376    }
3377
3378    /// Read `hash`'s segment membership; an untracked hash is
3379    /// [`crate::policy::Segment::Main`].
3380    #[must_use]
3381    pub fn segment_of(&self, hash: Hash) -> crate::policy::Segment {
3382        self.inner
3383            .segments
3384            .lock()
3385            .unwrap_or_else(PoisonError::into_inner)
3386            .get(&hash)
3387            .copied()
3388            .unwrap_or(crate::policy::Segment::Main)
3389    }
3390
3391    /// Sum the sizes of the members of `seg`, using `sizes` for per-hash bytes.
3392    /// For `Main` (the untracked default) this sums every hash in `sizes` not
3393    /// present in the segment map.
3394    #[must_use]
3395    pub fn segment_bytes(&self, seg: crate::policy::Segment, sizes: &HashMap<Hash, u64>) -> u64 {
3396        let guard = self
3397            .inner
3398            .segments
3399            .lock()
3400            .unwrap_or_else(PoisonError::into_inner);
3401        sizes
3402            .iter()
3403            .filter(|(h, _)| {
3404                guard
3405                    .get(*h)
3406                    .copied()
3407                    .unwrap_or(crate::policy::Segment::Main)
3408                    == seg
3409            })
3410            .fold(0u64, |acc, (_, sz)| acc.saturating_add(*sz))
3411    }
3412
3413    /// Snapshot the generic segment membership for the sweep's
3414    /// [`crate::policy::EvictionContext`]. Only non-default (`Probation`)
3415    /// entries are present.
3416    #[must_use]
3417    pub fn segments_snapshot(&self) -> HashMap<Hash, crate::policy::Segment> {
3418        self.inner
3419            .segments
3420            .lock()
3421            .unwrap_or_else(PoisonError::into_inner)
3422            .clone()
3423    }
3424
3425    /// Attempt to take (or refresh) a probe-triggered eviction hold on
3426    /// `hash` for [`crate::probe_hold::PROBE_HOLD_DURATION`] (ADR 005
3427    /// §Probe-triggered eviction hold).
3428    ///
3429    /// Returns [`ProbeHoldOutcome::Held`] when the blob is present, not
3430    /// operator-evicted, and a hold was placed for the full window. The other
3431    /// three variants classify why no hold happened, so the caller need not
3432    /// re-inspect the cache:
3433    /// - [`ProbeHoldOutcome::Unavailable`] — blob absent or operator-evicted.
3434    /// - [`ProbeHoldOutcome::HoldsDisabled`] — holds disabled by config
3435    ///   (`max_probe_holds == 0`); an intentional operator decision.
3436    /// - [`ProbeHoldOutcome::BudgetExhausted`] — present but every hold slot
3437    ///   is live (`max_probe_holds > 0`); genuine budget pressure.
3438    ///
3439    /// Placing a hold and advertising the blob are **not** the same decision:
3440    /// `BudgetExhausted` is advertised despite holding no slot. Callers should
3441    /// read [`ProbeHoldOutcome::advertises`] / [`ProbeHoldOutcome::hold_placed`]
3442    /// rather than comparing against `Held`.
3443    ///
3444    /// Per-blob semantics: a hash already held has its expiry refreshed and
3445    /// consumes no additional slot, so many peers probing one popular blob
3446    /// share a single hold (ADR 005 §Hold budget). The common
3447    /// already-held case is an O(1) lookup that skips the O(N) expiry
3448    /// sweep; the sweep runs only when no live hold exists, bounding map
3449    /// growth without a background task.
3450    ///
3451    /// The operator-evicted set is re-checked **while holding the
3452    /// `probe_holds` lock**, closing the TOCTOU window where a concurrent
3453    /// [`Self::evict`] (DMCA takedown) could land between the initial
3454    /// [`Self::has`] check and granting the hold — a takedown always wins
3455    /// (ADR 040 §Pinning, durable operator-evict, and the probe-hold stay
3456    /// engine-enforced).
3457    pub async fn try_probe_hold(&self, hash: Hash) -> CacheResult<ProbeHoldOutcome> {
3458        // `has` returns false for operator-evicted hashes too. This stays
3459        // *before* the `max == 0` check below so an absent/evicted blob is a
3460        // true negative (`Unavailable`), not a config-disable event.
3461        if !self.has(hash).await? {
3462            return Ok(ProbeHoldOutcome::Unavailable);
3463        }
3464        let max = self.inner.max_probe_holds.load(Ordering::Relaxed);
3465        // Holds disabled by config (#739): answer `HoldsDisabled` before
3466        // touching the lock. This is the "never advertise store-backed content"
3467        // path — it must override an existing live hold so that
3468        // if the budget is ever lowered to 0 while a hold is live, the disable
3469        // wins instead of the fast path below refreshing and re-signing the
3470        // hold. (Today only the tests lower it post-startup;
3471        // `cache.max_probe_holds` is restart-required, not hot-reloaded.) Also
3472        // skips the O(N) expiry sweep entirely while holds are off. Returning
3473        // before the under-lock `is_evicted` re-check is harmless: a blob
3474        // evicted concurrently here is still answered `has_blob: false` (the
3475        // misclassification is `Unavailable`→`HoldsDisabled`, a metric-only
3476        // miscount between two counters — never a false `has_blob: true`).
3477        if max == 0 {
3478            return Ok(ProbeHoldOutcome::HoldsDisabled);
3479        }
3480        let now = tokio::time::Instant::now();
3481        // `Instant + Duration` panics on overflow; saturate instead to keep
3482        // the workspace anti-panic policy (clippy `unwrap_used`/`panic`).
3483        let expiry = now
3484            .checked_add(crate::probe_hold::PROBE_HOLD_DURATION)
3485            .unwrap_or(now);
3486        let mut guard = self
3487            .inner
3488            .probe_holds
3489            .lock()
3490            .unwrap_or_else(PoisonError::into_inner);
3491
3492        // Fast path for popular re-probed blobs: a live hold already exists.
3493        // O(1), no sweep. Still re-check the takedown set under the lock so a
3494        // refresh can't resurrect just-refused content.
3495        if guard.get(&hash).is_some_and(|exp| *exp > now) {
3496            if self.refuses(hash) {
3497                return Ok(ProbeHoldOutcome::Unavailable);
3498            }
3499            guard.insert(hash, expiry);
3500            return Ok(ProbeHoldOutcome::Held);
3501        }
3502
3503        // No live hold — sweep expired entries before consulting the budget.
3504        guard.retain(|_, exp| *exp > now);
3505        // TOCTOU re-check: a concurrent `evict()` or denylist reload may have
3506        // completed after the `has()` above. Under the lock, a refused hash is
3507        // never held — a hold is what authorises signing `has_blob: true`, and
3508        // signing that for a *blacklisted* hash is the ADR 014
3509        // blacklist-violation evidence (and for a locally-evicted one, an
3510        // advertisement the serve path would only refuse).
3511        if self.refuses(hash) {
3512            return Ok(ProbeHoldOutcome::Unavailable);
3513        }
3514        if guard.len() >= max {
3515            // `max == 0` was already handled above, so reaching the budget
3516            // ceiling here is always genuine pressure: every slot is live
3517            // (#739). This is the "increase max_probe_holds" signal (ADR 005
3518            // §Hold budget) — never a config disable.
3519            return Ok(ProbeHoldOutcome::BudgetExhausted);
3520        }
3521        guard.insert(hash, expiry);
3522        Ok(ProbeHoldOutcome::Held)
3523    }
3524
3525    /// Number of currently-active (non-expired) probe holds, for the
3526    /// `probe_hold_slots_used` metric (ADR 005). Sweeps expired entries as
3527    /// a side effect, so a caller that samples it on its own schedule (the
3528    /// node's `/metrics` scrape) reads live holds even with no probe traffic.
3529    pub fn probe_hold_slots_used(&self) -> usize {
3530        let now = tokio::time::Instant::now();
3531        let mut guard = self
3532            .inner
3533            .probe_holds
3534            .lock()
3535            .unwrap_or_else(PoisonError::into_inner);
3536        guard.retain(|_, exp| *exp > now);
3537        guard.len()
3538    }
3539
3540    /// Fetch the blob by hash. Hits the local store on a cache hit; on a miss
3541    /// pulls from the configured origin, BLAKE3-verifies, and inserts before
3542    /// returning.
3543    ///
3544    /// Error variants callers commonly handle:
3545    /// - [`CacheError::NoOrigin`] — miss with no origin configured.
3546    /// - [`CacheError::NotFound`] — origin returned a definitive not-found
3547    ///   (e.g. HTTP 404).
3548    /// - [`CacheError::HashMismatch`] — origin returned bytes whose BLAKE3
3549    ///   hash didn't match the request; bytes are dropped, not cached.
3550    /// - [`CacheError::BlobTooLarge`] / [`CacheError::OriginError`] — size
3551    ///   cap tripped in the engine or in the origin, respectively.
3552    /// - [`CacheError::Store`] — local iroh-blobs store I/O failure.
3553    pub async fn get(&self, hash: Hash) -> CacheResult<Bytes> {
3554        if self.has(hash).await? {
3555            self.observe_hit(hash);
3556            let bytes = self.read_local(hash).await?;
3557            if let Some(m) = &self.inner.metrics {
3558                m.hits.inc();
3559                m.bytes_returned
3560                    .inc_by(u64::try_from(bytes.len()).unwrap_or(u64::MAX));
3561            }
3562            return Ok(bytes);
3563        }
3564
3565        // Logical-eviction guard (#279): once an operator has run
3566        // `decdn node evict <hash>`, a subsequent `get` must not silently
3567        // re-pull from the origin and undo the eviction. The eviction is
3568        // sticky for the life of `<cache_dir>/evicted.log` — there is no
3569        // "unevict" path; an operator who needs to re-cache a previously
3570        // evicted hash hand-edits the log and restarts.
3571        if self.refuses(hash) {
3572            if let Some(m) = &self.inner.metrics {
3573                m.misses.inc();
3574            }
3575            return Err(CacheError::NotFound { hash });
3576        }
3577
3578        // Coalesce concurrent pull-through requests for the same hash (#305).
3579        // A single lock acquisition atomically checks and inserts to avoid the
3580        // race where multiple tasks see an empty map and all proceed to pull.
3581        let bytes = loop {
3582            let state = {
3583                let mut guard = self.inner.lock_inflight();
3584                if let Some(n) = guard.get(&hash) {
3585                    Err(Arc::clone(n))
3586                } else {
3587                    let n = Arc::new(Notify::new());
3588                    guard.insert(hash, Arc::clone(&n));
3589                    Ok(n)
3590                }
3591            };
3592
3593            match state {
3594                // Another task owns the pull — wait, then retry from the top.
3595                Err(notify) => {
3596                    notify.notified().await;
3597                    if self.has(hash).await? {
3598                        let bytes = self.read_local(hash).await?;
3599                        // Waiter found the blob after the owner inserted it:
3600                        // semantically a hit. `bytes_returned` is bumped
3601                        // once after the loop for both arms; bump only
3602                        // `hits` here.
3603                        if let Some(m) = &self.inner.metrics {
3604                            m.hits.inc();
3605                        }
3606                        break bytes;
3607                    }
3608                    // First attempt failed — loop back and either wait on a
3609                    // new owner or become the owner ourselves.
3610                }
3611                // We are the owner — perform the pull. The guard's Drop impl
3612                // removes the inflight entry and wakes waiters even if this
3613                // task is cancelled mid-await, preventing the leak that would
3614                // otherwise hang every future request for `hash`.
3615                Ok(notify) => {
3616                    let _guard = InflightGuard {
3617                        hash,
3618                        inner: &self.inner,
3619                        notify: &notify,
3620                    };
3621                    break self.pull_through_bytes(hash).await?;
3622                }
3623            }
3624        };
3625        self.observe_hit(hash);
3626        if let Some(m) = &self.inner.metrics {
3627            m.bytes_returned
3628                .inc_by(u64::try_from(bytes.len()).unwrap_or(u64::MAX));
3629        }
3630        Ok(bytes)
3631    }
3632
3633    /// Ensure `hash` is present locally, pulling it through the origin chain on
3634    /// a miss — like [`Self::get`] but WITHOUT returning the bytes or bumping
3635    /// the `bytes_returned` / `hits` counters (which measure bytes served to a
3636    /// `get` caller). Use this to fill the cache as a *side effect* — e.g. the
3637    /// node-to-node cache-miss pull-through hook (#831) — so an internal fill is
3638    /// not miscounted as client-facing egress and the whole blob is not
3639    /// re-assembled into a buffer the caller would just drop. A hit is a no-op.
3640    ///
3641    /// That second guarantee is enforced, not merely intended: this path is
3642    /// `FillMode::CommitOnly` all the way down, and neither commit arm can hand a
3643    /// payload back under it — the streaming arm skips the `read_local`, the
3644    /// buffered arm drops its drain buffer (#1132). Before that the blob *was*
3645    /// read back and dropped, which is how the serve path's miss leg came to hold
3646    /// ~708 MB for a 708 MB blob.
3647    ///
3648    /// Note the buffered arm's cost is bounded by
3649    /// `cache.origin_retry.buffered_max_bytes` (4 MiB default) rather than by the
3650    /// mode: an origin that advertises a `size_hint` at or under it is drained
3651    /// before commit either way (a `None` hint always streams, and `0` disables
3652    /// buffering). `CommitOnly` guarantees no blob is handed *back*, not that no
3653    /// blob is ever briefly buffered.
3654    ///
3655    /// The origin-egress metric (`pull_through_bytes`) is still bumped by the
3656    /// pull, which is correct — those bytes really did leave an origin.
3657    ///
3658    /// # Errors
3659    ///
3660    /// Same set as [`Self::get`] (`NoOrigin` / `NotFound` / `HashMismatch` /
3661    /// `BlobTooLarge` / `OriginError` / `Store`).
3662    pub async fn populate(&self, hash: Hash) -> CacheResult<()> {
3663        self.populate_inner(hash, false).await
3664    }
3665
3666    /// Like [`Self::populate`], but restricted to the node's OWN configured
3667    /// origins (fs/http/s3): the `Peer` node→node origin is never consulted, so
3668    /// this fronts no upstream USDC (#1116). The serve path uses it to
3669    /// reactively fill from a local origin a blob a paying, lane-authorized
3670    /// client asked for — independent of `node_to_node_pull_through_enabled` —
3671    /// and to prefer a local origin over the paid peer window path. A hit is a
3672    /// no-op; absence from every local origin surfaces `NotFound`, and a chain
3673    /// with no non-`Peer` origin surfaces `NoOrigin`.
3674    ///
3675    /// # Errors
3676    ///
3677    /// Same set as [`Self::populate`].
3678    pub async fn populate_local(&self, hash: Hash) -> CacheResult<()> {
3679        self.populate_inner(hash, true).await
3680    }
3681
3682    /// Shared body of [`Self::populate`] / [`Self::populate_local`]. `local_only`
3683    /// threads through the coalescing loop into [`Self::pull_through`], where it
3684    /// skips the `Peer` origin.
3685    async fn populate_inner(&self, hash: Hash, local_only: bool) -> CacheResult<()> {
3686        if self.has(hash).await? {
3687            // Recency only — a fill is not a hit sighting. The paired serve
3688            // emits the one `observe` through `observe_hit` (ADR 040).
3689            self.record_access(hash);
3690            return Ok(());
3691        }
3692        // Logical-eviction guard (#279): never re-pull a deliberately evicted
3693        // hash (mirrors `get`).
3694        if self.refuses(hash) {
3695            if let Some(m) = &self.inner.metrics {
3696                m.misses.inc();
3697            }
3698            return Err(CacheError::NotFound { hash });
3699        }
3700        // Coalesce concurrent fills for the same hash (#305), mirroring `get`'s
3701        // loop but bumping no `get`-caller metrics: `populate` fills as a side
3702        // effect, so it counts neither a hit nor returned bytes.
3703        loop {
3704            let state = {
3705                let mut guard = self.inner.lock_inflight();
3706                if let Some(n) = guard.get(&hash) {
3707                    Err(Arc::clone(n))
3708                } else {
3709                    let n = Arc::new(Notify::new());
3710                    guard.insert(hash, Arc::clone(&n));
3711                    Ok(n)
3712                }
3713            };
3714            match state {
3715                // Another task owns the pull — wait, then re-check presence.
3716                Err(notify) => {
3717                    notify.notified().await;
3718                    if self.has(hash).await? {
3719                        break;
3720                    }
3721                }
3722                // We own the pull — the guard wakes waiters + clears the entry
3723                // even on cancellation.
3724                Ok(notify) => {
3725                    let _guard = InflightGuard {
3726                        hash,
3727                        inner: &self.inner,
3728                        notify: &notify,
3729                    };
3730                    // Fill-only: the payload is dropped, so the whole blob is
3731                    // never read back out of the store (#1132). The wrapper
3732                    // returns `()`, so there is nothing here to drop by accident.
3733                    self.pull_through_fill(hash, local_only).await?;
3734                    // ADR 040: consult the admission policy now that the fill
3735                    // succeeded and record the chosen segment. Under the default
3736                    // `AlwaysAdmit` the segment is `Main`, so `set_segment` is a
3737                    // no-op and the tag path below is unaffected — membership is
3738                    // pure in-memory metadata, no tag I/O.
3739                    let admission_ctx = crate::policy::AdmissionContext {
3740                        hash,
3741                        known_size: None,
3742                    };
3743                    let segment = self.admission_segment(&admission_ctx);
3744                    self.set_segment(hash, segment);
3745                    break;
3746                }
3747            }
3748        }
3749        // Recency only — the fill's admission read above already consulted the
3750        // estimate; the paired serve emits the one hit sighting (ADR 040).
3751        self.record_access(hash);
3752        Ok(())
3753    }
3754
3755    /// A permit from the own-origin draw pool ([`MAX_CONCURRENT_RANGE_PULLS`]).
3756    /// A full pool bumps `range_pull_permit_waits` and waits rather than
3757    /// degrading, because the degrade is a whole-blob origin pull — more egress,
3758    /// not less. A wait past [`RANGE_PULL_PERMIT_WARN_AFTER`] logs one warning.
3759    async fn range_pull_permit(&self) -> CacheResult<tokio::sync::OwnedSemaphorePermit> {
3760        let pool = &self.inner.own_origin_range_pulls;
3761        if let Ok(permit) = Arc::clone(pool).try_acquire_owned() {
3762            return Ok(permit);
3763        }
3764        if let Some(m) = &self.inner.metrics {
3765            m.range_pull_permit_waits.inc();
3766        }
3767        tracing::debug!(
3768            bound = MAX_CONCURRENT_RANGE_PULLS,
3769            "origin range-pull pool is full; waiting for a permit",
3770        );
3771        let closed =
3772            |e| CacheError::Internal(anyhow::Error::new(e).context("range-pull bound closed"));
3773        let mut acquire = std::pin::pin!(Arc::clone(pool).acquire_owned());
3774        if let Ok(permit) = tokio::time::timeout(RANGE_PULL_PERMIT_WARN_AFTER, &mut acquire).await {
3775            return permit.map_err(closed);
3776        }
3777        tracing::warn!(
3778            bound = MAX_CONCURRENT_RANGE_PULLS,
3779            waited_secs = RANGE_PULL_PERMIT_WARN_AFTER.as_secs(),
3780            "origin range-pull pool still full; a slow or hung origin may be holding \
3781             permits for whole draws, stalling this fill",
3782        );
3783        acquire.await.map_err(closed)
3784    }
3785
3786    /// The `{H}.obao4` outboard for `hash` (a `total_bytes`-byte blob): the
3787    /// cached copy of the exact length, or else the first origin's copy that
3788    /// passes the exact-length gate (`expected_outboard_len`, read with 64
3789    /// bytes of slack), which is then cached. `Ok(None)` when no origin serves
3790    /// one (or none are configured) — the caller degrades exactly as with an
3791    /// unsupported range. Each origin read runs under
3792    /// [`Self::set_origin_read_budget`]'s budget. A per-origin decline, wrong
3793    /// length, transport fault or timeout advances the chain.
3794    ///
3795    /// This is the outboard half of the own-origin serviceability probe
3796    /// ([`Self::origin_range_serviceable`]), which the serve-miss path runs
3797    /// before it signs a `StreamResponse`, so a blob no origin can prove is
3798    /// never advertised as serviceable. A cached copy answers without an origin
3799    /// read.
3800    /// The returned outboard is UNTRUSTED until it verifies against the root `H`
3801    /// (the range encode in [`Self::origin_range_wire`] is where that happens).
3802    ///
3803    /// # Errors
3804    ///
3805    /// [`CacheError::OriginError`] when no origin serves the outboard and at
3806    /// least one failed with a transport fault or a timeout (the last one).
3807    pub async fn origin_fetch_outboard_bytes(
3808        &self,
3809        hash: Hash,
3810        total_bytes: u64,
3811    ) -> CacheResult<Option<Bytes>> {
3812        Ok(self
3813            .outboard_with_origin(hash, total_bytes)
3814            .await?
3815            .map(|(ob, _)| ob))
3816    }
3817
3818    /// [`Self::origin_fetch_outboard_bytes`], with the index of the origin whose
3819    /// copy it is — the cached entry's origin, or the origin that served it.
3820    async fn outboard_with_origin(
3821        &self,
3822        hash: Hash,
3823        total_bytes: u64,
3824    ) -> CacheResult<Option<(Bytes, usize)>> {
3825        let expected_len = expected_outboard_len(total_bytes);
3826        if let Some(cached) = self.inner.outboards.get(hash, expected_len) {
3827            return Ok(Some((cached.bytes, cached.origin_ix)));
3828        }
3829        // A concurrent cold miss of this hash may be reading the outboard now:
3830        // wait for it, then take its cached copy.
3831        let flight = self.outboard_flight(hash);
3832        let _reading = flight.lock().await;
3833        if let Some(cached) = self.inner.outboards.get(hash, expected_len) {
3834            return Ok(Some((cached.bytes, cached.origin_ix)));
3835        }
3836        // A genuine transport fault on an origin (as opposed to a clean
3837        // `NotFound`/`Unsupported` decline) is remembered so it can be surfaced when
3838        // NO origin serves the outboard. The serviceability caller latches this into
3839        // `fault_seen` (#1129): an own-origin miss that fails because the operator's
3840        // origin is degraded must terminate as `InternalError`, not a bare
3841        // `NotFound`. A clean decline stays `Ok(None)` so the caller degrades
3842        // silently (ADR 037 §"Fallback is always correct").
3843        let mut last_err: Option<CacheError> = None;
3844        for (ix, origin) in self.inner.origins.iter().enumerate() {
3845            match self
3846                .fetch_gated_outboard(ix, origin, hash, expected_len)
3847                .await
3848            {
3849                Ok(GatedOutboard::Found(ob)) => return Ok(Some((ob, ix))),
3850                Ok(GatedOutboard::Declined | GatedOutboard::WrongLength) => {}
3851                Err(e) => last_err = Some(e),
3852            }
3853        }
3854        // No origin served the outboard. If any errored on the way, that transport
3855        // fault is the answer (degraded, not absent); otherwise it is a clean
3856        // absence and the caller degrades to the buffered path.
3857        match last_err {
3858            Some(e) => Err(e),
3859            None => Ok(None),
3860        }
3861    }
3862
3863    /// Stream the header-less interleaved bao **wire** (ADR 038) for
3864    /// `aligned`'s span out of the configured origins, verified against the
3865    /// root `H` — the raw-fetch half of a range pull WITHOUT the import (the
3866    /// node's `NodeAdmitStore` sink admits, fed by
3867    /// `decdn_client::BlobSource`).
3868    ///
3869    /// Each origin serves both halves of its own attempt: the outboard and the
3870    /// data. The origin whose outboard is cached goes first and reuses that copy,
3871    /// so a draw reads no outboard from the origin while the hash stays cached.
3872    /// Every other origin, in chain order, reads its own outboard through the
3873    /// exact-length gate and caches it. The first origin that serves the first
3874    /// window wins; a per-origin decline or transport fault advances the chain.
3875    /// Each origin read runs under [`Self::set_origin_read_budget`]'s budget. A
3876    /// verify fault evicts the copy the draw used, so the next draw reads the
3877    /// outboard again. The wire is produced
3878    /// window by window ([`crate::RANGE_PULL_WINDOW_BYTES`]) by a background
3879    /// encode that holds a permit from the engine-wide pool of
3880    /// [`crate::MAX_CONCURRENT_RANGE_PULLS`], so memory stays
3881    /// `O(window + outboard)` whatever the span (#2065).
3882    ///
3883    /// A verify failure here is a HARD fault ([`CacheError::VerifyFailed`]), NOT a
3884    /// degrade: by the time this runs the node has signed a
3885    /// `StreamResponse` committing to serve under `H`, so a corrupt or
3886    /// misconfigured OWN origin is a local-origin fault to surface, not upstream
3887    /// corruption to route around (there is no upstream, and no fallback still
3888    /// honours `H`). A fault after the wire has started ends it with a terminal
3889    /// `Err` from [`OriginRangeWire::next_chunk`].
3890    ///
3891    /// # Errors
3892    ///
3893    /// - [`CacheError::VerifyFailed`] — no origin opened the range, and an origin
3894    ///   served a wrong-length outboard or a wrong-length first window; neither
3895    ///   can verify against `H`.
3896    /// - [`CacheError::OriginError`] — no origin opened the range, and at least
3897    ///   one failed with a transport fault or a timeout (the last such fault).
3898    ///
3899    /// `Ok(None)` when every origin cleanly declines.
3900    pub async fn origin_range_wire(
3901        &self,
3902        hash: Hash,
3903        aligned: &AlignedRange,
3904    ) -> CacheResult<Option<OriginRangeWire>> {
3905        let permit = self.range_pull_permit().await?;
3906        let Some(cursor) = self.open_range_cursor(hash, aligned, None).await? else {
3907            return Ok(None);
3908        };
3909        OriginRangeWire::spawn(cursor, aligned, permit, self.inner.outboards.clone()).map(Some)
3910    }
3911
3912    /// The own-origin serviceability probe the serve-miss path runs before it
3913    /// signs a `StreamResponse`: whether an origin furnishes both the `{H}.obao4`
3914    /// outboard for `hash` (a `total_bytes`-byte blob) and ranged reads of its
3915    /// data. `false` sends the caller to the buffered whole-blob degrade
3916    /// (ADR 037 §"Fallback is always correct"), so an origin that publishes an
3917    /// outboard but declines `Range` does not have a signed stream fail on its
3918    /// first draw. The exception is an origin that served ranges earlier and
3919    /// has stopped since: it fails its draw like any other mid-life origin
3920    /// fault.
3921    ///
3922    /// The range half reads the first chunk group through the same origin
3923    /// chain a draw walks, starting at the origin that served the outboard. It
3924    /// reads nothing when that origin has already served a clean range window.
3925    /// The 0-byte blob has no data to range, so its outboard alone answers.
3926    ///
3927    /// # Errors
3928    ///
3929    /// - [`CacheError::OriginError`] — no origin serves both halves, and at
3930    ///   least one failed with a transport fault or a timeout.
3931    /// - [`CacheError::VerifyFailed`] — no origin serves both halves, and an
3932    ///   origin served a wrong-length outboard or first window, which cannot
3933    ///   verify.
3934    pub async fn origin_range_serviceable(
3935        &self,
3936        hash: Hash,
3937        total_bytes: u64,
3938    ) -> CacheResult<bool> {
3939        let Some((outboard, ix)) = self.outboard_with_origin(hash, total_bytes).await? else {
3940            return Ok(false);
3941        };
3942        if total_bytes == 0 || self.range_confirmed(ix) {
3943            return Ok(true);
3944        }
3945        let first_group =
3946            align_range(0, CHUNK_GROUP_BYTES.min(total_bytes), total_bytes).map_err(|e| {
3947                CacheError::Internal(anyhow::Error::from(e).context("range probe alignment"))
3948            })?;
3949        let opened = self
3950            .open_range_cursor(hash, &first_group, Some((ix, outboard)))
3951            .await?
3952            .is_some();
3953        if !opened {
3954            tracing::debug!(
3955                %hash,
3956                "no own origin serves ranged reads of this blob; the miss degrades to a \
3957                 whole-blob origin pull",
3958            );
3959        }
3960        Ok(opened)
3961    }
3962
3963    /// Whether the origin at `ix` has served a clean first range window.
3964    fn range_confirmed(&self, ix: usize) -> bool {
3965        self.inner
3966            .origin_range_confirmed
3967            .get(ix)
3968            .is_some_and(|c| c.load(Ordering::Relaxed))
3969    }
3970
3971    /// Open a cursor on the first origin that serves both the outboard and a
3972    /// first window of `aligned` of the right length. `preferred` (an origin
3973    /// index and the outboard it served) goes first and reuses that copy; with
3974    /// `None`, the origin whose outboard is cached goes first and reuses the
3975    /// cached copy. Every other origin, in chain order, reads its own outboard
3976    /// through the exact-length gate. A per-origin decline, transport fault,
3977    /// wrong-length outboard or wrong-length first window advances the chain,
3978    /// so a broken origin never shadows a healthy later one. The opened origin
3979    /// is marked range-confirmed. Errors as [`Self::origin_range_wire`].
3980    async fn open_range_cursor(
3981        &self,
3982        hash: Hash,
3983        aligned: &AlignedRange,
3984        preferred: Option<(usize, Bytes)>,
3985    ) -> CacheResult<Option<OriginRangeCursor>> {
3986        let expected_len = expected_outboard_len(aligned.blob_size());
3987        let cached = preferred.or_else(|| {
3988            self.inner
3989                .outboards
3990                .get(hash, expected_len)
3991                .map(|c| (c.origin_ix, c.bytes))
3992        });
3993        let first = cached.as_ref().map(|(ix, _)| *ix);
3994        let order = first
3995            .into_iter()
3996            .chain((0..self.inner.origins.len()).filter(|ix| Some(*ix) != first));
3997        let budget = self.origin_read_budget();
3998        let mut last_err: Option<CacheError> = None;
3999        let mut wrong_length = false;
4000        for ix in order {
4001            let Some(origin) = self.inner.origins.get(ix) else {
4002                continue;
4003            };
4004            let outboard = match &cached {
4005                Some((cached_ix, bytes)) if *cached_ix == ix => bytes.clone(),
4006                _ => match self.flighted_outboard(ix, origin, hash, expected_len).await {
4007                    Ok(GatedOutboard::Found(ob)) => ob,
4008                    Ok(GatedOutboard::Declined) => continue,
4009                    Ok(GatedOutboard::WrongLength) => {
4010                        wrong_length = true;
4011                        continue;
4012                    }
4013                    Err(e) => {
4014                        last_err = Some(e);
4015                        continue;
4016                    }
4017                },
4018            };
4019            let opened = OriginRangeCursor::open(
4020                Arc::clone(origin),
4021                hash,
4022                aligned,
4023                outboard,
4024                budget,
4025                self.inner.metrics.clone(),
4026            )
4027            .await;
4028            match opened {
4029                Ok(Some(cursor)) if cursor.first_is_wrong_length() => {
4030                    tracing::warn!(
4031                        %hash,
4032                        kind = ?origin.kind(),
4033                        "own origin served a wrong-length first range window; it cannot \
4034                         verify against H, trying next origin",
4035                    );
4036                    wrong_length = true;
4037                }
4038                Ok(Some(cursor)) => {
4039                    if let Some(confirmed) = self.inner.origin_range_confirmed.get(ix) {
4040                        confirmed.store(true, Ordering::Relaxed);
4041                    }
4042                    return Ok(Some(cursor));
4043                }
4044                Ok(None) => {}
4045                Err(e) => {
4046                    tracing::warn!(
4047                        %hash,
4048                        kind = ?origin.kind(),
4049                        error = %e.display_chain(),
4050                        "own origin range open failed; trying next origin",
4051                    );
4052                    last_err = Some(e);
4053                }
4054            }
4055        }
4056        match (last_err, wrong_length) {
4057            (Some(e), _) => Err(e),
4058            (None, true) => Err(CacheError::VerifyFailed { expected: hash }),
4059            (None, false) => Ok(None),
4060        }
4061    }
4062
4063    /// Import an already-encoded interleaved bao range for `hash`, verified
4064    /// against the root on import (iroh-blobs `import_bao_bytes`). Thin
4065    /// wrapper over the store call, exposed so `NodeRangedStore::admit` need
4066    /// not reach into the private store handle. Records recency, like every
4067    /// fill path, so the partial it leaves is an eviction candidate. An admit
4068    /// that completes a discovery block announces the hash on
4069    /// [`Self::subscribe_inserts`].
4070    pub async fn admit_bao(
4071        &self,
4072        hash: Hash,
4073        chunk_ranges: bao_tree::ChunkRanges,
4074        bao_bytes: bytes::Bytes,
4075    ) -> CacheResult<()> {
4076        let imported = self
4077            .inner
4078            .store
4079            .blobs()
4080            .import_bao_bytes(hash, chunk_ranges.clone(), bao_bytes)
4081            .await
4082            .map_err(|e| {
4083                CacheError::Store(anyhow::Error::from(e).context("admit_bao: import_bao_bytes"))
4084            });
4085        let finished = match imported {
4086            Ok(()) => self
4087                .protect_partial(hash)
4088                .await
4089                .map(|()| self.record_access(hash)),
4090            Err(e) => Err(e),
4091        };
4092        // On success and failure alike, as in `admit_bao_stream`: items the store
4093        // verified before a fault stay on disk.
4094        self.announce_completed_blocks(hash, &chunk_ranges).await;
4095        finished
4096    }
4097
4098    /// Stream the header-less bao for `chunk_ranges` of `hash` (a `total_bytes`
4099    /// blob) off `reader` into the cache as a **partial**, O(chunk-group),
4100    /// verifying against the root incrementally. Returns the drained `reader`
4101    /// (for `BlobSource::finish`).
4102    ///
4103    /// Drives `bao_tree`'s [`ResponseDecoder`] directly over the header-less wire
4104    /// (ADR 038 — the size is the trusted `total_bytes`, not an in-band prefix),
4105    /// forwarding each decoded [`BaoContentItem`] to iroh-blobs' `import_bao`
4106    /// handle. The decoder pulls one chunk at a time and the import channel
4107    /// backpressures, so memory stays O(one item), not O(range size).
4108    ///
4109    /// When a serve leg shares this fill (`session`), the decoder's `Parent` items
4110    /// ARE the outboard proof nodes, so they are captured into the shared session
4111    /// in the SAME decode pass — no post-admit `export_bao` read-back that would
4112    /// re-stream the whole range through the store actor a second time (#1790 item
4113    /// 4). Each leaf's run of proof nodes goes into the session in one batch just
4114    /// before that leaf goes to the store, so a parked serve leg encodes its first
4115    /// frame after the first leaf, not after the whole range, and wakes at most once
4116    /// per leaf. Front-to-back admits union to the whole tree.
4117    ///
4118    /// # Errors
4119    ///
4120    /// - [`CacheError::VerifyFailed`] — the decoder rejected a chunk group or
4121    ///   parent hash against the root `hash`: the forwarded bytes are corrupt
4122    ///   (a lying upstream). Nothing is admitted.
4123    /// - [`CacheError::Feed`] — a truncated or failed feed off `reader`, or a zero
4124    ///   `total_bytes` for a non-empty `hash`: the sender's fault (distinct from
4125    ///   corruption — see `classify_admit_decode_error`).
4126    /// - [`CacheError::Store`] — a fault of this node's store or its import
4127    ///   channel.
4128    ///
4129    /// The `reader` is carried on BOTH result arms: `Ok(reader)` on success and
4130    /// `Err((reader, err))` on failure. The error arm hands it back so the
4131    /// caller can recover a typed peer fault the reader parked while filling
4132    /// (`PullStalled`/`PullTimeout`/`UpstreamRefused`/`UpstreamVoucherRejected`/
4133    /// buyer-side `LocalPullFault`) — that parked fault is the real reason the
4134    /// stream stopped, and it beats the generic truncated-feed `CacheError` the
4135    /// decoder sees. This crate does not read the fault itself (it must not
4136    /// depend on `decdn-client`); it only returns the reader so the node ingest
4137    /// path can.
4138    pub async fn admit_bao_stream<R>(
4139        &self,
4140        hash: Hash,
4141        chunk_ranges: ChunkRanges,
4142        total_bytes: u64,
4143        reader: R,
4144        session: Option<&Arc<crate::FillSession>>,
4145    ) -> Result<R, (R, CacheError)>
4146    where
4147        R: AsyncStreamReader + Send,
4148    {
4149        let Some(size) = NonZeroU64::new(total_bytes) else {
4150            // An empty blob has no bao to decode. Mirror iroh-blobs' own
4151            // `import_bao_reader`: the canonical empty hash is a no-op success
4152            // (nothing to import, store, or protect), while a zero size under any
4153            // other hash is an upstream inconsistency (the signed `total_bytes`
4154            // disagrees with a non-empty content hash) — the sender's fault.
4155            if hash == Hash::EMPTY {
4156                return Ok(reader);
4157            }
4158            return Err((
4159                reader,
4160                CacheError::Feed(anyhow::anyhow!(
4161                    "admit_bao_stream: zero total_bytes for non-empty hash {hash}"
4162                )),
4163            ));
4164        };
4165        let tree = bao_tree::BaoTree::new(total_bytes, crate::range_pull::IROH_BLOCK_SIZE);
4166        let capture_into = session.cloned();
4167        let admitted = chunk_ranges.clone();
4168
4169        let handle = match self
4170            .inner
4171            .store
4172            .blobs()
4173            .import_bao(hash, size, ADMIT_BAO_LOCAL_UPDATE_CAP)
4174            .await
4175        {
4176            Ok(handle) => handle,
4177            Err(e) => {
4178                return Err((
4179                    reader,
4180                    CacheError::Store(
4181                        anyhow::Error::from(e).context("admit_bao_stream: import_bao"),
4182                    ),
4183                ));
4184            }
4185        };
4186        // Split the handle so the decode driver owns the item sender while the store
4187        // result is awaited concurrently. The sender is bounded, so both halves must
4188        // make progress together — fully draining the driver before awaiting the
4189        // result would deadlock once the channel fills.
4190        let tx = handle.tx;
4191        let rx = handle.rx;
4192
4193        let driver = async move {
4194            let mut decoder = ResponseDecoder::new(hash.into(), chunk_ranges, tree, reader);
4195            let mut pairs = Vec::new();
4196            loop {
4197                match decoder.next().await {
4198                    ResponseDecoderNext::More((rest, item)) => {
4199                        let item = match item {
4200                            Ok(item) => item,
4201                            // A decode/verify fault (corrupt bytes) or a truncated
4202                            // feed. Recover the reader and surface the decoder's
4203                            // `io::Error` for classification.
4204                            Err(e) => break (rest.finish(), Err(std::io::Error::from(e))),
4205                        };
4206                        // The `Parent` items ARE the outboard proof nodes; capture
4207                        // them for the serve leg here, in the one decode pass, rather
4208                        // than re-reading them back out of the store afterwards. The
4209                        // decoder yields a leaf's proof nodes, already verified, right
4210                        // before that leaf, so each run is published in one batch as
4211                        // its leaf arrives.
4212                        if let Some(session) = &capture_into {
4213                            if let BaoContentItem::Parent(parent) = &item {
4214                                pairs.push((parent.node, parent.pair));
4215                            } else if !pairs.is_empty() {
4216                                session.capture_many(pairs.drain(..));
4217                            }
4218                        }
4219                        if tx.send(item).await.is_err() {
4220                            // The store import ended before this item landed — its
4221                            // result (awaited below) explains why. Recover the reader.
4222                            break (rest.finish(), Ok(pairs));
4223                        }
4224                        decoder = rest;
4225                    }
4226                    ResponseDecoderNext::Done(reader) => break (reader, Ok(pairs)),
4227                }
4228            }
4229            // `tx` drops here, ending the fed item stream so the store finalizes.
4230        };
4231        // Await the store's result concurrently with the decode: the item channel is
4232        // bounded, so the store must drain it while the driver fills it.
4233        let ((reader, decode_res), store_res) = tokio::join!(driver, rx);
4234
4235        let finished: CacheResult<()> = 'finish: {
4236            // A decode/verify fault names the real cause (corrupt upstream vs
4237            // truncated feed) and wins over the store side.
4238            let pairs = match decode_res {
4239                Ok(pairs) => pairs,
4240                Err(io_err) => break 'finish Err(classify_admit_decode_error(hash, io_err)),
4241            };
4242            // Then the store's own result, or a dropped receiver (the store task died).
4243            match store_res {
4244                Ok(Ok(())) => {}
4245                Ok(Err(e)) => {
4246                    break 'finish Err(CacheError::Store(
4247                        anyhow::Error::from(e).context("admit_bao_stream: store import"),
4248                    ));
4249                }
4250                Err(_recv) => {
4251                    break 'finish Err(CacheError::Store(anyhow::anyhow!(
4252                        "admit_bao_stream: import result channel dropped"
4253                    )));
4254                }
4255            }
4256
4257            // ADR 040: consult the admission policy, then label the segment only
4258            // after `protect_partial` succeeds, so a failed protect leaves no stale
4259            // membership entry for an unprotected blob. Under the default
4260            // `AlwaysAdmit` the segment is `Main`, so `set_segment` is a no-op —
4261            // membership is pure in-memory metadata, no tag I/O.
4262            let admission_ctx = crate::policy::AdmissionContext {
4263                hash,
4264                known_size: Some(total_bytes),
4265            };
4266            let segment = self.admission_segment(&admission_ctx);
4267            if let Err(e) = self.protect_partial(hash).await {
4268                break 'finish Err(e);
4269            }
4270            self.set_segment(hash, segment);
4271            // Recency only, like every fill path: the partial becomes an eviction
4272            // candidate even when no serve follows (an aborted or unpaid serve leg
4273            // never reaches `observe_hit`), so the bytes `size_snapshot` counts can
4274            // be released.
4275            self.record_access(hash);
4276            // Capture any proof nodes left after the last leaf. `pairs` is empty
4277            // when no serve leg shares this fill.
4278            if let Some(session) = session {
4279                session.capture_many(pairs);
4280            }
4281            Ok(())
4282        };
4283        // On success and failure alike: the store keeps every item it verified
4284        // before a fault, and a gap-fill retry admits only the missing ranges, so
4285        // a block this admit completed is announced here or not at all.
4286        self.announce_completed_blocks(hash, &admitted).await;
4287        match finished {
4288            Ok(()) => Ok(reader),
4289            Err(e) => Err((reader, e)),
4290        }
4291    }
4292
4293    /// Best-effort total byte size of `hash` from the configured origins, for
4294    /// scoping a range pull ([`Self::origin_range_wire`] needs the exact blob
4295    /// size to align + verify a sub-range against the root `H`, and the
4296    /// `{H}.obao4` outboard alone doesn't pin the final chunk's length). Walks
4297    /// the origin fallback chain ([`Origin::size`] — HTTP `HEAD` / S3
4298    /// `HeadObject` / `fs` metadata) and returns the first known size; a
4299    /// per-origin `Ok(None)` (no object / compressed / unsupported) or a
4300    /// transport error advances the chain.
4301    ///
4302    /// Returns `Ok(None)` when every origin cleanly declines — the caller MUST
4303    /// then degrade to a whole-blob [`Self::populate`] / [`Self::get`]. This is
4304    /// a metadata probe only: it never fetches or caches bytes, so it carries no
4305    /// logical-eviction guard (the caller's serve path and whole-blob fallback
4306    /// both enforce it).
4307    ///
4308    /// # Errors
4309    ///
4310    /// - [`CacheError::NoOrigin`] when no origin is configured, so the caller
4311    ///   sees a coherent "can't range-pull" signal rather than a silent `None`.
4312    /// - [`CacheError::OriginError`] when NO origin answers and at least one
4313    ///   failed with a transport fault (the last such fault). A per-origin
4314    ///   fault still advances the chain — a later origin's answer wins — but a
4315    ///   probe that ends on faults reports a degraded node, not an empty one,
4316    ///   so the serve path's #1129 latch can turn the terminal miss into
4317    ///   `InternalError` instead of `NotFound`. Mirrors
4318    ///   [`Self::origin_fetch_outboard_bytes`].
4319    pub async fn origin_size(&self, hash: Hash) -> CacheResult<Option<u64>> {
4320        if self.inner.origins.is_empty() {
4321            return Err(CacheError::NoOrigin { hash });
4322        }
4323        let mut last_err: Option<CacheError> = None;
4324        for origin in &self.inner.origins {
4325            match origin.size(hash).await {
4326                Ok(Some(size)) => return Ok(Some(size)),
4327                Ok(None) => {}
4328                Err(e) => {
4329                    tracing::debug!(
4330                        %hash,
4331                        kind = ?origin.kind(),
4332                        error = %format_args!("{e:#}"),
4333                        "origin size probe failed; trying next origin",
4334                    );
4335                    last_err = Some(CacheError::OriginError {
4336                        hash,
4337                        source: e.into_inner(),
4338                    });
4339                }
4340            }
4341        }
4342        match last_err {
4343            Some(e) => Err(e),
4344            None => Ok(None),
4345        }
4346    }
4347
4348    /// Flush ephemeral state to disk. The iroh-blobs store does its own
4349    /// cleanup on drop, but only an explicit
4350    /// [`iroh_blobs::store::fs::FsStore`] shutdown guarantees that in-flight
4351    /// writes survive a crash of the surrounding process, so the runtime
4352    /// calls this during graceful shutdown.
4353    pub async fn shutdown(&self) -> CacheResult<()> {
4354        tracing::debug!("flushing cache engine store");
4355        self.inner
4356            .store
4357            .shutdown()
4358            .await
4359            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))
4360    }
4361
4362    /// Return the last access time for `hash`, or `None` if the hash has had
4363    /// no access since open.
4364    ///
4365    /// A blob that was on disk at open and has had no access since holds the
4366    /// open-time recency seed in `access_times`. The seed is not an access, so
4367    /// this returns `None` for it.
4368    pub fn last_accessed(&self, hash: Hash) -> Option<Instant> {
4369        self.inner
4370            .access_times
4371            .get(&hash)
4372            .map(|e| *e.value())
4373            .filter(|t| *t != self.inner.cold_seed_at)
4374    }
4375
4376    /// Collect every recency entry. Eviction logic can sort by value to
4377    /// determine LRU ordering.
4378    ///
4379    /// Not a point-in-time snapshot: the map is sharded and this walks it shard
4380    /// by shard, so a record that lands mid-walk may or may not appear (see the
4381    /// `access_times` field docs). Recency is advisory input to eviction
4382    /// ordering, so a hash missed by one walk is seen by the next.
4383    ///
4384    /// **Note:** this snapshot is the *raw* access map. It includes the
4385    /// open-time recency seed of every blob on disk at open that has had no
4386    /// access since, and it includes pinned hashes. Eviction implementations
4387    /// should use [`Self::eviction_candidates`] instead, which filters pinned
4388    /// hashes out so they survive LRU pressure (#276). The raw snapshot is
4389    /// still exposed because tests and observability paths sometimes want the
4390    /// unfiltered view.
4391    pub fn access_times_snapshot(&self) -> HashMap<Hash, Instant> {
4392        self.inner
4393            .access_times
4394            .iter()
4395            .map(|e| (*e.key(), *e.value()))
4396            .collect()
4397    }
4398
4399    /// Walk every blob in the local iroh-blobs store, complete or partial, and
4400    /// return its hash, excluding operator-evicted blobs
4401    /// ([ADR 011](https://github.com/decdn/decdn/blob/main/adr/011-content-takedown.md)). Consumed by the DHT
4402    /// republish scheduler at startup and by its lag sweep (ADR 022 §Bootstrap;
4403    /// AC 15 cold start, AC 20 lag re-seed): every held blob's first re-publish time is drawn from
4404    /// `uniform(0, 40 min)` per record, so the bootstrap `Store` rate matches
4405    /// steady-state by construction. The complementary
4406    /// [`Self::subscribe_inserts`] stream handles fresh commits and completed
4407    /// discovery blocks during steady state; the two together cover every blob
4408    /// the node may advertise.
4409    ///
4410    /// A partial is listed whether or not it covers a whole discovery block
4411    /// (ADR 022 §STORE Flow: a partial holder publishes once it verifies one).
4412    /// The walk reads only `status()`: deciding coverage here would `observe()`
4413    /// every partial at boot, and each `observe()` of an idle partial costs an
4414    /// fsync on the entry's idle shutdown. The republisher's due-time gate reads
4415    /// coverage instead, one hash at a time across the jittered window, and
4416    /// drops a partial that covers no block.
4417    ///
4418    /// This reads on-disk state. [`Self::open`] calls it to seed
4419    /// `access_times`, so every blob it returns at open is an eviction
4420    /// candidate. Boot therefore pays this walk twice: once for that seed and
4421    /// once for the DHT republish seed.
4422    ///
4423    /// Returns a [`Vec`] rather than an async stream because the consumer
4424    /// drains the input strictly into a hash set, so streaming saves nothing
4425    /// downstream. The transient is small: at C = 100k blobs the allocation
4426    /// is roughly 100k × 32 bytes = 3.2 MiB.
4427    ///
4428    /// Race semantics: a blob evicted between the iroh-blobs `list()`
4429    /// emission and the per-hash [`Self::is_evicted`] filter would be
4430    /// included, but downstream publish paths re-check the evicted set, so a
4431    /// stale entry in the scheduler's heap fails the membership check at
4432    /// publish time rather than re-advertising a takedown.
4433    ///
4434    /// Error semantics: a failure on the iroh-blobs cursor itself
4435    /// (`list()` or `stream.next()`) aborts the walk — we can't trust
4436    /// subsequent yields once the cursor is broken. But a per-blob
4437    /// `status()` failure logs-and-skips so a single inaccessible blob
4438    /// doesn't deny the seed for every other healthy blob. The
4439    /// republish surface is best-effort by design; partial completion
4440    /// strictly beats total failure.
4441    pub async fn iter_hashes(&self) -> CacheResult<Vec<Hash>> {
4442        let blobs = self.inner.store.blobs();
4443        let mut stream = blobs
4444            .list()
4445            .stream()
4446            .await
4447            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
4448        let mut out = Vec::new();
4449        while let Some(hash) = stream.next().await {
4450            let hash = hash.map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
4451            if self.refuses(hash) {
4452                continue;
4453            }
4454            let status = match blobs.status(hash).await {
4455                Ok(s) => s,
4456                Err(err) => {
4457                    tracing::warn!(
4458                        hash = %hash,
4459                        error = %err,
4460                        "iter_hashes: blob status() failed; skipping (seed continues with remaining blobs)"
4461                    );
4462                    continue;
4463                }
4464            };
4465            if !matches!(status, iroh_blobs::api::blobs::BlobStatus::NotFound) {
4466                out.push(hash);
4467            }
4468        }
4469        Ok(out)
4470    }
4471
4472    /// Return a snapshot of access times **excluding pinned hashes**.
4473    /// This is the canonical input to LRU eviction (#276): a pinned hash
4474    /// never appears here, so any candidate-picking sort or top-K query
4475    /// run against the result inherently respects the pinning policy.
4476    ///
4477    /// The exception is a pinned hash that is deny-listed (local or
4478    /// governance deny): it stays a candidate, because deny wins over pin.
4479    /// The deny set itself never removes a hash from the snapshot.
4480    ///
4481    /// The return type ([`EvictionCandidates`]) is a newtype with no
4482    /// public constructor — callers can iterate or `into_inner` but
4483    /// cannot fabricate one. This makes "pinned-already-excluded" a
4484    /// type-level invariant rather than a documentation claim.
4485    ///
4486    /// Every blob the open-time store walk finds is a candidate from open.
4487    /// Until it is accessed, it ranks older than every blob accessed since
4488    /// open.
4489    ///
4490    /// The pinned set is loaded once at the start of the call so a
4491    /// concurrent `set_pinned` swap doesn't change which hashes get
4492    /// filtered mid-iteration — the snapshot is consistent against
4493    /// *some* pinned generation, just not necessarily the very latest.
4494    ///
4495    /// A third filter layer (after pinned, before the LRU sort) drops any
4496    /// hash under an active probe-triggered eviction hold, even a deny-listed
4497    /// one (#318, ADR 005
4498    /// §Probe-triggered eviction hold; ADR 040 §Pinning, durable operator-evict,
4499    /// and the probe-hold stay engine-enforced:
4500    /// "a held hash is invisible to the LRU driver until the hold
4501    /// expires"). The held set is swept of expired entries here too, so a
4502    /// node with no probe traffic still releases stale holds.
4503    pub fn eviction_candidates(&self) -> EvictionCandidates {
4504        let pinned = self.inner.pinned.load();
4505        // Hoisted once, like `pinned`: a deny-listed hash stays an eviction
4506        // candidate even when pinned, so "deny wins over pin" holds on the space
4507        // path too, not only the takedown `evict()` actuator — otherwise a pinned
4508        // + governance-denied hash would sit on disk (unservable, since `refuses`
4509        // blocks it) until the watcher's `evict()` happened to run. These are the
4510        // lock-free deny halves of `refuses`; `is_evicted` is deliberately NOT
4511        // consulted — it would take a mutex per candidate during the shard walk
4512        // below for nothing, since `evict` removes the `access_times` entry, so an
4513        // already-evicted hash is never in this map to begin with, and the
4514        // open-time seed skips evicted hashes via `iter_hashes`.
4515        let denied = self.inner.denied.load();
4516        let chain_denied = self.inner.chain_denied.load();
4517        let now = tokio::time::Instant::now();
4518        let held: HashSet<Hash> = {
4519            let mut g = self
4520                .inner
4521                .probe_holds
4522                .lock()
4523                .unwrap_or_else(PoisonError::into_inner);
4524            g.retain(|_, exp| *exp > now);
4525            g.keys().copied().collect()
4526        };
4527        let map = self
4528            .inner
4529            .access_times
4530            .iter()
4531            .filter_map(|entry| {
4532                let (h, t) = (entry.key(), entry.value());
4533                // `held` short-circuits BEFORE the deny carve-out, so a probe-held
4534                // hash stays excluded even when denied: a probe-hold is transient
4535                // (seconds, self-expiring) and the takedown `evict()` is the
4536                // reclaim actuator for a denied hash regardless, so the space path
4537                // need not race the hold. The carve-out targets the *pin* (the
4538                // hold-forever case), which is the actual "worst combination".
4539                let deny_listed = denied.contains(h) || chain_denied.contains(h);
4540                if held.contains(h) || (pinned.contains(h) && !deny_listed) {
4541                    None
4542                } else {
4543                    Some((*h, *t))
4544                }
4545            })
4546            .collect();
4547        EvictionCandidates(map)
4548    }
4549
4550    /// Emit the hit signal for `hash`: bump its LRU recency AND forward one
4551    /// sighting to the shared frequency estimator (ADR 040 §Hit signal). This is
4552    /// the one hit sighting a served or `get` request produces.
4553    ///
4554    /// Every client-facing serve chokepoint calls this exactly once per served
4555    /// request — [`crate::CacheEngine::get`] on its own path, and the `node`
4556    /// crate's `deliver` / `serve_leg` on the paid serve paths. The fill paths
4557    /// ([`Self::populate`], [`Self::admit_bao_stream`]) deliberately do NOT emit
4558    /// it; they only record recency, so a miss that fills and then serves counts
4559    /// as ONE sighting (the serve's), never two. This also
4560    /// preserves the admission ordering invariant: a fill reads the frequency
4561    /// estimate for its admission decision before the paired serve emits this
4562    /// request's own `observe`.
4563    pub fn observe_hit(&self, hash: Hash) {
4564        self.record_access(hash);
4565        // Clone the estimator Arc out only when one is installed, dropping the
4566        // arc-swap guard before calling `observe`. The default (no-estimator)
4567        // path pays nothing — no clone, no refcount roundtrip — and the
4568        // estimator's own work never runs under the read guard.
4569        let est = (**self.inner.frequency.load()).clone();
4570        if let Some(est) = est {
4571            est.observe(hash);
4572        }
4573    }
4574
4575    /// Mark `hash` as in use now — recency only, no frequency observe. A serve
4576    /// calls this when it starts, so a blob carrying the open-time recency seed
4577    /// does not rank oldest, and first in line for eviction, while its bytes are
4578    /// on the wire. The serve's one frequency sighting still comes from
4579    /// [`Self::observe_hit`] at clean completion.
4580    pub fn touch_recency(&self, hash: Hash) {
4581        self.record_access(hash);
4582    }
4583
4584    /// Record an access for `hash` at the current instant — LRU recency only, no
4585    /// frequency observe. The fill paths use this so the blob becomes an eviction
4586    /// candidate without counting as a hit sighting; the paired serve emits the
4587    /// one sighting through [`Self::observe_hit`].
4588    fn record_access(&self, hash: Hash) {
4589        self.inner.access_times.insert(hash, Instant::now());
4590    }
4591
4592    /// Snapshot every on-disk blob keyed by hash with its byte size
4593    /// (`Complete` and `Partial` alike; a partial counts only its present
4594    /// bytes), the public form of the internal
4595    /// `snapshot_blob_sizes` helper. This is the authoritative disk-usage
4596    /// input for the capacity-eviction driver (#1173): unlike
4597    /// [`Self::eviction_candidates`] — which drops pinned and probe-held hashes
4598    /// and carries no sizes — this walks the store, so it counts every on-disk
4599    /// byte, including bytes released but not yet reclaimed by GC.
4600    ///
4601    /// Cost scales with the total blob set: one `status()` per blob, plus one
4602    /// `observe()` per partial blob whose memoized count is older than 30 s
4603    /// (each `observe()` of an idle partial fsyncs its store files). A
4604    /// partial's count can therefore trail its disk use by up to 30 s. Call it
4605    /// on the eviction sweep cadence, not per request.
4606    pub async fn size_snapshot(&self) -> CacheResult<HashMap<Hash, u64>> {
4607        snapshot_blob_sizes(
4608            &self.inner.store,
4609            &self.inner.partial_sizes,
4610            Instant::now(),
4611            self.inner.metrics.as_deref(),
4612        )
4613        .await
4614    }
4615
4616    /// Total on-disk bytes across all blobs, the sum of
4617    /// [`Self::size_snapshot`]. Saturating so an implausibly large store can
4618    /// never wrap. Drives the eviction driver's high-water comparison and the
4619    /// current-size cache-health reporting (#1173).
4620    pub async fn total_bytes(&self) -> CacheResult<u64> {
4621        Ok(self
4622            .size_snapshot()
4623            .await?
4624            .values()
4625            .fold(0u64, |acc, sz| acc.saturating_add(*sz)))
4626    }
4627
4628    /// Release `hash` for capacity eviction: drop its protecting named tag(s)
4629    /// so the bytes become eligible for the next iroh-blobs GC sweep, and
4630    /// forget its [`Self::access_times_snapshot`] entry so a later LRU sweep
4631    /// doesn't re-surface it. Returns the number of tags dropped.
4632    ///
4633    /// This is the LRU-driver counterpart to [`Self::evict`] and is
4634    /// deliberately *not* the same path (#1173). `evict` is the permanent,
4635    /// `fsync`'d, `evicted.log`-backed DMCA takedown: it records the hash in a
4636    /// durable logical-evicted set that blocks serving forever and survives
4637    /// restart. Capacity eviction must not do that — a blob dropped only for
4638    /// space pressure has to be freely re-pullable, and an unbounded takedown
4639    /// log per LRU eviction would be a durability leak. So this method only
4640    /// releases GC protection; the blob keeps serving until GC actually
4641    /// reclaims it, after which [`Self::has`] reports it absent naturally
4642    /// (the bytes are gone from the store, not logically masked).
4643    ///
4644    /// Pinned hashes are refused (returns `Ok(0)` without touching state) as a
4645    /// defense in depth — [`Self::eviction_candidates`] already excludes them,
4646    /// but the caller is a background loop and the pinned set can change
4647    /// between candidate selection and this call.
4648    ///
4649    /// The pin exemption is itself carved out for a **deny-listed** hash
4650    /// ([`Self::refuses`]): "deny wins over pin" on every path, so a pinned +
4651    /// governance/local-denied hash is still reclaimable here rather than being
4652    /// held on disk (unservable) until the takedown `evict()` runs. This only
4653    /// drops GC protection — the durable takedown record stays `evict`'s job.
4654    ///
4655    /// Best-effort against the GC cadence: like `evict`, actual disk reclaim
4656    /// only happens when periodic GC is enabled (`cache.gc_interval_sec > 0`).
4657    pub async fn release_for_eviction(&self, hash: Hash) -> CacheResult<u64> {
4658        if self.is_pinned(hash) && !self.refuses(hash) {
4659            return Ok(0);
4660        }
4661        // Drop the protecting tag(s) FIRST, and only forget the access-time
4662        // entry once that succeeded. The reverse order looks tempting (it would
4663        // stop a concurrent `eviction_candidates` re-picking the hash during the
4664        // slower tag walk) but it loses the blob on failure: `eviction_candidates`
4665        // iterates `access_times`, so a hash removed from it before an erroring
4666        // tag walk can never be re-selected, leaving bytes on disk *and*
4667        // GC-protected forever. A transient double-selection is harmless by
4668        // comparison — the second release is a no-op `Ok(0)`.
4669        let deleted = match self.drop_named_tags_for(hash).await {
4670            Ok(deleted) => deleted,
4671            Err(err) => {
4672                // Same observability as `evict`'s tag-drop failure: without this
4673                // the LRU path could burn down the candidate pool silently.
4674                if let Some(m) = &self.inner.metrics {
4675                    m.tag_drop_failures.inc();
4676                }
4677                return Err(err);
4678            }
4679        };
4680        self.inner.access_times.remove(&hash);
4681        self.inner
4682            .segments
4683            .lock()
4684            .unwrap_or_else(PoisonError::into_inner)
4685            .remove(&hash);
4686        Ok(deleted)
4687    }
4688
4689    async fn read_local(&self, hash: Hash) -> CacheResult<Bytes> {
4690        self.inner
4691            .store
4692            .blobs()
4693            .get_bytes(hash)
4694            .await
4695            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))
4696    }
4697
4698    /// Read `[byte_offset, byte_offset + byte_len)` of `hash` from the local
4699    /// store ([ADR 038 §Serve side](https://github.com/decdn/decdn/blob/main/adr/038-bao-verified-range-streaming.md)).
4700    /// `byte_len == 0` reads to the blob end. Works against a **partial** blob
4701    /// admitted by [`Self::admit_bao`] — only the bytes covered by an
4702    /// admitted (and thus already-verified) range are readable; asking for
4703    /// bytes outside the imported span surfaces a [`CacheError::Store`] from
4704    /// iroh-blobs rather than zero-filling.
4705    ///
4706    /// Concatenates the exported range into a contiguous [`Bytes`]; for the
4707    /// streaming serve path the node drives iroh-blobs' `export_ranges`
4708    /// directly. Provided here so the engine owns the local-read seam for both
4709    /// whole-blob ([`Self::get`]) and range serves.
4710    ///
4711    /// # Errors
4712    ///
4713    /// [`CacheError::Store`] if the store cannot satisfy the range (blob
4714    /// absent, the requested bytes were never imported / verified, or a
4715    /// read-to-end (`byte_len == 0`) was requested against a partial blob whose
4716    /// size the store cannot yet report).
4717    pub async fn export_range(
4718        &self,
4719        hash: Hash,
4720        byte_offset: u64,
4721        byte_len: u64,
4722    ) -> CacheResult<Bytes> {
4723        // `byte_len == 0` means "to end"; iroh-blobs `export_ranges` takes a
4724        // half-open `Range<u64>`, so resolve the end against the store's known
4725        // size. A `RangeFull`-style read isn't directly expressible, so query
4726        // the size and build the explicit bound.
4727        let end = if byte_len == 0 {
4728            match self
4729                .inner
4730                .store
4731                .blobs()
4732                .status(hash)
4733                .await
4734                .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?
4735            {
4736                iroh_blobs::api::blobs::BlobStatus::NotFound => {
4737                    return Err(CacheError::Store(anyhow::anyhow!(
4738                        "export_range: blob {hash} not present"
4739                    )));
4740                }
4741                // A partial blob whose size the store cannot yet report is an
4742                // explicit failure: silently treating `None` as "ends at
4743                // `byte_offset`" would return an empty range for a `byte_len ==
4744                // 0` ("to end") read and mask the real cause. Surface it so the
4745                // caller retries or falls back to a whole-blob serve.
4746                iroh_blobs::api::blobs::BlobStatus::Partial { size: None } => {
4747                    return Err(CacheError::Store(anyhow::anyhow!(
4748                        "export_range: store cannot report size for partial blob {hash}; \
4749                         cannot resolve a read-to-end bound"
4750                    )));
4751                }
4752                iroh_blobs::api::blobs::BlobStatus::Partial { size: Some(size) }
4753                | iroh_blobs::api::blobs::BlobStatus::Complete { size } => size,
4754            }
4755        } else {
4756            byte_offset.saturating_add(byte_len)
4757        };
4758        let bytes = self
4759            .inner
4760            .store
4761            .blobs()
4762            .export_ranges(hash, byte_offset..end)
4763            .concatenate()
4764            .await
4765            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
4766        Ok(Bytes::from(bytes))
4767    }
4768
4769    /// Whole-blob-in-memory form of [`Self::export_bao_range_stream`]: drains the
4770    /// stream into one contiguous `Bytes`. See that method for the wire format and
4771    /// the range semantics.
4772    ///
4773    /// **Peak memory is the whole aligned range.** The paid serve path must NOT
4774    /// use this — it drives the stream directly, so a large blob costs one frame
4775    /// of RAM rather than a copy of the blob (#1132). Every caller today is a test
4776    /// that needs a buffer to assert against; it is kept for them, and because the
4777    /// ADR 038 round-trip tests are more legible over a buffer than a stream.
4778    ///
4779    /// # Errors
4780    ///
4781    /// Everything [`Self::export_bao_range_stream`] can fail with, except that a
4782    /// fault discovered while exporting — including the truncation refusal —
4783    /// surfaces here as an `Err` return rather than as a terminal stream item,
4784    /// because nothing has been handed to a consumer yet.
4785    pub async fn export_bao_range(
4786        &self,
4787        hash: Hash,
4788        byte_offset: u64,
4789        byte_len: u64,
4790        blob_size: u64,
4791    ) -> CacheResult<Bytes> {
4792        let mut stream = self
4793            .export_bao_range_stream(hash, byte_offset, byte_len, blob_size)
4794            .await?;
4795        // Pre-size to the exact wire length (proof + data) so the buffer never
4796        // reallocates; `wire_len` walks the same node set the export stream emits.
4797        // The range re-validated cleanly inside the call above, so a re-alignment
4798        // fault here is unreachable — degrade to an unsized buffer rather than
4799        // duplicating the error mapping. `align_range_clamped` sizes the buffer
4800        // for an end past the blob too, rather than degrading to 0.
4801        let cap = align_range_clamped(byte_offset, byte_len, blob_size)
4802            .map_or(0, |a| usize::try_from(a.wire_len()).unwrap_or(0));
4803        let mut out = Vec::with_capacity(cap);
4804        while let Some(item) = stream.next().await {
4805            out.extend_from_slice(&item?);
4806        }
4807        Ok(Bytes::from(out))
4808    }
4809
4810    /// Export `[byte_offset, byte_offset + byte_len)` of `hash` as the
4811    /// **header-less bao interleaved verified-stream encoding** that travels on
4812    /// `cdn/client/v1` ([ADR 038 §Serve side](https://github.com/decdn/decdn/blob/main/adr/038-bao-verified-range-streaming.md)),
4813    /// yielded incrementally as the store produces it. `byte_len == 0` exports to
4814    /// the blob end. This is the *only* client-facing delivery path — there is no
4815    /// raw-byte fallback (ADR 038 AC#4) — so a whole-blob serve passes
4816    /// `byte_offset == 0, byte_len == 0`.
4817    ///
4818    /// The bytes are proof nodes (64 B each) interleaved with chunk-group data in
4819    /// tree order, **without** the 8-byte size header: the signed
4820    /// `StreamResponse.body.total_bytes` is the authoritative size, so the
4821    /// receiver builds its own `BaoTree` and never reads a header off the wire
4822    /// (avoids a second, unauthenticated size source — ADR 038 §Wire format).
4823    ///
4824    /// The range widens to enclosing 16 KiB chunk-group boundaries
4825    /// ([`align_range_clamped`]) because a bao proof anchors whole groups; an
4826    /// end past the blob clamps to it rather than being refused: a claimed
4827    /// size is a hint. The serve side does **not** trim back to the requested offset
4828    /// (trimming would break verification). The receiver discards the
4829    /// group-aligned prefix. The outboard is read from the store (built at
4830    /// import).
4831    /// Works against a **partial** blob (only imported/verified chunk groups are
4832    /// exportable, exactly like [`Self::export_range`]).
4833    ///
4834    /// Each item is one export item's serialization — a 64-byte proof pair or one
4835    /// chunk group's data — so a consumer that writes items straight to the wire
4836    /// holds O(chunk group) rather than O(blob). This is what the paid serve path
4837    /// drives (#1132), so a 708 MB blob costs O(chunk group) resident per
4838    /// concurrent serve, not ~708 MB.
4839    ///
4840    /// # Held content is validated as it is exported
4841    ///
4842    /// The store validates each exported item against the content root before
4843    /// it yields the item: every parent pair against the hash its parent
4844    /// expects, and every leaf's data against its parent's hash. Bytes or an
4845    /// outboard that diverged on disk after admission therefore never reach
4846    /// the stream. The export ends at the first mismatching chunk group with a
4847    /// terminal `Err` item, and the engine
4848    /// [quarantines](Self::is_quarantined) the hash.
4849    ///
4850    /// # Truncation is reported mid-stream
4851    ///
4852    /// The `!done` refusal below (the store's item channel closing without a
4853    /// terminal `Done`/`Error` — an actor crash or shutdown race) can only be
4854    /// detected at the END of the stream, by which point a streaming consumer has
4855    /// already put earlier bytes on the wire. It therefore surfaces as a terminal
4856    /// `Err` **item** rather than as a pre-flight error, and the consumer must
4857    /// abort the delivery on it. The billing invariant is unchanged: the client
4858    /// sees a short delivery, rejects it, and never pays the closing voucher — but
4859    /// the detection point is after the last byte, not before the first
4860    /// (#915 review, #1132).
4861    ///
4862    /// # Errors
4863    ///
4864    /// [`CacheError::Store`] if `byte_offset` is at or past the blob end, or
4865    /// (for the 0-byte case) the blob is absent. An end past the blob clamps.
4866    /// Faults discovered while exporting, the truncation refusal included,
4867    /// arrive as `Err` items in the stream.
4868    pub async fn export_bao_range_stream(
4869        &self,
4870        hash: Hash,
4871        byte_offset: u64,
4872        byte_len: u64,
4873        blob_size: u64,
4874    ) -> CacheResult<Pin<Box<dyn futures_util::Stream<Item = CacheResult<Bytes>> + Send>>> {
4875        // `blob_size` is the authoritative whole-blob size supplied by the caller
4876        // (the signed `total_bytes`), NOT resolved from the store. An origin-tier
4877        // range pull (#823) imports a *partial* blob whose `status()` size is
4878        // `None`, so re-deriving the size here would fail the very ranged serve the
4879        // partial import enabled. The BaoTree geometry (and hence the proof) is a
4880        // function of the whole-blob size, so the partial and the caller agree by
4881        // construction (#915, ADR 038).
4882
4883        // Snap to chunk-group boundaries. Only a start at or past the blob end
4884        // is rejected; an end past the blob clamps to it instead.
4885        // `align_range_clamped` owns the bound check, the same one the
4886        // dispatch-tier gate applies before this runs.
4887        let aligned = align_range_clamped(byte_offset, byte_len, blob_size).map_err(|e| {
4888            CacheError::Store(anyhow::Error::from(e).context("export_bao_range: range alignment"))
4889        })?;
4890
4891        // A 0-byte blob (#1054) has no chunk groups and no proof: the header-less
4892        // wire form is empty. Return an empty stream directly rather than driving an
4893        // empty `export_bao` stream, whose terminal `Done` we would otherwise depend
4894        // on to clear the `!done` guard. Still confirm presence first: the documented
4895        // contract errors on an absent blob, the non-empty path below faults on
4896        // `export_bao` for a missing hash, and `has` honors a logical eviction
4897        // (#279) — so a present-only early return keeps behavior consistent and
4898        // never serves an empty body for a hash this node has taken down.
4899        //
4900        // An empty stream (rather than a single empty item) is what keeps the serve
4901        // path's "the empty blob goes straight to `StreamEnd`" property: `ChunkData`
4902        // cannot hold an empty payload (#1088), so a zero-length item would be
4903        // unsendable.
4904        if blob_size == 0 {
4905            if !self.has(hash).await? {
4906                return Err(CacheError::Store(anyhow::anyhow!(
4907                    "export_bao_range: blob {hash} not present"
4908                )));
4909            }
4910            return Ok(Box::pin(futures_util::stream::empty()));
4911        }
4912
4913        let stream = self
4914            .inner
4915            .store
4916            .blobs()
4917            .export_bao(hash, aligned.chunk_ranges().clone())
4918            .stream();
4919        // Serialize the header-less wire form: Parent → 64 bytes (left‖right
4920        // hashes), Leaf → its data; skip the `Size` item (the header) and stop on
4921        // `Done`. Mirrors iroh-blobs' `ExportBaoProgress::write` minus the header.
4922        //
4923        // The `bool` in the unfold state is "this stream is finished" — set after
4924        // yielding a terminal error so the consumer cannot poll past it into a
4925        // second, spurious truncation error.
4926        let engine = self.clone();
4927        let requested = aligned.chunk_ranges().clone();
4928        Ok(Box::pin(futures_util::stream::unfold(
4929            (stream, false),
4930            move |(mut stream, finished)| {
4931                let engine = engine.clone();
4932                let requested = requested.clone();
4933                async move {
4934                    if finished {
4935                        return None;
4936                    }
4937                    loop {
4938                        match stream.next().await {
4939                            Some(EncodedItem::Size(_)) => {}
4940                            Some(EncodedItem::Parent(parent)) => {
4941                                let mut frame = BytesMut::with_capacity(64);
4942                                frame.extend_from_slice(parent.pair.0.as_bytes());
4943                                frame.extend_from_slice(parent.pair.1.as_bytes());
4944                                return Some((Ok(frame.freeze()), (stream, false)));
4945                            }
4946                            Some(EncodedItem::Leaf(leaf)) => {
4947                                return Some((Ok(leaf.data), (stream, false)));
4948                            }
4949                            Some(EncodedItem::Done) => return None,
4950                            Some(EncodedItem::Error(cause)) => {
4951                                engine
4952                                    .quarantine_on_mismatch(hash, &cause, &requested)
4953                                    .await;
4954                                let err = CacheError::Store(
4955                                    anyhow::Error::from(cause).context("export_bao stream failed"),
4956                                );
4957                                return Some((Err(err), (stream, true)));
4958                            }
4959                            // The store's item channel closing without a terminal
4960                            // `Done`/`Error` (actor crash / shutdown race) would
4961                            // otherwise yield a silently TRUNCATED wire that the serve
4962                            // path bills the client for and the client rejects as a
4963                            // short delivery — with no server-side signal. Refuse
4964                            // instead (#915 review).
4965                            None => {
4966                                let err = CacheError::Store(anyhow::anyhow!(
4967                                    "export_bao stream for {hash} ended without Done; \
4968                                 refusing truncated export"
4969                                ));
4970                                return Some((Err(err), (stream, true)));
4971                            }
4972                        }
4973                    }
4974                }
4975            },
4976        )))
4977    }
4978
4979    /// Collect the outboard `(node, (left, right))` hash pairs iroh-blobs emits for
4980    /// `chunk_ranges` of `hash`, read from the store's outboard.
4981    ///
4982    /// The store validates each pair and each leaf of the range against the content
4983    /// root as it exports them, like [`Self::export_bao_range_stream`]. A stored
4984    /// hash mismatch returns `Err` and [quarantines](Self::is_quarantined) the
4985    /// hash.
4986    ///
4987    /// The serve leg's shared outboard (ADR 038) is fed from these so it can drive
4988    /// a coherent whole-range bao encode while the pull fills the cache
4989    /// incrementally. Call it for each range as it is admitted (and for the held
4990    /// part of the serve's range at serve start). It reads and verifies every leaf
4991    /// of `chunk_ranges`, so its cost grows with the held bytes it is given.
4992    /// `export_bao` emits every proof `Parent` on the path to the range PLUS the
4993    /// right-siblings covering
4994    /// still-absent content, so the union over a front-to-back admit sequence is the
4995    /// whole tree's internal nodes. Leaf data and the size header are skipped.
4996    ///
4997    /// `chunk_ranges` must be PRESENT (a just-admitted or held range): the proof
4998    /// nodes for absent siblings are emitted from the outboard regardless, but a
4999    /// range whose own leaves are absent faults `export_bao`. On a partial blob
5000    /// that fault can be a hash mismatch, because an absent range reads back as
5001    /// zeros. The quarantine ignores a mismatch outside the present ranges, so
5002    /// a precondition breach returns `Err` without withdrawing the hash.
5003    pub async fn outboard_pairs(
5004        &self,
5005        hash: Hash,
5006        chunk_ranges: &bao_tree::ChunkRanges,
5007    ) -> CacheResult<
5008        Vec<(
5009            bao_tree::TreeNode,
5010            (bao_tree::blake3::Hash, bao_tree::blake3::Hash),
5011        )>,
5012    > {
5013        let mut stream = self
5014            .inner
5015            .store
5016            .blobs()
5017            .export_bao(hash, chunk_ranges.clone())
5018            .stream();
5019        let mut out = Vec::new();
5020        while let Some(item) = stream.next().await {
5021            match item {
5022                EncodedItem::Parent(parent) => out.push((parent.node, parent.pair)),
5023                EncodedItem::Leaf(_) | EncodedItem::Size(_) => {}
5024                EncodedItem::Done => break,
5025                EncodedItem::Error(cause) => {
5026                    self.quarantine_on_mismatch(hash, &cause, chunk_ranges)
5027                        .await;
5028                    return Err(CacheError::Store(
5029                        anyhow::Error::from(cause)
5030                            .context("outboard_pairs: export_bao stream failed"),
5031                    ));
5032                }
5033            }
5034        }
5035        Ok(out)
5036    }
5037
5038    /// Whether the origin chain has any origin `pull_through` would actually try
5039    /// for the given mode: any origin at all normally, or any non-`Peer` origin
5040    /// under `local_only` (#1116).
5041    fn has_eligible_origin(&self, local_only: bool) -> bool {
5042        self.inner
5043            .origins
5044            .iter()
5045            .any(|o| !local_only || o.kind() != OriginKind::Peer)
5046    }
5047
5048    /// [`Self::pull_through`] for a caller that needs the bytes ([`Self::get`]).
5049    ///
5050    /// Exists so no caller has to name a [`FillMode`] or unwrap the `Option`: the
5051    /// mode↔shape correspondence is resolved here, once. A `None` under
5052    /// [`FillMode::ReturnBytes`] is a logic regression, not a runtime condition —
5053    /// surface it as a `Store` fault, since the anti-panic policy rules out an
5054    /// `expect` and a silent empty `Bytes` would be worse (the caller would serve
5055    /// a zero-length blob).
5056    async fn pull_through_bytes(&self, hash: Hash) -> CacheResult<Bytes> {
5057        match self
5058            .pull_through(hash, false, FillMode::ReturnBytes)
5059            .await?
5060        {
5061            Some(bytes) => Ok(bytes),
5062            None => Err(CacheError::Store(anyhow::anyhow!(
5063                "pull_through returned no bytes for {hash} under FillMode::ReturnBytes"
5064            ))),
5065        }
5066    }
5067
5068    /// [`Self::pull_through`] for a caller that only wants the blob present
5069    /// ([`Self::populate`] / [`Self::populate_local`]).
5070    ///
5071    /// The `()` return is the point: under [`FillMode::CommitOnly`] there is no
5072    /// payload to hand back, and a caller that cannot receive one cannot
5073    /// accidentally keep a whole blob alive (#1132).
5074    async fn pull_through_fill(&self, hash: Hash, local_only: bool) -> CacheResult<()> {
5075        self.pull_through(hash, local_only, FillMode::CommitOnly)
5076            .await
5077            .map(drop)
5078    }
5079
5080    /// Walk the origin chain to fill `hash`.
5081    ///
5082    /// The mode↔return-shape correspondence is exact in both directions:
5083    /// [`FillMode::ReturnBytes`] always yields `Some` on success and
5084    /// [`FillMode::CommitOnly`] always yields `None`. Prefer the
5085    /// [`Self::pull_through_bytes`] / [`Self::pull_through_fill`] wrappers, which
5086    /// are total and hide the `Option` entirely.
5087    ///
5088    /// Runs inside an `origin_pull` span: one per chain walk, with the paid
5089    /// node→node fallback's `node_pull` span nested inside when the walk reaches
5090    /// the `Peer` origin.
5091    #[allow(clippy::too_many_lines)]
5092    // One linear chain walk; each outcome arm carries the rationale for its own fallback/return decision, and splitting the match out would separate those from the loop state (`last_err`, `any_not_found`, `any_short_circuit`) they exist to explain.
5093    #[tracing::instrument(
5094        name = "origin_pull",
5095        skip_all,
5096        fields(
5097            %hash,
5098            local_only = local_only,
5099            outcome = tracing::field::Empty,
5100            error = tracing::field::Empty,
5101            otel.status_code = tracing::field::Empty,
5102        )
5103    )]
5104    async fn pull_through(
5105        &self,
5106        hash: Hash,
5107        local_only: bool,
5108        mode: FillMode,
5109    ) -> CacheResult<Option<Bytes>> {
5110        let result: CacheResult<Option<Bytes>> = async {
5111            // Every pull_through entry is a `get()` cache miss, regardless
5112            // of how the pull resolves. Coalesced waiters that find a hit
5113            // on retry never call `pull_through`, so they never reach this
5114            // bump (their `hits` increment lives in the waiter branch of
5115            // `get`).
5116            if let Some(m) = &self.inner.metrics {
5117                m.misses.inc();
5118            }
5119            // Reject pulls with no *eligible* origin early. With `local_only`
5120            // (#1116) the `Peer` origin (the paid node→node fallback) is skipped, so
5121            // a chain of nothing but `Peer` origins is a fast `NoOrigin`, like an
5122            // empty chain — checked before the `origin_fetches` bump so that metric
5123            // still counts only real local fetch attempts.
5124            if !self.has_eligible_origin(local_only) {
5125                return Err(CacheError::NoOrigin { hash });
5126            }
5127
5128            // Bump the per-fetch denominator once per `pull_through` —
5129            // preserving #285 semantics where the counter is per
5130            // cache-miss-call, not per origin attempted. Alerts that
5131            // page on `origin_retry_exhausted_total / origin_fetches_total`
5132            // continue to be a per-call ratio; the new
5133            // `origin_fallback_total` separately counts chain-walk steps.
5134            if let Some(m) = &self.inner.metrics {
5135                m.origin_fetches.inc();
5136            }
5137            let max_blob_bytes = self.inner.max_blob_bytes;
5138            let policy = self.inner.retry_policy;
5139
5140            // Fallback chain walk (#284). Each origin gets its own retry
5141            // budget; on retry-exhaustion / Permanent / NotFound we advance
5142            // to the next entry. Deterministic per-origin failures
5143            // (`HashMismatch`, `BlobTooLarge`, `Store`) intentionally do
5144            // not fall back — they indicate a misbehaving backend that
5145            // must surface, not be masked by trying a different one.
5146            let mut last_err: Option<OriginPullError> = None;
5147            let mut any_not_found = false;
5148            let mut any_short_circuit = false;
5149            let total = self.inner.origins.len();
5150            for (idx, origin) in self.inner.origins.iter().enumerate() {
5151                // #1116: a `local_only` populate never touches the `Peer` origin
5152                // (the paid node→node fallback), so an operator serving its OWN
5153                // configured fs/http/s3 origin fronts no upstream USDC. Skipped
5154                // before the breaker/retry machinery so it costs nothing.
5155                if local_only && origin.kind() == OriginKind::Peer {
5156                    continue;
5157                }
5158                let origin = Arc::clone(origin);
5159                // Per-origin circuit-breaker (#963). An OPEN breaker
5160                // short-circuits this origin *before* the retry/backoff
5161                // loop runs, so a sustained outage on this backend costs no
5162                // backoff — the chain just advances to the next origin (or,
5163                // if every origin is OPEN, surfaces a fast `OriginError`).
5164                // `breakers` is parallel to `origins` by index; `get` keeps
5165                // the access non-panicking per the workspace anti-panic
5166                // policy (a missing slot would be a construction bug, in
5167                // which case we degrade to "no breaker" rather than panic).
5168                let breaker = self.inner.breakers.get(idx);
5169                // Admit (or short-circuit) under the breaker. The `Proceed`
5170                // arm carries a `TrialGuard` that owns any HALF-OPEN trial
5171                // slot; it MUST stay alive across the `.await` below so that
5172                // a cancelled future (client disconnect / timeout) drops it
5173                // and reclaims the slot rather than leaking it (#963). A
5174                // `None` breaker (construction degraded to "no breaker")
5175                // admits unconditionally with no guard.
5176                let trial_guard = match breaker.map(OriginBreaker::acquire) {
5177                    Some(Admission::ShortCircuit) => {
5178                        any_short_circuit = true;
5179                        self.emit_breaker_short_circuit_advance(hash, idx, total, origin.kind());
5180                        continue;
5181                    }
5182                    Some(Admission::Proceed(_state, guard)) => Some(guard),
5183                    None => None,
5184                };
5185
5186                let (outcome, terminal) =
5187                    run_with_retry_classified(policy, self.inner.metrics.as_ref(), hash, || {
5188                        self.pull_through_attempt(
5189                            Arc::clone(&origin),
5190                            hash,
5191                            max_blob_bytes,
5192                            policy,
5193                            mode,
5194                        )
5195                    })
5196                    .await;
5197                // Commit the breaker outcome through the guard (defusing its
5198                // cancellation-release path). A `None` guard is the degraded
5199                // "no breaker" case and records nothing.
5200                Self::record_breaker_outcome(trial_guard, terminal);
5201
5202                // Track *this iteration's* outcome class so the post-match
5203                // log records the correct cause. `last_err.is_some()` is
5204                // cumulative across iterations and would mislabel a later
5205                // NotFound advance as "primary failed" once any earlier
5206                // origin had errored.
5207                let advance_was_error = match outcome {
5208                    Ok(PullThroughOutcome::Bytes(bytes)) => {
5209                        // Announce the successful commit to any DHT
5210                        // republish-scheduler subscribers (ADR 022 §STORE
5211                        // Flow). `broadcast::send` returns `Err(SendError)`
5212                        // only when there are no active subscribers, which
5213                        // is the normal state when no DHT republish task
5214                        // exists — ignore. A local-store hit in `get` does
5215                        // not emit: a hit changes nothing the republisher
5216                        // advertises; commits and completed blocks do.
5217                        let _ = self.inner.inserts_tx.send(hash);
5218                        return Ok(Some(bytes));
5219                    }
5220                    // Same successful commit, minus the read-back the caller did not
5221                    // want (#1132) — so it must broadcast the insert identically.
5222                    Ok(PullThroughOutcome::Committed) => {
5223                        let _ = self.inner.inserts_tx.send(hash);
5224                        return Ok(None);
5225                    }
5226                    Ok(PullThroughOutcome::NotFound) => {
5227                        any_not_found = true;
5228                        false
5229                    }
5230                    Ok(PullThroughOutcome::BlobTooLarge) => {
5231                        return Err(CacheError::BlobTooLarge {
5232                            hash,
5233                            limit_bytes: max_blob_bytes,
5234                        });
5235                    }
5236                    Ok(PullThroughOutcome::HashMismatch { actual }) => {
5237                        return Err(CacheError::HashMismatch {
5238                            expected: hash,
5239                            actual,
5240                        });
5241                    }
5242                    Ok(PullThroughOutcome::Store(err)) => return Err(CacheError::Store(err)),
5243                    Err(e) => {
5244                        last_err = Some(e);
5245                        true
5246                    }
5247                };
5248
5249                // Final entry already tried; don't log a "fallback" for a
5250                // chain that has nowhere left to advance.
5251                if idx + 1 < total {
5252                    self.emit_chain_advance(
5253                        hash,
5254                        idx,
5255                        origin.kind(),
5256                        advance_was_error,
5257                        last_err.as_ref(),
5258                    );
5259                }
5260            }
5261
5262            // All origins exhausted. Any non-NotFound failure beats a pure
5263            // NotFound because "known backend errored" is more diagnostic
5264            // than "no backend had it" — operators triaging a 5xx benefit
5265            // from the underlying error, and a real NotFound only fires
5266            // when every origin agreed the blob is absent.
5267            if let Some(e) = last_err {
5268                Err(CacheError::OriginError {
5269                    hash,
5270                    source: e.into_inner(),
5271                })
5272            } else if any_short_circuit {
5273                // Every origin that wasn't a definitive NotFound was
5274                // short-circuited by an OPEN breaker (#963). There is no
5275                // `last_err` to surface (we never ran the retry loop for
5276                // those origins), but returning `NotFound` would be wrong —
5277                // the blob may well exist; we just refused to pull it while
5278                // the origin is shedding load. Surface a fast `OriginError`
5279                // so the caller sees "origin unavailable" rather than a
5280                // spurious 404, *without* having incurred any backoff.
5281                Err(CacheError::OriginError {
5282                    hash,
5283                    source: anyhow::anyhow!(
5284                        "origin circuit-breaker open: all eligible origins are \
5285                         fast-failing during a sustained outage (#963)"
5286                    ),
5287                })
5288            } else if any_not_found {
5289                Err(CacheError::NotFound { hash })
5290            } else {
5291                // Structurally unreachable: every iteration of the loop
5292                // above takes exactly one match arm. The five non-`Err`
5293                // arms all `return`; the `NotFound` arm sets
5294                // `any_not_found`; the breaker short-circuit sets
5295                // `any_short_circuit`; the `Err` arm sets `last_err`. To
5296                // reach this branch the chain must be non-empty (`is_empty()`
5297                // check at the top of `pull_through`) and have produced
5298                // no `last_err`, no `any_not_found`, and no
5299                // `any_short_circuit` — impossible under the current
5300                // `PullThroughOutcome` taxonomy. Reaching it
5301                // would mean a future variant was added without wiring
5302                // the corresponding flag, and a debug-only assert would
5303                // compile out in release builds. Emit an operator-visible
5304                // log and fall through to a `NotFound` surface so the
5305                // observable behaviour stays bounded (`unreachable!()`
5306                // would also be correct but the workspace policy prefers
5307                // a logged fallback over a release-panic in a hot path).
5308                tracing::error!(
5309                    hash = %hash,
5310                    "internal invariant violated: non-empty origin chain produced \
5311                     neither a NotFound nor an Err — likely a missing flag on a new \
5312                     PullThroughOutcome variant",
5313                );
5314                Err(CacheError::NotFound { hash })
5315            }
5316        }
5317        .await;
5318        record_origin_pull(&tracing::Span::current(), &result);
5319        result
5320    }
5321
5322    /// Bump the `origin_fallback` counter and emit a structured log for
5323    /// the chain-advance event (#284). Kept out of `pull_through`'s
5324    /// hot-path loop body so it stays small enough for clippy's
5325    /// cognitive-complexity lint, and so the WHY of the
5326    /// `warn!`-vs-`info!` branch decision lives in one named place
5327    /// rather than inline with retry control flow.
5328    ///
5329    /// `advance_was_error` is the *current iteration's* outcome class
5330    /// (not the cumulative `last_err.is_some()`), so a chain like
5331    /// `[Permanent, NotFound, Found]` logs `warn!` at the 0→1 step
5332    /// and `info!` at the 1→2 step rather than mislabeling the second
5333    /// step as "primary failed" by virtue of an earlier error still
5334    /// living in `last_err`. The caller is responsible for the
5335    /// `idx + 1 < total` guard that prevents a final-entry log; this
5336    /// method assumes a real advance is about to happen.
5337    fn emit_chain_advance(
5338        &self,
5339        hash: Hash,
5340        idx: usize,
5341        origin_kind: OriginKind,
5342        advance_was_error: bool,
5343        last_err: Option<&OriginPullError>,
5344    ) {
5345        if let Some(m) = &self.inner.metrics {
5346            m.origin_fallback.inc();
5347        }
5348        if advance_was_error {
5349            // Carry this origin's error in the log so operators
5350            // triaging the user-visible 5xx see *this* backend's
5351            // failure mode, not just the chain-final one surfaced via
5352            // `CacheError::OriginError`. `last_err` was assigned by
5353            // the `Err(e)` arm of this iteration and is `Some` here.
5354            tracing::warn!(
5355                hash = %hash,
5356                origin_index = idx,
5357                origin_kind = ?origin_kind,
5358                error = last_err.map(|e| format!("{e:#}")).unwrap_or_default(),
5359                "advancing to next origin in fallback chain (origin failed)",
5360            );
5361        } else {
5362            tracing::info!(
5363                hash = %hash,
5364                origin_index = idx,
5365                origin_kind = ?origin_kind,
5366                "advancing to next origin in fallback chain (NotFound)",
5367            );
5368        }
5369    }
5370
5371    /// Commit a per-origin breaker the health verdict for a completed
5372    /// attempt (#963) by recording it through the admission's
5373    /// [`crate::circuit_breaker::TrialGuard`].
5374    ///
5375    /// A transient that exhausted the retry budget is `Unavailable`
5376    /// (counts toward the trip threshold); everything else — bytes,
5377    /// `NotFound`, permanent per-object errors (404/4xx/decode/cap), and
5378    /// even a `Store`/`HashMismatch`/`BlobTooLarge` (the origin DID
5379    /// respond) — is `Available` and resets the failure count.
5380    ///
5381    /// Recording defuses the guard's cancellation-release path, so the
5382    /// HALF-OPEN trial slot it may own is resolved exactly once. A `None`
5383    /// guard is the degraded "no breaker" case (construction produced no
5384    /// breaker for this index) and records nothing.
5385    fn record_breaker_outcome(guard: Option<TrialGuard<'_>>, terminal: Option<TerminalFailure>) {
5386        let Some(guard) = guard else { return };
5387        let outcome = match terminal {
5388            Some(TerminalFailure::TransientExhausted) => OriginOutcome::Unavailable,
5389            Some(TerminalFailure::Permanent) | None => OriginOutcome::Available,
5390        };
5391        guard.record(outcome);
5392    }
5393
5394    /// Handle a per-origin circuit-breaker short-circuit (#963) that
5395    /// advances the fallback chain: bump `origin_fallback` and emit the
5396    /// operator-visible advance breadcrumb, both gated by `idx + 1 <
5397    /// total` so a short-circuit on the chain-final origin neither
5398    /// logs nor counts a non-existent advance.
5399    ///
5400    /// The `origin_fallback` bump matches the non-breaker advances in
5401    /// [`Self::emit_chain_advance`] — a short-circuit IS a real
5402    /// fallback-chain step, so omitting it would make
5403    /// `origin_fallback_total` undercount. The short-circuit *load-shed*
5404    /// counter itself is bumped separately inside
5405    /// [`crate::circuit_breaker::OriginBreaker::acquire`] (so it counts
5406    /// even on the chain-final origin, where no advance fires).
5407    fn emit_breaker_short_circuit_advance(
5408        &self,
5409        hash: Hash,
5410        idx: usize,
5411        total: usize,
5412        origin_kind: OriginKind,
5413    ) {
5414        if idx + 1 >= total {
5415            return;
5416        }
5417        if let Some(m) = &self.inner.metrics {
5418            m.origin_fallback.inc();
5419        }
5420        tracing::warn!(
5421            hash = %hash,
5422            origin_index = idx,
5423            origin_kind = ?origin_kind,
5424            "advancing to next origin in fallback chain (circuit-breaker open)",
5425        );
5426    }
5427
5428    /// A single end-to-end pull-through attempt: origin.fetch +
5429    /// (buffer-then-commit | stream-and-commit) + hash verify + tag
5430    /// promote. Body-phase errors classified as
5431    /// [`OriginPullError::Transient`] re-enter the retry loop; the
5432    /// non-[`OriginPullError`] outcomes (`Store` / `HashMismatch` /
5433    /// `BlobTooLarge`) ride out through [`PullThroughOutcome`] because
5434    /// they are deterministic and retry would not help.
5435    ///
5436    /// Why this method instead of inlining into `pull_through`: the
5437    /// retry loop ([`run_with_retry_classified`]) needs a callable that
5438    /// produces a fresh attempt on each invocation — the side-channel `Arc`s,
5439    /// `TempTag`s, and origin futures all have to be re-created per
5440    /// attempt and can't be reused across iterations.
5441    ///
5442    /// Under [`FillMode::CommitOnly`] (the `populate` fill path) this returns
5443    /// [`PullThroughOutcome::Committed`] rather than a payload — the streaming arm
5444    /// by skipping its `read_local`, the buffered arm by dropping its drain
5445    /// buffer. See that variant's docs (#1132).
5446    #[allow(clippy::too_many_lines)] // Linear per-attempt flow; the failure-classification arms each need their own context comment, and splitting them across functions would obscure the sequence more than the length.
5447    async fn pull_through_attempt(
5448        &self,
5449        origin: Arc<dyn Origin>,
5450        hash: Hash,
5451        max_blob_bytes: u64,
5452        policy: RetryPolicy,
5453        mode: FillMode,
5454    ) -> Result<PullThroughOutcome, OriginPullError> {
5455        // The origin handle is now plumbed in by the caller
5456        // (`pull_through`'s fallback-chain loop, #284) so this method
5457        // is agnostic to chain position and works identically for the
5458        // singular-origin (chain length 1) and multi-origin paths.
5459        let fetch = origin.fetch(hash, max_blob_bytes).await?;
5460        let (stream, size_hint) = match fetch {
5461            crate::origin::OriginFetch::NotFound => return Ok(PullThroughOutcome::NotFound),
5462            crate::origin::OriginFetch::AlreadyAdmitted => {
5463                // The origin admitted + verified the blob into the store itself
5464                // (node-to-node pull, #1682). Skip the drain / import_and_verify_stream
5465                // and reuse the same post-commit tail the streamed path runs.
5466                return match mode {
5467                    FillMode::CommitOnly => Ok(PullThroughOutcome::Committed),
5468                    FillMode::ReturnBytes => match self.read_local(hash).await {
5469                        Ok(bytes) => Ok(PullThroughOutcome::Bytes(bytes)),
5470                        Err(CacheError::Store(err)) => Ok(PullThroughOutcome::Store(err)),
5471                        Err(other) => Ok(PullThroughOutcome::Store(
5472                            anyhow::Error::from(other).context(
5473                                "read_local returned unexpected variant after AlreadyAdmitted",
5474                            ),
5475                        )),
5476                    },
5477                };
5478            }
5479            crate::origin::OriginFetch::Found { stream, size_hint } => (stream, size_hint),
5480        };
5481
5482        // Pre-stream cap: if the adapter advertised a length, reject
5483        // before reading the first byte. The post-stream cap below is
5484        // load-bearing too — origins can lie or omit the hint. This
5485        // is a deterministic operator-visible cap breach; surface as
5486        // `BlobTooLarge` directly without burning retry budget.
5487        if let Some(advertised) = size_hint
5488            && advertised > max_blob_bytes
5489        {
5490            return Ok(PullThroughOutcome::BlobTooLarge);
5491        }
5492
5493        if should_buffer(size_hint, policy.buffered_max_bytes) {
5494            let drain_cap = policy.buffered_max_bytes.min(max_blob_bytes);
5495            let bytes = match drain_to_bytes(stream, drain_cap, self.inner.metrics.as_ref()).await {
5496                Ok(b) => b,
5497                // Cap-breach: surface as `BlobTooLarge` directly (typed
5498                // outcome). Routing through `classify_io_error` would
5499                // collapse it to a generic `OriginError` and operators
5500                // alerting on the typed `BlobTooLarge` metric would
5501                // lose visibility.
5502                Err(e) if is_blob_too_large_marker(&e) => {
5503                    return Ok(PullThroughOutcome::BlobTooLarge);
5504                }
5505                Err(e) => return Err(classify_io_error(e)),
5506            };
5507            return self.commit_buffered_bytes(hash, bytes, mode).await;
5508        }
5509
5510        // Streaming path: drive the origin stream into the store, verify the
5511        // hash, and promote — shared verbatim with the node-driven tee sink
5512        // (#856) via `import_and_verify_stream`. On a committed blob the engine's
5513        // existing `get()` callers (admin RPC, metrics tests) want the full
5514        // payload as `Bytes`, so re-read it from the local store (one mmap'd read
5515        // with `fs-store`, no extra origin egress).
5516        match self
5517            .import_and_verify_stream(hash, stream, max_blob_bytes)
5518            .await?
5519        {
5520            // The fill-only caller (`populate` / `populate_local`) drops the bytes,
5521            // so reading them back would re-materialise the whole blob for nothing
5522            // — the exact allocation the streaming import above just avoided
5523            // (#1132). Skip it.
5524            StreamCommitOutcome::Committed if mode == FillMode::CommitOnly => {
5525                Ok(PullThroughOutcome::Committed)
5526            }
5527            StreamCommitOutcome::Committed => match self.read_local(hash).await {
5528                Ok(bytes) => Ok(PullThroughOutcome::Bytes(bytes)),
5529                Err(CacheError::Store(err)) => Ok(PullThroughOutcome::Store(err)),
5530                Err(other) => {
5531                    // `read_local` only surfaces `Store`; any other variant is a
5532                    // logic regression. Map to `Store` so the outer
5533                    // `pull_through` still surfaces a coherent error; the inner
5534                    // anyhow chain preserves the cause.
5535                    Ok(PullThroughOutcome::Store(
5536                        anyhow::Error::from(other).context(
5537                            "read_local returned unexpected variant after successful commit",
5538                        ),
5539                    ))
5540                }
5541            },
5542            StreamCommitOutcome::HashMismatch { actual } => {
5543                Ok(PullThroughOutcome::HashMismatch { actual })
5544            }
5545            StreamCommitOutcome::BlobTooLarge => Ok(PullThroughOutcome::BlobTooLarge),
5546            StreamCommitOutcome::Store(err) => Ok(PullThroughOutcome::Store(err)),
5547        }
5548    }
5549
5550    /// Drive a chunk stream into the iroh-blobs store, capturing mid-stream
5551    /// errors via the side channel, verify the committed hash against `hash`,
5552    /// and on match promote the temp tag to a named tag. This is the
5553    /// commit-and-verify tail of the origin pull-through
5554    /// ([`Self::pull_through_attempt`]); `stream` is an `Origin::fetch` stream.
5555    /// It does NOT broadcast the insert or read the bytes back; the caller owns
5556    /// those (the pull-through re-reads for its `Bytes` return).
5557    ///
5558    /// `count_and_cap_stream` enforces `max_blob_bytes` and bumps the
5559    /// origin-egress metric per chunk, so the pulled bytes are metered as the
5560    /// upstream egress they genuinely are (those bytes left an origin) without
5561    /// touching the `get`-caller hit/returned counters.
5562    async fn import_and_verify_stream<S>(
5563        &self,
5564        hash: Hash,
5565        stream: S,
5566        max_blob_bytes: u64,
5567    ) -> Result<StreamCommitOutcome, OriginPullError>
5568    where
5569        S: futures_util::Stream<Item = std::io::Result<Bytes>> + Send + Sync + Unpin + 'static,
5570    {
5571        // `iroh-blobs::add_stream` swallows the upstream `io::Error` (it
5572        // `?`-propagates inside an async block whose error is discarded), so
5573        // without this side-channel capture the engine sees only "unexpected end
5574        // of stream" and operators lose the actionable upstream message. Failed
5575        // attempts strand a partial `TempTag` worth of bytes; iroh-blobs GC
5576        // reclaims them at `cache.gc_interval_sec` cadence.
5577        let captured_err: Arc<Mutex<Option<std::io::Error>>> = Arc::new(Mutex::new(None));
5578        let counted = count_and_cap_stream(
5579            stream,
5580            max_blob_bytes,
5581            self.inner.metrics.clone(),
5582            captured_err.clone(),
5583        );
5584        let progress = self.inner.store.blobs().add_stream(counted).await;
5585        let temp_tag_result = progress.temp_tag().await;
5586
5587        // Side-channel-recorded error wins over both the iroh-blobs Err arm AND a
5588        // "successful" partial import, because the latter's hash is
5589        // deterministically wrong and we'd rather surface the real cause than a
5590        // confusing `HashMismatch`. Drop the temp tag (regardless of inner
5591        // Ok/Err) so iroh-blobs GC reclaims the partial bytes.
5592        let captured = captured_err
5593            .lock()
5594            .unwrap_or_else(PoisonError::into_inner)
5595            .take();
5596        if let Some(upstream) = captured {
5597            drop(temp_tag_result);
5598            // Cap-breach mid-stream: typed `BlobTooLargeMarker` is the documented
5599            // escape hatch — surface as the typed `BlobTooLarge` outcome rather
5600            // than routing through `classify_io_error` which would collapse it to
5601            // a generic `OriginError`.
5602            if is_blob_too_large_marker(&upstream) {
5603                return Ok(StreamCommitOutcome::BlobTooLarge);
5604            }
5605            // Otherwise classify via the shared body-phase classifier: typed
5606            // `OriginError::*` inners surface as Permanent (decompression
5607            // failures, etc.); `io::ErrorKind`-Transient kinds (ConnectionReset,
5608            // TimedOut, …) surface as Transient and re-enter the retry loop.
5609            return Err(classify_io_error(upstream));
5610        }
5611
5612        let temp_tag = match temp_tag_result {
5613            Ok(tt) => tt,
5614            Err(err) => {
5615                // No upstream-captured error: the failure is on iroh-blobs' side
5616                // (disk write, actor crash, serialization-task panic, etc.).
5617                // Surface as `Store` so operators routing on origin-vs-store
5618                // don't misclassify a local store problem as a remote origin one.
5619                return Ok(StreamCommitOutcome::Store(
5620                    anyhow::Error::from(err)
5621                        .context("iroh-blobs add_stream failed during pull-through"),
5622                ));
5623            }
5624        };
5625
5626        let actual = temp_tag.hash();
5627        if actual != hash {
5628            // Drop the temp tag without promotion → the wrong-hash bytes are
5629            // never tagged, so they are GC-eligible inside iroh-blobs and the
5630            // next sweep reclaims them. Deterministic protocol violation: a clean
5631            // stream that hashed wrong is not a transport failure — retry won't
5632            // help.
5633            //
5634            // We deliberately do NOT logically evict `actual`. The persisted
5635            // evicted set is reserved for operator DMCA/corruption takedowns
5636            // (#279); reusing it here would durably censor `actual` — and since
5637            // content is BLAKE3-addressed, bytes whose hash is `actual` *are* the
5638            // authorized content for `actual`. A malicious upstream that answers
5639            // a pull for `hash` with a victim blob's bytes could otherwise make
5640            // us permanently blacklist that legitimate blob (#853). The requested
5641            // hash `hash` is correctly never committed.
5642            drop(temp_tag);
5643            return Ok(StreamCommitOutcome::HashMismatch { actual });
5644        }
5645
5646        // Promote the temp tag to a named tag — same effect as
5647        // `add_bytes(...).await`, which goes through `with_tag()` (iroh-blobs
5648        // `blobs.rs:624-632`). The name is opaque; the store auto-assigns it.
5649        // Tag-create failure is store-side, not origin-side: surface as `Store`
5650        // so retry-class taxonomy doesn't pick it up.
5651        let haf = temp_tag.hash_and_format();
5652        // A pull that started before a quarantine must not protect the corrupt
5653        // entry the quarantine released to GC. The import landed on that entry,
5654        // so nothing verified is lost.
5655        if self.is_quarantined(hash) {
5656            drop(temp_tag);
5657            return Ok(StreamCommitOutcome::Committed);
5658        }
5659        if let Err(err) = self.inner.store.tags().create(haf).await {
5660            return Ok(StreamCommitOutcome::Store(anyhow::Error::from(err)));
5661        }
5662        drop(temp_tag);
5663        Ok(StreamCommitOutcome::Committed)
5664    }
5665
5666    /// Commit a fully-buffered payload from the drain path. Drains do
5667    /// not go through `count_and_cap_stream` (their cap was already
5668    /// enforced inline), so this just hands the buffered `Bytes` to
5669    /// iroh-blobs and verifies the resulting hash. Splitting the
5670    /// commit out of `pull_through_attempt` keeps the two body-phase
5671    /// paths (drain vs. streaming) symmetric: each is followed by the
5672    /// same commit-and-verify sequence.
5673    async fn commit_buffered_bytes(
5674        &self,
5675        hash: Hash,
5676        bytes: Bytes,
5677        mode: FillMode,
5678    ) -> Result<PullThroughOutcome, OriginPullError> {
5679        // iroh-blobs `add_bytes` returns `Ok(NamedTag)` directly,
5680        // skipping the `TempTag` intermediary used by `add_stream`.
5681        // The named tag protects the blob from GC and is the same
5682        // shape the streaming path produces after `tags().create()`.
5683        let tag = match self.inner.store.blobs().add_bytes(bytes.clone()).await {
5684            Ok(t) => t,
5685            Err(err) => {
5686                return Ok(PullThroughOutcome::Store(
5687                    anyhow::Error::from(err)
5688                        .context("iroh-blobs add_bytes failed during pull-through"),
5689                ));
5690            }
5691        };
5692        let actual = tag.hash;
5693        if actual != hash {
5694            // Unlike the streaming path (which drops an unpromoted `TempTag`),
5695            // `add_bytes` already created a *persistent named tag* protecting
5696            // these wrong-hash bytes from GC. Delete it so the bytes become
5697            // GC-eligible — matching the streaming path's drop semantics and
5698            // avoiding the unbounded-disk-growth leak (#837). Best-effort: a
5699            // delete failure only delays reclaim, so it is logged, not
5700            // propagated over the `HashMismatch` we owe the caller.
5701            //
5702            // As on the streaming path we do NOT logically evict `actual`:
5703            // the persisted evicted set is for deliberate takedowns only, and
5704            // logically evicting a content-addressed hash here is the
5705            // durable-censorship vector in #853.
5706            if let Err(err) = self.inner.store.tags().delete(tag.name).await {
5707                if let Some(m) = &self.inner.metrics {
5708                    m.tag_drop_failures.inc();
5709                }
5710                tracing::warn!(
5711                    expected = %hash,
5712                    %actual,
5713                    error = %err,
5714                    "hash-mismatch tag delete failed (drain path); wrong-hash bytes stay GC-protected (not auto-retried)",
5715                );
5716            }
5717            return Ok(PullThroughOutcome::HashMismatch { actual });
5718        }
5719        match mode {
5720            FillMode::ReturnBytes => Ok(PullThroughOutcome::Bytes(bytes)),
5721            // The drain already holds these bytes, so unlike the streaming path
5722            // there is nothing to *avoid* re-reading here — dropping them is not a
5723            // memory win. It is a CONTRACT win: it makes "CommitOnly ⟺ None" true
5724            // in both directions, so the wrappers are total and a reader of
5725            // `FillMode::CommitOnly` can trust it end to end rather than
5726            // discovering this arm as an exception.
5727            FillMode::CommitOnly => {
5728                drop(bytes);
5729                Ok(PullThroughOutcome::Committed)
5730            }
5731        }
5732    }
5733}
5734
5735/// Whether a pull-through hands the committed blob back to its caller.
5736///
5737/// Replaces a `want_bytes: bool` that sat directly beside `local_only: bool` in
5738/// `pull_through`'s argument list — two adjacent booleans the compiler would let
5739/// you swap silently, in a chain where getting it wrong either reinstates the
5740/// #1132 whole-blob read-back or stops `get` consulting peer origins. The
5741/// `local_only` half would at least be caught — `populate_local_skips_peer_origin_and_fills_from_local`
5742/// asserts `peer_fetches == 0`. The read-back half is the silent one, and is what
5743/// the enum is really buying.
5744#[derive(Clone, Copy, PartialEq, Eq, Debug)]
5745enum FillMode {
5746    /// [`CacheEngine::get`] — the caller needs the payload. The streaming arm
5747    /// reads the committed blob back out of the store to produce it; the buffered
5748    /// arm already holds it and returns its drain buffer.
5749    ReturnBytes,
5750    /// [`CacheEngine::populate`] — the caller drops the payload, so it is never
5751    /// read back (#1132). Serving a 708 MB blob would otherwise cost that much
5752    /// again on the miss leg for a buffer nobody looks at.
5753    CommitOnly,
5754}
5755
5756/// Per-attempt outcomes that ride out of the retry loop without
5757/// classification: each is a deterministic, non-retry-class result
5758/// the engine maps directly to a `CacheError` variant.
5759#[derive(Debug)]
5760enum PullThroughOutcome {
5761    Bytes(Bytes),
5762    /// The blob committed to the local store, but the caller asked not to have it
5763    /// read back ([`FillMode::CommitOnly`] — the fill-only path, #1132). Carrying no
5764    /// payload is the entire point: re-reading a 708 MB blob to hand it to
5765    /// [`CacheEngine::populate`], which drops it, was a whole-blob allocation on
5766    /// the serve path's miss leg.
5767    Committed,
5768    NotFound,
5769    BlobTooLarge,
5770    HashMismatch {
5771        actual: Hash,
5772    },
5773    Store(anyhow::Error),
5774}
5775
5776/// Outcome of [`CacheEngine::import_and_verify_stream`] — the commit-and-verify
5777/// tail of the origin pull-through. Mirrors [`PullThroughOutcome`] minus the
5778/// `Bytes`/`NotFound` arms: a stream import either commits, hashes wrong,
5779/// overruns the cap, or hits a store fault.
5780#[derive(Debug)]
5781enum StreamCommitOutcome {
5782    Committed,
5783    BlobTooLarge,
5784    HashMismatch { actual: Hash },
5785    Store(anyhow::Error),
5786}
5787
5788/// Bound on the store's `import_bao` local update queue for
5789/// [`CacheEngine::admit_bao_stream`]: at most this many decoded [`BaoContentItem`]s
5790/// may be in flight to the store before the decode driver awaits. Small enough to
5791/// cap resident memory (a handful of `cdn/client/v1` chunks), large enough that the
5792/// store import and the decode overlap rather than ping-ponging one item at a time.
5793const ADMIT_BAO_LOCAL_UPDATE_CAP: usize = 8;
5794
5795/// Classify the `io::Error` [`CacheEngine::admit_bao_stream`]'s decoder surfaces. A
5796/// genuine bao verify rejection (chunk-group or parent hash mismatch) is an
5797/// `io::Error` of kind `InvalidData` (see `bao_tree::io::error::DecodeError`'s
5798/// `From<DecodeError> for io::Error`); a truncated/short feed instead surfaces
5799/// `UnexpectedEof`, and any other failure is the feed's own read or transport
5800/// fault. Only the mismatch is a corrupt-upstream `VerifyFailed`; everything else
5801/// is a `Feed` fault. Neither is this node's store.
5802fn classify_admit_decode_error(hash: Hash, e: std::io::Error) -> CacheError {
5803    if e.kind() == std::io::ErrorKind::InvalidData {
5804        CacheError::VerifyFailed { expected: hash }
5805    } else {
5806        CacheError::Feed(anyhow::Error::from(e).context("admit_bao_stream: decode/feed failed"))
5807    }
5808}
5809/// True when the body-phase `io::Error` wraps a typed
5810/// [`BlobTooLargeMarker`] — meaning the cap (engine-level
5811/// `count_and_cap_stream`, adapter-level HTTP chunk cap, or the
5812/// drain-path running total) tripped. Lets the engine surface
5813/// `CacheError::BlobTooLarge` directly instead of routing through
5814/// [`classify_io_error`] which would collapse the typed marker into a
5815/// generic `OriginError`.
5816fn is_blob_too_large_marker(e: &std::io::Error) -> bool {
5817    e.get_ref()
5818        .is_some_and(<dyn std::error::Error + Send + Sync>::is::<BlobTooLargeMarker>)
5819}
5820
5821/// Exact byte length a correct pre-order bao outboard for a `blob_size`-byte
5822/// blob has, under iroh-blobs' canonical `IROH_BLOCK_SIZE` (#823). Used to
5823/// bound the untrusted `{H}.obao4` read on the range-pull path before
5824/// `encode_verified_range`'s authoritative length check rejects a malformed
5825/// one. Saturates to `u64::MAX` only if the upstream `outboard_size` ever
5826/// exceeds `u64` (it cannot for any real blob).
5827pub(crate) fn expected_outboard_len(blob_size: u64) -> u64 {
5828    bao_tree::BaoTree::new(blob_size, crate::range_pull::IROH_BLOCK_SIZE).outboard_size()
5829}
5830
5831pub(crate) use crate::origin::BlobTooLargeMarker;
5832
5833/// Adapter that bumps the `pull_through_bytes` metric per chunk,
5834/// captures any upstream error into `captured_err`, and *terminates
5835/// the stream cleanly* (yields `None`, never `Err`) on cap breach
5836/// or upstream error.
5837///
5838/// The engine pipes the returned stream directly into
5839/// [`iroh_blobs::api::blobs::Blobs::add_stream`], so the I/O bound
5840/// becomes the chunk size from the origin (typically a few KiB to a
5841/// few MiB depending on the backend) — a 10 GB blob never pins
5842/// 10 GB of process RSS (issue #271).
5843///
5844/// **Why `None`-on-error rather than `Err`:** iroh-blobs'
5845/// `add_stream` send loop propagates a yielded `Err` via `?`,
5846/// dropping the bidi-channel sender. Its server-side companion
5847/// actor then waits forever for `Done` before yielding any
5848/// progress item, hanging the whole import. Yielding `None` lets
5849/// `add_stream` send the `Done` marker so the import commits
5850/// (under whatever hash the partial bytes produce); the engine
5851/// reads `captured_err` to surface the *real* failure instead of
5852/// the misleading `HashMismatch` the partial-import would
5853/// otherwise produce. The partial blob is left as an unprotected
5854/// `TempTag` and reclaimed by iroh-blobs' GC.
5855///
5856/// **Wrapping the inner stream in `Option`** ensures polling
5857/// returns `None` once we've terminated. Polling a stream after
5858/// a terminal error is undefined; without the sentinel, a
5859/// consumer that resumes polling could re-enter the adapter and
5860/// re-poll an already-errored upstream.
5861///
5862/// The `Send + Sync + 'static` bound on the returned stream is fixed
5863/// by `add_stream`'s signature.
5864fn count_and_cap_stream<S>(
5865    stream: S,
5866    max_bytes: u64,
5867    metrics: Option<Arc<CacheMetrics>>,
5868    captured_err: Arc<Mutex<Option<std::io::Error>>>,
5869) -> impl futures_util::Stream<Item = std::io::Result<Bytes>> + Send + Sync + 'static
5870where
5871    S: futures_util::Stream<Item = std::io::Result<Bytes>> + Send + Sync + Unpin + 'static,
5872{
5873    use futures_util::StreamExt;
5874    futures_util::stream::unfold(
5875        (Some(stream), 0u64, metrics, captured_err),
5876        move |(maybe_s, total, metrics, captured)| async move {
5877            let mut s = maybe_s?;
5878            let next = s.next().await?;
5879            match next {
5880                Err(e) => {
5881                    // Move the real error into the side channel
5882                    // (preserves any typed inner like
5883                    // `io::Error::other(OriginError::*)` that
5884                    // `HttpOrigin` packs in) and *terminate the
5885                    // stream cleanly* by returning `None` on the
5886                    // next poll. We deliberately do **not** yield
5887                    // the error to iroh-blobs' `add_stream`: when
5888                    // we yield `Err` from the source stream,
5889                    // `add_stream`'s send loop returns early via
5890                    // `?`, dropping the bidi-channel sender — but
5891                    // its companion server-side actor then waits
5892                    // forever for `Done` before yielding any
5893                    // progress item, hanging the whole import.
5894                    // Yielding `None` instead lets `add_stream`
5895                    // send the `Done` marker, the import commits
5896                    // with whatever bytes were already received
5897                    // (under a wrong hash), and the engine reads
5898                    // `captured_err` to surface the *real* failure
5899                    // instead of the misleading `HashMismatch` the
5900                    // partial-import would otherwise produce. The
5901                    // partial blob is left as an unprotected
5902                    // `TempTag` and reclaimed by iroh-blobs' GC.
5903                    *captured.lock().unwrap_or_else(PoisonError::into_inner) = Some(e);
5904                    None
5905                }
5906                Ok(chunk) => {
5907                    // Bill the chunk before the cap check: the
5908                    // origin already sent us the bytes, so the
5909                    // operator-visible egress meter must count them
5910                    // (matches the pre-streaming "every byte fetched
5911                    // is paid" intent at engine.rs:952-960). A
5912                    // chunk that lands the running total past
5913                    // `max_bytes` is still counted in
5914                    // `pull_through_bytes` even though we then
5915                    // abort the import.
5916                    if let Some(m) = metrics.as_ref() {
5917                        m.pull_through_bytes
5918                            .inc_by(u64::try_from(chunk.len()).unwrap_or(u64::MAX));
5919                    }
5920                    let new_total = total.saturating_add(chunk.len() as u64);
5921                    if new_total > max_bytes {
5922                        // Pack a typed `BlobTooLargeMarker` into the
5923                        // io::Error so the engine surfaces
5924                        // `CacheError::BlobTooLarge` (recovered via
5925                        // `io::Error::get_ref` downcast).
5926                        let err = std::io::Error::other(BlobTooLargeMarker { max_bytes });
5927                        *captured.lock().unwrap_or_else(PoisonError::into_inner) = Some(err);
5928                        return None;
5929                    }
5930                    Some((Ok(chunk), (Some(s), new_total, metrics, captured)))
5931                }
5932            }
5933        },
5934    )
5935}
5936
5937/// Record the end of an `origin_pull` span once. A
5938/// clean miss (`NotFound` / `NoOrigin`) is `not_found` with no error status,
5939/// since walking a chain that lacks the blob is normal; every other failure
5940/// is `failed` with its error text and an error status.
5941fn record_origin_pull<T>(span: &tracing::Span, result: &CacheResult<T>) {
5942    match result {
5943        Ok(_) => {
5944            span.record("outcome", "filled");
5945        }
5946        Err(CacheError::NotFound { .. } | CacheError::NoOrigin { .. }) => {
5947            span.record("outcome", "not_found");
5948        }
5949        Err(e) => {
5950            span.record("outcome", "failed");
5951            span.record("error", tracing::field::display(e.display_chain()));
5952            span.record("otel.status_code", "ERROR");
5953        }
5954    }
5955}
5956
5957#[cfg(test)]
5958#[allow(
5959    clippy::unwrap_used,
5960    clippy::expect_used,
5961    clippy::panic,
5962    clippy::indexing_slicing,
5963    clippy::cast_possible_truncation
5964)]
5965mod tests;