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(¤t);
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(¤t);
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: ¬ify,
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: ¬ify,
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;