decdn_cache/fill_session.rs
1//! `FillSession` — the cache-resident fill-coordination primitive the
2//! serve-miss legs share (ADR 038).
3//!
4//! # Why this exists
5//!
6//! The serve leg must emit ONE coherent whole-range bao verified stream
7//! while the pull leg fills the cache incrementally beside it. `bao_tree`'s async
8//! [`encode_ranges_validated`](bao_tree::io::fsm::encode_ranges_validated) is the
9//! right engine — it walks the range pre-order, reading leaf DATA and, for every
10//! internal node, the `(left, right)` hash pair from an [`Outboard`]. The data the
11//! serve reads from the cache (via ranged export, gated on the present-range watch).
12//! But the OUTBOARD it cannot: iroh-blobs keeps a partial blob's outboard in actor
13//! memory and exposes no reader, and a pre-order walk needs proof hashes for
14//! not-yet-filled right-spine subtrees (the root's `right_hash` is emitted before
15//! any right-half data lands) that are NOT derivable from present data.
16//!
17//! So the cache keeps its own copy, captured as each range admits (the pull leg
18//! already receives those exact proof nodes in the verified upstream bao it ingests,
19//! and the cache's `admit_bao_stream` captures them via [`FillSession::capture`] into
20//! a whole-tree buffer). The serve leg reads them through a [`SessionOutboardReader`],
21//! whose [`Outboard::load`] AWAITS a node that has not been captured yet — racing the
22//! registry-wide fill liveness so a fill that dies (or a genuinely missing node after
23//! a clean pull) fails the serve rather than hanging.
24//!
25//! # The outboard is per-HASH, shared by every fill of that hash
26//!
27//! A blob is BLAKE3-addressed, so its tree geometry — and thus every captured
28//! `(left, right)` node — is a property of the HASH, not of any single pull. When two
29//! serve-misses want overlapping bytes of one hash, one opens a pull for its
30//! remainder and the other for its own; both admit into the SAME store and each admit
31//! captures proof nodes into the ONE per-hash [`HashOutboard`]. Because
32//! `attach ∪ remainder = R` by construction, the two pulls together capture every
33//! proof node a coherent encode of `R` needs, into that single buffer. A serve leg
34//! reading `R` therefore does not care which pull filled what — it reads the shared
35//! per-hash outboard and the source-agnostic present-range watch.
36//!
37//! The one thing that must generalize past a single pull is termination: "await this
38//! node, or fail" has to consult ALL live fills whose `covered` ranges intersect the
39//! node's byte span, not one pull's terminal signal. [`FillSession::range_still_live`]
40//! (backed by [`FillRegistry::range_still_live`]) answers exactly that — it settles
41//! a parked reader once no live session's `covered` still intersects the range,
42//! which is the N-fill generalization of the single-pull failure race. A settled
43//! read is served from the local store when the store holds what it needs, and
44//! fails otherwise; it never hangs.
45//!
46//! # Ownership seam (cache owns the structure, the node drives it)
47//!
48//! The pull leg (paid peer or own-origin) is node-side and alone knows when/why it
49//! terminated. So the session exposes [`FillSession::mark_ended`]; the node's pull
50//! leg calls it on every exit. The cache owns the DATA STRUCTURE; the node DRIVES
51//! it. [`FillError`] is a flattened terminal message (`anyhow::Error` is not `Clone`).
52
53use std::collections::HashMap;
54use std::io;
55use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
56use std::sync::{Arc, Mutex as StdMutex, OnceLock, PoisonError, Weak};
57
58use bao_tree::io::fsm::Outboard;
59use bao_tree::{BaoTree, BlockSize, ChunkNum, ChunkRanges, TreeNode, blake3};
60use decdn_bao_range::align_range_clamped;
61use tokio::sync::Notify;
62use tokio_util::sync::CancellationToken;
63
64use crate::Hash;
65use crate::engine::CacheEngine;
66use crate::error::{CacheResult, ErrorChain};
67
68/// iroh-blobs' block size — 16 KiB chunk groups (`2^4` 1 KiB chunks). Identical by
69/// construction to `decdn_bao_range::IROH_BLOCK_SIZE` and
70/// `iroh_blobs::store::IROH_BLOCK_SIZE`; the tree geometry (and thus every node
71/// offset) must match the store's, since the captured hashes are the store's.
72const IROH_BLOCK_SIZE: BlockSize = BlockSize::from_chunk_log(4);
73
74/// Bytes per outboard node (a `(left, right)` blake3 hash pair).
75const HASH_PAIR_BYTES: usize = 64;
76
77/// Bytes per bao chunk (the `ChunkNum` unit). `ChunkNum(n)` starts at `n * 1024`.
78const CHUNK_BYTES: u64 = 1024;
79
80/// A flattened terminal error for a fill: the node's pull leg records why its pull
81/// ended, and the serve-side [`SessionOutboardReader`] / data reader surface it.
82/// A `String` message rather than `anyhow::Error` because the outcome must be
83/// `Clone` (every observer reads it) and `anyhow::Error` is not.
84#[derive(Debug, Clone)]
85pub struct FillError(String);
86
87impl FillError {
88 /// Wrap a flattened terminal message.
89 #[must_use]
90 pub fn new(msg: impl Into<String>) -> Self {
91 Self(msg.into())
92 }
93}
94
95impl std::fmt::Display for FillError {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 f.write_str(&self.0)
98 }
99}
100
101impl std::error::Error for FillError {}
102
103/// The pre-order outboard buffer + a per-node "captured" flag. Guarded together;
104/// the lock is never held across an await.
105///
106/// Both vectors are allocated lazily on the first capture (see
107/// [`HashOutboard::write_pair`]), not at construction: empty ⇒ nothing captured
108/// yet, which [`HashOutboard::try_load`] reads as "absent" exactly as an all-false
109/// `captured` did. This keeps the MB-scale zeroing for a large blob off the global
110/// registry lock that [`HashOutboard::new`] runs under.
111#[derive(Debug)]
112struct OutboardState {
113 /// `tree.outboard_size()` bytes once sized: node `n`'s pair lives at
114 /// `tree.pre_order_offset(n) * 64`. Empty until the first capture sizes it.
115 bytes: Vec<u8>,
116 /// `captured[i]` is set once node index `i` (its `pre_order_offset`) is saved.
117 /// Empty until the first capture sizes it, alongside `bytes`.
118 captured: Vec<bool>,
119}
120
121/// The captured whole-tree outboard for ONE hash, shared by every [`FillSession`]
122/// that fills that hash. A blob's tree geometry is fixed by its BLAKE3 root, so a
123/// node captured by any pull — of any range, from any source — is valid for every
124/// serve leg reading that hash. The registry keys exactly one of these per hash and
125/// every session for the hash references it, which is what makes partial-overlap
126/// proof-sourcing free: pull A (admitting `attach`) and pull B (admitting
127/// `remainder`) capture into the same buffer, and a serve leg for `R = attach ∪
128/// remainder` reads every node either supplied.
129#[derive(Debug)]
130pub struct HashOutboard {
131 tree: BaoTree,
132 root: blake3::Hash,
133 state: StdMutex<OutboardState>,
134 /// Notified after every [`Self::capture`] so a parked [`Outboard::load`]
135 /// re-checks. Runtime-agnostic, so it crosses the pull/serve runtimes safely.
136 captured: Notify,
137 /// Notified when ANY session for this hash ends or is cancelled, so a parked
138 /// reader re-checks [`FillRegistry::range_still_live`] and fails a range no live
139 /// pull will fill. Shared as an `Arc` so a data reader on the node side can hold
140 /// an owned handle without naming this cache-private type.
141 liveness: Arc<Notify>,
142}
143
144impl HashOutboard {
145 /// Build the empty outboard for a `total_bytes`-byte blob rooted at `root`.
146 ///
147 /// O(1): the `tree.outboard_size()`-byte buffer is NOT allocated here — the
148 /// first [`Self::write_pair`] sizes it under the per-hash `state` lock. This
149 /// keeps a session's outboard construction under the global [`FillRegistry`]
150 /// lock free of MB-scale zeroing, so one large-blob claim does not stall
151 /// every other hash's claim/wakeup for the allocation duration, and a session
152 /// that only adopts the canonical outboard allocates nothing to discard.
153 #[must_use]
154 fn new(root: blake3::Hash, total_bytes: u64) -> Arc<Self> {
155 Arc::new(Self {
156 tree: BaoTree::new(total_bytes, IROH_BLOCK_SIZE),
157 root,
158 state: StdMutex::new(OutboardState {
159 bytes: Vec::new(),
160 captured: Vec::new(),
161 }),
162 captured: Notify::new(),
163 liveness: Arc::new(Notify::new()),
164 })
165 }
166
167 /// Write one internal node's `(left, right)` hash pair into `state`, returning
168 /// whether the pair was actually persisted. `false` for a leaf (no outboard
169 /// slot), an offset past `usize`, or a slot outside the buffers — nothing was
170 /// stored, so the caller must NOT wake readers on its behalf. `true` only once
171 /// both the hash bytes and the captured flag are set, which is exactly the state
172 /// [`Self::try_load`] reads back. Idempotent: re-saving a node (a re-admitted
173 /// range) overwrites with the same bytes. Does NOT notify; the caller wakes
174 /// parked readers once the batch is in, so an N-node admit fires `captured` a
175 /// single time rather than N.
176 fn write_pair(
177 &self,
178 state: &mut OutboardState,
179 node: TreeNode,
180 pair: (blake3::Hash, blake3::Hash),
181 ) -> bool {
182 let Some(offset) = self.tree.pre_order_offset(node) else {
183 return false; // leaf: no hash pair in the outboard
184 };
185 let Ok(idx) = usize::try_from(offset) else {
186 return false; // offset beyond usize: no addressable slot
187 };
188 // Lazily size the outboard on the first capture, under this per-hash
189 // `state` lock rather than the global registry lock `new` runs under. A
190 // real interior node (a `Some` offset) implies `outboard_size() >= 64`, so
191 // an empty buffer here always means "not yet sized", never a zero-node
192 // tree. Sized exactly once: every later capture sees a non-empty buffer.
193 // Bail if the size is not `usize`-representable rather than allocating
194 // `usize::MAX` — the same "not addressable, not capturable" degrade as the
195 // `idx` guard above, never a process-aborting allocation.
196 if state.bytes.is_empty() {
197 let Ok(size) = usize::try_from(self.tree.outboard_size()) else {
198 return false;
199 };
200 state.bytes = vec![0u8; size];
201 state.captured = vec![false; size / HASH_PAIR_BYTES];
202 }
203 let byte_off = idx.saturating_mul(HASH_PAIR_BYTES);
204 let (l, r) = pair;
205 let bytes_written = if let Some(slot) =
206 state.bytes.get_mut(byte_off..byte_off + HASH_PAIR_BYTES)
207 && let Some((left, right)) = slot.split_at_mut_checked(32)
208 {
209 left.copy_from_slice(l.as_bytes());
210 right.copy_from_slice(r.as_bytes());
211 true
212 } else {
213 false
214 };
215 let flag_set = if let Some(flag) = state.captured.get_mut(idx) {
216 *flag = true;
217 true
218 } else {
219 false
220 };
221 bytes_written && flag_set
222 }
223
224 /// Capture one internal node's `(left, right)` hash pair, then wake parked
225 /// readers. A `node` with no outboard slot (a leaf) is ignored and does not notify.
226 fn capture(&self, node: TreeNode, pair: (blake3::Hash, blake3::Hash)) {
227 let wrote = {
228 let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
229 self.write_pair(&mut state, node, pair)
230 };
231 if wrote {
232 self.captured.notify_waiters();
233 }
234 }
235
236 /// Capture a batch of node pairs under one lock acquisition, then fire
237 /// `captured` exactly once. A serve leg parked on any of these nodes reads them
238 /// all back the same as a per-node [`Self::capture`] loop would, but every parked
239 /// reader wakes once per batch instead of once per node. The admit path hands
240 /// each leaf's run of proof nodes here just before the leaf. Leaves are ignored;
241 /// the notify fires only if at least one internal node was written.
242 fn capture_many(
243 &self,
244 pairs: impl IntoIterator<Item = (TreeNode, (blake3::Hash, blake3::Hash))>,
245 ) {
246 let wrote = {
247 let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
248 pairs.into_iter().fold(false, |acc, (node, pair)| {
249 self.write_pair(&mut state, node, pair) || acc
250 })
251 };
252 if wrote {
253 self.captured.notify_waiters();
254 }
255 }
256
257 /// Read node `idx`'s captured pair, or `None` if not captured yet. Never holds
258 /// the lock across an await.
259 fn try_load(&self, idx: usize) -> Option<(blake3::Hash, blake3::Hash)> {
260 let state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
261 if state.captured.get(idx).copied() != Some(true) {
262 return None;
263 }
264 let byte_off = idx.saturating_mul(HASH_PAIR_BYTES);
265 let slot = state.bytes.get(byte_off..byte_off + HASH_PAIR_BYTES)?;
266 let (l, r) = slot.split_at_checked(32)?;
267 let left: [u8; 32] = l.try_into().ok()?;
268 let right: [u8; 32] = r.try_into().ok()?;
269 Some((blake3::Hash::from(left), blake3::Hash::from(right)))
270 }
271}
272
273/// A forward-only content frontier shared between a fill's serve legs and its pull.
274///
275/// Holders read it with [`Self::get`]. Only the owning [`FillSession`] raises it,
276/// and every raise that moves the value wakes a pull parked in
277/// [`DownstreamWatch::past`]. No holder can lower the frontier or raise it without
278/// that wakeup, so a parked pull never misses an advance.
279#[derive(Debug, Clone)]
280pub struct Frontier {
281 /// The frontier value, a high-water mark.
282 value: Arc<AtomicU64>,
283 /// The session's downstream wakeup, notified when [`Self::raise`] moves `value`.
284 advanced: Arc<Notify>,
285}
286
287impl Frontier {
288 /// A frontier at `start` that notifies `advanced` on each advance.
289 fn new(start: u64, advanced: Arc<Notify>) -> Self {
290 Self {
291 value: Arc::new(AtomicU64::new(start)),
292 advanced,
293 }
294 }
295
296 /// The current frontier value.
297 #[must_use]
298 pub fn get(&self) -> u64 {
299 self.value.load(Ordering::Acquire)
300 }
301
302 /// Raise the frontier to `to` and wake the parked pull if the value moved. A
303 /// lower `to` changes nothing.
304 fn raise(&self, to: u64) {
305 if self.value.fetch_max(to, Ordering::AcqRel) < to {
306 self.advanced.notify_waiters();
307 }
308 }
309}
310
311/// A fill's serve demand: one standing demand per serve leg that has run out of
312/// encoded bytes while its encode waits on content this fill produces.
313///
314/// A leg stands its demand through its [`DemandSlot`] and withdraws it once its
315/// encode moves again or the leg ends. [`Self::get`] reads the NEAREST standing
316/// demand, so a leg parked far down the blob never hides a leg parked at the pull's
317/// frontier. Every change to the nearest demand wakes a pull parked in
318/// [`DownstreamWatch::past`].
319#[derive(Debug, Clone)]
320pub struct Demand {
321 /// The content end each starved serve leg awaits, keyed by its slot id.
322 legs: Arc<StdMutex<HashMap<u64, u64>>>,
323 /// The session's downstream wakeup, shared with its paid frontier.
324 advanced: Arc<Notify>,
325}
326
327impl Demand {
328 /// An empty demand that notifies `advanced` when its nearest value changes.
329 fn new(advanced: Arc<Notify>) -> Self {
330 Self {
331 legs: Arc::new(StdMutex::new(HashMap::new())),
332 advanced,
333 }
334 }
335
336 /// The nearest content end a starved serve leg awaits, or `0` when none waits.
337 #[must_use]
338 pub fn get(&self) -> u64 {
339 nearest(&self.legs.lock().unwrap_or_else(PoisonError::into_inner))
340 }
341
342 /// Record that leg `leg` awaits content up to `end`.
343 fn stand(&self, leg: u64, end: u64) {
344 self.update(|legs| {
345 legs.insert(leg, end);
346 });
347 }
348
349 /// Drop leg `leg`'s standing demand, if any.
350 fn withdraw(&self, leg: u64) {
351 self.update(|legs| {
352 legs.remove(&leg);
353 });
354 }
355
356 /// Apply `change` and wake the parked pull when the nearest demand moved.
357 fn update(&self, change: impl FnOnce(&mut HashMap<u64, u64>)) {
358 let moved = {
359 let mut legs = self.legs.lock().unwrap_or_else(PoisonError::into_inner);
360 let before = nearest(&legs);
361 change(&mut legs);
362 nearest(&legs) != before
363 };
364 if moved {
365 self.advanced.notify_waiters();
366 }
367 }
368}
369
370/// The smallest standing demand in `legs`, or `0` when it is empty.
371fn nearest(legs: &HashMap<u64, u64>) -> u64 {
372 legs.values().copied().min().unwrap_or(0)
373}
374
375/// Source of [`DemandSlot`] ids, unique across every fill in the process.
376static NEXT_DEMAND_SLOT: AtomicU64 = AtomicU64::new(1);
377
378/// One serve leg's handle on the serve demand of its blob's live fills. Minted by
379/// [`FillSession::demand_slot`].
380///
381/// [`Self::stand`] routes the leg's demand to the live fill whose covered range
382/// holds the awaited byte, and withdraws it from every other fill of the hash:
383/// only that fill's pull can produce the byte. Dropping the slot withdraws the
384/// demand, so a finished or abandoned leg leaves nothing standing.
385#[derive(Debug)]
386pub struct DemandSlot {
387 /// The serve leg's own session, the route to the hash's registry.
388 session: Arc<FillSession>,
389 /// This slot's key in every [`Demand`] it stands in.
390 id: u64,
391}
392
393impl DemandSlot {
394 /// Stand this leg's demand for content up to `end`, clamped to the blob size.
395 /// It replaces the leg's previous demand. An `end` of `0` withdraws it.
396 pub fn stand(&self, end: u64) {
397 let end = end.min(self.session.total_bytes());
398 let Some(byte) = end.checked_sub(1) else {
399 self.withdraw();
400 return;
401 };
402 match self.session.registry_and_hash() {
403 Some((registry, hash)) => registry.route_demand(hash, self.id, byte, end),
404 None => self.session.route_own_demand(self.id, byte, end),
405 }
406 }
407
408 /// Withdraw this leg's demand from every fill of the hash.
409 pub fn withdraw(&self) {
410 match self.session.registry_and_hash() {
411 Some((registry, hash)) => registry.withdraw_demand(hash, self.id),
412 None => self.session.serve_demand.withdraw(self.id),
413 }
414 }
415}
416
417impl Drop for DemandSlot {
418 fn drop(&mut self) {
419 self.withdraw();
420 }
421}
422
423/// A pull leg's view of its session's two downstream frontiers: the paid frontier
424/// ([`FillSession::served_frontier`]) and the serve demand
425/// ([`FillSession::serve_demand`]). Read-only and cheap to clone, so a pull on its
426/// own runtime holds an owned copy. Minted by [`FillSession::downstream_watch`].
427#[derive(Debug, Clone)]
428pub struct DownstreamWatch {
429 /// The session's paid content frontier.
430 served_paid: Frontier,
431 /// The session's serve demand. Shares its wakeup with `served_paid`.
432 serve_demand: Demand,
433}
434
435impl DownstreamWatch {
436 /// The current paid content frontier.
437 #[must_use]
438 pub fn served_paid(&self) -> u64 {
439 self.served_paid.get()
440 }
441
442 /// The nearest content end a starved serve leg awaits, or `0` when none waits.
443 #[must_use]
444 pub fn serve_demand(&self) -> u64 {
445 self.serve_demand.get()
446 }
447
448 /// Resolve once `served_paid` lies past `observed_paid` or the serve demand
449 /// differs from `observed_demand`. Returns at once when either already holds.
450 /// The demand counts any change: a nearer leg parking lowers it, and the pull
451 /// must re-decide for that leg as much as for a farther one.
452 ///
453 /// Registers the wakeup BEFORE it re-reads the frontiers. `notify_waiters` wakes
454 /// only waiters registered when it fires and stores no permit, so an advance
455 /// that lands between the caller reading the frontiers and this park would
456 /// otherwise be lost, and the pull would wait forever (#1673).
457 pub async fn past(&self, observed_paid: u64, observed_demand: u64) {
458 let notified = self.served_paid.advanced.notified();
459 tokio::pin!(notified);
460 loop {
461 notified.as_mut().enable();
462 if self.served_paid() > observed_paid || self.serve_demand() != observed_demand {
463 return;
464 }
465 notified.as_mut().await;
466 notified.set(self.served_paid.advanced.notified());
467 }
468 }
469}
470
471/// One in-flight fill of a `total_bytes`-byte blob rooted at `root`.
472///
473/// Holds a reference to the per-hash [`HashOutboard`] (captured by the cache admit
474/// path via [`Self::capture`], read by [`SessionOutboardReader`]), the pull's
475/// terminal outcome ([`Self::mark_ended`] / [`Self::outcome`]), the ranges this fill
476/// covers ([`Self::set_covered`]), and the shared downstream frontiers the pull
477/// leg paces against ([`Self::served_frontier`] / [`Self::serve_demand`], awaited
478/// through [`Self::downstream_watch`]). Shared
479/// behind an `Arc`; every field is interior-mutable, so both legs hold
480/// `Arc<FillSession>` and coordinate on it.
481#[derive(Debug)]
482pub struct FillSession {
483 /// The per-hash captured outboard. A freshly built session starts with its own,
484 /// but registration ([`FillRegistry::register_fill`] / [`FillRegistry::claim`])
485 /// swaps in the hash's canonical one via [`Self::adopt_outboard`], so all
486 /// sessions for a hash — and every reader they mint — share the ONE buffer. The
487 /// swap runs under the registry lock before the session is handed to a pull leg
488 /// or a serve leg, so no capture or read observes a stale outboard.
489 outboard: StdMutex<Arc<HashOutboard>>,
490 /// The pull leg's terminal outcome — `None` while running, `Some(Ok)` on a
491 /// clean end, `Some(Err)` on a failure.
492 ended: StdMutex<Option<Result<(), FillError>>>,
493 /// The client's PAID content frontier: the serve leg raises it after each
494 /// voucher batch commits ([`Self::advance_served`]), the pull leg's pacer reads
495 /// it to bound `pulled − served_paid ≤ window`, plus one floor to serve
496 /// `serve_demand`. A cloneable handle, so a pull leg on its own runtime can hold
497 /// an owned copy.
498 served_paid: Frontier,
499 /// The ABSOLUTE content offset the owning request starts at — the value
500 /// `served_paid` is seeded with. A pull leg's `RampPacer` ramps on
501 /// `served_paid − served_start` plus the owning stream's carried lane credit,
502 /// so a resumed request ramps from that credit, not from its blob offset.
503 served_start: u64,
504 /// Where the starved serve legs reading this fill's content wait. A serve leg's
505 /// frame consumer stands a demand through its [`DemandSlot`] when it has no
506 /// encoded bytes left and its encode is parked on a leaf or a proof node no pull
507 /// has produced ([`SessionOutboardReader::parked_on`]), and withdraws it once
508 /// the encode moves again. A park with encoded bytes still buffered is
509 /// look-ahead and stands nothing, so a client that stops paying cannot drag a
510 /// pull past its window. When the nearest demand lies within one chunk group
511 /// past a pull's frontier, that pull's pacer draws one window floor even with
512 /// its window full. Without it, a pull window that closes before the serve leg's
513 /// credit window leaves both legs waiting on each other. Shares one wakeup with
514 /// `served_paid`, so a parked pull wakes on either.
515 serve_demand: Demand,
516 /// The chunk ranges THIS fill will produce — exactly the bytes this pull
517 /// fetches (its `missing_ranges ∩ R`). [`FillRegistry::range_still_live`]
518 /// intersects a reader's node range against the union of all live sessions'
519 /// `covered` to decide whether to keep awaiting or fail. Set at registration via
520 /// [`Self::set_covered`]; defaults to [`ChunkRanges::all`] so a session built but
521 /// not yet range-scoped coalesces conservatively.
522 covered: StdMutex<ChunkRanges>,
523 /// Live observer count: the pull owner plus every serve leg attached to this
524 /// fill. An [`ObserverLease`] increments on attach and decrements on drop; when
525 /// it reaches 0 while the pull is still running ([`Self::outcome`] is `None`),
526 /// the last-out drop fires [`Self::cancel`] to stop ingest (#1610).
527 observers: AtomicUsize,
528 /// Cancels the pull when the last observer leaves before the pull ends. The
529 /// node's pull leg selects on it to abort ingest; the tagged partial persists
530 /// for a later resume.
531 cancel: CancellationToken,
532 /// A back-link to the owning registry + this session's hash, set once at
533 /// registration ([`Self::bind_registry`]). It lets a reader consult ALL
534 /// live sessions for the hash via [`FillRegistry::range_still_live`]. A session
535 /// built standalone (in tests) leaves this unset and falls back to its own
536 /// single-fill liveness — correct, because a standalone session is the only fill.
537 registry: OnceLock<(Weak<FillRegistry>, Hash)>,
538 /// The node's pull-thread [`JoinHandle`](std::thread::JoinHandle), parked here by
539 /// the owner right after it spawns the pull, so whichever observer leaves LAST
540 /// joins it. That frees an owner whose OWN client finishes first from parking in
541 /// the join while other observers still stream: a non-last-out release returns
542 /// immediately, and the true last-out [`ObserverLease`] drop takes the handle back
543 /// out to join OFF the map lock. `None` when no pull was spawned (an attach, or a
544 /// spawn that failed).
545 pull_thread: StdMutex<Option<std::thread::JoinHandle<()>>>,
546}
547
548impl FillSession {
549 /// Construct the fill session for a `total_bytes`-byte blob rooted at `root`,
550 /// with its served frontier at 0. An owning branch for a request that starts
551 /// past byte 0 uses [`Self::starting_at`] instead.
552 #[must_use]
553 pub fn new(root: blake3::Hash, total_bytes: u64) -> Arc<Self> {
554 Self::starting_at(root, total_bytes, 0)
555 }
556
557 /// Construct the fill session for a `total_bytes`-byte blob rooted at `root`,
558 /// with its served frontier at `served_start`: the ABSOLUTE content offset the
559 /// request starts at, so a non-zero-offset request shows no phantom lead. The
560 /// session starts with a private per-hash outboard, replaced by the registry's
561 /// canonical one at registration.
562 #[must_use]
563 pub fn starting_at(root: blake3::Hash, total_bytes: u64, served_start: u64) -> Arc<Self> {
564 let downstream_advanced = Arc::new(Notify::new());
565 Arc::new(Self {
566 outboard: StdMutex::new(HashOutboard::new(root, total_bytes)),
567 ended: StdMutex::new(None),
568 served_paid: Frontier::new(served_start, Arc::clone(&downstream_advanced)),
569 served_start,
570 serve_demand: Demand::new(downstream_advanced),
571 covered: StdMutex::new(ChunkRanges::all()),
572 observers: AtomicUsize::new(0),
573 cancel: CancellationToken::new(),
574 registry: OnceLock::new(),
575 pull_thread: StdMutex::new(None),
576 })
577 }
578
579 /// Park the owner's pull-thread handle on the session so whichever observer leaves
580 /// LAST joins it. Called once, on an owning branch, right after the pull thread is
581 /// spawned. The pure-attach path never calls this — it drives no pull of its own.
582 ///
583 /// Refuses to overwrite an already-parked handle: a session has exactly one owning
584 /// pull, so a second park cannot happen — but if a future regression called this
585 /// twice, overwriting would DROP the first handle and detach its thread, orphaning
586 /// a pull that keeps paying/draining. So keep the first (the one the last-out
587 /// observer will join) and `debug_assert` the double-park loudly in tests/dev.
588 pub fn set_pull_handle(&self, handle: std::thread::JoinHandle<()>) {
589 let mut slot = self
590 .pull_thread
591 .lock()
592 .unwrap_or_else(PoisonError::into_inner);
593 debug_assert!(
594 slot.is_none(),
595 "a FillSession parks exactly one pull-thread handle; a second park would \
596 detach the first and orphan its pull"
597 );
598 if slot.is_none() {
599 *slot = Some(handle);
600 }
601 }
602
603 /// Take the parked pull-thread handle, if any. Called only by the last-out lease
604 /// teardown, so exactly one caller ever receives it — the one that then joins the
605 /// thread off-task.
606 fn take_pull_handle(&self) -> Option<std::thread::JoinHandle<()>> {
607 self.pull_thread
608 .lock()
609 .unwrap_or_else(PoisonError::into_inner)
610 .take()
611 }
612
613 /// The per-hash outboard this session currently references. Cloned out (a cheap
614 /// `Arc` bump) rather than borrowed, so the swap lock is never held across a
615 /// capture or a read.
616 fn outboard(&self) -> Arc<HashOutboard> {
617 self.outboard
618 .lock()
619 .unwrap_or_else(PoisonError::into_inner)
620 .clone()
621 }
622
623 /// Adopt the hash's canonical outboard (shared with sibling sessions). Called
624 /// once, under the registry lock, before this session drives a pull or mints a
625 /// reader — so every later capture / read lands in the one shared buffer.
626 fn adopt_outboard(&self, shared: Arc<HashOutboard>) {
627 *self.outboard.lock().unwrap_or_else(PoisonError::into_inner) = shared;
628 }
629
630 /// Bind this session to its registry + hash, so a reader it mints can ask the
631 /// registry which live fills still cover a range. Called once at registration.
632 fn bind_registry(&self, registry: Weak<FillRegistry>, hash: Hash) {
633 let _ = self.registry.set((registry, hash));
634 }
635
636 /// Set the chunk ranges this fill will produce. Called once at registration,
637 /// before the pull streams — the registry reads it to plan observer attach and
638 /// to answer [`Self::range_still_live`].
639 pub fn set_covered(&self, covered: ChunkRanges) {
640 *self.covered.lock().unwrap_or_else(PoisonError::into_inner) = covered;
641 }
642
643 /// A clone of this fill's `covered` ranges.
644 fn covered_ranges(&self) -> ChunkRanges {
645 self.covered
646 .lock()
647 .unwrap_or_else(PoisonError::into_inner)
648 .clone()
649 }
650
651 /// The blob's total byte length. The attach path signs its `StreamResponse`
652 /// from this without re-running the upstream header handshake — the session
653 /// already knows the geometry from its `BaoTree`.
654 #[must_use]
655 pub fn total_bytes(&self) -> u64 {
656 self.outboard().tree.size()
657 }
658
659 /// The ABSOLUTE content offset this session's owning request starts at.
660 #[must_use]
661 pub const fn served_start(&self) -> u64 {
662 self.served_start
663 }
664
665 /// The current PAID content frontier (absolute): how far the observers of
666 /// this fill have paid, and therefore how far past it the pull may run.
667 #[must_use]
668 pub fn served_paid(&self) -> u64 {
669 self.served_paid.get()
670 }
671
672 /// The live observer count (pull owner + attached serve legs).
673 #[must_use]
674 pub fn observer_count(&self) -> usize {
675 self.observers.load(Ordering::Acquire)
676 }
677
678 /// Whether the pull has been cancelled because the last observer left.
679 #[must_use]
680 pub fn is_cancelled(&self) -> bool {
681 self.cancel.is_cancelled()
682 }
683
684 /// The pull leg's cancellation token — it selects on this to abort ingest when
685 /// the last observer leaves before the pull completes.
686 #[must_use]
687 pub const fn cancel_token(&self) -> &CancellationToken {
688 &self.cancel
689 }
690
691 /// Whether this session is dead: the pull has recorded a terminal outcome, or
692 /// the fill was cancelled because its last observer left. A dead session must
693 /// not block a fresh pull (its `covered` is excluded from the coverage union)
694 /// and must not be an attach target.
695 #[must_use]
696 fn is_dead(&self) -> bool {
697 self.outcome().is_some() || self.cancel.is_cancelled()
698 }
699
700 /// Mint a serve-side [`SessionOutboardReader`] over this hash's shared outboard.
701 /// The reader snapshots the (already-adopted) outboard and holds this session so
702 /// its termination race can reach the registry. A serve path attaches the store
703 /// ([`SessionOutboardReader::with_store_fallback`]); without it, a load of a node
704 /// no live fill captures fails.
705 #[must_use]
706 pub fn outboard_reader(self: &Arc<Self>) -> SessionOutboardReader {
707 SessionOutboardReader {
708 session: Arc::clone(self),
709 outboard: self.outboard(),
710 parked_on: Arc::new(AtomicU64::new(0)),
711 store: None,
712 }
713 }
714
715 /// The shared PAID content frontier the pull leg's pacer bounds against.
716 #[must_use]
717 pub const fn served_frontier(&self) -> &Frontier {
718 &self.served_paid
719 }
720
721 /// Raise the PAID content frontier to `served` after a downstream voucher batch
722 /// commits, waking the parked pull if it moved. A lower value changes nothing,
723 /// so N observers advancing one shared frontier never regress each other.
724 pub fn advance_served(&self, served: u64) {
725 self.served_paid.raise(served);
726 }
727
728 /// Raise the PAID content frontier to `served` on behalf of an observer whose
729 /// delivery starts at content `offset`, but only when this session's frontier
730 /// already reaches `offset`.
731 ///
732 /// Under partial-overlap coalescing an observer consumes a SUFFIX of this fill's
733 /// covered range, starting at `offset`. Its payment extends this fill's paid
734 /// PREFIX only once that prefix reaches `offset`. Before that, the bytes in
735 /// `[frontier, offset)` are unpaid, and raising the frontier past them would let
736 /// the pull run its window over bytes no observer of this fill has paid for.
737 pub fn extend_served_from(&self, offset: u64, served: u64) {
738 if self.served_paid.get() >= offset {
739 self.served_paid.raise(served);
740 }
741 }
742
743 /// A read-only watch over both downstream frontiers, for a pull leg to pace
744 /// against and park on.
745 #[must_use]
746 pub fn downstream_watch(&self) -> DownstreamWatch {
747 DownstreamWatch {
748 served_paid: self.served_paid.clone(),
749 serve_demand: self.serve_demand.clone(),
750 }
751 }
752
753 /// The serve demand of this fill. When its nearest value lies within one chunk
754 /// group past a pull's frontier, that pull's pacer draws one window floor even
755 /// with its window full; a demand further out is ignored.
756 #[must_use]
757 pub const fn serve_demand(&self) -> &Demand {
758 &self.serve_demand
759 }
760
761 /// Mint the [`DemandSlot`] one serve leg reading this fill stands its demand
762 /// through. Under coalescing the awaited byte may belong to a sibling fill of
763 /// the hash, so the slot routes each demand through the registry.
764 #[must_use]
765 pub fn demand_slot(self: &Arc<Self>) -> DemandSlot {
766 DemandSlot {
767 session: Arc::clone(self),
768 id: NEXT_DEMAND_SLOT.fetch_add(1, Ordering::Relaxed),
769 }
770 }
771
772 /// The owning registry and this session's hash, when registered and the
773 /// registry is still alive.
774 fn registry_and_hash(&self) -> Option<(Arc<FillRegistry>, Hash)> {
775 self.registry
776 .get()
777 .and_then(|(weak, hash)| weak.upgrade().map(|registry| (registry, *hash)))
778 }
779
780 /// Stand slot `slot`'s demand for content up to `end` here when this live fill
781 /// produces `byte`, and withdraw it otherwise.
782 fn route_own_demand(&self, slot: u64, byte: u64, end: u64) {
783 let chunk = bao_tree::ChunkNum::full_chunks(byte);
784 let produces = !self.is_dead() && {
785 let covered = self.covered.lock().unwrap_or_else(PoisonError::into_inner);
786 !(&*covered & &ChunkRanges::from(chunk..chunk + 1)).is_empty()
787 };
788 if produces {
789 self.serve_demand.stand(slot, end);
790 } else {
791 self.serve_demand.withdraw(slot);
792 }
793 }
794
795 /// The per-hash liveness signal, notified whenever any session for this hash
796 /// ends or is cancelled. A serve-side data reader awaits it (alongside the
797 /// present-range watch) so a read of a range no live pull will fill settles
798 /// rather than hanging: served from the store when it holds the bytes, failed
799 /// otherwise. Handed out as an owned `Arc<Notify>` so the node's data reader
800 /// need not name the cache-private [`HashOutboard`].
801 #[must_use]
802 pub fn liveness_signal(&self) -> Arc<Notify> {
803 Arc::clone(&self.outboard().liveness)
804 }
805
806 /// Whether any LIVE fill of this hash still covers `range`, so a not-yet-present
807 /// node / leaf in `range` may still be filled. A registry-bound session consults
808 /// EVERY live session for the hash (the N-fill generalization); a standalone
809 /// session (unregistered, in tests) is the only fill, so it answers from its own
810 /// liveness. Returns `false` when nothing live covers `range` — the caller then
811 /// fails the read instead of hanging.
812 #[must_use]
813 pub fn range_still_live(&self, range: &ChunkRanges) -> bool {
814 match self.registry.get() {
815 Some((weak, hash)) => match weak.upgrade() {
816 Some(registry) => registry.range_still_live(*hash, range),
817 // The registry (the whole cache) is gone; nothing can fill anything.
818 None => false,
819 },
820 // Standalone: this session is the only fill, so its liveness decides.
821 None => !self.is_dead() && !(&self.covered_ranges() & range).is_empty(),
822 }
823 }
824
825 /// Capture one internal node's `(left, right)` hash pair into the per-hash
826 /// outboard. Idempotent; a leaf (no outboard slot) is ignored. Every
827 /// `admit_bao_stream` for the hash, from any pull or source, calls this — so the
828 /// one shared buffer accumulates the whole tree's proof.
829 pub fn capture(&self, node: TreeNode, pair: (blake3::Hash, blake3::Hash)) {
830 self.outboard().capture(node, pair);
831 }
832
833 /// Capture a batch of node pairs into the per-hash outboard in one shot, waking
834 /// parked serve legs once for the batch rather than once per node. The admit path
835 /// hands each leaf's run of proof nodes here just before the leaf, so a serve leg
836 /// reads a leaf's proof as soon as that leaf arrives, with at most one wake per
837 /// leaf.
838 pub fn capture_many(
839 &self,
840 pairs: impl IntoIterator<Item = (TreeNode, (blake3::Hash, blake3::Hash))>,
841 ) {
842 self.outboard().capture_many(pairs);
843 }
844
845 /// Record the pull leg's terminal outcome and wake parked readers. Idempotent
846 /// on the wake; the outcome is set once by the pull leg on its single exit.
847 /// Fires the per-hash liveness so a reader awaiting a range this fill covered
848 /// re-checks [`Self::range_still_live`] at once.
849 pub fn mark_ended(&self, result: Result<(), FillError>) {
850 {
851 let mut guard = self.ended.lock().unwrap_or_else(PoisonError::into_inner);
852 *guard = Some(result);
853 }
854 self.outboard().liveness.notify_waiters();
855 }
856
857 /// The pull leg's terminal outcome, if it has ended: `Some(Ok)` clean,
858 /// `Some(Err)` failed. `None` while the pull is still running.
859 #[must_use]
860 pub fn outcome(&self) -> Option<Result<(), FillError>> {
861 self.ended
862 .lock()
863 .unwrap_or_else(PoisonError::into_inner)
864 .clone()
865 }
866}
867
868/// Serve-side [`Outboard`] over a hash's shared [`HashOutboard`]. Its
869/// [`Outboard::load`] awaits a not-yet-captured node, racing the registry-wide fill
870/// liveness for the no-hang guarantee: once no live fill still covers the node's
871/// byte range, it reads the node from the local store when the reader carries a
872/// store fallback ([`Self::with_store_fallback`]), and fails when that cannot
873/// supply it. Minted via [`FillSession::outboard_reader`].
874#[derive(Debug)]
875pub struct SessionOutboardReader {
876 session: Arc<FillSession>,
877 /// The shared per-hash outboard, snapshot at mint (after the session adopted the
878 /// canonical one), so every `load` reads whatever any pull for the hash captured.
879 outboard: Arc<HashOutboard>,
880 /// The content end this reader waits on, written before each park: one byte
881 /// into the node whose pair no pull has captured. The reader does NOT raise
882 /// serve demand itself — an encoder reads ahead of what its consumer needs, so
883 /// a park here alone does not mean the serve leg is stuck. The frame consumer
884 /// stands this value via its [`DemandSlot`] only once it is
885 /// starved of encoded bytes ([`Self::parked_on`]).
886 parked_on: Arc<AtomicU64>,
887 /// The local store, read for a node no live fill will capture
888 /// ([`Self::with_store_fallback`]). `None` fails such a load.
889 store: Option<StoreFallback>,
890}
891
892/// The local store a [`SessionOutboardReader`] reads a node's pair from when no
893/// live fill will capture it.
894#[derive(Debug)]
895struct StoreFallback {
896 engine: CacheEngine,
897 /// The store's present chunks of the hash as last read. A node they already
898 /// meet needs no fresh read.
899 present: ChunkRanges,
900}
901
902impl StoreFallback {
903 /// Capture into `outboard` the pairs `export_bao` emits for ONE chunk group the
904 /// store holds under a node of chunk range `node_range`: every parent on the
905 /// group's proof path, the node among them, plus the right-siblings over
906 /// absent content. The store reads and verifies at most that one group's
907 /// leaves, however much content the node spans. Captures nothing when the
908 /// store holds nothing under the node. Returns whether the export yielded any
909 /// pairs.
910 async fn capture(
911 &mut self,
912 outboard: &HashOutboard,
913 node_range: &ChunkRanges,
914 ) -> CacheResult<bool> {
915 let hash = Hash::from_bytes(*outboard.root.as_bytes());
916 if (node_range & &self.present).is_empty() {
917 self.present = self
918 .engine
919 .present_ranges(hash)
920 .await?
921 .chunk_ranges()
922 .clone();
923 }
924 let held = node_range & &self.present;
925 let Some(first) = held.boundaries().first().copied() else {
926 return Ok(false);
927 };
928 let group_chunks = 1u64 << IROH_BLOCK_SIZE.chunk_log();
929 let group_start = first.0 / group_chunks * group_chunks;
930 let group = ChunkRanges::from(
931 ChunkNum(group_start)..ChunkNum(group_start.saturating_add(group_chunks)),
932 );
933 let pairs = self.engine.outboard_pairs(hash, &(&held & &group)).await?;
934 let exported = !pairs.is_empty();
935 outboard.capture_many(pairs);
936 Ok(exported)
937 }
938}
939
940impl SessionOutboardReader {
941 /// Read a node no live fill will capture from `engine`, the local store, before
942 /// failing the load. The store can hold content under such a node: a pull that
943 /// faulted landed part of its range first, or an earlier fill stored it. The
944 /// store's outboard then holds the node's pair. Without the fallback such a
945 /// load fails.
946 #[must_use]
947 pub fn with_store_fallback(mut self, engine: CacheEngine) -> Self {
948 self.store = Some(StoreFallback {
949 engine,
950 present: ChunkRanges::empty(),
951 });
952 self
953 }
954
955 /// The cell this reader writes the content end it waits on into. A frame
956 /// consumer shares it with its data reader and stands it as serve demand
957 /// while it has no encoded bytes left to take.
958 #[must_use]
959 pub fn parked_on(&self) -> Arc<AtomicU64> {
960 Arc::clone(&self.parked_on)
961 }
962
963 /// Log a node the store fallback supplied. After a failed pull, or once
964 /// every covering pull ended, the store is the expected source: debug. After
965 /// this session's own pull completed cleanly, the pull should have captured
966 /// the node itself, so the fallback covers a capture defect: warn.
967 fn log_store_hit(&self, node: TreeNode) {
968 let hash = self.outboard.root;
969 if matches!(self.session.outcome(), Some(Ok(()))) {
970 tracing::warn!(
971 %hash,
972 ?node,
973 "a clean pull never captured an outboard node; read it from the store"
974 );
975 } else {
976 tracing::debug!(
977 %hash,
978 ?node,
979 "read an outboard node no live fill will capture from the store"
980 );
981 }
982 }
983
984 /// The terminal error for a node no live fill will supply: a precise message
985 /// when this session's own pull ended (the standalone / single-fill case), a
986 /// generic one when the covering fills were siblings.
987 fn dead_range_error(&self, node: TreeNode) -> io::Error {
988 match self.session.outcome() {
989 Some(Err(msg)) => io::Error::other(format!(
990 "upstream pull failed before supplying outboard node {node:?}: {msg}"
991 )),
992 Some(Ok(())) => io::Error::other(format!(
993 "upstream pull completed but outboard node {node:?} was never captured"
994 )),
995 None => io::Error::other(format!(
996 "no live fill covers outboard node {node:?}; every covering pull ended"
997 )),
998 }
999 }
1000}
1001
1002impl Outboard for SessionOutboardReader {
1003 fn root(&self) -> blake3::Hash {
1004 self.outboard.root
1005 }
1006
1007 fn tree(&self) -> BaoTree {
1008 self.outboard.tree
1009 }
1010
1011 async fn load(&mut self, node: TreeNode) -> io::Result<Option<(blake3::Hash, blake3::Hash)>> {
1012 let Some(offset) = self.outboard.tree.pre_order_offset(node) else {
1013 return Ok(None); // leaf: bao_tree sources this from the data reader
1014 };
1015 let idx = usize::try_from(offset).unwrap_or(usize::MAX);
1016 let node_range = ChunkRanges::from(node.chunk_range());
1017 loop {
1018 // Register the wakers BEFORE inspecting shared state, so a capture /
1019 // fill-end recorded concurrently cannot slip between the check and the
1020 // await.
1021 let captured = self.outboard.captured.notified();
1022 let liveness = self.outboard.liveness.notified();
1023 tokio::pin!(captured);
1024 tokio::pin!(liveness);
1025 captured.as_mut().enable();
1026 liveness.as_mut().enable();
1027
1028 if let Some(pair) = self.outboard.try_load(idx) {
1029 return Ok(Some(pair));
1030 }
1031
1032 // Not captured yet. If no live fill still covers this node's range, no
1033 // pull will capture the node — decide now. A clean end may lag one
1034 // capture behind the outcome, so re-check once. Then read the node
1035 // from the store, which may hold content under it that a fill landed
1036 // before it ended, and fail only when the store cannot supply it.
1037 if !self.session.range_still_live(&node_range) {
1038 if let Some(pair) = self.outboard.try_load(idx) {
1039 return Ok(Some(pair));
1040 }
1041 let exported = match self.store.as_mut() {
1042 Some(fallback) => fallback.capture(&self.outboard, &node_range).await,
1043 None => Ok(false),
1044 };
1045 match exported {
1046 Ok(exported) => {
1047 if let Some(pair) = self.outboard.try_load(idx) {
1048 if exported {
1049 self.log_store_hit(node);
1050 }
1051 return Ok(Some(pair));
1052 }
1053 if exported {
1054 return Err(io::Error::other(format!(
1055 "the store holds content under outboard node {node:?} but \
1056 its export did not yield the node"
1057 )));
1058 }
1059 }
1060 Err(err) => {
1061 return Err(io::Error::other(format!(
1062 "{}; the store read for it failed: {}",
1063 self.dead_range_error(node),
1064 ErrorChain::new(&err)
1065 )));
1066 }
1067 }
1068 return Err(self.dead_range_error(node));
1069 }
1070
1071 // A pull captures this pair only once it fetches into the node's range.
1072 // Record the first byte of that range as what this encode waits on; the
1073 // frame consumer turns it into serve demand if it starves on this park.
1074 let node_start = node.chunk_range().start.to_bytes();
1075 self.parked_on
1076 .store(node_start.saturating_add(1), Ordering::Relaxed);
1077
1078 // Await the next capture or a liveness change, then re-check.
1079 tokio::select! {
1080 biased;
1081 () = liveness.as_mut() => {}
1082 () = captured.as_mut() => {}
1083 }
1084 }
1085 }
1086}
1087
1088/// RAII observer handle on a [`FillSession`]. One is minted for the pull owner and
1089/// one per attached serve leg ([`FillRegistry::claim`]). Releasing it decrements the
1090/// session's observer count; the last-out release, if the pull is still running,
1091/// cancels the pull so a fill no client is waiting on stops ingesting (#1610) — and
1092/// hands back the session's parked pull-thread handle so THAT caller joins it
1093/// off-task (the owner-join hand-off: whoever leaves LAST joins the pull, freeing an
1094/// owner whose own client finished first).
1095///
1096/// The decrement + cancel decision runs UNDER the registry map lock — the same
1097/// lock `claim`/`register_fill` hold when they attach an observer. That mutual
1098/// exclusion closes the resurrection race: a concurrent claim cannot `fetch_add` a
1099/// session 0→1 in the window between this drop's last-out `fetch_sub` (1→0) and its
1100/// `cancel()`, so no live observer can end up bound to a cancelled fill. The lease
1101/// therefore holds a [`Weak`] back to the registry (plus the session's `hash`) to
1102/// reach that lock at drop. If the upgrade fails the registry is gone, so the fill
1103/// is moot and the drop just skips. It only TAKES the pull handle under the lock;
1104/// the JOIN always runs off it.
1105#[derive(Debug)]
1106pub struct ObserverLease {
1107 session: Arc<FillSession>,
1108 registry: Weak<FillRegistry>,
1109 hash: Hash,
1110 /// Cleared once teardown has run. [`Self::release`] consumes the lease and runs
1111 /// teardown explicitly; the value then still `Drop`s, so this flag makes the
1112 /// second pass a no-op — the decrement fires exactly once.
1113 armed: bool,
1114}
1115
1116impl ObserverLease {
1117 /// Release the lease at a serve leg's teardown, handing back the parked
1118 /// pull-thread handle when THIS release is the last one out. The caller joins that
1119 /// handle OFF the registry map lock and off its accept task (on a blocking
1120 /// thread). A non-last-out release returns `None`, so an owner whose own client
1121 /// finished first returns at once, leaving the pull filling for the remaining
1122 /// observers — the last of which cancels it (#1610 preserved). Named so teardown
1123 /// reads as intent rather than an incidental drop.
1124 #[must_use]
1125 pub fn release(mut self) -> Option<std::thread::JoinHandle<()>> {
1126 self.teardown()
1127 }
1128
1129 /// The last-out decrement + cancel, shared by [`Self::release`] and [`Drop`].
1130 /// Runs under the registry map lock — the SAME lock `claim`/`register_fill` hold
1131 /// to attach — so the decrement, the map removal, and the cancel decision cannot
1132 /// interleave with an attach (the #1610 resurrection-race fix). It only TAKES the
1133 /// pull handle here; it never JOINS under the lock. Idempotent via `armed`.
1134 fn teardown(&mut self) -> Option<std::thread::JoinHandle<()>> {
1135 if !self.armed {
1136 return None;
1137 }
1138 self.armed = false;
1139 let Some(registry) = self.registry.upgrade() else {
1140 // Registry dropped — the fill (and its map entry) are gone; nothing can
1141 // attach, so the decrement + cancel are moot. Skip.
1142 return None;
1143 };
1144 // Take the SAME lock `claim`/`register_fill` hold to attach, so the
1145 // decrement, the map removal, and the cancel decision cannot interleave with
1146 // an attach.
1147 let mut map = registry.map.lock().unwrap_or_else(PoisonError::into_inner);
1148 // `fetch_sub` returns the PREVIOUS value; `1` means this drop took the
1149 // count to 0.
1150 let prev = self.session.observers.fetch_sub(1, Ordering::AcqRel);
1151 if prev != 1 {
1152 return None;
1153 }
1154 // Last observer left: free the session from the map so dead sessions do not
1155 // accumulate forever. Remove by pointer identity so a sibling session for the
1156 // same hash survives; drop the `hash` key if its Vec empties (the per-hash
1157 // outboard then drops once every reader releases it).
1158 if let Some(entry) = map.get_mut(&self.hash) {
1159 entry.sessions.retain(|s| !Arc::ptr_eq(s, &self.session));
1160 if entry.sessions.is_empty() {
1161 map.remove(&self.hash);
1162 }
1163 }
1164 // Cancel only if the pull has not already ended — a completed pull needs no
1165 // cancel, and a failed one already terminated. The session is removed from the
1166 // map regardless. Firing the per-hash liveness wakes any reader parked on a
1167 // range this fill covered so it re-checks at once.
1168 if self.session.outcome().is_none() {
1169 self.session.cancel.cancel();
1170 }
1171 self.session.outboard().liveness.notify_waiters();
1172 // Hand the parked pull-thread handle to this last-out caller to join off-task.
1173 // Taking (not joining) it here keeps the map lock free of a blocking join.
1174 self.session.take_pull_handle()
1175 }
1176}
1177
1178impl Drop for ObserverLease {
1179 fn drop(&mut self) {
1180 let _ = self.teardown();
1181 }
1182}
1183
1184/// The atomic outcome of [`FillRegistry::claim`]: a serve-miss either coalesces
1185/// wholly onto live pulls (`Attach`), owns a fresh pull for its whole range
1186/// (`Owner`), or — on a partial overlap — owns a fresh pull for its non-overlapping
1187/// REMAINDER while attaching to a live sibling for the overlap (`Mixed`). Every
1188/// variant carries the [`FillSession`]s and [`ObserverLease`]s the caller holds
1189/// for the life of its serve leg. `claim` decides the split AND registers any new
1190/// owner session under ONE map-lock acquisition, so two concurrent fresh misses for
1191/// the same blob cannot both open a pull.
1192#[derive(Debug)]
1193pub enum FillClaim {
1194 /// A live pull already covers the whole request; run a serve leg over `session`
1195 /// and open NO new pull. `lease` is an observer lease on the existing fill.
1196 Attach {
1197 /// The live session to serve from (largest overlap with the request).
1198 session: Arc<FillSession>,
1199 /// The observer lease held for the life of the attaching serve leg.
1200 lease: ObserverLease,
1201 },
1202 /// No live pull covers the request; the caller owns the freshly-registered
1203 /// `session` (scoped to the WHOLE request) and must spawn the pull. `lease` is
1204 /// the owner lease (observer count starts at 1).
1205 Owner {
1206 /// The freshly-registered session the caller must drive a pull for.
1207 session: Arc<FillSession>,
1208 /// The owner lease (count 1) held for the life of the owning pull.
1209 lease: ObserverLease,
1210 },
1211 /// A partial overlap: a live sibling covers a prefix/suffix of the request. The
1212 /// caller OWNS a fresh pull for the contiguous `remainder` (`[remainder_offset,
1213 /// remainder_offset + remainder_len)`) and ATTACHES to the sibling for the
1214 /// overlap, serving the whole request from the shared per-hash cache + outboard.
1215 /// Two pulls, each fetching its bytes once.
1216 Mixed {
1217 /// The freshly-registered session covering the remainder; drive its pull.
1218 owner: Arc<FillSession>,
1219 /// The owner lease (count 1) for the remainder pull.
1220 owner_lease: ObserverLease,
1221 /// The live sibling covering the overlap; its pull is shared, not re-opened.
1222 attach: Arc<FillSession>,
1223 /// The observer lease on the sibling, held for the life of the serve leg.
1224 attach_lease: ObserverLease,
1225 /// The remainder's byte offset — the pull leg fetches `[offset, offset+len)`.
1226 remainder_offset: u64,
1227 /// The remainder's byte length.
1228 remainder_len: u64,
1229 },
1230}
1231
1232/// Total chunk count spanned by a (disjoint, sorted) chunk-range set. Boundaries
1233/// come in `[start, end)` pairs; a trailing unpaired boundary (an open-ended set)
1234/// contributes nothing, which is fine for the finite `covered ∩ R` overlaps this
1235/// measures.
1236fn chunk_span(ranges: &ChunkRanges) -> u64 {
1237 let (pairs, _open_end) = ranges.boundaries().as_chunks::<2>();
1238 pairs
1239 .iter()
1240 .map(|[start, end]| end.0.saturating_sub(start.0))
1241 .sum()
1242}
1243
1244/// The single contiguous byte span `[offset, offset+len)` of a chunk-range set, or
1245/// `None` if it is empty or split into two or more disjoint pieces. A `Mixed` claim
1246/// only opens a remainder pull when the remainder is one contiguous range (a
1247/// prefix/suffix overlap), because the pull leg fetches one `[offset, len)` gap; a
1248/// remainder split by an interior overlap falls back to a conservative whole-request
1249/// pull. `total` clamps the final (possibly ragged) group to the blob end.
1250fn contiguous_byte_span(ranges: &ChunkRanges, total: u64) -> Option<(u64, u64)> {
1251 match ranges.boundaries() {
1252 [start, end] => {
1253 let offset = start.0.saturating_mul(CHUNK_BYTES);
1254 let end_bytes = end.0.saturating_mul(CHUNK_BYTES).min(total);
1255 Some((offset, end_bytes.saturating_sub(offset)))
1256 }
1257 _ => None,
1258 }
1259}
1260
1261/// One hash's live in-flight fills plus the ONE per-hash outboard they all capture
1262/// into and serve from.
1263#[derive(Debug)]
1264struct HashEntry {
1265 /// The canonical per-hash outboard, adopted by every session for this hash.
1266 outboard: Arc<HashOutboard>,
1267 /// The live fills for this hash (each a distinct pull). Removed by pointer
1268 /// identity when a session's last observer leaves.
1269 sessions: Vec<Arc<FillSession>>,
1270}
1271
1272/// Range-aware coalescing registry: the live in-flight fills, keyed by hash, plus a
1273/// per-hash captured outboard. A serve-miss consults [`Self::claim`] to split its
1274/// range into an attach (covered by a running pull) and a remainder (fetch it), and
1275/// [`Self::range_still_live`] answers a parked reader's "keep awaiting or fail?".
1276/// Purely synchronous range math under a std `Mutex`; the lock is never held across
1277/// an await.
1278#[derive(Debug, Default)]
1279pub struct FillRegistry {
1280 map: StdMutex<HashMap<Hash, HashEntry>>,
1281 /// Cache metrics for the coalescing decisions this registry makes; `None`
1282 /// in unit fixtures.
1283 metrics: Option<Arc<crate::metrics::CacheMetrics>>,
1284}
1285
1286impl FillRegistry {
1287 /// An empty registry with no metrics handle (unit fixtures).
1288 #[must_use]
1289 pub fn new() -> Self {
1290 Self::with_metrics(None)
1291 }
1292
1293 /// An empty registry that reports its coalescing decisions to `metrics`.
1294 #[must_use]
1295 pub fn with_metrics(metrics: Option<Arc<crate::metrics::CacheMetrics>>) -> Self {
1296 Self {
1297 map: StdMutex::new(HashMap::new()),
1298 metrics,
1299 }
1300 }
1301
1302 /// Increment `session`'s observer count and mint its RAII lease. The caller
1303 /// MUST already hold the map lock — the increment shares the lock the lease's
1304 /// drop takes for its decrement, so attach and teardown are mutually exclusive.
1305 fn mint_lease(self: &Arc<Self>, session: &Arc<FillSession>, hash: Hash) -> ObserverLease {
1306 session.observers.fetch_add(1, Ordering::AcqRel);
1307 ObserverLease {
1308 session: Arc::clone(session),
1309 registry: Arc::downgrade(self),
1310 hash,
1311 armed: true,
1312 }
1313 }
1314
1315 /// Insert a freshly-built `session` for `hash` under the held map lock: adopt the
1316 /// hash's canonical outboard (or seed it from this session if it is the first),
1317 /// bind the session's registry back-link, and publish it. The session's
1318 /// `covered` must already be set by the caller.
1319 fn insert_session(
1320 self: &Arc<Self>,
1321 map: &mut HashMap<Hash, HashEntry>,
1322 hash: Hash,
1323 session: &Arc<FillSession>,
1324 ) {
1325 let entry = map.entry(hash).or_insert_with(|| HashEntry {
1326 outboard: session.outboard(),
1327 sessions: Vec::new(),
1328 });
1329 session.adopt_outboard(Arc::clone(&entry.outboard));
1330 session.bind_registry(Arc::downgrade(self), hash);
1331 entry.sessions.push(Arc::clone(session));
1332 }
1333
1334 /// Publish a new pull's `session` (already scoped via [`FillSession::set_covered`]
1335 /// to the bytes it will fetch) and return the owner lease (observer count starts
1336 /// at 1). The entry stays in the map until the owning pull is joined node-side.
1337 /// A test-facing helper; production registers via [`Self::claim`].
1338 pub fn register_fill(
1339 self: &Arc<Self>,
1340 hash: Hash,
1341 session: &Arc<FillSession>,
1342 ) -> ObserverLease {
1343 let mut map = self.map.lock().unwrap_or_else(PoisonError::into_inner);
1344 self.insert_session(&mut map, hash, session);
1345 self.mint_lease(session, hash)
1346 }
1347
1348 /// The blob length of a LIVE in-flight fill for `hash`, if one runs — an
1349 /// ADVISORY peek the serve-miss path uses to skip its upstream header handshake
1350 /// when a pull it can coalesce onto already exists. Returns the `total_bytes` of
1351 /// the first non-dead session under `hash` (every session for one hash shares
1352 /// the blob's geometry, so any live one's length is THE length); `None` when no
1353 /// live fill exists (empty, or only dead sessions remain). Advisory only: a
1354 /// concurrent last-observer drop can retire the peeked session before the
1355 /// caller's atomic [`Self::claim`], which then owns a fresh pull and opens the
1356 /// leg late — so this NEVER replaces `claim`, it only removes the handshake in
1357 /// the common coalescing case.
1358 #[must_use]
1359 pub fn in_flight_total(&self, hash: Hash) -> Option<u64> {
1360 let map = self.map.lock().unwrap_or_else(PoisonError::into_inner);
1361 map.get(&hash)?
1362 .sessions
1363 .iter()
1364 .find(|session| !session.is_dead())
1365 .map(|session| session.total_bytes())
1366 }
1367
1368 /// Route slot `slot`'s demand for content up to `end` to the live fill of `hash`
1369 /// that produces `byte`, and withdraw it from every other fill of the hash
1370 /// ([`DemandSlot::stand`]).
1371 fn route_demand(&self, hash: Hash, slot: u64, byte: u64, end: u64) {
1372 let map = self.map.lock().unwrap_or_else(PoisonError::into_inner);
1373 if let Some(entry) = map.get(&hash) {
1374 for session in &entry.sessions {
1375 session.route_own_demand(slot, byte, end);
1376 }
1377 }
1378 }
1379
1380 /// Withdraw slot `slot`'s demand from every fill of `hash`.
1381 fn withdraw_demand(&self, hash: Hash, slot: u64) {
1382 let map = self.map.lock().unwrap_or_else(PoisonError::into_inner);
1383 if let Some(entry) = map.get(&hash) {
1384 for session in &entry.sessions {
1385 session.serve_demand.withdraw(slot);
1386 }
1387 }
1388 }
1389
1390 /// Whether any LIVE fill of `hash` still covers `range`. A parked reader calls
1391 /// this (via [`FillSession::range_still_live`]) to decide "keep awaiting a
1392 /// capture / present-range advance, or fail because no pull will ever fill this".
1393 /// `true` iff some live session's `covered` intersects `range`.
1394 #[must_use]
1395 pub fn range_still_live(&self, hash: Hash, range: &ChunkRanges) -> bool {
1396 let map = self.map.lock().unwrap_or_else(PoisonError::into_inner);
1397 match map.get(&hash) {
1398 Some(entry) => entry
1399 .sessions
1400 .iter()
1401 .any(|s| !s.is_dead() && !(&s.covered_ranges() & range).is_empty()),
1402 None => false,
1403 }
1404 }
1405
1406 /// Atomically decide attach/own/mixed AND register any new owner session, all
1407 /// under ONE map-lock acquisition. This closes the coalescing TOCTOU race a
1408 /// separate plan-then-register pair would leave open: between two lock
1409 /// acquisitions another thread can register, so two concurrent fresh misses for
1410 /// the same blob would both see an empty registry and both open a pull. `claim`
1411 /// holds the lock across the whole decision, so exactly one of two racing
1412 /// identical claims registers an owner and the other attaches.
1413 ///
1414 /// `R = align_range_clamped(offset, len, total)`: an end past `total` clamps to
1415 /// it, so a request whose claimed size grows still coalesces
1416 /// with a live fill instead of always falling to Owner. On an align error (a
1417 /// start at or past `total`) or empty `R`, the caller takes the Owner path (its
1418 /// own fetch surfaces any out-of-bounds error).
1419 /// Otherwise, over LIVE sessions **whose paid frontier reaches `R`'s aligned
1420 /// fetch start** (a session behind that frontier is excluded — attaching to it
1421 /// would park this request on a pull that advances only as the OTHER client
1422 /// pays, #2062; the exclusion trades duplicate egress for the overlap, counted
1423 /// as `fill_not_coalesced`), `covered_union = ⋃ covered`,
1424 /// `attach = R ∩ covered_union`, `remainder = R − covered_union`:
1425 /// - `attach` empty → OWNER of the whole `R`.
1426 /// - `remainder` empty (a live pull covers all of `R`) → ATTACH to the
1427 /// largest-overlap session; `make_session` is NOT called.
1428 /// - both non-empty, and the largest-overlap sibling covers ALL of `attach`, and
1429 /// `R − sibling.covered` is ONE contiguous span → MIXED: own a pull for that
1430 /// remainder, attach the sibling for the overlap.
1431 /// - otherwise (a multi-sibling union, or an interior overlap splitting the
1432 /// remainder in two) → OWNER of the whole `R`, the conservative fallback.
1433 /// It never wedges; like the frontier exclusion above it can double-pull
1434 /// an overlap a live sibling also covers.
1435 ///
1436 /// `make_session` runs under the lock only on an owning branch (never
1437 /// built-and-dropped on a pure attach); it does no await (a std lock) and only
1438 /// allocates, so holding the lock across it is safe.
1439 pub fn claim(
1440 self: &Arc<Self>,
1441 hash: Hash,
1442 offset: u64,
1443 len: u64,
1444 total: u64,
1445 make_session: impl FnOnce() -> Arc<FillSession>,
1446 ) -> FillClaim {
1447 let mut map = self.map.lock().unwrap_or_else(PoisonError::into_inner);
1448
1449 // R = the chunk ranges the request spans. An align error (a start at or
1450 // past the blob end) or an empty R has no coalescable range, so fall
1451 // straight to the Owner path. An end past the blob clamps instead.
1452 let (r, fetch_start) = match align_range_clamped(offset, len, total) {
1453 Ok(aligned) => (aligned.chunk_ranges().clone(), aligned.fetch_start()),
1454 Err(_) => (ChunkRanges::empty(), 0),
1455 };
1456
1457 if !r.is_empty() {
1458 let mut covered_union = ChunkRanges::empty();
1459 let mut best: Option<(Arc<FillSession>, ChunkRanges, u64)> = None;
1460 let mut not_coalesced = false;
1461 if let Some(entry) = map.get(&hash) {
1462 for session in &entry.sessions {
1463 // Dead sessions (ended or cancelled) may never deliver, so their
1464 // `covered` neither blocks a fresh pull nor is an attach target.
1465 // Reading `is_dead` under the map lock pins it against a
1466 // concurrent last-out lease drop (which fires `cancel()` under
1467 // this same lock).
1468 if session.is_dead() {
1469 continue;
1470 }
1471 // A live fill advances only as ITS payer pays: the pull runs one
1472 // credit window past `served_paid` and parks. A request that
1473 // starts AHEAD of that frontier would park on it too, and an
1474 // observer's payments extend a fill's paid prefix only once the
1475 // prefix reaches the observer's start (`extend_served_from`).
1476 // With the owner not paying — a header-only open (the CLI's
1477 // multi-source size probe), or a stalled client — the
1478 // observer starves until its own stall
1479 // budget expires. So such a request is not coalesced: it OWNS
1480 // its own span, and the two fills coexist under the hash. A
1481 // request at or behind the frontier attaches; its payments
1482 // extend the shared prefix at once.
1483 //
1484 // The cost is duplicate egress on the overlap, and only on the
1485 // part of it not yet in the store: the owner's pull fills just
1486 // the gaps the store is missing when it starts, so an overlap
1487 // the live fill already landed is never fetched again. The
1488 // part still to land is at most the live fill's credit window
1489 // plus its in-flight draws, and only attaching could save it,
1490 // which is what starves. So the rule stays, and a claim that
1491 // skips an overlapping session is counted once.
1492 if fetch_start > session.served_paid() {
1493 if !(&r & &session.covered_ranges()).is_empty() {
1494 not_coalesced = true;
1495 tracing::debug!(
1496 %hash,
1497 fetch_start,
1498 served_paid = session.served_paid(),
1499 "fill not coalesced: request starts ahead of the live fill's paid frontier",
1500 );
1501 }
1502 continue;
1503 }
1504 let covered = session.covered_ranges();
1505 let overlap = &r & &covered;
1506 if !overlap.is_empty() {
1507 let span = chunk_span(&overlap);
1508 if best
1509 .as_ref()
1510 .is_none_or(|(_, _, best_span)| span > *best_span)
1511 {
1512 best = Some((Arc::clone(session), covered.clone(), span));
1513 }
1514 }
1515 covered_union |= covered;
1516 }
1517 }
1518
1519 if not_coalesced && let Some(m) = &self.metrics {
1520 m.fill_not_coalesced.inc();
1521 }
1522
1523 let attach = &r & &covered_union;
1524 let remainder = &r - &covered_union;
1525
1526 if !attach.is_empty()
1527 && let Some((sibling, sib_covered, _)) = best
1528 {
1529 if remainder.is_empty() {
1530 // Whole R covered by live pulls → pure ATTACH to the sibling.
1531 let lease = self.mint_lease(&sibling, hash);
1532 return FillClaim::Attach {
1533 session: sibling,
1534 lease,
1535 };
1536 }
1537 // Partial overlap. Coalesce only when ONE sibling covers all of the
1538 // overlap AND the bytes it does not cover form ONE contiguous span
1539 // the remainder pull can fetch as a single gap. Otherwise fall
1540 // through to a conservative whole-R owner (no double-pull, no wedge).
1541 let attach_covered_by_sibling = (&attach - &sib_covered).is_empty();
1542 let remainder_vs_sibling = &r - &sib_covered;
1543 if attach_covered_by_sibling
1544 && let Some((rem_offset, rem_len)) =
1545 contiguous_byte_span(&remainder_vs_sibling, total)
1546 {
1547 let owner = make_session();
1548 owner.set_covered(remainder_vs_sibling);
1549 self.insert_session(&mut map, hash, &owner);
1550 let owner_lease = self.mint_lease(&owner, hash);
1551 let attach_lease = self.mint_lease(&sibling, hash);
1552 return FillClaim::Mixed {
1553 owner,
1554 owner_lease,
1555 attach: sibling,
1556 attach_lease,
1557 remainder_offset: rem_offset,
1558 remainder_len: rem_len,
1559 };
1560 }
1561 }
1562 }
1563
1564 // OWNER: no overlap, an empty/unalignable R, or the conservative fallback.
1565 // Scope the session to the WHOLE R (so a later identical claim attaches),
1566 // register + mint the owner lease under the SAME lock.
1567 let owner = make_session();
1568 owner.set_covered(r);
1569 self.insert_session(&mut map, hash, &owner);
1570 let lease = self.mint_lease(&owner, hash);
1571 FillClaim::Owner {
1572 session: owner,
1573 lease,
1574 }
1575 }
1576}
1577
1578#[cfg(test)]
1579#[allow(
1580 clippy::unwrap_used,
1581 clippy::expect_used,
1582 clippy::panic,
1583 clippy::indexing_slicing
1584)] // tests
1585mod tests;
1586
1587#[cfg(test)]
1588#[allow(
1589 clippy::unwrap_used,
1590 clippy::expect_used,
1591 clippy::panic,
1592 clippy::indexing_slicing
1593)] // tests
1594mod fill_registry_tests;