use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use alloy::primitives::U256;
use bao_tree::ChunkRanges;
use decdn_bao_range::{
AlignedRange, CHUNK_GROUP_BYTES, RangedStore, RangedStoreError, align_range,
};
use decdn_protocol::VoucherRejectReason;
use crate::fault::HealExhausted;
use crate::pacer::{DownstreamFrontier, PaceDecision, PaceState};
use crate::source::{BlobSource, IngestEnd, IngestStore, SourceFuture};
use crate::{
MAX_RESUME_ATTEMPTS, Pacer, PoolContext, PoolLedger, UpstreamPullHeader,
UpstreamVoucherRejected, heal_watermark_desync, reject_empty_claim_for_nonempty_root,
rejection_watermark,
};
#[derive(Debug)]
#[cfg_attr(not(feature = "test-util"), non_exhaustive)]
pub struct PoolExhausted {
pub gap_start: u64,
pub gap_len: u64,
}
impl std::fmt::Display for PoolExhausted {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"gap [{}, +{}) of blob cannot be funded: the remaining deposit cannot cover the \
next voucher",
self.gap_start, self.gap_len
)
}
}
impl std::error::Error for PoolExhausted {}
#[derive(Debug)]
#[doc(hidden)]
pub struct LegNoProgress {
pub offset: u64,
pub len: u64,
pub paid_frontier: u64,
pub delivered_frontier: u64,
pub paid_wire: u64,
}
impl std::fmt::Display for LegNoProgress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"leg [{}, +{}) finished cleanly but advanced neither the paid frontier ({}) nor \
the delivered frontier ({}), with {} wire bytes credited; refusing to re-open \
and re-pay the same range",
self.offset, self.len, self.paid_frontier, self.delivered_frontier, self.paid_wire
)
}
}
impl std::error::Error for LegNoProgress {}
#[derive(Debug, Clone, Copy)]
struct CleanLeg {
offset: u64,
len: u64,
paid_frontier: u64,
delivered_frontier: u64,
generation: u64,
}
impl CleanLeg {
const fn stalled(
self,
paid_frontier: u64,
delivered_frontier: u64,
paid_wire: u64,
generation: u64,
) -> Option<LegNoProgress> {
if generation != self.generation
|| paid_frontier > self.paid_frontier
|| delivered_frontier > self.delivered_frontier
{
return None;
}
Some(LegNoProgress {
offset: self.offset,
len: self.len,
paid_frontier: self.paid_frontier,
delivered_frontier: self.delivered_frontier,
paid_wire,
})
}
}
#[doc(hidden)]
pub struct SharedPool<'a> {
pub spent: &'a (dyn Fn() -> U256 + Send + Sync),
pub quotes: Option<&'a crate::credential::QuoteMax>,
}
impl std::fmt::Debug for SharedPool<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SharedPool")
.field("spent", &(self.spent)())
.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[doc(hidden)]
pub enum WaitReason {
WindowFull,
MinDraw,
}
#[doc(hidden)]
pub trait PacingWait: Send + Sync {
fn wait(
&self,
observed: DownstreamFrontier,
reason: WaitReason,
) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>;
}
fn locked_deposit(ctx: &Mutex<PoolContext>) -> anyhow::Result<U256> {
Ok(ctx
.lock()
.map_err(|_| anyhow::anyhow!("pool context lock poisoned"))?
.deposit)
}
const CHUNK_BYTES: u64 = 1024;
pub(crate) const PRESENT_RECORD_FLUSH_INTERVAL: Duration = Duration::from_secs(5);
struct OwedSpans<'l> {
ledger: &'l PoolLedger,
hash: [u8; 32],
spans: Vec<(u64, u64)>,
unbilled_from: AtomicU64,
}
impl<'l> OwedSpans<'l> {
fn take(ledger: &'l PoolLedger, hash: [u8; 32]) -> Self {
Self {
ledger,
hash,
spans: ledger.take_unpaid(hash),
unbilled_from: AtomicU64::new(0),
}
}
fn unbilled_from(&self, offset: u64) {
self.unbilled_from.store(offset, Ordering::Relaxed);
}
fn billed(&self) {
self.unbilled_from(u64::MAX);
}
}
impl Drop for OwedSpans<'_> {
fn drop(&mut self) {
let from = self.unbilled_from.load(Ordering::Relaxed);
for &(start, len) in &self.spans {
let end = start.saturating_add(len);
if end > from {
self.ledger.note_unpaid(self.hash, start.max(from), end);
}
}
}
}
pub(crate) async fn drive_with_interval_flush<St, Fut>(
store: &St,
interval: Duration,
fut: Fut,
) -> anyhow::Result<()>
where
St: IngestStore,
Fut: Future<Output = anyhow::Result<()>>,
{
tokio::pin!(fut);
let mut tick = tokio::time::interval(interval);
tick.tick().await; let mut flush: Option<SourceFuture<'_, ()>> = None;
loop {
tokio::select! {
biased;
res = &mut fut => {
if let Some(pending) = flush.take() {
let flushed = pending.await;
res?;
return flushed;
}
return res;
}
flushed = await_flush(&mut flush), if flush.is_some() => {
flush = None;
flushed?;
}
_ = tick.tick() => {
if flush.is_none() {
flush = Some(store.flush_present_record());
}
}
}
}
}
async fn await_flush(flush: &mut Option<SourceFuture<'_, ()>>) -> anyhow::Result<()> {
match flush {
Some(pending) => pending.await,
None => std::future::pending().await,
}
}
#[derive(Debug, Default)]
pub(crate) struct UnitProgress {
pub(crate) verified: std::sync::atomic::AtomicU64,
pub(crate) frontier: std::sync::atomic::AtomicU64,
pub(crate) first_byte: std::sync::OnceLock<tokio::time::Instant>,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct DriveCounters {
resume_attempts: u32,
next_voucher_cost: U256,
pub(crate) gap_paid_frontier: Option<u64>,
}
impl DriveCounters {
pub(crate) const fn new() -> Self {
Self {
resume_attempts: 0,
next_voucher_cost: U256::ZERO,
gap_paid_frontier: None,
}
}
}
fn log_healed_retry(
hash: [u8; 32],
offset: u64,
attempt: u32,
err: &anyhow::Error,
healed: Option<crate::Healed>,
) {
tracing::info!(
hash = %blake3::Hash::from_bytes(hash).to_hex(),
offset,
attempt,
max_attempts = MAX_RESUME_ATTEMPTS,
reason = ?err
.downcast_ref::<UpstreamVoucherRejected>()
.map(|rejected| rejected.reason),
?healed,
"voucher rejection healed the lane ledger; reopening the leg"
);
}
fn scopes_to_source(err: &anyhow::Error) -> bool {
err.downcast_ref::<UpstreamVoucherRejected>()
.is_some_and(|r| {
matches!(
r.reason,
VoucherRejectReason::UnderFold
| VoucherRejectReason::AmountRegression
| VoucherRejectReason::Underpaid
| VoucherRejectReason::BytesRegression
)
})
}
fn voucher_cost(header: &UpstreamPullHeader) -> U256 {
U256::from(header.interval_bytes)
.saturating_mul(U256::from(header.rate_per_mb))
.div_ceil(U256::from(decdn_protocol::MB_BYTES))
}
pub(crate) fn contiguous_byte_ranges(ranges: &ChunkRanges, total_bytes: u64) -> Vec<(u64, u64)> {
let boundaries = ranges.boundaries();
let mut out = Vec::new();
let mut it = boundaries.iter();
while let (Some(a), Some(b)) = (it.next(), it.next()) {
let start = a.0.saturating_mul(CHUNK_BYTES).min(total_bytes);
let end = b.0.saturating_mul(CHUNK_BYTES).min(total_bytes);
if end > start {
out.push((start, end - start));
}
}
out
}
pub(crate) fn ranges_content_len(ranges: &ChunkRanges, total_bytes: u64) -> u64 {
contiguous_byte_ranges(ranges, total_bytes)
.iter()
.map(|(_, len)| *len)
.fold(0u64, u64::saturating_add)
}
pub(crate) fn contiguous_frontier(
missing: &ChunkRanges,
total_bytes: u64,
gap_start: u64,
gap_end: u64,
) -> u64 {
contiguous_byte_ranges(missing, total_bytes)
.into_iter()
.find(|&(start, len)| start.saturating_add(len) > gap_start && start < gap_end)
.map_or(gap_end, |(start, _)| start.max(gap_start).min(gap_end))
}
pub(crate) async fn missing_below_bound<St>(
store: &St,
start: u64,
len: u64,
) -> anyhow::Result<(ChunkRanges, u64)>
where
St: RangedStore + ?Sized,
{
loop {
let bound = store.total_bytes();
let end = start.saturating_add(len).min(bound);
if start >= end {
return Ok((ChunkRanges::empty(), bound));
}
match store.missing_ranges(start, end - start).await {
Ok(missing) => return Ok((missing, bound)),
Err(decdn_bao_range::RangedStoreError::Alignment(_)) if store.total_bytes() < bound => {
}
Err(e) => return Err(anyhow::Error::new(e).context(crate::LocalPullFault)),
}
}
}
#[allow(clippy::too_many_arguments)]
#[doc(hidden)]
pub async fn drive<St, S, P>(
store: &St,
source: &S,
pacer: &P,
ctx: &Arc<Mutex<PoolContext>>,
ledger: &Arc<PoolLedger>,
hash: [u8; 32],
offset: u64,
len: u64,
on_progress: Option<&(dyn Fn(u64, u64) + Send + Sync + '_)>,
pacing_wait: Option<&dyn PacingWait>,
downstream: Option<&(dyn Fn() -> DownstreamFrontier + Send + Sync)>,
pool: Option<&SharedPool<'_>>,
) -> anyhow::Result<()>
where
St: IngestStore,
S: BlobSource,
P: Pacer,
{
let total_bytes = store.total_bytes();
reject_empty_claim_for_nonempty_root(total_bytes, hash)?;
if let Some(cb) = on_progress {
let base_present = ranges_content_len(
&store.present_ranges().await.map_err(store_query_fault)?,
total_bytes,
);
cb(base_present, total_bytes);
}
let missing = store
.missing_ranges(offset, len)
.await
.map_err(store_query_fault)?;
let owed = OwedSpans::take(ledger, hash);
let gaps = crate::ledger::merge_spans(
contiguous_byte_ranges(&missing, total_bytes)
.into_iter()
.chain(owed.spans.iter().copied())
.map(|(start, len)| (start, start.saturating_add(len)))
.collect(),
);
let owed = &owed;
let outcome = drive_with_interval_flush(store, PRESENT_RECORD_FLUSH_INTERVAL, async move {
let mut counters = DriveCounters::new();
for (gap_start, gap_len) in gaps {
owed.unbilled_from(gap_start);
fill_gap(
store,
source,
pacer,
ctx,
ledger,
hash,
gap_start,
gap_len,
&mut counters,
on_progress,
None,
None,
pacing_wait,
downstream,
pool,
None,
)
.await
.inspect_err(|_| {
owed.unbilled_from(counters.gap_paid_frontier.unwrap_or(gap_start));
})?;
}
owed.billed();
Ok(())
})
.await;
let flushed = store.flush_present_record().await;
outcome?;
flushed?;
if store.is_complete().await.map_err(store_query_fault)? {
store.finalize().await?;
}
Ok(())
}
pub(crate) fn leg_range(
resume_start: u64,
gap_end: u64,
up_to_bytes: u64,
max_blob_size_bytes: u64,
total_bytes: u64,
) -> anyhow::Result<AlignedRange> {
let draw_len = gap_end.saturating_sub(resume_start).min(up_to_bytes);
let draw_len = if max_blob_size_bytes > 0 {
let cap_end = max_blob_size_bytes.saturating_add(CHUNK_GROUP_BYTES);
draw_len.min(cap_end.saturating_sub(resume_start))
} else {
draw_len
};
Ok(align_range(resume_start, draw_len, total_bytes)?)
}
fn store_query_fault(err: RangedStoreError) -> anyhow::Error {
let store_failed = matches!(err, RangedStoreError::Backend(_));
let err = anyhow::Error::new(err);
if store_failed {
err.context(crate::LocalPullFault)
} else {
err
}
}
async fn range_set_gaps<St: RangedStore + ?Sized>(
store: &St,
ranges: &[(u64, u64)],
) -> anyhow::Result<Vec<(u64, u64)>> {
let mut missing = ChunkRanges::empty();
for &(offset, len) in ranges {
missing |= store
.missing_ranges(offset, len)
.await
.map_err(store_query_fault)?;
}
Ok(contiguous_byte_ranges(&missing, store.total_bytes()))
}
#[doc(hidden)]
pub async fn first_leg<St: RangedStore + ?Sized>(
store: &St,
ranges: &[(u64, u64)],
up_to_bytes: u64,
max_blob_size_bytes: u64,
) -> anyhow::Result<Option<AlignedRange>> {
let gaps = range_set_gaps(store, ranges).await?;
let Some(&(start, len)) = gaps.first() else {
return Ok(None);
};
leg_range(
start,
start.saturating_add(len),
up_to_bytes,
max_blob_size_bytes,
store.total_bytes(),
)
.map(Some)
}
fn warn_on_new_hole(
warned_hole: &mut Option<u64>,
hash: [u8; 32],
gap_start: u64,
hole: u64,
gap_end: u64,
missing_bytes: u64,
) {
let present_after_hole = gap_end.saturating_sub(missing_bytes).saturating_sub(hole);
if present_after_hole == 0 || *warned_hole == Some(hole) {
return;
}
*warned_hole = Some(hole);
tracing::warn!(
hash = %blake3::Hash::from_bytes(hash).to_hex(),
gap_start,
hole,
gap_end,
present_after_hole,
"the store holds bytes after a hole in the gap; the next leg opens at the hole and \
pays for them again"
);
}
async fn ended_frontiers<St: RangedStore + ?Sized>(
store: &St,
ledger: &PoolLedger,
gap_start: u64,
asked_end: u64,
leg_anchor: Option<(u64, U256)>,
) -> Option<(u64, u64)> {
let (still_missing, total_bytes) =
missing_below_bound(store, gap_start, asked_end.saturating_sub(gap_start))
.await
.ok()?;
let gap_end = asked_end.min(total_bytes);
let delivered = contiguous_frontier(&still_missing, total_bytes, gap_start, gap_end);
let (leg_start, baseline) = leg_anchor.unwrap_or((gap_start, ledger.committed().bytes));
let paid_wire =
u64::try_from(ledger.committed().bytes.saturating_sub(baseline)).unwrap_or(u64::MAX);
let paid = crate::sink::content_paid_frontier(leg_start, total_bytes, paid_wire)
.min(gap_end)
.min(delivered);
Some((paid, delivered))
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_lines)]
pub(crate) async fn fill_gap<St, S, P>(
store: &St,
source: &S,
pacer: &P,
ctx: &Arc<Mutex<PoolContext>>,
ledger: &Arc<PoolLedger>,
hash: [u8; 32],
gap_start: u64,
gap_len: u64,
counters: &mut DriveCounters,
on_progress: Option<&(dyn Fn(u64, u64) + Send + Sync + '_)>,
progress_agg: Option<&std::sync::atomic::AtomicU64>,
unit: Option<&UnitProgress>,
pacing_wait: Option<&dyn PacingWait>,
downstream: Option<&(dyn Fn() -> DownstreamFrontier + Send + Sync)>,
pool: Option<&SharedPool<'_>>,
stop_at: Option<&std::sync::atomic::AtomicU64>,
) -> anyhow::Result<()>
where
St: IngestStore,
S: BlobSource,
P: Pacer,
{
let spent = move |own_amount: U256| pool.map_or(own_amount, |p| (p.spent)());
counters.gap_paid_frontier = None;
let mut leg_anchor: Option<(u64, U256)> = None;
let mut clean_leg: Option<CleanLeg> = None;
let asked_end = gap_start.saturating_add(gap_len);
let mut warned_hole: Option<u64> = None;
loop {
let asked_end = stop_at.map_or(asked_end, |end| asked_end.min(end.load(Ordering::Acquire)));
let (still_missing, total_bytes) =
missing_below_bound(store, gap_start, asked_end.saturating_sub(gap_start)).await?;
let gap_end = asked_end.min(total_bytes);
if gap_start >= gap_end {
return Ok(());
}
let gap_len = gap_end - gap_start;
let delivered_frontier =
contiguous_frontier(&still_missing, total_bytes, gap_start, gap_end);
warn_on_new_hole(
&mut warned_hole,
hash,
gap_start,
delivered_frontier,
gap_end,
ranges_content_len(&still_missing, total_bytes),
);
let max_blob_size_bytes = source.max_blob_size_bytes();
if max_blob_size_bytes > 0 && delivered_frontier > max_blob_size_bytes {
return Err(anyhow::Error::new(crate::BlobTooLarge {
reached: delivered_frontier,
ceiling: max_blob_size_bytes,
}));
}
let committed = ledger.committed();
let deposit = locked_deposit(ctx)?;
let remaining_deposit = deposit.saturating_sub(spent(committed.amount));
let (leg_start, leg_baseline) = *leg_anchor.get_or_insert((gap_start, committed.bytes));
let paid_wire_this_leg =
u64::try_from(committed.bytes.saturating_sub(leg_baseline)).unwrap_or(u64::MAX);
let paid_frontier =
crate::sink::content_paid_frontier(leg_start, total_bytes, paid_wire_this_leg)
.min(gap_end)
.min(delivered_frontier);
let paid_cleared = paid_frontier.saturating_sub(gap_start);
counters.gap_paid_frontier = Some(paid_frontier);
if let Some(stuck) = clean_leg.take().and_then(|leg| {
leg.stalled(
paid_frontier,
delivered_frontier,
paid_wire_this_leg,
ledger.generation(),
)
}) {
return Err(anyhow::Error::new(stuck));
}
let downstream_now = downstream.map_or(
DownstreamFrontier {
served_paid: paid_frontier,
serve_demand: 0,
},
|f| f(),
);
let state = PaceState {
cleared_bytes: paid_cleared,
requested_bytes: gap_len,
remaining_deposit,
next_voucher_cost: counters.next_voucher_cost,
pulled_frontier: delivered_frontier,
gap_remaining: gap_end.saturating_sub(delivered_frontier),
downstream: downstream_now,
};
match pacer.decide(&state) {
PaceDecision::Done => return Ok(()),
decision @ (PaceDecision::Wait | PaceDecision::WaitForMinDraw) => {
if let Some(hook) = pacing_wait {
let reason = if decision == PaceDecision::Wait {
WaitReason::WindowFull
} else {
WaitReason::MinDraw
};
hook.wait(downstream_now, reason).await;
continue;
}
anyhow::bail!(
"gap [{gap_start}, +{gap_len}) of blob paced to Wait but no \
PacingWait hook was supplied: this is unreachable on the client \
path (BudgetPacer never returns Wait) — a WindowPacer caller \
must pass a PacingWait"
);
}
PaceDecision::Refuse => {
ledger.note_unpaid(hash, paid_frontier, delivered_frontier);
counters.gap_paid_frontier = Some(paid_frontier);
return Err(anyhow::Error::new(PoolExhausted { gap_start, gap_len }));
}
PaceDecision::Draw { up_to_bytes } => {
if paid_frontier >= gap_end {
return Ok(());
}
let resume_start = paid_frontier;
let aligned = leg_range(
resume_start,
gap_end,
up_to_bytes,
max_blob_size_bytes,
total_bytes,
)?;
let base_present = ranges_content_len(
&(store
.present_ranges()
.await
.map_err(|e| anyhow::Error::new(e).context(crate::LocalPullFault))?),
total_bytes,
);
let leg_reported = std::sync::atomic::AtomicU64::new(0);
let leg_start = aligned.fetch_start();
let reporter = move |received: u64| {
let delta =
received.saturating_sub(leg_reported.swap(received, Ordering::Relaxed));
if let Some(unit) = unit {
if delta > 0 {
unit.first_byte.get_or_init(tokio::time::Instant::now);
}
unit.verified.fetch_add(delta, Ordering::Relaxed);
unit.frontier
.fetch_max(leg_start.saturating_add(received), Ordering::SeqCst);
}
let Some(cb) = on_progress else { return };
let end = leg_start.saturating_add(received);
let prev_end = leg_start.saturating_add(received.saturating_sub(delta));
let fresh = end.saturating_sub(prev_end.max(delivered_frontier));
let position = match progress_agg {
Some(delivered) => delivered
.fetch_add(fresh, Ordering::Relaxed)
.saturating_add(fresh)
.min(total_bytes),
None => base_present
.saturating_add(end.saturating_sub(delivered_frontier.max(leg_start)))
.min(total_bytes),
};
cb(position, total_bytes);
};
let generation_at_open = ledger.generation();
let leg: anyhow::Result<()> = match source.open(hash, aligned.clone()).await {
Ok((header, reader)) => {
counters.next_voucher_cost = voucher_cost(&header);
if let Some(quotes) = pool.and_then(|p| p.quotes) {
quotes.observe(header.rate_per_mb, header.interval_bytes);
}
leg_anchor = Some((resume_start, ledger.committed().bytes));
match store
.ingest_stream(
&aligned,
reader,
Some(&reporter),
header.total_bytes,
stop_at,
)
.await
{
Ok((reader, IngestEnd::Drained)) => {
source.finish(reader).await.map(|_vp| ())
}
Ok((reader, IngestEnd::Stopped)) => {
source.stop(reader).await.map(|_vp| ())
}
Err(err) => Err(err),
}
}
Err(err) => Err(err),
};
if let Err(err) = leg {
let watermark = {
let guard = ctx
.lock()
.map_err(|_| anyhow::anyhow!("pool context lock poisoned"))?;
rejection_watermark(&err, &guard)
};
let healed = match watermark {
Some(watermark) => heal_watermark_desync(&err, watermark, ledger).await,
None => None,
};
let past_budget = counters.resume_attempts >= MAX_RESUME_ATTEMPTS;
if healed.is_some() && past_budget && scopes_to_source(&err) {
return Err(err.context(HealExhausted));
}
if healed.is_some() && !past_budget {
counters.resume_attempts = counters.resume_attempts.saturating_add(1);
log_healed_retry(
hash,
resume_start,
counters.resume_attempts,
&err,
healed,
);
leg_anchor = Some((delivered_frontier, ledger.committed().bytes));
continue;
}
if let Some((paid, delivered)) =
ended_frontiers(store, ledger, gap_start, asked_end, leg_anchor).await
{
counters.gap_paid_frontier = Some(paid);
if crate::classify(&err) == crate::Fault::Unaffordable {
ledger.note_unpaid(hash, paid, delivered);
}
}
return Err(err);
}
clean_leg = Some(CleanLeg {
offset: aligned.fetch_start(),
len: aligned.fetch_len(),
paid_frontier,
delivered_frontier,
generation: generation_at_open,
});
}
}
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::cast_possible_truncation
)] mod tests;