decdn_client/sink.rs
1//! Streaming-decode support types for the paid pull path (#1120, ADR 038).
2//!
3//! [`PullReader`] adapts a live [`UpstreamPull`] to the bao decoder's
4//! byte-reader trait while [`StashedFault`] preserves the pull's typed faults
5//! (stall, refusal, voucher rejection) so a misbehaving peer stays scoreable —
6//! the decoder itself can only say "the bytes stopped arriving". The streaming
7//! decode loops that consume these are
8//! [`crate::ranged_store::ClientRangedStore::ingest_stream`], the node's cache
9//! admit, and (`test-util`) the in-memory `stream_fetch*` wrappers.
10//!
11//! [`content_paid_frontier`] maps a paid WIRE-byte watermark back to the
12//! content-byte frontier it covers, for the resume-at-paid-frontier gate.
13//!
14//! [`BlobCache`] is the injected `(hash, range)` cache the
15//! [`Streamer`](crate::Streamer) consults and fills. It is pure — it names no
16//! blob store — which is what keeps `decdn-client` `iroh-blobs`-free; the
17//! default cache is the no-op [`NoCache`].
18
19use bao_tree::io::DecodeError;
20use bytes::{Bytes, BytesMut};
21use decdn_bao_range::{CHUNK_GROUP_BYTES, align_range, bao_encoded_size};
22
23use crate::{HashMismatch, UpstreamPull};
24
25/// An [`iroh_io::AsyncStreamReader`] over a live [`UpstreamPull`], so the bao
26/// decoder can pull wire bytes on demand instead of being handed a complete
27/// buffer.
28///
29/// # Why the error is stashed rather than propagated
30///
31/// The reader trait speaks `io::Error`, but `next_chunk` produces the typed
32/// errors the reputation layer classifies on (`PullStalled`, `PullTimeout`,
33/// `UpstreamRefused`, `UpstreamVoucherRejected`). Collapsing those into an
34/// `io::Error` would silently downgrade, say, a peer's mid-stream refusal into
35/// an anonymous decode failure and stop it being scored. So the original
36/// `anyhow::Error` is parked in `fault` and the trait returns a placeholder; the
37/// decode loop that owns the reader
38/// ([`crate::ranged_store::ClientRangedStore::ingest_stream`], or the `test-util`
39/// in-memory decoder) checks `fault` first and returns the real error, using the
40/// decoder's complaint only when there is no parked fault.
41#[derive(Debug)]
42#[doc(hidden)]
43pub struct PullReader {
44 pull: UpstreamPull,
45 /// Wire bytes received but not yet consumed by the decoder.
46 buf: BytesMut,
47 /// `StreamEnd` seen — no more chunks will arrive.
48 ended: bool,
49 /// The first `next_chunk` fault, preserved with its type. See the type docs.
50 fault: Option<anyhow::Error>,
51}
52
53impl PullReader {
54 pub(crate) fn new(pull: UpstreamPull) -> Self {
55 Self {
56 pull,
57 buf: BytesMut::new(),
58 ended: false,
59 fault: None,
60 }
61 }
62
63 /// Recover the inner [`UpstreamPull`] once the decode loop is done with this
64 /// reader, so [`crate::source::BlobSource::finish`] (or the `test-util`
65 /// in-memory wrapper) can drain it to the stream end and recover the acked
66 /// voucher watermark.
67 ///
68 /// Any buffered-but-unconsumed wire bytes and a parked fault are dropped
69 /// silently: a caller only calls this after the decode loop reached its clean
70 /// `Done` state.
71 pub(crate) fn into_inner(self) -> UpstreamPull {
72 self.pull
73 }
74
75 /// Pull chunks until `buf` holds `n` bytes, the stream ends, or a fault is
76 /// parked.
77 async fn fill_to(&mut self, n: usize) {
78 while !self.ended && self.fault.is_none() && self.buf.len() < n {
79 match self.pull.next_chunk().await {
80 Ok(Some(chunk)) => self.buf.extend_from_slice(&chunk),
81 Ok(None) => self.ended = true,
82 Err(e) => self.fault = Some(e),
83 }
84 }
85 }
86
87 /// Take up to `n` buffered bytes. A short read is the decoder's EOF signal,
88 /// which it turns into `ParentNotFound`/`LeafNotFound` — the driver maps
89 /// that to a truncation, not to corruption.
90 fn take(&mut self, n: usize) -> Bytes {
91 let take = self.buf.len().min(n);
92 self.buf.split_to(take).freeze()
93 }
94}
95
96impl iroh_io::AsyncStreamReader for PullReader {
97 async fn read_bytes(&mut self, len: usize) -> std::io::Result<Bytes> {
98 self.fill_to(len).await;
99 Ok(self.take(len))
100 }
101
102 async fn read<const L: usize>(&mut self) -> std::io::Result<[u8; L]> {
103 self.fill_to(L).await;
104 let got = self.take(L);
105 let mut out = [0u8; L];
106 if got.len() < L {
107 return Err(std::io::Error::new(
108 std::io::ErrorKind::UnexpectedEof,
109 "paid pull ended before the bao decoder's next fixed-size read",
110 ));
111 }
112 out.copy_from_slice(&got);
113 Ok(out)
114 }
115}
116
117/// A reader that may be holding back a richer error than the one the bao decoder
118/// will report.
119///
120/// The decoder can only say "the bytes stopped arriving". When the reader knows
121/// WHY — a stalled peer, a mid-stream refusal, a rejected voucher — that reason
122/// is what the caller needs in order to score the peer, so it wins.
123///
124/// Public because it is a supertrait of [`crate::source::BaoRangeReader`]: a
125/// [`crate::source::BlobSource`]'s reader must preserve the pull's typed faults
126/// so the gap-driven driver (#1608) surfaces them for scoring.
127#[doc(hidden)]
128pub trait StashedFault {
129 /// Take the parked typed fault, if any. Returns `None` on a source whose
130 /// bytes carry the whole story (an in-memory buffer, a scripted double).
131 fn take_fault(&mut self) -> Option<anyhow::Error>;
132}
133
134impl StashedFault for PullReader {
135 fn take_fault(&mut self) -> Option<anyhow::Error> {
136 self.fault.take()
137 }
138}
139
140/// A plain in-memory wire buffer has no out-of-band failure mode: whatever the
141/// decoder says about it is the whole story.
142///
143/// Test-only, and gated to say so: it exists purely so the streaming ingest
144/// loop can be driven from a fixed wire buffer. A test using it exercises the
145/// `None` branch, where the decoder's error is the whole story, so it must not
146/// be the only coverage of the fault-precedence rule.
147#[cfg(test)]
148impl StashedFault for Bytes {
149 fn take_fault(&mut self) -> Option<anyhow::Error> {
150 None
151 }
152}
153
154/// Split a bao decode failure into "the peer lied" and "the stream ended early",
155/// the taxonomy every decoding consumer and the node-side tee share (ADR 038).
156/// Only a hash mismatch is provably corruption; a truncated stream is
157/// transport-class and must not tar the peer as a liar.
158///
159/// `pub(crate)`: [`crate::ranged_store::ClientRangedStore::ingest_stream`] and the
160/// in-memory `stream_fetch*` decoder reuse this exact classification rather than
161/// duplicating the match.
162pub(crate) fn classify_decode_error(err: DecodeError) -> anyhow::Error {
163 match err {
164 DecodeError::ParentHashMismatch(_) | DecodeError::LeafHashMismatch(_) => {
165 anyhow::Error::new(HashMismatch)
166 }
167 DecodeError::Io(io_err) => anyhow::Error::new(io_err).context("bao decode read failed"),
168 not_found @ (DecodeError::ParentNotFound(_) | DecodeError::LeafNotFound(_)) => {
169 anyhow::anyhow!("bao stream truncated mid-tree: {not_found}")
170 }
171 }
172}
173
174/// Map a **paid wire-byte watermark** back to the **content-byte frontier** it
175/// covers, for a stream that began at content offset `fetch_start` of a
176/// `total_bytes`-byte blob (#1497).
177///
178/// Vouchers pay for **wire** bytes — bao-encoded content PLUS interleaved proof
179/// (ADR 038 §Payment metering) — so `paid_wire` (the channel-cumulative bytes an
180/// ACCEPTED voucher covered, minus this stream's baseline) is a WIRE quantity.
181/// Resuming at `fetch_start + paid_wire` would treat it as a CONTENT offset and,
182/// since wire ≥ content, overshoot the true content paid-frontier: the content
183/// span between the real frontier and that overshoot would never be billed on the
184/// resumed leg (a systematic under-pay), and a large overshoot can even exceed the
185/// on-disk content length. This maps `paid_wire` back into content space instead.
186///
187/// # What it returns, and why it never under-pays
188///
189/// The result is the **largest chunk-group boundary `C` in `[fetch_start,
190/// total_bytes]` whose bao wire cost is fully within `paid_wire`** — i.e. the
191/// greatest `C` with `wire([fetch_start, C)) <= paid_wire`, where `wire(range)` is
192/// [`bao_encoded_size`] over the real `total_bytes` tree (the identical walk the
193/// serve-side encoder and `aligned_wire_len` use, so it equals the bytes actually
194/// on the wire, byte-for-byte).
195///
196/// Because a bao pre-order stream emits every chunk group's proof-and-data before
197/// it descends into any later subtree, the wire position at which content offset
198/// `C` finishes is exactly `wire([fetch_start, C))` (same tree, same pre-order
199/// walk, same node set up to `C`; later subtrees' interior nodes are emitted only
200/// after `C`). So `wire([fetch_start, C)) <= paid_wire` proves every byte needed
201/// to deliver content `[fetch_start, C)` landed inside the paid wire prefix — that
202/// content is paid for. Resuming at `C` therefore re-fetches only `[C, end)`;
203/// nothing past a paid frontier is skipped, so it **cannot under-pay**. The only
204/// slack is the sub-group tail between `C` and the true (possibly mid-group) paid
205/// frontier: that content is re-fetched and re-paid once on the next leg, a
206/// bounded over-pay of **strictly less than one chunk group** (16 KiB).
207///
208/// `fetch_start` MUST be a chunk-group boundary (every resume offset is — it comes
209/// from a checkpointed group boundary or a previous call to this function). A `paid_wire` of
210/// `0`, or a `total_bytes` of `0`, yields `fetch_start` (nothing paid on this leg
211/// ⇒ resume where it began).
212#[must_use]
213#[doc(hidden)]
214pub fn content_paid_frontier(fetch_start: u64, total_bytes: u64, paid_wire: u64) -> u64 {
215 // Wire cost of content `[fetch_start, c)` over the full `total_bytes` tree.
216 // `align_range` bound-checks and snaps to groups; `wire_len` is
217 // `bao_encoded_size(total_bytes, ranges)` — the exact on-wire byte count. An
218 // out-of-range `c` (only reachable if `fetch_start >= total_bytes`, which a
219 // live mid-fetch never is) maps to `u64::MAX`, excluding it from the search.
220 let wire_to = |c: u64| -> u64 {
221 if c <= fetch_start {
222 return 0;
223 }
224 let len = c.saturating_sub(fetch_start);
225 align_range(fetch_start, len, total_bytes).map_or(u64::MAX, |r| {
226 bao_encoded_size(total_bytes, r.chunk_ranges())
227 })
228 };
229
230 let span = total_bytes.saturating_sub(fetch_start);
231 if paid_wire == 0 || span == 0 {
232 return fetch_start.min(total_bytes);
233 }
234 // Candidate content frontiers are the group boundaries `C(k) = fetch_start +
235 // k·GROUP`, capped at `total_bytes` (the final partial group). `wire_to` is
236 // monotonic non-decreasing in `C`, so binary-search the largest `k` that fits.
237 let c_of = |k: u64| -> u64 {
238 k.saturating_mul(CHUNK_GROUP_BYTES)
239 .saturating_add(fetch_start)
240 .min(total_bytes)
241 };
242 let k_max = span.div_ceil(CHUNK_GROUP_BYTES);
243 let (mut lo, mut hi, mut best) = (0u64, k_max, 0u64);
244 while lo <= hi {
245 let mid = lo.saturating_add((hi - lo) / 2);
246 if wire_to(c_of(mid)) <= paid_wire {
247 best = mid;
248 lo = mid.saturating_add(1);
249 } else if mid == 0 {
250 break;
251 } else {
252 hi = mid.saturating_sub(1);
253 }
254 }
255 c_of(best)
256}
257
258/// A boxed, `Send` future returned by the output/cache trait methods below —
259/// the same boxed-future async-trait shape [`crate::source::SourceFuture`] uses
260/// on the input side, so a sink or cache can be held as `&dyn`.
261pub type SinkFuture<'a, T> =
262 core::pin::Pin<Box<dyn core::future::Future<Output = anyhow::Result<T>> + Send + 'a>>;
263
264/// An injected content cache the `Streamer` consults and fills, keyed by
265/// `(hash, offset)` over content bytes.
266///
267/// Pure by design: the trait names no blob store, which is what keeps
268/// `decdn-client` `iroh-blobs`-free — a blob-store-backed implementation lives
269/// ABOVE this crate and is injected. The default is the no-op [`NoCache`] (never
270/// hits, never stores), so a caller that wants no revisit-reuse pays nothing.
271///
272/// On a clean finish the `Streamer` tees the whole verified blob to
273/// [`put`](BlobCache::put). On a revisit it asks [`get`](BlobCache::get) for the
274/// whole blob: a whole-blob hit is served straight from the cache with no fetch,
275/// and anything less is treated as a miss and refetched in full — the store's
276/// gap-driven resume, not the cache, is what avoids re-pulling a partial prefix
277/// today. (Serving a cached PARTIAL prefix and fetching only the complement is a
278/// planned enhancement; [`get`](BlobCache::get) already reports a partial hold as
279/// a miss so an implementer need not stitch fragments.)
280pub trait BlobCache: Send + Sync {
281 /// Whether this cache actually stores what it is given. A caller that would
282 /// have to materialize the whole blob just to tee it here (the `Streamer`'s
283 /// revisit tee) skips that work — and the memory it costs — when this is
284 /// `false`. Defaults to `true`; a no-op cache overrides it.
285 fn caches(&self) -> bool {
286 true
287 }
288
289 /// Return cached content bytes for exactly `[offset, offset + len)` of
290 /// `hash`, or `None` on a miss. A partial hold is a miss — the caller fetches
291 /// the whole range rather than stitching a fragment.
292 ///
293 /// # Errors
294 ///
295 /// Whatever the backing cache raises. A miss is `Ok(None)`, not an error.
296 fn get(&self, hash: [u8; 32], offset: u64, len: u64) -> SinkFuture<'_, Option<Bytes>>;
297
298 /// Store verified content `bytes` as `[offset, offset + bytes.len())` of
299 /// `hash`. A cache that does not want the range may drop it.
300 ///
301 /// # Errors
302 ///
303 /// Whatever the backing cache raises.
304 fn put(&self, hash: [u8; 32], offset: u64, bytes: Bytes) -> SinkFuture<'_, ()>;
305}
306
307/// The default [`BlobCache`]: never caches. Every [`get`](BlobCache::get) misses
308/// and every [`put`](BlobCache::put) discards, so a `Streamer` built with it
309/// always fetches the whole range and stores nothing. A caller that wants
310/// revisit-reuse injects a real cache above `decdn-client`.
311#[derive(Clone, Copy, Debug, Default)]
312pub struct NoCache;
313
314impl BlobCache for NoCache {
315 fn caches(&self) -> bool {
316 false
317 }
318
319 fn get(&self, _hash: [u8; 32], _offset: u64, _len: u64) -> SinkFuture<'_, Option<Bytes>> {
320 Box::pin(async { Ok(None) })
321 }
322
323 fn put(&self, _hash: [u8; 32], _offset: u64, _bytes: Bytes) -> SinkFuture<'_, ()> {
324 Box::pin(async { Ok(()) })
325 }
326}
327
328// ---------------------------------------------------------------------------
329// Test double: an in-memory BlobCache for the `Streamer` and cache unit tests.
330// ---------------------------------------------------------------------------
331
332/// The exact key an in-memory [`BlobCache`] entry hits on: `(hash, offset, len)`.
333#[cfg(test)]
334type CacheKey = ([u8; 32], u64, u64);
335
336/// An in-memory [`BlobCache`] that stores each `put` under its exact
337/// `(hash, offset, len)` key and hits only on an identical key. The minimum a
338/// `Streamer` cache test needs to prove complement-only fetching.
339#[cfg(test)]
340#[derive(Debug, Default)]
341pub(crate) struct MemoryBlobCache {
342 entries: std::sync::Mutex<std::collections::HashMap<CacheKey, Bytes>>,
343}
344
345#[cfg(test)]
346impl MemoryBlobCache {
347 /// An empty cache.
348 #[must_use]
349 pub(crate) fn new() -> Self {
350 Self::default()
351 }
352}
353
354#[cfg(test)]
355impl BlobCache for MemoryBlobCache {
356 fn get(&self, hash: [u8; 32], offset: u64, len: u64) -> SinkFuture<'_, Option<Bytes>> {
357 Box::pin(async move {
358 let entries = self
359 .entries
360 .lock()
361 .map_err(|_| anyhow::anyhow!("memory cache lock poisoned"))?;
362 Ok(entries.get(&(hash, offset, len)).cloned())
363 })
364 }
365
366 fn put(&self, hash: [u8; 32], offset: u64, bytes: Bytes) -> SinkFuture<'_, ()> {
367 Box::pin(async move {
368 let len = u64::try_from(bytes.len())
369 .map_err(|_| anyhow::anyhow!("cached range length exceeds u64"))?;
370 let mut entries = self
371 .entries
372 .lock()
373 .map_err(|_| anyhow::anyhow!("memory cache lock poisoned"))?;
374 entries.insert((hash, offset, len), bytes);
375 Ok(())
376 })
377 }
378}
379
380#[cfg(test)]
381mod tests;