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