mod admit_store;
mod backend_source;
mod funder;
mod pull_leg;
mod ranged_pull;
mod timed_source;
pub(crate) use admit_store::NodeAdmitStore;
pub(crate) use backend_source::BackendSource;
pub(crate) use funder::NodeFunder;
use funder::{SETTLE_POLL_STEP, settle_window};
pub(crate) use pull_leg::{PrimeLeg, PullLegTarget, run_local_pull_leg, run_pull_leg};
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use alloy::dyn_abi::Eip712Domain;
use alloy::primitives::{Address, B256, U256};
use decdn_bao_range::align_range;
use decdn_cache::origin::{Origin, OriginFetch};
use decdn_cache::{Hash, OriginKind, OriginPullError};
use decdn_client::{
BudgetPacer, PeerSource, PrimedSource, RecoveryGate, Stepped, drive, first_leg,
};
use decdn_common::redact::sanitize_err_chain;
use decdn_protocol::client::{StreamError, VoucherRejectReason};
use iroh::{Endpoint, EndpointAddr, PublicKey};
use timed_source::{TimedSource, timed_open};
use tokio_util::sync::CancellationToken;
use tracing::{Instrument as _, debug, info, warn};
use decdn_reputation::{LocalReputation, Outcome};
use decdn_incentive::PoolOpenFailureReason;
use decdn_protocol::client::NO_NAMESPACE;
use crate::buyer_ledgers::BuyerLedgers;
use crate::buyer_pool::{OpenReported, PoolOpenPending, PoolOpener};
use crate::dht::negative_cache::Hash as DhtHash;
use crate::dht::routing::{NodeId as DhtNodeId, RoutingTable};
use crate::dht::{
LookupConfig, NegativeProbeCache, NodeAddressResolver, OriginDirectory, PositiveProbeCache,
ProbedProvider, StakerSet,
};
use crate::metrics::Metrics;
use crate::selection::{
Candidate, MAX_PROVIDER_ATTEMPTS, PROBE_EARLY_EXIT_CANDIDATES, PROBE_TIMEOUT, rank_candidates,
};
use decdn_client::buyer_pool::ProgressWrite;
use decdn_client::probe::probe_once;
use decdn_client::{
BlobTooLarge, Cumulative, HashMismatch, LegNoProgress, LocalPullFault, PoolContext, PoolLedger,
PullDeadlines, PullStalled, PullTimeout, RateAboveCeiling, ResumeOffsetPastEnd,
UpstreamRateLimited, UpstreamRefused, UpstreamVoucherRejected, VoucherProgress,
effective_rate_ceiling, sign_client_binding,
};
#[derive(Clone, Copy)]
pub(crate) enum ProbeGather {
EarlyExit,
CoverageUnion,
}
#[derive(Default)]
struct CoverageUnionTracker {
blocks: std::collections::BTreeSet<u32>,
target: u32,
}
impl CoverageUnionTracker {
fn from_candidates(candidates: &[Candidate]) -> Self {
let mut union = Self::default();
for candidate in candidates {
union.add(candidate);
}
union
}
fn add(&mut self, candidate: &Candidate) {
let Some(bytes) = candidate.total_bytes_hint else {
return;
};
let holder_blocks = decdn_protocol::num_blocks(bytes);
self.target = self.target.max(holder_blocks);
self.blocks.extend(
candidate
.coverage
.covered_blocks()
.filter(|&block| block < holder_blocks),
);
}
fn spans(&self) -> bool {
self.target > 0 && (0..self.target).all(|block| self.blocks.contains(&block))
}
}
pub(super) fn coverage_spans(candidates: &[Candidate]) -> bool {
CoverageUnionTracker::from_candidates(candidates).spans()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PoolOpenArm {
Pending,
FundingNeeded,
LocalFault,
Reported,
Residual,
}
fn classify_pool_open_arm(err: &anyhow::Error) -> PoolOpenArm {
if err.downcast_ref::<PoolOpenPending>().is_some() {
PoolOpenArm::Pending
} else if err.downcast_ref::<PoolOpenFailureReason>()
== Some(&PoolOpenFailureReason::InsufficientDeposit)
{
PoolOpenArm::FundingNeeded
} else if err.downcast_ref::<LocalPullFault>().is_some() {
PoolOpenArm::LocalFault
} else if err.downcast_ref::<OpenReported>().is_some() {
PoolOpenArm::Reported
} else {
PoolOpenArm::Residual
}
}
#[allow(clippy::cognitive_complexity)]
fn record_pool_open_failure(
deps: &NodeOriginDeps,
provider_addr: Address,
err: &anyhow::Error,
) -> PullMiss {
let arm = classify_pool_open_arm(err);
if arm == PoolOpenArm::Pending {
deps.metrics.node_pull_pool_open_pending();
debug!(%provider_addr, error = %err, "node-origin: pool open still in flight; trying the next candidate");
return PullMiss::Clean;
}
if arm == PoolOpenArm::FundingNeeded {
deps.metrics.node_pull_local_fault();
warn!(
%provider_addr, error = %sanitize_err_chain(err),
"node-origin: this node's wallet cannot fund a buyer pool — it cannot pay \
any provider; exonerating the upstream and answering a miss"
);
return PullMiss::FundingNeeded;
}
if arm == PoolOpenArm::LocalFault {
deps.metrics.node_pull_local_fault();
warn!(
%provider_addr, error = %sanitize_err_chain(err),
"node-origin: LOCAL buyer-side fault opening a pool — this node cannot pay \
any provider; exonerating the upstream and refusing rather than reporting a miss"
);
return PullMiss::LocalFault;
}
if arm == PoolOpenArm::Reported {
debug!(%provider_addr, error = %err, "node-origin: buyer pool open failed (reported by the open task)");
return PullMiss::Clean;
}
deps.metrics.node_pull_pool_open_failure();
let reason = err.downcast_ref::<PoolOpenFailureReason>().copied();
if let Some(reason) = reason {
deps.metrics.pool_open_failure_by_reason(reason);
}
if let Some(reason) = reason {
warn!(
%provider_addr,
reason = reason.as_label(),
error = %sanitize_err_chain(err),
"node-origin: buyer pool open/reuse failed on a classified chain fault \
raised outside the open task"
);
} else {
warn!(
%provider_addr,
reason = "unclassified",
error = %sanitize_err_chain(err),
"node-origin: buyer pool open/reuse failed (raised outside the open task — \
suspect this node's store or lock state, not the peer)"
);
}
deps.metrics.node_pull_local_fault();
PullMiss::LocalFault
}
#[derive(Debug, Clone)]
pub struct NodeOriginConfig {
pub probe_fanout: usize,
pub pull_timeout: Duration,
pub stall_window: Duration,
pub min_throughput_bps: u64,
pub max_blob_size_bytes: u64,
pub max_rate_per_mb: u64,
pub event_poll_interval: Duration,
pub lookup: LookupConfig,
pub own_region: Option<String>,
pub serve_economics: Arc<dyn crate::serve_economics::ServeEconomicsPolicy>,
pub operator_shares: crate::fee_shares::OperatorShares,
pub frequency_estimator: Option<Arc<dyn decdn_cache::FrequencyEstimator>>,
pub sell_rate_base: u64,
pub warming: Arc<crate::warming_allowance::WarmingAllowance>,
}
const REGION_LATENCY_MAX_MS: u32 = 150;
fn region_latency_penalty_applies(own_region: Option<&str>, claimed: &str, rtt_ms: u32) -> bool {
!claimed.is_empty() && own_region == Some(claimed) && rtt_ms > REGION_LATENCY_MAX_MS
}
impl NodeOriginConfig {
fn deadlines(&self) -> anyhow::Result<PullDeadlines> {
PullDeadlines::new(
self.pull_timeout,
self.stall_window,
self.min_throughput_bps,
)
.map_err(|err| {
anyhow::anyhow!(
"node pull deadlines are unusable ({err}); \
check cache.node_pull_timeout_sec and cache.node_pull_stall_window_sec"
)
.context(LocalPullFault)
})
}
}
pub struct NodeOriginDeps {
pub endpoint: Endpoint,
pub dial_runtime: tokio::runtime::Handle,
pub routing_table: Arc<Mutex<RoutingTable>>,
pub staker_set: Arc<dyn StakerSet>,
pub origin_directory: Arc<dyn OriginDirectory>,
pub addr_resolver: Arc<dyn NodeAddressResolver>,
pub buyer: Arc<dyn PoolOpener>,
pub self_id: DhtNodeId,
pub slash_domain: Eip712Domain,
pub bind_domain: Eip712Domain,
pub local_rep: Arc<LocalReputation>,
pub negative_cache: NegativeProbeCache,
pub probe_cache: PositiveProbeCache,
pub metrics: Arc<Metrics>,
pub registry_regions: Arc<std::sync::RwLock<HashMap<DhtNodeId, String>>>,
pub config: NodeOriginConfig,
pub ledgers: Arc<BuyerLedgers>,
pub wedged_providers: Arc<Mutex<HashMap<DhtNodeId, u64>>>,
pub engine: decdn_cache::CacheEngine,
}
impl std::fmt::Debug for NodeOriginDeps {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NodeOriginDeps")
.field("self_id", &self.self_id)
.field("config", &self.config)
.finish_non_exhaustive()
}
}
impl NodeOriginDeps {
fn record_wedged(&self, pk: &PublicKey) {
let node = DhtNodeId::from_bytes(*pk.as_bytes());
record_wedged_at(
&mut self
.wedged_providers
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
node,
crate::payment_settlement::unix_now(),
);
}
fn provider_is_wedged(&self, peer: &DhtNodeId, now_secs: u64) -> bool {
let mut wedged = self
.wedged_providers
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
prune_and_check_wedged(&mut wedged, peer, now_secs)
}
}
fn record_wedged_at(wedged: &mut HashMap<DhtNodeId, u64>, node: DhtNodeId, now_secs: u64) {
wedged.insert(
node,
now_secs.saturating_add(WEDGED_PROVIDER_SUPPRESSION_SECS),
);
}
fn prune_and_check_wedged(
wedged: &mut HashMap<DhtNodeId, u64>,
peer: &DhtNodeId,
now_secs: u64,
) -> bool {
wedged.retain(|_, expires_at| *expires_at > now_secs);
wedged.contains_key(peer)
}
#[derive(Debug, Default)]
struct PendingOpens(std::sync::Mutex<HashMap<[u8; 32], usize>>);
impl PendingOpens {
fn contains(&self, hash: &[u8; 32]) -> bool {
self.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.contains_key(hash)
}
}
#[derive(Debug)]
#[must_use = "an open is marked in progress only while its guard lives"]
struct PendingOpen {
pending: Arc<PendingOpens>,
hash: [u8; 32],
}
impl PendingOpen {
fn enter(pending: &Arc<PendingOpens>, hash: [u8; 32]) -> Self {
let mut opens = pending
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let count = opens.entry(hash).or_insert(0);
*count = count.saturating_add(1);
Self {
pending: Arc::clone(pending),
hash,
}
}
}
impl Drop for PendingOpen {
fn drop(&mut self) {
let mut opens = self
.pending
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(count) = opens.get_mut(&self.hash) {
*count = count.saturating_sub(1);
if *count == 0 {
opens.remove(&self.hash);
}
}
}
}
#[derive(Debug, Clone)]
pub struct NodeOrigin {
deps: Arc<OnceLock<NodeOriginDeps>>,
pending: Arc<PendingOpens>,
}
impl NodeOrigin {
#[must_use]
pub fn new() -> Self {
Self {
deps: Arc::new(OnceLock::new()),
pending: Arc::new(PendingOpens::default()),
}
}
pub fn provision(&self, deps: NodeOriginDeps) {
if self.deps.set(deps).is_err() {
warn!("NodeOrigin provisioned more than once; keeping the first dependency set");
}
}
pub(crate) fn deps_arc(&self) -> Arc<OnceLock<NodeOriginDeps>> {
Arc::clone(&self.deps)
}
fn enter_open(&self, hash: Hash) -> PendingOpen {
PendingOpen::enter(&self.pending, *hash.as_bytes())
}
pub(crate) fn refuses_whole_blob(&self, hash: Hash, requester: [u8; 32]) -> bool {
self.deps.get().is_some_and(|deps| {
self.pending.contains(hash.as_bytes())
&& deps.staker_set.is_active(&DhtNodeId::from_bytes(requester))
})
}
pub(crate) fn max_blob_size_bytes(&self) -> u64 {
self.deps
.get()
.map_or(0, |deps| deps.config.max_blob_size_bytes)
}
#[cfg(any(test, feature = "test-support"))]
#[must_use]
pub fn is_suppressed(&self, peer: PublicKey, hash: Hash) -> bool {
self.deps.get().is_some_and(|deps| {
deps.negative_cache.contains_active(
&DhtNodeId::from_bytes(*peer.as_bytes()),
&DhtHash::from_bytes(*hash.as_bytes()),
)
})
}
}
impl Default for NodeOrigin {
fn default() -> Self {
Self::new()
}
}
impl Origin for NodeOrigin {
fn fetch(
&self,
hash: Hash,
_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, OriginPullError>> + Send + '_>> {
let deps_lock = Arc::clone(&self.deps);
let span = tracing::info_span!(
"node_pull",
%hash,
outcome = tracing::field::Empty,
);
Box::pin(async move {
let result = async move {
let Some(deps) = deps_lock.get() else {
return Ok(OriginFetch::NotFound);
};
let hash_bytes = *hash.as_bytes();
let target = DhtHash::from_bytes(hash_bytes);
let mut budget = MAX_PROVIDER_ATTEMPTS;
let mut attempt_metered = false;
let mut miss = PullMiss::Clean;
let gate = Arc::new(RecoveryGate::with_settle(settle_window(
deps.config.event_poll_interval,
)));
let seen = deps.buyer.pool_deposit().unwrap_or(U256::ZERO);
let mut unfunded: Vec<Candidate> = Vec::new();
if let Some(cached) = cached_candidates(deps, target).await {
deps.metrics.probe_cache_hit();
deps.metrics.node_pull_attempt();
attempt_metered = true;
let outcome =
try_pull(&deps_lock, deps, &cached, hash_bytes, budget, &gate).await;
match outcome.payload {
Ok(()) => return Ok(OriginFetch::AlreadyAdmitted),
Err(failed) => miss = miss.or(failed),
}
unfunded.extend(outcome.unfunded);
budget = budget.saturating_sub(outcome.attempts);
deps.probe_cache.invalidate(&target);
if budget == 0 {
debug!(%hash, "node-origin: probe-cache candidates exhausted the attempt budget");
return recover_unfunded(
&deps_lock, deps, hash_bytes, unfunded, miss, &gate, seen,
)
.await;
}
} else {
deps.metrics.probe_cache_miss();
}
let providers = discover(deps, hash_bytes, U256::ZERO).await;
if providers.is_empty() {
if !attempt_metered {
deps.metrics.node_pull_no_providers();
}
debug!(%hash, "node-origin: no providers discovered for cache-miss pull");
return recover_unfunded(
&deps_lock, deps, hash_bytes, unfunded, miss, &gate, seen,
)
.await;
}
if !attempt_metered {
deps.metrics.node_pull_attempt();
}
let ranked = probe_and_rank(
deps,
providers,
hash_bytes,
ProbeGather::EarlyExit,
U256::ZERO,
)
.await;
let outcome = try_pull(&deps_lock, deps, &ranked, hash_bytes, budget, &gate).await;
match outcome.payload {
Ok(()) => Ok(OriginFetch::AlreadyAdmitted),
Err(failed) => {
unfunded.extend(outcome.unfunded);
recover_unfunded(
&deps_lock,
deps,
hash_bytes,
unfunded,
miss.or(failed),
&gate,
seen,
)
.await
}
}
}
.instrument(span.clone())
.await;
span.record(
"outcome",
match &result {
Ok(OriginFetch::AlreadyAdmitted | OriginFetch::Found { .. }) => "filled",
Ok(OriginFetch::NotFound) => "not_found",
Err(_) => "failed",
},
);
result
})
}
fn kind(&self) -> OriginKind {
OriginKind::Peer
}
}
fn miss_answer(miss: PullMiss) -> Result<OriginFetch, OriginPullError> {
match miss {
PullMiss::Clean | PullMiss::BelowMargin | PullMiss::FundingNeeded | PullMiss::Unfunded => {
Ok(OriginFetch::NotFound)
}
PullMiss::LocalFault => Err(OriginPullError::Permanent(anyhow::anyhow!(
"node-origin: a LOCAL fault hit at least one attempted candidate and none \
delivered; this node cannot complete the pull, so it refuses rather than \
signing a NotFound for content that may well exist (#1560)"
))),
}
}
async fn discover(
deps: &NodeOriginDeps,
hash_bytes: [u8; 32],
namespace_id: U256,
) -> Vec<DhtNodeId> {
let target = DhtHash::from_bytes(hash_bytes);
let providers: Vec<DhtNodeId> = crate::dht::find_providers(
&deps.endpoint,
&deps.routing_table,
&deps.staker_set,
&deps.negative_cache,
deps.self_id,
target,
deps.config.lookup,
Some(&deps.metrics),
)
.await
.into_iter()
.map(|(node, _coverage)| node)
.collect();
if providers.is_empty() {
directory_origins(deps, namespace_id).await
} else {
providers
}
}
async fn directory_origins(deps: &NodeOriginDeps, namespace_id: U256) -> Vec<DhtNodeId> {
let mut origins = deps.origin_directory.lookup_origins(namespace_id).await;
origins.retain(|origin| *origin != deps.self_id);
origins
}
async fn probe_and_rank(
deps: &NodeOriginDeps,
providers: Vec<DhtNodeId>,
hash_bytes: [u8; 32],
gather: ProbeGather,
namespace_id: U256,
) -> Vec<Candidate> {
let target = DhtHash::from_bytes(hash_bytes);
let mut candidates: Vec<Candidate> = Vec::new();
let probed = probe_round(deps, &providers, hash_bytes, gather, &mut candidates).await;
if matches!(gather, ProbeGather::CoverageUnion) && !coverage_spans(&candidates) {
let directory = directory_origins(deps, namespace_id).await;
let listed = directory.len();
let origins: Vec<DhtNodeId> = directory
.into_iter()
.filter(|origin| !probed.contains(origin))
.collect();
let hash = Hash::from_bytes(hash_bytes);
let holders = candidates.len();
if origins.is_empty() {
debug!(
%hash,
%namespace_id,
holders,
listed,
"node-origin: probed holders do not span the blob and the origin directory \
adds no unprobed candidate"
);
} else {
probe_round(deps, &origins, hash_bytes, gather, &mut candidates).await;
debug!(
%hash,
%namespace_id,
holders,
origins = origins.len(),
admitted = candidates.len().saturating_sub(holders),
spans = coverage_spans(&candidates),
"node-origin: probed holders did not span the blob; probed origin-directory \
candidates"
);
}
}
let ranked = rank(candidates);
deps.probe_cache.insert(
target,
ranked
.iter()
.map(|c| ProbedProvider {
node_id: DhtNodeId::from_bytes(c.node_id),
rate_per_mb: c.rate_per_mb,
rtt_ms: c.rtt_ms,
coverage: c.coverage.clone(),
total_bytes_hint: c.total_bytes_hint,
})
.collect(),
);
ranked
}
async fn probe_round(
deps: &NodeOriginDeps,
peers: &[DhtNodeId],
hash_bytes: [u8; 32],
gather: ProbeGather,
candidates: &mut Vec<Candidate>,
) -> Vec<DhtNodeId> {
use futures_util::stream::StreamExt;
let mut union = CoverageUnionTracker::from_candidates(candidates);
let now_secs = crate::payment_settlement::unix_now();
let target = DhtHash::from_bytes(hash_bytes);
let collection_started = Instant::now();
let selected: Vec<DhtNodeId> = peers
.iter()
.copied()
.filter(|peer| !deps.negative_cache.contains_active(peer, &target))
.filter(|peer| !deps.provider_is_wedged(peer, now_secs))
.take(deps.config.probe_fanout)
.collect();
let mut probes: futures_util::stream::FuturesUnordered<_> = selected
.iter()
.map(|&peer| probe_candidate(deps, peer, hash_bytes))
.collect();
let sent_any = !probes.is_empty();
while let Some(result) = probes.next().await {
if let Some(candidate) = result {
let done = match gather {
ProbeGather::EarlyExit => {
candidates.push(candidate);
candidates.len() >= PROBE_EARLY_EXIT_CANDIDATES
}
ProbeGather::CoverageUnion => {
union.add(&candidate);
candidates.push(candidate);
union.spans()
}
};
if done {
break;
}
}
}
drop(probes);
if sent_any {
deps.metrics
.probe_collection_latency(collection_started.elapsed());
}
selected
}
fn score_probe_latency(deps: &NodeOriginDeps, pk: PublicKey, region: &str, rtt: u32, direct: bool) {
if !direct {
debug!(
peer = %pk,
rtt_ms = rtt,
"node-origin: probe RTT has no direct path behind it; ranking on the exchange RTT"
);
}
if region_latency_penalty_applies(deps.config.own_region.as_deref(), region, rtt) {
debug!(
peer = %pk,
own_region = deps.config.own_region.as_deref().unwrap_or(""),
claimed_region = %region,
rtt_ms = rtt,
direct,
ceiling_ms = REGION_LATENCY_MAX_MS,
"node-origin: same-region claim contradicted by probe latency; \
applying ADR 030 latency penalty"
);
deps.metrics.node_region_latency_penalty();
record_outcome(deps, pk, &Outcome::RegionLatencyMismatch);
}
}
fn rank(candidates: Vec<Candidate>) -> Vec<Candidate> {
rank_candidates(candidates)
.into_iter()
.map(|r| r.candidate)
.collect()
}
#[allow(clippy::cognitive_complexity)]
async fn probe_candidate(
deps: &NodeOriginDeps,
peer: DhtNodeId,
hash_bytes: [u8; 32],
) -> Option<Candidate> {
let Ok(pk) = PublicKey::from_bytes(peer.as_bytes()) else {
return None;
};
let probe_ts = now_micros();
let (resp, resp_ext, probe_rtt) = match probe_once(
&deps.endpoint,
EndpointAddr::new(pk),
hash_bytes,
probe_ts,
PROBE_TIMEOUT,
)
.await
{
Ok(ok) => ok,
Err(err) => {
if err.downcast_ref::<UpstreamRateLimited>().is_some() {
debug!(error = %err, "node-origin: probe shed by the upstream's rate limiter; suppressing briefly, not scoring");
deps.metrics.node_upstream_rate_limited();
deps.negative_cache.record_failure_with_ttl(
peer,
DhtHash::from_bytes(hash_bytes),
REFUSAL_SUPPRESSION_TTL,
);
return None;
}
debug!(
peer = %pk,
provider_addr = ?deps.addr_resolver.address_of(&peer),
error = %err,
"node-origin: probe failed; scoring provider unreachable"
);
record_outcome(deps, pk, &Outcome::Unreachable);
return None;
}
};
let Some(provider_addr) = deps.addr_resolver.address_of(&peer) else {
debug!("node-origin: probed peer has no resolvable operator address; skipping");
return None;
};
if let Err(err) = decdn_client::probe::verify_probe_response(
&resp,
provider_addr,
&deps.slash_domain,
hash_bytes,
probe_ts,
) {
debug!(error = %err, "node-origin: dropping an unverifiable probe response");
return None;
}
if !resp_ext.consistent_with(resp.body.has_blob) {
debug!(
has_blob = resp.body.has_blob,
"node-origin: dropping a probe response with has_blob/coverage mismatch"
);
return None;
}
if !resp.body.has_blob {
deps.negative_cache
.record_failure(peer, DhtHash::from_bytes(hash_bytes));
return None;
}
let rtt = ms_to_u32(probe_rtt.ms);
let region = crate::dht::capacity_bond_registry::region_of(
&deps.registry_regions,
DhtNodeId::from_bytes(*peer.as_bytes()),
)
.unwrap_or_default();
score_probe_latency(deps, pk, ®ion, rtt, probe_rtt.direct);
Some(Candidate {
node_id: *peer.as_bytes(),
rate_per_mb: resp.body.rate_per_mb,
rtt_ms: rtt,
reputation: peer_reputation(deps, pk),
region,
stake: 0,
coverage: resp_ext.coverage,
total_bytes_hint: resp_ext.total_bytes,
})
}
async fn cached_candidates(deps: &NodeOriginDeps, target: DhtHash) -> Option<Vec<Candidate>> {
let providers = deps.probe_cache.get(&target)?;
let now_secs = crate::payment_settlement::unix_now();
let mut candidates = Vec::with_capacity(providers.len());
for provider in providers {
if deps
.negative_cache
.contains_active(&provider.node_id, &target)
|| deps.provider_is_wedged(&provider.node_id, now_secs)
{
continue;
}
if !deps.staker_set.is_active(&provider.node_id) {
continue;
}
let Ok(pk) = PublicKey::from_bytes(provider.node_id.as_bytes()) else {
continue;
};
let region =
crate::dht::capacity_bond_registry::region_of(&deps.registry_regions, provider.node_id)
.unwrap_or_default();
candidates.push(Candidate {
node_id: *provider.node_id.as_bytes(),
rate_per_mb: provider.rate_per_mb,
rtt_ms: provider.rtt_ms,
reputation: peer_reputation(deps, pk),
region,
stake: 0,
coverage: provider.coverage.clone(),
total_bytes_hint: provider.total_bytes_hint,
});
}
if candidates.is_empty() {
return None;
}
Some(rank(candidates))
}
struct PullOutcome<T> {
payload: Result<T, PullMiss>,
attempts: usize,
unfunded: Vec<Candidate>,
}
#[must_use]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PullMiss {
Clean,
LocalFault,
BelowMargin,
FundingNeeded,
Unfunded,
}
impl PullMiss {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Clean => "clean_miss",
Self::LocalFault => "local_fault",
Self::BelowMargin => "below_margin",
Self::FundingNeeded => "funding_needed",
Self::Unfunded => "unfunded",
}
}
#[allow(clippy::match_same_arms)]
const fn for_verdict(verdict: PullVerdict) -> Self {
match verdict {
PullVerdict::OurLocalFault => Self::LocalFault,
PullVerdict::Oversize
| PullVerdict::RateCeiling
| PullVerdict::OurDeadline
| PullVerdict::Stalled
| PullVerdict::RateLimited
| PullVerdict::OurDeadLane(_)
| PullVerdict::OurVoucherRetryable(_)
| PullVerdict::Refused(RefusalVerdict::DurableMiss | RefusalVerdict::Transient)
| PullVerdict::Corruption
| PullVerdict::LegNoProgress
| PullVerdict::Unreachable => Self::Clean,
PullVerdict::Refused(RefusalVerdict::OurFault) => Self::Clean,
}
}
const fn or(self, other: Self) -> Self {
match (self, other) {
(Self::LocalFault, _) | (_, Self::LocalFault) => Self::LocalFault,
(Self::FundingNeeded, _) | (_, Self::FundingNeeded) => Self::FundingNeeded,
(Self::Unfunded, _) | (_, Self::Unfunded) => Self::Unfunded,
(Self::BelowMargin, _) | (_, Self::BelowMargin) => Self::BelowMargin,
(Self::Clean, Self::Clean) => Self::Clean,
}
}
pub const fn is_local_fault(self) -> bool {
matches!(self, Self::LocalFault)
}
}
enum EconGate {
Skip,
Allow {
rate_ceiling: u64,
speculative: bool,
},
}
fn economic_ceiling(
deps: &NodeOriginDeps,
source: crate::warming_allowance::SourceId,
heat: u32,
candidate_rate: u64,
) -> EconGate {
let sell = deps.config.sell_rate_base;
let operator_bps = deps.config.operator_shares.bps();
let warm = deps.config.warming.available(source);
let source_hex = || -> String {
use std::fmt::Write as _;
source
.as_bytes()
.iter()
.take(8)
.fold(String::with_capacity(16), |mut acc, b| {
let _ = write!(acc, "{b:02x}");
acc
})
};
if !warm && deps.config.warming.enabled() {
deps.metrics.warming_speculative_blocked();
debug!(
source = %source_hex(),
"node-origin: warming allowance spent for source; buying at amortized floor only"
);
}
let mk = |warming_available| crate::serve_economics::ServeEconomicsCtx {
sell_rate_per_mb: sell,
operator_bps,
heat_estimate: heat,
warming_available,
};
let ceiling = deps.config.serve_economics.max_buy_per_mb(&mk(warm));
let amortized_floor = deps.config.serve_economics.max_buy_per_mb(&mk(false));
match ceiling {
None => EconGate::Allow {
rate_ceiling: effective_rate_ceiling(candidate_rate, deps.config.max_rate_per_mb),
speculative: false,
},
Some(max_buy) if candidate_rate > max_buy => {
let regime = if warm {
crate::metrics::ServeEconomicsRegime::Warming
} else {
crate::metrics::ServeEconomicsRegime::Amortized
};
deps.metrics.serve_economics_refused(regime);
debug!(
source = %source_hex(),
candidate_rate,
max_buy,
?regime,
heat,
"node-origin: candidate rate above serve-economics ceiling; declining relay"
);
EconGate::Skip
}
Some(max_buy) => {
let bound = effective_rate_ceiling(deps.config.max_rate_per_mb, max_buy);
let speculative = candidate_rate > amortized_floor.unwrap_or(candidate_rate);
EconGate::Allow {
rate_ceiling: effective_rate_ceiling(candidate_rate, bound),
speculative,
}
}
}
}
const fn mb_of(bytes: u64) -> u64 {
bytes.div_ceil(decdn_protocol::MB_BYTES)
}
fn heat_of(deps: &NodeOriginDeps, hash_bytes: [u8; 32]) -> u32 {
deps.config
.frequency_estimator
.as_ref()
.map_or(0, |e| e.estimate(Hash::from(hash_bytes)))
}
async fn try_pull(
deps_lock: &Arc<OnceLock<NodeOriginDeps>>,
deps: &NodeOriginDeps,
ranked: &[Candidate],
hash_bytes: [u8; 32],
budget: usize,
gate: &Arc<RecoveryGate>,
) -> PullOutcome<()> {
let mut attempts = 0;
let mut miss = PullMiss::Clean;
let mut unfunded = Vec::new();
for candidate in ranked.iter().take(budget) {
attempts += 1;
match pull_from_candidate(deps_lock, deps, candidate, hash_bytes, gate).await {
Ok(()) => {
return PullOutcome {
payload: Ok(()),
attempts,
unfunded,
};
}
Err(failed) => {
if failed == PullMiss::Unfunded {
unfunded.push(candidate.clone());
}
miss = miss.or(failed);
}
}
}
PullOutcome {
payload: Err(miss),
attempts,
unfunded,
}
}
async fn recover_unfunded(
deps_lock: &Arc<OnceLock<NodeOriginDeps>>,
deps: &NodeOriginDeps,
hash_bytes: [u8; 32],
mut unfunded: Vec<Candidate>,
mut miss: PullMiss,
gate: &Arc<RecoveryGate>,
mut seen: U256,
) -> Result<OriginFetch, OriginPullError> {
while !unfunded.is_empty() {
if gate.settling(tokio::time::Instant::now()) {
tokio::time::sleep(SETTLE_POLL_STEP).await;
} else {
match node_step(gate, deps, seen, unfunded.len()).await {
Ok(deposit) => seen = deposit,
Err(end) => return miss_answer(miss.or(end)),
}
}
let outcome = try_pull(deps_lock, deps, &unfunded, hash_bytes, unfunded.len(), gate).await;
match outcome.payload {
Ok(()) => return Ok(OriginFetch::AlreadyAdmitted),
Err(failed) => miss = miss.or(failed),
}
unfunded = outcome.unfunded;
}
miss_answer(miss)
}
async fn held_bytes(store: &NodeAdmitStore, total_bytes: u64) -> Option<u64> {
use decdn_client::RangedStore as _;
let missing = store.missing_ranges(0, total_bytes).await.ok()?;
let chunk = |n: bao_tree::ChunkNum| n.0.saturating_mul(1024).min(total_bytes);
let mut bounds = missing.boundaries().iter().copied();
let mut missing_bytes = 0u64;
while let Some(start) = bounds.next() {
let end = bounds.next().map_or(total_bytes, chunk);
missing_bytes = missing_bytes.saturating_add(end.saturating_sub(chunk(start)));
}
Some(total_bytes.saturating_sub(missing_bytes))
}
pub(super) async fn node_step(
gate: &RecoveryGate,
deps: &NodeOriginDeps,
seen: U256,
candidates: usize,
) -> Result<U256, PullMiss> {
let funder = NodeFunder::new(Arc::clone(&deps.buyer), Arc::clone(&deps.metrics), seen);
let current = || deps.buyer.pool_deposit().unwrap_or(seen);
let mut top_ups = gate.top_ups();
match gate
.step(&funder, seen, &mut top_ups, current, U256::ZERO)
.await
{
Stepped::Raised(deposit) => Ok(deposit),
Stepped::Replaced(replaced) => {
info!(
closed = %replaced.closed,
opened = %replaced.opened,
"node-origin: the buyer pool accepts no more funds; a new pool replaces it"
);
gate.start_next_pass();
Ok(deps.buyer.pool_deposit().unwrap_or(seen))
}
end @ (Stepped::NoProgress | Stepped::Unavailable) => {
info!(
candidates,
why = no_step_why(&end),
"node-origin: no candidate serves at this node's funding; the fill ends funding \
needed"
);
Err(PullMiss::FundingNeeded)
}
Stepped::Failed(err) => {
warn!(
error = %sanitize_err_chain(&err),
"node-origin: the funding recovery step failed"
);
Err(PullMiss::LocalFault)
}
}
}
const fn no_step_why(stepped: &Stepped) -> &'static str {
if matches!(stepped, Stepped::NoProgress) {
"no byte was verified since the last recovery step"
} else {
"the recovery step could not add funds"
}
}
fn bind_upstream_ctx(deps: &NodeOriginDeps, ctx: PoolContext) -> anyhow::Result<PoolContext> {
let own_node_id = B256::from(*deps.endpoint.id().as_bytes());
let binding = sign_client_binding(&ctx.client_signer, own_node_id, &deps.bind_domain)?;
Ok(ctx.with_client_binding(binding))
}
fn lane_ledger(
deps: &NodeOriginDeps,
provider_addr: Address,
ctx: &PoolContext,
) -> Arc<PoolLedger> {
deps.ledgers.get_or_seed(
decdn_incentive::LaneKey {
pool_id: ctx.pool_id,
signer: ctx.client_signer.address(),
provider: provider_addr,
},
Cumulative {
bytes: ctx.prior_bytes_delivered,
amount: ctx.prior_amount,
},
)
}
async fn pull_from_candidate(
deps_lock: &Arc<OnceLock<NodeOriginDeps>>,
deps: &NodeOriginDeps,
candidate: &Candidate,
hash_bytes: [u8; 32],
gate: &Arc<RecoveryGate>,
) -> Result<(), PullMiss> {
let span = tracing::info_span!(
"upstream_stream",
otel.kind = "client",
direction = "outbound",
peer = %DhtNodeId::from_bytes(candidate.node_id),
local_node_id = %deps.endpoint.id(),
hash = %DhtHash::from_bytes(hash_bytes),
pool_id = tracing::field::Empty,
outcome = tracing::field::Empty,
);
let opened = AtomicBool::new(false);
let result = pull_from_candidate_in_span(deps_lock, deps, candidate, hash_bytes, &opened, gate)
.instrument(span.clone())
.await;
if opened.load(Ordering::Relaxed) {
deps.metrics.outbound_stream_ended(result.is_ok());
}
span.record(
"outcome",
match result {
Ok(()) => "filled",
Err(miss) => miss.as_str(),
},
);
result
}
#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
async fn pull_from_candidate_in_span(
deps_lock: &Arc<OnceLock<NodeOriginDeps>>,
deps: &NodeOriginDeps,
candidate: &Candidate,
hash_bytes: [u8; 32],
opened: &AtomicBool,
gate: &Arc<RecoveryGate>,
) -> Result<(), PullMiss> {
let Ok(pk) = PublicKey::from_bytes(&candidate.node_id) else {
return Err(PullMiss::Clean);
};
let Some(provider_addr) = deps
.addr_resolver
.address_of(&DhtNodeId::from_bytes(candidate.node_id))
else {
debug!("node-origin: candidate has no resolvable operator address; skipping");
return Err(PullMiss::Clean);
};
let heat = heat_of(deps, hash_bytes);
let (rate_ceiling, speculative) =
match economic_ceiling(deps, candidate.node_id.into(), heat, candidate.rate_per_mb) {
EconGate::Allow {
rate_ceiling,
speculative,
} => (rate_ceiling, speculative),
EconGate::Skip => return Err(PullMiss::BelowMargin),
};
let ctx = match deps
.buyer
.open_or_reuse_pool(provider_addr, crate::selection::POOL_OPEN_CALLER_BUDGET)
.await
{
Ok(ctx) => ctx,
Err(err) => {
return Err(record_pool_open_failure(deps, provider_addr, &err));
}
};
let ctx = match bind_upstream_ctx(deps, ctx) {
Ok(ctx) => ctx,
Err(err) => {
let verdict = classify_pull_failure(deps, pk, provider_addr, hash_bytes, None, &err);
return Err(PullMiss::for_verdict(verdict));
}
};
tracing::Span::current().record("pool_id", tracing::field::display(ctx.pool_id));
let started = Instant::now();
let deadlines = match deps.config.deadlines() {
Ok(deadlines) => deadlines,
Err(err) => {
let verdict = classify_pull_failure(deps, pk, provider_addr, hash_bytes, None, &err);
return Err(PullMiss::for_verdict(verdict));
}
};
let ledger = lane_ledger(deps, provider_addr, &ctx);
let _stream_guard = deps.metrics.outbound_stream_guard();
opened.store(true, Ordering::Relaxed);
let handshake_source = PeerSource::new(
&deps.endpoint,
EndpointAddr::new(pk),
Arc::new(Mutex::new(ctx.clone())),
Arc::clone(&ledger),
&deps.slash_domain,
provider_addr,
NO_NAMESPACE,
deps.config.max_blob_size_bytes,
rate_ceiling,
deadlines,
None,
)
.with_dial_runtime(deps.dial_runtime.clone());
let handshake = timed_open(
handshake_source.open_whole(hash_bytes),
Arc::clone(&deps.metrics),
);
let (header, whole) = match handshake.await {
Ok(pair) => pair,
Err(err) => {
return Err(candidate_miss(
deps,
pk,
provider_addr,
hash_bytes,
ctx.pool_id,
&err,
));
}
};
let opened_at = tokio::time::Instant::now();
let total_bytes = header.total_bytes;
let hash = Hash::from(hash_bytes);
let endpoint = deps.endpoint.clone();
let dial_runtime = deps.dial_runtime.clone();
let slash_domain = deps.slash_domain.clone();
let engine = deps.engine.clone();
let metrics = Arc::clone(&deps.metrics);
let ledger_for_drive = Arc::clone(&ledger);
let deps_for_thread = Arc::clone(deps_lock);
let max_blob_size_bytes = deps.config.max_blob_size_bytes;
let gate_for_thread = Arc::clone(gate);
let cancel = CancellationToken::new();
let cancel_for_thread = cancel.clone();
let _cancel_guard = cancel.drop_guard();
let pull_span = tracing::Span::current();
let join = tokio::task::spawn_blocking(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let _entered = pull_span.enter();
Ok::<_, std::io::Error>(rt.block_on(async move {
let store = NodeAdmitStore::new(engine, hash, total_bytes, None)
.counting_received(Arc::clone(&metrics));
let pool_id = ctx.pool_id;
let prior_amount = ctx.prior_amount;
let ctx = Arc::new(std::sync::Mutex::new(ctx));
let _settle = SettleOnDrop {
deps: Arc::clone(&deps_for_thread),
provider_addr,
pool_id,
prior_amount,
ledger: Arc::clone(&ledger_for_drive),
};
let source = PeerSource::new(
&endpoint,
EndpointAddr::new(pk),
Arc::clone(&ctx),
Arc::clone(&ledger_for_drive),
&slash_domain,
provider_addr,
NO_NAMESPACE,
max_blob_size_bytes,
rate_ceiling,
deadlines,
None,
)
.with_dial_runtime(dial_runtime);
let source = PrimedSource::new(TimedSource::new(source, Arc::clone(&metrics)));
let whole_range = align_range(0, 0, total_bytes).ok();
let first = first_leg(&store, &[(0, 0)], u64::MAX, max_blob_size_bytes)
.await
.ok()
.flatten();
match whole_range {
Some(range) if first.as_ref() == Some(&range) => {
source.prime(hash_bytes, range, header, whole, opened_at);
}
_ => drop((header, whole)),
}
let pacer = BudgetPacer::new();
let held_before = held_bytes(&store, total_bytes).await;
let cancelled;
let result = tokio::select! {
biased;
r = drive(
&store,
&source,
&pacer,
&ctx,
&ledger_for_drive,
hash_bytes,
0,
total_bytes,
None,
None,
None,
None,
) => {
cancelled = false;
r
}
() = cancel_for_thread.cancelled() => {
cancelled = true;
Ok(())
}
};
if let (Some(before), Some(after)) =
(held_before, held_bytes(&store, total_bytes).await)
&& after > before
{
gate_for_thread.record_verified(after - before);
}
source.clear();
(result, pool_id, cancelled)
}))
})
.await;
let elapsed = started.elapsed();
let (result, pool_id, cancelled) = match join {
Ok(Ok(triple)) => triple,
Ok(Err(err)) => {
warn!(error = %err, "node-origin: pull-thread runtime build failed; local fault");
return Err(PullMiss::LocalFault);
}
Err(join_err) => {
warn!(%join_err, "node-origin: pull thread panicked; local fault");
return Err(PullMiss::LocalFault);
}
};
if cancelled {
return Err(PullMiss::Clean);
}
match result {
Ok(()) => {
if speculative {
deps.config.warming.debit_speculative(
candidate.node_id.into(),
Hash::from_bytes(hash_bytes),
candidate.rate_per_mb.saturating_mul(mb_of(total_bytes)),
);
}
record_outcome(
deps,
pk,
&Outcome::Delivered {
bytes: total_bytes,
elapsed,
},
);
Ok(())
}
Err(err) => Err(candidate_miss(
deps,
pk,
provider_addr,
hash_bytes,
pool_id,
&err,
)),
}
}
fn candidate_miss(
deps: &NodeOriginDeps,
pk: PublicKey,
provider_addr: Address,
hash_bytes: [u8; 32],
pool_id: B256,
err: &anyhow::Error,
) -> PullMiss {
let verdict = classify_pull_failure(deps, pk, provider_addr, hash_bytes, Some(pool_id), err);
match PullMiss::for_verdict(verdict) {
PullMiss::Clean if decdn_client::classify(err) == decdn_client::Fault::Unaffordable => {
PullMiss::Unfunded
}
miss => miss,
}
}
struct SettleOnDrop {
deps: Arc<OnceLock<NodeOriginDeps>>,
provider_addr: Address,
pool_id: B256,
prior_amount: U256,
ledger: Arc<PoolLedger>,
}
impl std::fmt::Debug for SettleOnDrop {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SettleOnDrop")
.field("provider_addr", &self.provider_addr)
.field("pool_id", &self.pool_id)
.finish_non_exhaustive()
}
}
impl Drop for SettleOnDrop {
fn drop(&mut self) {
let Some(deps) = self.deps.get() else {
return;
};
let progress = VoucherProgress::from_ledger(&self.ledger, self.prior_amount);
persist_buyer_progress(deps, self.provider_addr, self.pool_id, &progress);
}
}
fn persist_buyer_progress(
deps: &NodeOriginDeps,
provider_addr: Address,
pool_id: B256,
progress: &VoucherProgress,
) {
let Some(write) = ProgressWrite::of(progress) else {
return;
};
if let Err(err) = deps.buyer.record_progress(provider_addr, pool_id, write) {
deps.metrics.node_pull_progress_persist_failure();
warn!(%provider_addr, error = %err, "node-origin: failed to persist buyer voucher progress");
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RefusalVerdict {
DurableMiss,
Transient,
OurFault,
}
const REFUSAL_SUPPRESSION_TTL: Duration = Duration::from_secs(30);
#[allow(clippy::match_same_arms)]
const fn classify_refusal(error: &StreamError) -> RefusalVerdict {
match error {
StreamError::Declined => RefusalVerdict::DurableMiss,
StreamError::NotFound => RefusalVerdict::Transient,
StreamError::Unfunded => RefusalVerdict::Transient,
StreamError::VoucherRejected { .. } => RefusalVerdict::OurFault,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PullVerdict {
Oversize,
RateCeiling,
OurDeadline,
Stalled,
RateLimited,
OurDeadLane(VoucherRejectReason),
OurVoucherRetryable(VoucherRejectReason),
Refused(RefusalVerdict),
OurLocalFault,
Corruption,
LegNoProgress,
Unreachable,
}
fn wedged_lane(
deps: &NodeOriginDeps,
pk: PublicKey,
provider_addr: Address,
hash_bytes: [u8; 32],
reason: VoucherRejectReason,
pool_id: Option<B256>,
) {
deps.metrics.node_pull_pool_wedged();
deps.negative_cache.record_failure_with_ttl(
DhtNodeId::from_bytes(*pk.as_bytes()),
DhtHash::from_bytes(hash_bytes),
REFUSAL_SUPPRESSION_TTL,
);
deps.record_wedged(&pk);
warn!(
%provider_addr, ?pool_id, ?reason,
"node-origin: upstream rejected our voucher on terms this lane cannot recover from; \
suppressing the provider for a bounded window while the pool's own deposit is \
unaffected (#1122)"
);
}
const WEDGED_PROVIDER_SUPPRESSION_SECS: u64 = 3600;
const fn voucher_verdict(reason: VoucherRejectReason, has_bundle: bool) -> PullVerdict {
match reason {
VoucherRejectReason::Underpaid if !has_bundle => PullVerdict::OurLocalFault,
VoucherRejectReason::BadSignature
| VoucherRejectReason::WrongSigner
| VoucherRejectReason::BadPreimage
| VoucherRejectReason::ChainIndexZero
| VoucherRejectReason::ChunkPriceMismatch => PullVerdict::OurLocalFault,
VoucherRejectReason::PoolExhausted | VoucherRejectReason::UnanchoredPreimage => {
PullVerdict::OurVoucherRetryable(reason)
}
VoucherRejectReason::WrongPool
| VoucherRejectReason::WrongProvider
| VoucherRejectReason::AmountRegression
| VoucherRejectReason::BytesRegression
| VoucherRejectReason::UnderFold
| VoucherRejectReason::Underpaid
| VoucherRejectReason::SpendingCapExhausted
| VoucherRejectReason::SignerCapExhausted
| VoucherRejectReason::CapabilityExpired => PullVerdict::OurDeadLane(reason),
}
}
fn pull_verdict(err: &anyhow::Error) -> PullVerdict {
if err.downcast_ref::<BlobTooLarge>().is_some() {
return PullVerdict::Oversize;
}
if err.downcast_ref::<RateAboveCeiling>().is_some() {
return PullVerdict::RateCeiling;
}
if err.downcast_ref::<ResumeOffsetPastEnd>().is_some() {
return PullVerdict::OurLocalFault;
}
if err.downcast_ref::<LegNoProgress>().is_some() {
return PullVerdict::LegNoProgress;
}
if err.downcast_ref::<PullTimeout>().is_some() {
return PullVerdict::OurDeadline;
}
if err.downcast_ref::<PullStalled>().is_some() {
return PullVerdict::Stalled;
}
if err.downcast_ref::<UpstreamRateLimited>().is_some() {
return PullVerdict::RateLimited;
}
if let Some(rejected) = err.downcast_ref::<UpstreamVoucherRejected>() {
return voucher_verdict(rejected.reason, rejected.bundle.is_some());
}
if err.downcast_ref::<decdn_client::PoolExhausted>().is_some() {
return PullVerdict::OurVoucherRetryable(VoucherRejectReason::PoolExhausted);
}
if err.downcast_ref::<LocalPullFault>().is_some() {
return PullVerdict::OurLocalFault;
}
if let Some(refused) = err.downcast_ref::<UpstreamRefused>() {
if let StreamError::VoucherRejected { reason, bundle } = refused.error() {
return voucher_verdict(*reason, bundle.is_some());
}
return PullVerdict::Refused(classify_refusal(refused.error()));
}
if err.downcast_ref::<HashMismatch>().is_some() {
return PullVerdict::Corruption;
}
if is_local_store_fault(err) {
return PullVerdict::OurLocalFault;
}
PullVerdict::Unreachable
}
fn is_local_store_fault(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
matches!(
cause.downcast_ref::<decdn_cache::CacheError>(),
Some(decdn_cache::CacheError::Store(_) | decdn_cache::CacheError::Internal(_))
)
})
}
fn record_backpressure_refusal(
deps: &NodeOriginDeps,
provider_addr: Address,
err: &anyhow::Error,
) -> Option<PullVerdict> {
let verdict = backpressure_verdict(err)?;
if verdict == PullVerdict::RateLimited {
deps.metrics.node_upstream_rate_limited();
} else {
deps.metrics.node_pull_refused();
deps.metrics.node_pull_refused_unattributable();
}
debug!(%provider_addr, error = %err, "node-origin: upstream refused a leg for backpressure; not suppressing it, as the refusal usually clears with time");
Some(verdict)
}
fn backpressure_verdict(err: &anyhow::Error) -> Option<PullVerdict> {
let verdict = pull_verdict(err);
match verdict {
PullVerdict::RateLimited => Some(verdict),
PullVerdict::Refused(RefusalVerdict::Transient) => {
let at_open = err.downcast_ref::<UpstreamRefused>().is_some_and(|r| {
r.evidence().is_some() && !matches!(r.error(), StreamError::Unfunded)
});
at_open.then_some(verdict)
}
_ => None,
}
}
fn record_backpressure_exhausted(
deps: &NodeOriginDeps,
pk: PublicKey,
hash_bytes: [u8; 32],
waits: u32,
) {
deps.metrics.node_pull_backpressure_exhausted();
deps.negative_cache.record_failure_with_ttl(
DhtNodeId::from_bytes(*pk.as_bytes()),
DhtHash::from_bytes(hash_bytes),
REFUSAL_SUPPRESSION_TTL,
);
warn!(
peer = %pk,
hash = %Hash::from(hash_bytes),
waits,
"node-origin: the only holder of a still-missing range kept refusing for \
backpressure; dropping it and ending the assembly short"
);
}
#[allow(clippy::cognitive_complexity)]
fn classify_pull_failure(
deps: &NodeOriginDeps,
pk: PublicKey,
provider_addr: Address,
hash_bytes: [u8; 32],
pool_id: Option<B256>,
err: &anyhow::Error,
) -> PullVerdict {
let suppress = |ttl: Option<Duration>| {
let node = DhtNodeId::from_bytes(*pk.as_bytes());
let hash = DhtHash::from_bytes(hash_bytes);
match ttl {
Some(ttl) => deps.negative_cache.record_failure_with_ttl(node, hash, ttl),
None => deps.negative_cache.record_failure(node, hash),
}
};
let verdict = pull_verdict(err);
match verdict {
PullVerdict::Oversize => {
deps.metrics.node_pull_too_large();
debug!(%provider_addr, error = %err, "node-origin: upstream blob crossed our size ceiling on received bytes; pull aborted");
}
PullVerdict::RateCeiling => {
deps.metrics.node_pull_rate_above_ceiling();
suppress(Some(REFUSAL_SUPPRESSION_TTL));
debug!(%provider_addr, error = %err, "node-origin: upstream quoted above our rate ceiling; refused before paying, suppressing the pair");
}
PullVerdict::OurDeadline => {
deps.metrics.node_pull_timeout();
suppress(Some(REFUSAL_SUPPRESSION_TTL));
debug!(%provider_addr, error = %err, "node-origin: pull hit our local deadline; suppressing briefly, not tarring upstream reputation");
}
PullVerdict::Stalled => {
deps.metrics.node_pull_stalled();
suppress(Some(REFUSAL_SUPPRESSION_TTL));
debug!(%provider_addr, error = %err, "node-origin: upstream fell below the throughput floor; suppressing briefly, not tarring upstream reputation");
}
PullVerdict::RateLimited => {
deps.metrics.node_upstream_rate_limited();
suppress(Some(REFUSAL_SUPPRESSION_TTL));
debug!(%provider_addr, error = %err, ttl = ?REFUSAL_SUPPRESSION_TTL, "node-origin: upstream shed the pull at the transport (rate-limited); suppressing briefly, not tarring upstream reputation");
}
PullVerdict::OurDeadLane(reason) => {
deps.metrics.node_pull_voucher_rejected();
wedged_lane(deps, pk, provider_addr, hash_bytes, reason, pool_id);
}
PullVerdict::OurVoucherRetryable(reason) => {
deps.metrics.node_pull_voucher_rejected();
debug!(
%provider_addr, ?reason, error = %err,
"node-origin: upstream rejected the voucher but the lane is healthy; \
left intact for a retry"
);
}
PullVerdict::Refused(verdict) => {
deps.metrics.node_pull_refused();
match verdict {
RefusalVerdict::DurableMiss => {
suppress(None);
debug!(%provider_addr, error = %err, "node-origin: upstream declined this blob; negative-caching this (peer, hash) for the full TTL without tarring reputation");
}
RefusalVerdict::Transient => {
deps.metrics.node_pull_refused_unattributable();
suppress(Some(REFUSAL_SUPPRESSION_TTL));
debug!(%provider_addr, error = %err, ttl = ?REFUSAL_SUPPRESSION_TTL, "node-origin: upstream refused for a reason we cannot attribute to it; briefly suppressing this (peer, hash) without tarring reputation");
}
RefusalVerdict::OurFault => {
debug!(%provider_addr, error = %err, "node-origin: upstream refused on OUR payment fault; exonerating and leaving it selectable");
}
}
}
PullVerdict::OurLocalFault => {
deps.metrics.node_pull_local_fault();
warn!(
%provider_addr, error = %format_args!("{err:#}"),
"node-origin: LOCAL fault during a pull (signer/encode/range/store) — not the \
upstream's; exonerating it"
);
}
PullVerdict::LegNoProgress => {
if let Some(stuck) = err.downcast_ref::<LegNoProgress>() {
warn_leg_no_progress(&deps.metrics, hash_bytes, Some((pk, provider_addr)), stuck);
}
suppress(Some(REFUSAL_SUPPRESSION_TTL));
}
PullVerdict::Corruption => {
debug!(peer = %pk, %provider_addr, error = %err, "node-origin: upstream served corrupt bytes; scoring corruption");
record_outcome(deps, pk, &Outcome::Corruption);
}
PullVerdict::Unreachable => {
debug!(peer = %pk, %provider_addr, error = %format_args!("{err:#}"), "node-origin: upstream pull failed; scoring unreachable");
record_outcome(deps, pk, &Outcome::Unreachable);
}
}
verdict
}
pub(super) fn warn_leg_no_progress(
metrics: &crate::metrics::Metrics,
hash_bytes: [u8; 32],
provider: Option<(PublicKey, Address)>,
stuck: &LegNoProgress,
) {
metrics.node_pull_leg_no_progress();
warn!(
hash = %DhtHash::from_bytes(hash_bytes),
provider = ?provider.map(|(pk, _)| pk),
provider_addr = ?provider.map(|(_, addr)| addr),
offset = stuck.offset,
len = stuck.len,
paid_frontier = stuck.paid_frontier,
delivered_frontier = stuck.delivered_frontier,
paid_wire = stuck.paid_wire,
"node-origin: a clean leg advanced neither the paid nor the delivered frontier; \
ending the gap instead of re-paying the range",
);
}
#[allow(clippy::cast_possible_truncation)] fn peer_reputation(deps: &NodeOriginDeps, pk: PublicKey) -> f32 {
deps.local_rep.score(pk) as f32
}
const REPUTATION_LOG_TARGET: &str = "decdn::reputation";
fn record_outcome(deps: &NodeOriginDeps, pk: PublicKey, outcome: &Outcome) {
fold_outcome(&deps.local_rep, &deps.metrics, pk, outcome);
}
fn fold_outcome(local_rep: &LocalReputation, metrics: &Metrics, pk: PublicKey, outcome: &Outcome) {
let score = local_rep.record(pk, *outcome);
debug!(
target: REPUTATION_LOG_TARGET,
peer = %pk, ?outcome, score,
"node-origin: reputation outcome recorded"
);
match *outcome {
Outcome::Delivered { .. } => metrics.node_pull_success(),
Outcome::Corruption => metrics.node_pull_corruption(),
Outcome::Unreachable => metrics.node_pull_unreachable(),
_ => {}
}
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] fn ms_to_u32(rtt_ms: f64) -> u32 {
if rtt_ms >= f64::from(u32::MAX) {
u32::MAX
} else if rtt_ms <= 0.0 {
0
} else {
rtt_ms as u32
}
}
fn now_micros() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| u64::try_from(d.as_micros()).unwrap_or(u64::MAX))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests;