mod addr_publish;
pub mod eviction;
mod reachability;
pub mod reload;
pub(crate) use reload::emit_config_notices;
pub use reload::{LogLevelApply, LogLevelSetter, ReloadSnapshot, RuntimeReloadState};
use crate::chain_events::boot_retry::{BOOT_CHAIN_RETRY_BUDGET, BootRetry};
use crate::chain_events::shared_head::{HeadSource, SharedHead};
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6};
use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::Context;
use decdn_cache::{
CacheEngine, FilesystemOrigin, HttpOrigin, Origin, S3Credentials, S3Origin, S3OriginConfig,
};
use decdn_common::config::{ResolvedDiscovery, ResolvedOrigin, ResolvedS3Credentials};
use iroh::address_lookup::{DnsAddressLookup, MemoryLookup, PkarrPublisher};
use iroh::endpoint::{BindOpts, IdleTimeout, QuicTransportConfig, VarInt, presets};
use iroh::protocol::Router;
use iroh::{Endpoint, EndpointAddr, PublicKey, RelayMap, RelayMode, RelayUrl, SecretKey};
use addr_publish::node_publish_filter;
use noq_proto::congestion::Bbr3Config;
use tokio::sync::oneshot;
use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
use crate::admin;
use crate::dht::{DhtRateLimiter, RecordStore, RecordStoreConfig, StakerSet};
use crate::dispatch::ConnectionLimiter;
use crate::handlers::client::{ClientHandler, ClientProtocol, MAX_CLIENT_STREAMS};
use crate::handlers::dht::DhtHandler;
use crate::handlers::probe::{ProbeHandler, StakeLanePolicy as ProbeStakeLanePolicy};
use crate::handlers::probe_rate_limit::ProbeRateLimiter;
use crate::metrics;
use crate::payment_settlement::PoolSettlementService;
use crate::pool_store::PersistentPoolStateStore;
use crate::stop_handle::StopHandle;
use alloy::network::EthereumWallet;
use alloy::primitives::U256;
use alloy::providers::{Provider, ProviderBuilder};
use alloy::signers::local::PrivateKeySigner;
use decdn_common::address::parse_nonzero_address;
use decdn_common::config::ResolvedConfig;
use decdn_common::redact::{
redact_userinfo, sanitize_err_chain, sanitize_error_sources, sanitize_rpc_display,
};
use decdn_incentive::PoolStateStore;
use decdn_incentive::eth_identity;
const SHUTDOWN_DEADLINE: Duration = Duration::from_secs(15);
const DISPATCH_GC_INTERVAL: Duration = Duration::from_mins(1);
const EGRESS_EWMA_INTERVAL: Duration = Duration::from_secs(1);
const DHT_RATE_LIMIT_GC_INTERVAL: Duration = Duration::from_mins(1);
const PROBE_RATE_LIMIT_GC_INTERVAL: Duration = Duration::from_mins(1);
const REPUTATION_EVICT_INTERVAL: Duration = Duration::from_hours(1);
pub(crate) const QUIC_MAX_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
const QUIC_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(10);
const QUIC_MAX_CONCURRENT_BIDI_STREAMS: u32 = 100;
const FEE_ROUTER_OPERATOR_BPS_FLOOR: u16 = 4000;
const FEE_SHARE_BOOT_BUDGET: Duration = Duration::from_mins(1);
fn with_poll_interval<P: Provider>(provider: P, interval: Duration) -> P {
provider.client().set_poll_interval(interval);
provider
}
async fn gate_listener_on_blacklist_sync<T>(
readiness: oneshot::Receiver<crate::blacklist_watcher::InitialSyncResult>,
start_listener: impl FnOnce() -> T,
) -> anyhow::Result<T> {
readiness
.await
.context("blacklist watcher exited before initial sync completed")?
.map_err(anyhow::Error::msg)
.context("initial ContentBlacklist sync unavailable")?;
Ok(start_listener())
}
type HttpUrl = alloy::transports::http::reqwest::Url;
#[derive(Clone)]
struct ProviderFactory {
url: HttpUrl,
metrics: Arc<metrics::Metrics>,
}
impl ProviderFactory {
const fn new(url: HttpUrl, metrics: Arc<metrics::Metrics>) -> Self {
Self { url, metrics }
}
fn client(&self) -> alloy::rpc::client::RpcClient {
crate::rpc_metrics::metered_http_client(self.url.clone(), &self.metrics)
}
fn read_only(&self, interval: Duration) -> impl Provider + Clone + use<> {
with_poll_interval(
ProviderBuilder::new().connect_client(self.client()),
interval,
)
}
fn shared_head(&self) -> impl Provider + Clone + use<> {
ProviderBuilder::new().connect_client(self.client())
}
fn seller_wallet(
&self,
signer: PrivateKeySigner,
interval: Duration,
) -> impl Provider + Clone + use<> {
self.wallet(signer, interval)
}
fn buyer_wallet(
&self,
signer: PrivateKeySigner,
interval: Duration,
) -> impl Provider + Clone + use<> {
self.wallet(signer, interval)
}
fn wallet(
&self,
signer: PrivateKeySigner,
interval: Duration,
) -> impl Provider + Clone + use<> {
with_poll_interval(
ProviderBuilder::new()
.with_simple_nonce_management()
.wallet(EthereumWallet::from(signer))
.connect_client(self.client()),
interval,
)
}
}
fn quic_transport_config() -> anyhow::Result<QuicTransportConfig> {
let idle_timeout: IdleTimeout = QUIC_MAX_IDLE_TIMEOUT.try_into().map_err(|e| {
anyhow::anyhow!(
"BUG: QUIC_MAX_IDLE_TIMEOUT={QUIC_MAX_IDLE_TIMEOUT:?} not representable as IdleTimeout: {e}"
)
})?;
Ok(QuicTransportConfig::builder()
.max_idle_timeout(Some(idle_timeout))
.keep_alive_interval(QUIC_KEEP_ALIVE_INTERVAL)
.max_concurrent_bidi_streams(VarInt::from_u32(QUIC_MAX_CONCURRENT_BIDI_STREAMS))
.congestion_controller_factory(Arc::new(Bbr3Config::default()))
.build())
}
fn spawn_periodic<F>(
tasks: &mut JoinSet<()>,
name: &'static str,
interval: Duration,
mut tick: F,
) -> oneshot::Sender<()>
where
F: FnMut() + Send + 'static,
{
let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
tasks.spawn(async move {
let mut ticker = tokio::time::interval(interval);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
ticker.tick().await;
loop {
tokio::select! {
biased;
_ = &mut stop_rx => {
tracing::debug!(task = name, "periodic task shutdown signal received");
return;
}
_ = ticker.tick() => tick(),
}
}
});
stop_tx
}
fn warn_if_pins_exceed_cache(cache: &decdn_cache::CacheEngine, cache_size_mb: u64) {
let pinned = cache.pinned_snapshot();
let pinned_bytes: u64 = pinned
.iter()
.filter_map(|h| cache.origin_held_size(decdn_cache::Hash::from_bytes(*h.as_bytes())))
.fold(0u64, u64::saturating_add);
let limit_bytes = cache_size_mb.saturating_mul(1024 * 1024);
if pinned_bytes > limit_bytes {
tracing::warn!(
pinned_count = pinned.len(),
pinned_bytes,
cache_size_mb,
limit_bytes,
"cache.pinned_hashes is larger than cache.cache_size_mb; pinned blobs are \
LRU-exempt, so the eviction driver can never reach its high-water target and \
disk use will exceed the configured ceiling. Raise cache_size_mb or trim the \
pin set (#1130)"
);
}
}
fn log_keyspace_gc(kind: &'static str, layer: &'static str, sweep: Option<(usize, usize)>) {
if let Some((before, after)) = sweep {
tracing::debug!(
layer,
before,
after,
dropped = before.saturating_sub(after),
"{kind} rate-limit GC sweep complete"
);
}
}
struct Infra {
node_metrics: Arc<metrics::Metrics>,
secret_key: SecretKey,
eth_signer: Arc<PrivateKeySigner>,
payment_pool_deployment: crate::pool_store::Deployment,
concrete_pool_store: Arc<PersistentPoolStateStore>,
pool_state_store: Arc<dyn PoolStateStore>,
watcher_checkpoint_store: Arc<dyn decdn_incentive::KeyedCheckpointStore>,
receipt_sink: Arc<dyn crate::receipt_log::ReceiptSink>,
receipt_writer: StopHandle,
lane_flush_task: tokio::task::JoinHandle<()>,
node_origin: Option<crate::node_origin::NodeOrigin>,
pull_through_origin: Option<Arc<crate::node_origin::NodeOrigin>>,
cache: CacheEngine,
eviction_policy: Arc<dyn decdn_cache::EvictionPolicy>,
frequency_estimator: Option<Arc<dyn decdn_cache::FrequencyEstimator>>,
ep: Endpoint,
limiter: Arc<ConnectionLimiter>,
}
#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
async fn build_infra(
cfg: &ResolvedConfig,
secret_key: SecretKey,
reload_state: &RuntimeReloadState,
node_metrics: Arc<metrics::Metrics>,
) -> anyhow::Result<Infra> {
check_rpc_reachability(&cfg.blockchain.rpc_url).await?;
node_metrics.probe_hold_slots_max(cfg.cache.max_probe_holds);
let eth_signer = Arc::new(load_eth_signer(cfg).await?);
tracing::info!(address = %eth_signer.address(), "loaded eth keystore");
let pool_store_data_dir = cfg.identity.data_dir.clone();
let pool_store_deployment = crate::pool_store::Deployment {
chain_id: cfg.blockchain.chain_id,
payment_pool: parse_nonzero_address(
&cfg.blockchain.payment_pool_address,
"blockchain.payment_pool_address",
)?,
};
let preflight_rpc_url: HttpUrl = cfg
.blockchain
.rpc_url
.parse()
.context("blockchain.rpc_url is not a valid URL (value redacted; check the config)")?;
check_deployment_preflight(
ProviderFactory::new(preflight_rpc_url, Arc::clone(&node_metrics)).shared_head(),
pool_store_deployment,
&BootRetry::new(DEPLOYMENT_PREFLIGHT_BUDGET, Arc::clone(&node_metrics)),
)
.await?;
let concrete_pool_store: Arc<PersistentPoolStateStore> = Arc::new(
tokio::task::spawn_blocking(move || {
PersistentPoolStateStore::open(&pool_store_data_dir, pool_store_deployment)
})
.await
.context("pool state store open task panicked")?
.context("failed to open pool state store (issue #527 voucher replay guard)")?,
);
let pool_state_store: Arc<dyn PoolStateStore> = concrete_pool_store.clone();
let watcher_checkpoint_store: Arc<dyn decdn_incentive::KeyedCheckpointStore> = Arc::new(
crate::payment_settlement::DebouncedCheckpointStore::new(concrete_pool_store.clone()),
);
let persisted_count = tokio::task::spawn_blocking({
let store = Arc::clone(&pool_state_store);
move || store.load_all()
})
.await
.context("pool state store load task panicked")?
.context("failed to hydrate persisted lane state")?
.len();
tracing::info!(
lanes = persisted_count,
"pool state store ready (issue #527 replay guard active)",
);
let receipt_log_data_dir = cfg.identity.data_dir.clone();
let receipt_log_policy = crate::receipt_log::RotationPolicy::from(&cfg.receipts);
let receipt_log: Arc<dyn crate::receipt_log::ReceiptLog> =
match tokio::task::spawn_blocking(move || {
crate::receipt_log::JsonlReceiptLog::open(&receipt_log_data_dir, receipt_log_policy)
})
.await
.context("download-receipt log open task panicked")?
{
Ok(log) => {
tracing::info!(path = %log.path().display(), "download-receipt log ready");
Arc::new(log)
}
Err(e) => {
tracing::error!(
error = %e,
event = "download_receipt_log_open_failed",
"failed to open download-receipt log; continuing without the audit log \
(paid delivery is unaffected; revenue reconciliation will be incomplete)",
);
Arc::new(crate::receipt_log::NoopReceiptLog)
}
};
let (receipt_sink, receipt_writer) =
crate::receipt_log::spawn_receipt_writer(receipt_log, Arc::clone(&node_metrics));
let flush_store = Arc::clone(&pool_state_store);
let flush_metrics = Arc::clone(&node_metrics);
let flush_interval =
std::time::Duration::from_millis(cfg.payment.voucher_commit_interval_ms.max(1));
let lane_flush_task = tokio::spawn(async move {
let mut ticker = tokio::time::interval(flush_interval);
ticker.tick().await; loop {
ticker.tick().await;
let store = Arc::clone(&flush_store);
let res = tokio::task::spawn_blocking(move || store.flush()).await;
match res {
Ok(Ok(())) => {}
Ok(Err(err)) => {
flush_metrics.lane_flush_failure();
tracing::warn!(error = %err, "lane store background flush failed; retrying next tick");
}
Err(join_err) => {
flush_metrics.lane_flush_failure();
tracing::warn!(%join_err, "lane store flush task join failed");
}
}
}
});
let node_origin = cfg
.cache
.node_to_node_pull_through_enabled
.then(crate::node_origin::NodeOrigin::new);
let pull_through_origin = node_origin.clone().map(Arc::new);
let cache = build_cache(
cfg,
Arc::clone(&node_metrics),
node_origin.clone().map(|o| Arc::new(o) as Arc<dyn Origin>),
)
.await?;
reload_state.attach_cache(Some(cache.clone()));
node_metrics.attach_probe_holds(cache.clone());
let (eviction_policy, estimator) = wire_cache_policies(&cfg.cache, &cache);
let retry = cfg.cache.origin_retry;
tracing::info!(
cache_dir = %cfg.cache.cache_dir.display(),
has_origin = !cfg.cache.origins.is_empty(),
origin_count = cfg.cache.origins.len(),
origin_kinds = %origin_kinds_label(&cfg.cache.origins),
pinned_hashes = cfg.cache.pinned_hashes.len(),
retry_max_retries = retry.max_retries,
retry_initial_backoff_ms = retry.initial_backoff_ms,
retry_max_backoff_ms = retry.max_backoff_ms,
retry_jitter_ratio = retry.jitter_ratio,
"cache engine ready",
);
let transport_config =
quic_transport_config().context("failed to build QUIC transport config")?;
let ep = build_endpoint(
&secret_key,
cfg.network.bind_port,
&cfg.network.relay_urls,
&cfg.network.discovery,
transport_config,
)
.await
.context("failed to build iroh endpoint")?;
node_metrics
.register_iroh_endpoint(&ep)
.context("failed to register iroh metrics")?;
let limiter = Arc::new(ConnectionLimiter::new(
&cfg.security,
Arc::clone(&node_metrics),
));
reload_state.attach_limiter(Some(Arc::clone(&limiter)));
reload_state.attach_metrics(Arc::clone(&node_metrics));
Ok(Infra {
node_metrics,
secret_key,
eth_signer,
payment_pool_deployment: pool_store_deployment,
concrete_pool_store,
pool_state_store,
watcher_checkpoint_store,
receipt_sink,
receipt_writer,
lane_flush_task,
node_origin,
pull_through_origin,
cache,
eviction_policy,
frequency_estimator: estimator,
ep,
limiter,
})
}
struct ServeInputs {
ep: Endpoint,
probe_handler: Arc<ProbeHandler>,
client_handler: Arc<ClientHandler>,
dht_handler: Arc<DhtHandler>,
blacklist_ready_rx: oneshot::Receiver<crate::blacklist_watcher::InitialSyncResult>,
}
#[allow(clippy::cognitive_complexity)]
async fn serve_until_shutdown(
reload_state: &RuntimeReloadState,
config_path: Option<&Path>,
drain_trigger: &Arc<admin::DrainTrigger>,
serve_in: ServeInputs,
) -> anyhow::Result<(Router, ShutdownSignal)> {
let ServeInputs {
ep,
probe_handler,
client_handler,
dht_handler,
blacklist_ready_rx,
} = serve_in;
let router = gate_listener_on_blacklist_sync(blacklist_ready_rx, || {
Router::builder(ep.clone())
.accept(ProbeHandler::ALPN, probe_handler)
.accept(ClientProtocol::ALPN, ClientProtocol::new(client_handler))
.accept(DhtHandler::ALPN, dht_handler)
.spawn()
})
.await?;
let mut shutdown_streams = ShutdownStreams::install();
let mut hup_stream = HupStream::install();
let signal = loop {
tokio::select! {
sig = shutdown_streams.recv() => break sig,
() = hup_stream.recv() => {
match config_path {
Some(path) => {
if let Err(err) = reload_state.reload(path).await {
tracing::warn!(error = %err, "config reload error");
}
}
None => {
tracing::warn!(
"SIGHUP received but no config file path is in use; ignoring"
);
}
}
}
() = drain_trigger.wait() => {
tracing::info!("admin_v1_drain received; initiating graceful shutdown");
break ShutdownSignal::AdminDrain;
}
}
};
tracing::info!(signal = %signal, "shutdown signal received; closing router");
Ok((router, signal))
}
struct ChainHandlers<P: Provider + Clone + 'static> {
providers: ProviderFactory,
event_poll_interval: Duration,
slash_domain: alloy::dyn_abi::Eip712Domain,
bind_domain: alloy::dyn_abi::Eip712Domain,
staker_set: Arc<dyn StakerSet>,
poller: crate::chain_events::resumable_watcher::WatcherHandle,
slash_store: crate::slash_watcher::SlashStore,
node_address_resolver: Option<Arc<dyn crate::dht::NodeAddressResolver>>,
probe_rate_limiter: Arc<ProbeRateLimiter>,
probe_handler: Arc<ProbeHandler>,
dht_rate_limiter: Arc<DhtRateLimiter>,
record_store: Arc<std::sync::Mutex<RecordStore>>,
origin_directory: Arc<dyn crate::dht::origin::OriginDirectory>,
dht_handler: Arc<DhtHandler>,
dht_routing: Arc<std::sync::Mutex<crate::dht::RoutingTable>>,
registry_regions: Arc<std::sync::RwLock<std::collections::HashMap<crate::dht::NodeId, String>>>,
client_handler: Arc<ClientHandler>,
shed_controller: Arc<crate::load_shed::LoadShedController>,
payment_service: PoolSettlementService<P>,
blacklist_ready_rx: oneshot::Receiver<crate::blacklist_watcher::InitialSyncResult>,
binding_report: crate::binding_check::BindingReport,
serve_economics: Arc<dyn crate::serve_economics::ServeEconomicsPolicy>,
operator_shares: crate::fee_shares::OperatorShares,
warming: Arc<crate::warming_allowance::WarmingAllowance>,
warming_creditor: StopHandle,
}
fn chain_poller_builder(
head: Arc<dyn HeadSource>,
poll_interval: Duration,
blockchain: &decdn_common::config::ResolvedBlockchain,
node_metrics: &Arc<metrics::Metrics>,
) -> crate::chain_events::multiplexed_poller::MultiplexedPollerBuilder {
let span_metrics = Arc::clone(node_metrics);
crate::chain_events::multiplexed_poller::MultiplexedPollerBuilder::new(head, poll_interval)
.max_backfill_span(blockchain.get_logs_max_block_span)
.on_span_change(Box::new(move |span| span_metrics.chain_get_logs_span(span)))
.on_range_rejection(metrics::metric_hook(
node_metrics,
metrics::Metrics::chain_get_logs_range_rejected,
))
.on_window_retry(metrics::metric_hook(
node_metrics,
metrics::Metrics::chain_get_logs_retried,
))
.on_window_deferred(metrics::metric_hook(
node_metrics,
metrics::Metrics::chain_get_logs_deferred,
))
}
#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
async fn build_chain_and_handlers(
cfg: &ResolvedConfig,
reload_state: &RuntimeReloadState,
infra: &Infra,
) -> anyhow::Result<ChainHandlers<impl Provider + Clone + 'static>> {
let slash_judge_addr = parse_nonzero_address(
&cfg.blockchain.slash_judge_address,
"blockchain.slash_judge_address",
)?;
let slash_domain =
decdn_incentive::slash_judge_domain(cfg.blockchain.chain_id, slash_judge_addr);
let rpc_url: HttpUrl = cfg.blockchain.rpc_url.parse().with_context(|| {
format!(
"blockchain.rpc_url {:?} is not a valid URL",
cfg.blockchain.rpc_url
)
})?;
let capacity_bond_addr = parse_nonzero_address(
&cfg.blockchain.capacity_bond_address,
"blockchain.capacity_bond_address",
)?;
let content_blacklist_addr = parse_nonzero_address(
cfg.blockchain
.content_blacklist_address
.as_deref()
.context("missing mandatory blockchain.content_blacklist_address")?,
"blockchain.content_blacklist_address",
)?;
let providers = ProviderFactory::new(rpc_url, Arc::clone(&infra.node_metrics));
let event_poll_interval = Duration::from_millis(cfg.blockchain.event_poll_interval_ms);
let chain_provider = providers.read_only(event_poll_interval);
let head: Arc<dyn HeadSource> = Arc::new(SharedHead::new(
providers.shared_head(),
event_poll_interval,
));
let mut poller_routes: Vec<crate::chain_events::multiplexed_poller::Route> = Vec::new();
let poller_provider = providers.read_only(event_poll_interval);
let boot = BootRetry::new(BOOT_CHAIN_RETRY_BUDGET, Arc::clone(&infra.node_metrics));
let registry = crate::dht::capacity_bond_registry::bootstrap(
chain_provider,
capacity_bond_addr,
Arc::clone(&head),
cfg.cache.node_to_node_pull_through_enabled,
Arc::clone(&infra.node_metrics),
&boot,
)
.await
.with_context(|| format!("CapacityBond registry bootstrap at {capacity_bond_addr}"))?;
let staker_set: Arc<dyn StakerSet> = registry.staker_set;
let registry_regions = Arc::clone(®istry.regions);
let operator_to_node = Arc::clone(®istry.operator_to_node);
infra
.ep
.address_lookup()
.map_err(|e| anyhow::anyhow!("endpoint address lookup unavailable: {e}"))?
.add(registry.dial_addrs.lookup());
let own_id = crate::dht::routing::NodeId::from_bytes(*infra.ep.id().as_bytes());
reachability::report(
&infra.node_metrics,
cfg.network.bind_port,
®istry.dial_addrs.get(&own_id).unwrap_or_default(),
);
poller_routes.push(registry.route);
let (slash_store, slash_route) = crate::slash_watcher::bootstrap(
providers.read_only(event_poll_interval),
capacity_bond_addr,
infra.eth_signer.address(),
Arc::clone(&head),
Arc::clone(&infra.node_metrics),
&boot,
)
.await
.context("bootstrap the slash-detection watcher")?;
poller_routes.push(slash_route);
let node_address_resolver: Option<Arc<dyn crate::dht::NodeAddressResolver>> =
if cfg.cache.node_to_node_pull_through_enabled {
registry.node_addresses
} else {
None
};
let stake_lane_policy =
NonZeroUsize::new(cfg.cache.stake_lane_reserved_holds).and_then(|reserved| {
if cfg.cache.max_probe_holds == 0 {
tracing::warn!(
reserved_holds = reserved.get(),
"cache.stake_lane_reserved_holds is set but cache.max_probe_holds=0; \
the stake-lane reservation has no effect (probe holds are disabled)"
);
None
} else {
Some(ProbeStakeLanePolicy::new(
Arc::clone(&staker_set),
reserved,
cfg.cache.max_probe_holds,
))
}
});
let probe_rate_limiter = Arc::new(ProbeRateLimiter::from_resolved(
&cfg.probe,
Arc::clone(&infra.node_metrics),
));
let chain_freshness = crate::chain_freshness::ChainFreshness::new(Duration::from_secs(
cfg.blockchain.chain_staleness_grace_sec,
));
let probe_handler = Arc::new(ProbeHandler::new(
infra.secret_key.public(),
cfg.payment.rate_per_mb,
Arc::clone(&infra.node_metrics),
Arc::clone(&infra.limiter),
Arc::clone(&probe_rate_limiter),
infra.cache.clone(),
Arc::clone(&infra.eth_signer),
slash_domain.clone(),
stake_lane_policy,
cfg.cache.relay_foreign_namespaces,
Some(chain_freshness.clone()),
));
let dht_rate_limiter = Arc::new(DhtRateLimiter::from_resolved(
&cfg.dht,
Arc::clone(&infra.node_metrics),
));
let record_store = Arc::new(std::sync::Mutex::new(RecordStore::new(
RecordStoreConfig::default(),
)));
let origin_directory: Arc<dyn crate::dht::origin::OriginDirectory> =
if let Some(origin_addr) = cfg.blockchain.origin_assignment_address.as_deref() {
let origin_assignment_addr =
parse_nonzero_address(origin_addr, "blockchain.origin_assignment_address")?;
Arc::new(crate::dht::ChainOriginDirectory::new(
providers.read_only(event_poll_interval),
origin_assignment_addr,
Arc::clone(&operator_to_node),
Arc::clone(&staker_set),
reload_state.content_denylist(),
cfg.blockchain.origin_directory_cache_capacity,
Duration::from_secs(cfg.blockchain.origin_directory_positive_ttl_sec),
Duration::from_secs(cfg.blockchain.origin_directory_negative_ttl_sec),
Arc::clone(&infra.node_metrics),
))
} else {
Arc::new(crate::dht::origin::EmptyOriginDirectory)
};
let dht_handler = Arc::new(DhtHandler::new(
infra.secret_key.public(),
Arc::clone(&dht_rate_limiter),
Arc::clone(&infra.limiter),
Arc::clone(&infra.node_metrics),
Arc::clone(&staker_set),
Arc::clone(&record_store),
));
let dht_routing = dht_handler.routing_table();
let payment_pool_addr = infra.payment_pool_deployment.payment_pool;
let crate::fee_shares::FeeShareSeed {
router: fee_router_addr,
operator_bps: seed_operator_bps,
} = crate::fee_shares::seed_from_chain(
providers.read_only(event_poll_interval),
payment_pool_addr,
FEE_ROUTER_OPERATOR_BPS_FLOOR,
&boot.capped(FEE_SHARE_BOOT_BUDGET),
)
.await;
infra
.node_metrics
.fee_shares_watcher_unregistered(fee_router_addr.is_none());
let operator_shares = crate::fee_shares::OperatorShares::new(seed_operator_bps);
tracing::info!(
bps = seed_operator_bps,
"seeded live operator fee share for serve-economics"
);
let serve_economics = crate::serve_economics::select_policy(&cfg.cache.serve_economics);
let warming = Arc::new(crate::warming_allowance::WarmingAllowance::new(
cfg.cache.serve_economics.warming_budget,
cfg.cache.serve_economics.warming_refill,
));
let binding_report = crate::binding_check::check(
providers.read_only(event_poll_interval),
capacity_bond_addr,
infra.eth_signer.address(),
*infra.secret_key.public().as_bytes(),
)
.await;
let voucher_domain = infra.payment_pool_deployment.voucher_domain();
let bind_domain =
decdn_incentive::bind_node_id_domain(cfg.blockchain.chain_id, capacity_bond_addr);
let local_populate = (!cfg.cache.origins.is_empty())
.then(|| Duration::from_secs(cfg.cache.node_pull_timeout_sec));
let mut pull_through = None;
let mut pull_through_origin = None;
if cfg.cache.node_to_node_pull_through_enabled {
let per_candidate = Duration::from_secs(cfg.cache.node_pull_timeout_sec);
let stall = Duration::from_secs(cfg.cache.node_pull_stall_window_sec);
let outer_deadline = crate::selection::outer_pull_deadline(per_candidate, stall);
pull_through = Some(outer_deadline);
if let Some(origin) = &infra.pull_through_origin {
pull_through_origin = Some(Arc::clone(origin));
}
}
let content_denylist = reload_state.content_denylist();
let (redeem_tx, redeem_rx) =
tokio::sync::mpsc::channel(crate::payment_settlement::REDEEM_HINT_CAPACITY);
let shed_controller = crate::load_shed::LoadShedController::from_config(&cfg.load_shed);
reload_state.attach_load_shed(Some(Arc::clone(&shed_controller)));
let coarse_clock = Arc::new(crate::coarse_clock::CoarseClock::new());
crate::coarse_clock::CoarseClock::spawn_refresher(
&coarse_clock,
std::time::Duration::from_millis(500),
);
let pool_view = crate::pool_view::PoolProjection::new();
let wallet_provider = providers.seller_wallet((*infra.eth_signer).clone(), event_poll_interval);
let resolving_pool_view = crate::payment_settlement::ResolvingPoolView::new(
decdn_incentive::payment_pool::PaymentPool::new(payment_pool_addr, wallet_provider.clone()),
pool_view.clone(),
Arc::clone(&infra.node_metrics),
);
let (warming_credit, warming_creditor) = crate::warming_allowance::spawn_warming_creditor(
Arc::clone(&warming),
Arc::clone(&infra.node_metrics),
);
let client_deps = crate::handlers::client::ClientHandlerDeps {
node_id: infra.secret_key.public(),
metrics: Arc::clone(&infra.node_metrics),
limiter: Arc::clone(&infra.limiter),
shed: Arc::clone(&shed_controller),
cache: infra.cache.clone(),
eth_signer: Arc::clone(&infra.eth_signer),
slash_domain: slash_domain.clone(),
voucher_domain: voucher_domain.clone(),
bind_domain: bind_domain.clone(),
pool_state_store: Arc::clone(&infra.pool_state_store),
receipt_sink: Arc::clone(&infra.receipt_sink),
pool_view: Some(Arc::new(resolving_pool_view) as Arc<dyn crate::pool_view::PoolView>),
pool_min_remaining_deposit: U256::from(
cfg.blockchain.pool_min_remaining_deposit_micro_usdc,
),
rate_per_mb: cfg.payment.rate_per_mb,
max_concurrent_streams: MAX_CLIENT_STREAMS,
content_deny: Arc::clone(&content_denylist),
chain_freshness: Some(chain_freshness.clone()),
redeem_hint: Some(redeem_tx.clone()),
pull_through,
local_populate,
pull_through_origin,
credit_max: cfg.payment.credit_max,
credit_ramp_divisor: cfg.payment.credit_ramp_divisor,
frame_target_bytes: cfg.payment.frame_target_bytes,
idle_timeout: None,
pool_recheck_interval: None,
pool_floor_signer_live_windows: cfg.blockchain.pool_floor_signer_live_windows,
warming_credit,
operator_shares: operator_shares.clone(),
relay_foreign_namespaces: cfg.cache.relay_foreign_namespaces,
coarse_clock: Some(coarse_clock),
redeem_interval_secs: cfg.blockchain.redeem_interval_secs,
};
let client_handler = Arc::new(ClientHandler::new(client_deps)?);
let (payment_service, settlement_route) = PoolSettlementService::bootstrap(
wallet_provider,
payment_pool_addr,
infra.eth_signer.address(),
Arc::clone(&infra.pool_state_store),
Arc::clone(&infra.watcher_checkpoint_store),
Arc::clone(&client_handler),
U256::from(cfg.blockchain.redeem_threshold_micro_usdc),
usize::try_from(cfg.blockchain.redeem_max_vouchers_per_tx).unwrap_or(usize::MAX),
Duration::from_secs(cfg.blockchain.redeem_interval_secs),
Arc::clone(&infra.node_metrics),
&boot,
pool_view,
redeem_tx,
redeem_rx,
)
.await
.context("PaymentPool settlement service bootstrap")?;
poller_routes.push(settlement_route);
let (blacklist_ready_tx, blacklist_ready_rx) = oneshot::channel();
let blacklist_route = crate::blacklist_watcher::bootstrap(
providers.read_only(event_poll_interval),
content_blacklist_addr,
infra.eth_signer.address(),
infra.cache.clone(),
Arc::clone(&warming),
Arc::clone(&head),
Duration::from_secs(cfg.blockchain.content_blacklist_poll_interval_sec),
blacklist_ready_tx,
&infra.node_metrics,
Arc::clone(&content_denylist),
chain_freshness,
&boot,
)
.await
.context("blacklist compliance watcher boot enumeration")?;
poller_routes.push(blacklist_route);
if let Some(addr) = fee_router_addr {
let fee_shares_route = crate::fee_shares_watcher::route(
providers.read_only(event_poll_interval),
addr,
operator_shares.clone(),
Duration::from_secs(cfg.blockchain.fee_shares_poll_interval_sec),
&infra.node_metrics,
);
poller_routes.push(fee_shares_route);
}
let poller = chain_poller_builder(
Arc::clone(&head),
event_poll_interval,
&cfg.blockchain,
&infra.node_metrics,
);
let poller = poller_routes
.into_iter()
.fold(
poller,
crate::chain_events::multiplexed_poller::MultiplexedPollerBuilder::route,
)
.build()
.context("assemble the multiplexed chain-event poller")?;
let poller = crate::chain_events::multiplexed_poller::spawn(poller_provider, poller);
let bootstrap_outcome = crate::dht::bootstrap::bootstrap(
&infra.ep,
infra.secret_key.public(),
&dht_routing,
&staker_set,
)
.await;
tracing::info!(
seeds = bootstrap_outcome.seeds_seen,
inserted = bootstrap_outcome.seeds_inserted,
find_node_ok = bootstrap_outcome.find_node_ok,
find_node_err = bootstrap_outcome.find_node_err,
closer_added = bootstrap_outcome.closer_peers_inserted,
"dht bootstrap complete"
);
infra.node_metrics.dht_bootstrap_find_node_failures(
u64::try_from(bootstrap_outcome.find_node_err).unwrap_or(u64::MAX),
);
infra
.node_metrics
.dht_routing_table_size(crate::dht::chain_projection::with_lock(
&dht_routing,
"dht routing table",
|table| table.len(),
));
Ok(ChainHandlers {
providers,
event_poll_interval,
slash_domain,
bind_domain,
staker_set,
poller,
slash_store,
node_address_resolver,
probe_rate_limiter,
probe_handler,
dht_rate_limiter,
record_store,
origin_directory,
dht_handler,
dht_routing,
registry_regions,
client_handler,
shed_controller,
payment_service,
blacklist_ready_rx,
binding_report,
serve_economics,
operator_shares,
warming,
warming_creditor,
})
}
struct Background {
metrics_stop_tx: oneshot::Sender<()>,
dispatch_gc_stop_tx: oneshot::Sender<()>,
egress_ewma_stop_tx: oneshot::Sender<()>,
origin_rescan_stop_tx: Option<oneshot::Sender<()>>,
record_store_gc_stop_tx: oneshot::Sender<()>,
eviction_stop_tx: oneshot::Sender<()>,
dht_rate_limit_gc_stop_tx: oneshot::Sender<()>,
probe_rate_limit_gc_stop_tx: oneshot::Sender<()>,
reputation_evict_stop_tx: oneshot::Sender<()>,
republish_stop: CancellationToken,
bucket_refresh_stop_tx: oneshot::Sender<()>,
buyer_bootstrap_stop_tx: oneshot::Sender<()>,
rpc_watchdog: Option<(oneshot::Sender<()>, tokio::task::JoinHandle<()>)>,
admin_stop_tx: Option<oneshot::Sender<()>>,
drain_trigger: Arc<admin::DrainTrigger>,
tasks: JoinSet<()>,
}
#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
async fn spawn_background_tasks<P: Provider + Clone + 'static>(
cfg: &ResolvedConfig,
config_path: Option<&PathBuf>,
reload_state: &Arc<RuntimeReloadState>,
infra: &Infra,
ch: &ChainHandlers<P>,
started_at: std::time::Instant,
) -> anyhow::Result<Background> {
let buyer_wallet_provider = ch
.providers
.buyer_wallet((*infra.eth_signer).clone(), ch.event_poll_interval);
let buyer_pool_store: Arc<dyn decdn_incentive::BuyerPoolStore> = Arc::new(
crate::pool_store::BuyerPoolStoreHandle::new(Arc::clone(&infra.concrete_pool_store)),
);
let buyer_pool_store_for_admin = Arc::clone(&buyer_pool_store);
let metrics_addr = std::net::SocketAddr::new(
cfg.observability.metrics_bind,
cfg.observability.metrics_port,
);
let metrics_listener =
metrics::bind(metrics_addr).context("failed to bind metrics listener")?;
let (metrics_stop_tx, metrics_stop_rx) = oneshot::channel::<()>();
let metrics_handle = Arc::clone(&infra.node_metrics);
let mut tasks = JoinSet::new();
tasks.spawn(async move {
if let Err(err) = metrics::serve(metrics_listener, metrics_handle, metrics_stop_rx).await {
tracing::error!(error = %err, "metrics server exited with error");
}
});
let dispatch_gc_stop_tx = {
let limiter = Arc::clone(&infra.limiter);
spawn_periodic(&mut tasks, "dispatch_gc", DISPATCH_GC_INTERVAL, move || {
if let Some((before, after)) = limiter.gc_per_source() {
tracing::debug!(
before,
after,
dropped = before.saturating_sub(after),
"dispatch GC sweep complete"
);
}
})
};
let egress_ewma_stop_tx = {
let shed = Arc::clone(&ch.shed_controller);
let warming = Arc::clone(&ch.warming);
let metrics = Arc::clone(&infra.node_metrics);
spawn_periodic(&mut tasks, "egress_ewma", EGRESS_EWMA_INTERVAL, move || {
let bps = shed.sample_egress(EGRESS_EWMA_INTERVAL.as_secs());
metrics.load_shed_egress_bps(i64::try_from(bps).unwrap_or(i64::MAX));
metrics.load_shed_pressure_active(shed.pressure_active());
metrics.load_shed_streams_in_flight(shed.node_in_flight());
metrics.warming_sources_blocked(warming.blocked_source_count());
})
};
let record_store_gc_stop_tx = {
let records = Arc::clone(&ch.record_store);
spawn_periodic(
&mut tasks,
"record_store_gc",
DISPATCH_GC_INTERVAL,
move || {
let now_us = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| u64::try_from(d.as_micros()).unwrap_or(u64::MAX));
let removed = if let Ok(mut store) = records.lock() {
store.gc(now_us)
} else {
tracing::error!("dht record-store mutex poisoned; skipping GC sweep this tick");
0
};
if removed > 0 {
tracing::debug!(removed, "dht record-store GC sweep complete");
}
},
)
};
let eviction_stop_tx = {
if cfg.cache.gc_interval_sec == 0 {
tracing::warn!(
cache_size_mb = cfg.cache.cache_size_mb,
"eviction driver is running but cache.gc_interval_sec = 0: released blobs are \
never reclaimed, so cache.cache_size_mb cannot be enforced. Set a nonzero \
gc_interval_sec (default 300) to make eviction reclaim disk."
);
}
let (eviction_stop_tx, eviction_stop_rx) = oneshot::channel::<()>();
let params = eviction::EvictionParams {
cache_size_mb: cfg.cache.cache_size_mb,
disk_headroom_mb: cfg.cache.disk_headroom_mb,
high_water_pct: cfg.cache.eviction_high_water_pct,
target_pct: cfg.cache.eviction_target_pct,
per_sweep_budget: cfg.cache.eviction_per_sweep_budget,
tick: Duration::from_secs(cfg.cache.eviction_tick_secs),
};
tasks.spawn(eviction::run(
infra.cache.clone(),
infra.node_metrics.cache_metrics(),
params,
cfg.cache.cache_dir.clone(),
infra.eviction_policy.clone(),
Arc::clone(&ch.warming),
eviction_stop_rx,
));
eviction_stop_tx
};
let dht_rate_limit_gc_stop_tx = {
let limiter = Arc::clone(&ch.dht_rate_limiter);
spawn_periodic(
&mut tasks,
"dht_rate_limit_gc",
DHT_RATE_LIMIT_GC_INTERVAL,
move || {
log_keyspace_gc("dht", "per_ip", limiter.gc_per_ip());
log_keyspace_gc("dht", "per_peer", limiter.gc_per_peer());
},
)
};
let probe_rate_limit_gc_stop_tx = {
let limiter = Arc::clone(&ch.probe_rate_limiter);
spawn_periodic(
&mut tasks,
"probe_rate_limit_gc",
PROBE_RATE_LIMIT_GC_INTERVAL,
move || {
log_keyspace_gc("probe", "per_ip", limiter.gc_per_ip());
log_keyspace_gc("probe", "per_peer", limiter.gc_per_peer());
},
)
};
let republish_scheduler = Arc::new(crate::dht::RepublishScheduler::new());
let republish_stop = CancellationToken::new();
let cache_inserts_rx = infra.cache.subscribe_inserts();
infra.cache.rescan_origins().await;
let snapshot =
crate::dht::publish::holder_snapshot(&infra.cache, cfg.cache.relay_foreign_namespaces)
.await;
snapshot.report_degradation(&infra.node_metrics, "cold start");
let cold_start_count = republish_scheduler.seed_cold_start(
snapshot
.hashes
.into_iter()
.map(|h| decdn_protocol::ContentHash::from_bytes(*h.as_bytes())),
);
tracing::info!(
cold_start_count,
"republish scheduler seeded from existing cache + origin-held index (ADR 022 §Bootstrap cold-start, #1130)"
);
tasks.spawn(crate::dht::publish::run_republish(
infra.ep.clone(),
infra.secret_key.public(),
Arc::clone(&ch.dht_routing),
Arc::clone(&republish_scheduler),
infra.cache.clone(),
cfg.cache.relay_foreign_namespaces,
Arc::clone(&infra.node_metrics),
cache_inserts_rx,
republish_stop.clone(),
));
let origin_rescan_stop_tx = if cfg.cache.fs_rescan_interval_sec > 0 {
let cache = infra.cache.clone();
let scheduler = Arc::clone(&republish_scheduler);
Some(spawn_periodic(
&mut tasks,
"origin_rescan",
Duration::from_secs(cfg.cache.fs_rescan_interval_sec),
move || {
let cache = cache.clone();
let scheduler = Arc::clone(&scheduler);
tokio::spawn(async move {
cache.rescan_origins().await;
let report = cache.origin_held_snapshot();
if report.probe_faults > 0 || report.enumerate_failures > 0 {
tracing::warn!(
faults = report.probe_faults,
enumerate_failures = report.enumerate_failures,
"origin rescan could not resolve every candidate; the announce set omits anything it could not confirm and no earlier pass had indexed"
);
}
scheduler.seed_cold_start(
report
.hashes
.into_iter()
.map(|h| decdn_protocol::ContentHash::from_bytes(*h.as_bytes())),
);
});
},
))
} else {
None
};
warn_if_pins_exceed_cache(&infra.cache, cfg.cache.cache_size_mb);
let (bucket_refresh_stop_tx, bucket_refresh_stop_rx) = oneshot::channel::<()>();
let bucket_refresh_clock = Arc::new(std::sync::atomic::AtomicU64::new(0));
tasks.spawn(crate::dht::bucket_refresh::run_bucket_refresh(
infra.ep.clone(),
infra.secret_key.public(),
Arc::clone(&ch.dht_routing),
bucket_refresh_stop_rx,
crate::dht::bucket_refresh::BUCKET_REFRESH_TICK,
Arc::clone(&bucket_refresh_clock),
Arc::clone(&infra.node_metrics),
));
infra.node_metrics.rpc_healthy(true);
let rpc_watchdog = if cfg.blockchain.rpc_watchdog_interval_sec > 0 {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.context("failed to build HTTP client for RPC watchdog")?;
let interval = Duration::from_secs(cfg.blockchain.rpc_watchdog_interval_sec);
let (tx, rx) = oneshot::channel::<()>();
let handle = spawn_rpc_watchdog(
client,
cfg.blockchain.rpc_url.clone(),
interval,
Arc::clone(&infra.node_metrics),
rx,
);
Some((tx, handle))
} else {
tracing::info!("RPC watchdog disabled (blockchain.rpc_watchdog_interval_sec = 0)");
None
};
let admin_listener = if let Some(admin_port) = cfg.observability.admin_port {
let admin_addr = std::net::SocketAddr::from(([127, 0, 0, 1], admin_port));
Some(admin::bind(admin_addr).context("failed to bind admin listener")?)
} else {
tracing::info!("admin server disabled (observability.admin_port = 0)");
None
};
let local_reputation = Arc::new(
decdn_reputation::LocalReputation::new(
decdn_reputation::LocalReputationConfig::default()
.with_max_delta_per_update(decdn_reputation::LOCAL_SCORE_MAX_DELTA_PER_REPORT),
)
.context("local reputation config invalid")?,
);
let reputation_evict_stop_tx = {
let rep = Arc::clone(&local_reputation);
spawn_periodic(
&mut tasks,
"reputation_evict",
REPUTATION_EVICT_INTERVAL,
move || {
rep.evict();
},
)
};
let (buyer_bootstrap_stop_tx, buyer_bootstrap_stop_rx) = oneshot::channel::<()>();
let buyer_working_deposit = U256::from(cfg.blockchain.buyer_working_deposit_micro_usdc);
let buyer_ensure_max_approval = cfg.blockchain.buyer_max_approve;
let buyer_signer_address = infra.eth_signer.address();
let pull_through_enabled = cfg.cache.node_to_node_pull_through_enabled;
let node_origin_self_id = crate::dht::NodeId::from_bytes(*infra.secret_key.public().as_bytes());
let node_origin_slash_domain = ch.slash_domain.clone();
let node_origin_bind_domain = ch.bind_domain.clone();
let node_origin_config = crate::node_origin::NodeOriginConfig {
probe_fanout: cfg.cache.node_pull_probe_fanout,
pull_timeout: std::time::Duration::from_secs(cfg.cache.node_pull_timeout_sec),
stall_window: std::time::Duration::from_secs(cfg.cache.node_pull_stall_window_sec),
min_throughput_bps: cfg.cache.node_pull_min_throughput_bps,
max_blob_size_bytes: cfg
.cache
.max_blob_size_mb
.saturating_mul(decdn_protocol::MB_BYTES),
max_rate_per_mb: cfg.cache.max_rate_per_mb,
event_poll_interval: std::time::Duration::from_millis(
cfg.blockchain.event_poll_interval_ms,
),
lookup: crate::dht::LookupConfig::default(),
own_region: cfg.identity.region.clone(),
serve_economics: ch.serve_economics.clone(),
operator_shares: ch.operator_shares.clone(),
frequency_estimator: infra.frequency_estimator.clone(),
sell_rate_base: cfg.payment.rate_per_mb,
warming: Arc::clone(&ch.warming),
};
let ep_for_buyer = infra.ep.clone();
let eth_signer_for_buyer = Arc::clone(&infra.eth_signer);
let resolver_opt = ch.node_address_resolver.clone();
let node_origin_opt = infra.node_origin.clone();
let dht_routing_c = Arc::clone(&ch.dht_routing);
let staker_set_c = Arc::clone(&ch.staker_set);
let origin_directory_c = Arc::clone(&ch.origin_directory);
let local_reputation_c = Arc::clone(&local_reputation);
let registry_regions_c = Arc::clone(&ch.registry_regions);
let node_metrics_for_buyer = Arc::clone(&infra.node_metrics);
let node_metrics_for_origin = Arc::clone(&infra.node_metrics);
let node_origin_engine = infra.cache.clone();
let mut buyer_bootstrap_stop_rx = buyer_bootstrap_stop_rx;
let buyer_deployment = infra.payment_pool_deployment;
let payment_pool_addr_for_buyer = buyer_deployment.payment_pool;
tasks.spawn(async move {
let service = tokio::select! {
biased;
_ = &mut buyer_bootstrap_stop_rx => return,
res = crate::buyer_pool::BuyerPoolService::bootstrap(
buyer_wallet_provider,
buyer_deployment,
buyer_signer_address,
buyer_pool_store,
eth_signer_for_buyer,
buyer_working_deposit,
buyer_ensure_max_approval,
node_metrics_for_buyer,
) => match res {
Ok(service) => Arc::new(service),
Err(err) => {
tracing::warn!(
error = %sanitize_err_chain(&err),
payment_pool_addr = %payment_pool_addr_for_buyer,
"buyer-side PaymentPool bootstrap failed; node→node paid cache-miss \
pulls are DISABLED for this process (seller settlement is unaffected). \
This condition is sticky — restart the node to retry. Check: (1) \
blockchain.payment_pool_address is correct, (2) the RPC endpoint is \
reachable, (3) the wallet holds gas for the one-time USDC approve."
);
if pull_through_enabled {
tracing::warn!(
"cache.node_to_node_pull_through_enabled is set, but the buyer service \
failed to bootstrap; pull-through stays DISABLED this process \
(restart to retry)"
);
}
return;
}
}
};
if let Some(origin) = node_origin_opt.as_ref() {
if let Some(resolver) = resolver_opt.as_ref() {
origin.provision(crate::node_origin::NodeOriginDeps {
endpoint: ep_for_buyer.clone(),
dial_runtime: tokio::runtime::Handle::current(),
routing_table: dht_routing_c,
staker_set: staker_set_c,
origin_directory: origin_directory_c,
addr_resolver: Arc::clone(resolver),
buyer: Arc::clone(&service) as Arc<dyn crate::buyer_pool::PoolOpener>,
self_id: node_origin_self_id,
slash_domain: node_origin_slash_domain,
bind_domain: node_origin_bind_domain,
local_rep: local_reputation_c,
negative_cache: crate::dht::NegativeProbeCache::new(),
probe_cache: crate::dht::PositiveProbeCache::new(),
metrics: node_metrics_for_origin,
registry_regions: registry_regions_c,
config: node_origin_config,
ledgers: Arc::new(crate::buyer_ledgers::BuyerLedgers::default()),
wedged_providers: Arc::new(std::sync::Mutex::new(
std::collections::HashMap::new(),
)),
engine: node_origin_engine.clone(),
});
tracing::info!(
"node-to-node cache-miss pull-through provisioned and enabled (#831)"
);
} else {
tracing::warn!(
"cache.node_to_node_pull_through_enabled is set, but the node-address \
resolver failed to bootstrap; pull-through stays DISABLED this process \
(restart to retry)"
);
}
}
let _service = service;
let _ = buyer_bootstrap_stop_rx.await;
});
let drain_trigger = Arc::new(admin::DrainTrigger::new());
let admin_stop_tx = if let Some(listener) = admin_listener {
let (tx, rx) = oneshot::channel::<()>();
let reload_hook = config_path.map(|path| admin::ReloadHook {
reload_state: Arc::clone(reload_state),
config_path: path.clone(),
});
let state = admin::AdminState::new(
*infra.secret_key.public().as_bytes(),
started_at,
infra.cache.clone(),
reload_hook,
Arc::clone(&drain_trigger),
Arc::clone(&infra.node_metrics),
)
.with_dht(admin::DhtStatusHandles {
routing: Arc::clone(&ch.dht_routing),
staker_set: Arc::clone(&ch.staker_set),
record_store: Arc::clone(&ch.record_store),
republish: Arc::clone(&republish_scheduler),
refresh_clock: Arc::clone(&bucket_refresh_clock),
refresh_interval: crate::dht::bucket_refresh::BUCKET_REFRESH_TICK,
})
.with_lanes(admin::LaneStatusHandles {
pool_store: Arc::clone(&infra.pool_state_store),
lane_activity: ch.client_handler.lane_activity_clock(),
redeem_threshold_micro_usdc: cfg.blockchain.redeem_threshold_micro_usdc,
})
.with_buyer_pools(Arc::clone(&buyer_pool_store_for_admin))
.with_slash_detection(admin::SlashStatusHandles {
store: Arc::clone(&ch.slash_store),
})
.with_binding(ch.binding_report)
.with_operator_address(infra.eth_signer.address())
.with_warming(Arc::clone(&ch.warming))
.with_staker_set(Arc::clone(&ch.staker_set))
.with_denylist(reload_state.content_denylist());
tasks.spawn(async move {
if let Err(err) = admin::serve(listener, state, rx).await {
tracing::error!(error = %err, "admin server exited with error");
}
});
Some(tx)
} else {
None
};
tracing::info!(
event = "startup_banner",
node_id = %infra.secret_key.public(),
version = env!("CARGO_PKG_VERSION"),
region = cfg.identity.region.as_deref().unwrap_or(""),
bind_port = cfg.network.bind_port,
metrics_addr = %metrics_addr,
admin_port = ?cfg.observability.admin_port,
rate_per_mb = cfg.payment.rate_per_mb,
cache_dir = %cfg.cache.cache_dir.display(),
has_origin = !cfg.cache.origins.is_empty(),
origin_count = cfg.cache.origins.len(),
origin_kinds = %origin_kinds_label(&cfg.cache.origins),
"node runtime ready"
);
Ok(Background {
metrics_stop_tx,
dispatch_gc_stop_tx,
egress_ewma_stop_tx,
origin_rescan_stop_tx,
record_store_gc_stop_tx,
eviction_stop_tx,
dht_rate_limit_gc_stop_tx,
probe_rate_limit_gc_stop_tx,
reputation_evict_stop_tx,
republish_stop,
bucket_refresh_stop_tx,
buyer_bootstrap_stop_tx,
rpc_watchdog,
admin_stop_tx,
drain_trigger,
tasks,
})
}
pub async fn run(
cfg: ResolvedConfig,
secret_key: SecretKey,
config_path: Option<PathBuf>,
reload_state: Arc<RuntimeReloadState>,
node_metrics: Arc<metrics::Metrics>,
) -> anyhow::Result<()> {
let started_at = std::time::Instant::now();
let infra = build_infra(&cfg, secret_key, &reload_state, node_metrics).await?;
let ch = build_chain_and_handlers(&cfg, &reload_state, &infra).await?;
let bg = spawn_background_tasks(
&cfg,
config_path.as_ref(),
&reload_state,
&infra,
&ch,
started_at,
)
.await?;
let (router, signal) = serve_until_shutdown(
&reload_state,
config_path.as_deref(),
&bg.drain_trigger,
ServeInputs {
ep: infra.ep,
probe_handler: ch.probe_handler,
client_handler: ch.client_handler,
dht_handler: ch.dht_handler,
blacklist_ready_rx: ch.blacklist_ready_rx,
},
)
.await?;
let handles = ShutdownHandles {
metrics_stop_tx: bg.metrics_stop_tx,
dispatch_gc_stop_tx: bg.dispatch_gc_stop_tx,
egress_ewma_stop_tx: bg.egress_ewma_stop_tx,
buyer_bootstrap_stop_tx: bg.buyer_bootstrap_stop_tx,
origin_rescan_stop_tx: bg.origin_rescan_stop_tx,
record_store_gc_stop_tx: bg.record_store_gc_stop_tx,
eviction_stop_tx: bg.eviction_stop_tx,
dht_rate_limit_gc_stop_tx: bg.dht_rate_limit_gc_stop_tx,
probe_rate_limit_gc_stop_tx: bg.probe_rate_limit_gc_stop_tx,
reputation_evict_stop_tx: bg.reputation_evict_stop_tx,
republish_stop: bg.republish_stop,
bucket_refresh_stop_tx: bg.bucket_refresh_stop_tx,
admin_stop_tx: bg.admin_stop_tx,
rpc_watchdog: bg.rpc_watchdog,
poller: ch.poller,
payment_service: ch.payment_service,
receipt_writer: infra.receipt_writer,
warming_creditor: ch.warming_creditor,
lane_flush_task: infra.lane_flush_task,
pool_state_store: infra.pool_state_store,
node_metrics: infra.node_metrics,
tasks: bg.tasks,
};
shutdown(handles, router, signal, &bg.drain_trigger, infra.cache).await
}
struct ShutdownHandles<P: Provider + Clone + 'static> {
metrics_stop_tx: oneshot::Sender<()>,
dispatch_gc_stop_tx: oneshot::Sender<()>,
egress_ewma_stop_tx: oneshot::Sender<()>,
buyer_bootstrap_stop_tx: oneshot::Sender<()>,
origin_rescan_stop_tx: Option<oneshot::Sender<()>>,
record_store_gc_stop_tx: oneshot::Sender<()>,
eviction_stop_tx: oneshot::Sender<()>,
dht_rate_limit_gc_stop_tx: oneshot::Sender<()>,
probe_rate_limit_gc_stop_tx: oneshot::Sender<()>,
reputation_evict_stop_tx: oneshot::Sender<()>,
republish_stop: CancellationToken,
bucket_refresh_stop_tx: oneshot::Sender<()>,
admin_stop_tx: Option<oneshot::Sender<()>>,
rpc_watchdog: Option<(oneshot::Sender<()>, tokio::task::JoinHandle<()>)>,
poller: crate::chain_events::resumable_watcher::WatcherHandle,
payment_service: PoolSettlementService<P>,
receipt_writer: StopHandle,
warming_creditor: StopHandle,
lane_flush_task: tokio::task::JoinHandle<()>,
pool_state_store: Arc<dyn PoolStateStore>,
node_metrics: Arc<metrics::Metrics>,
tasks: JoinSet<()>,
}
async fn close_router(router: &Router, deadline: Duration) -> bool {
match tokio::time::timeout(deadline, router.shutdown()).await {
Ok(Ok(())) => true,
Ok(Err(err)) => {
tracing::error!(
error = %err,
"router shutdown task failed; the endpoint may not have closed"
);
false
}
Err(_) => {
tracing::error!(
?deadline,
"router shutdown did not finish within the deadline, most likely because the \
endpoint close waits on a connection whose QUIC driver is gone; continuing \
with redeem and flush"
);
false
}
}
}
#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
async fn shutdown<P: Provider + Clone + 'static>(
handles: ShutdownHandles<P>,
router: Router,
signal: ShutdownSignal,
drain_trigger: &Arc<admin::DrainTrigger>,
cache: CacheEngine,
) -> anyhow::Result<()> {
let ShutdownHandles {
metrics_stop_tx,
dispatch_gc_stop_tx,
egress_ewma_stop_tx,
buyer_bootstrap_stop_tx,
origin_rescan_stop_tx,
record_store_gc_stop_tx,
eviction_stop_tx,
dht_rate_limit_gc_stop_tx,
probe_rate_limit_gc_stop_tx,
reputation_evict_stop_tx,
republish_stop,
bucket_refresh_stop_tx,
mut admin_stop_tx,
rpc_watchdog,
poller,
payment_service,
receipt_writer,
warming_creditor,
lane_flush_task,
pool_state_store,
node_metrics,
mut tasks,
} = handles;
if metrics_stop_tx.send(()).is_err() {
tracing::warn!("metrics server exited before shutdown signal was sent");
}
let _ = dispatch_gc_stop_tx.send(());
let _ = egress_ewma_stop_tx.send(());
let _ = buyer_bootstrap_stop_tx.send(());
if let Some(tx) = origin_rescan_stop_tx {
let _ = tx.send(());
}
let _ = record_store_gc_stop_tx.send(());
let _ = eviction_stop_tx.send(());
let _ = dht_rate_limit_gc_stop_tx.send(());
let _ = probe_rate_limit_gc_stop_tx.send(());
let _ = reputation_evict_stop_tx.send(());
republish_stop.cancel();
let _ = bucket_refresh_stop_tx.send(());
let stop_order = admin_stop_order(signal, drain_trigger);
if matches!(stop_order, AdminStopOrder::Early)
&& let Some(tx) = admin_stop_tx.take()
&& tx.send(()).is_err()
{
tracing::warn!("admin server exited before shutdown signal was sent");
}
let rpc_watchdog_handle = if let Some((tx, handle)) = rpc_watchdog {
if tx.send(()).is_err() {
tracing::warn!("RPC watchdog exited before shutdown signal was sent");
}
Some(handle)
} else {
None
};
close_router(&router, SHUTDOWN_DEADLINE).await;
poller.shutdown();
receipt_writer.cancel();
warming_creditor.cancel();
payment_service.shutdown(SHUTDOWN_DEADLINE).await;
let flush_result = match tokio::task::spawn_blocking(move || pool_state_store.flush()).await {
Ok(result) => result,
Err(join_err) => Err(decdn_incentive::StoreError::Backend(format!(
"flush join: {join_err}"
))),
};
if let Err(err) = flush_result {
node_metrics.lane_flush_failure();
tracing::warn!(error = %err, "final lane store flush on shutdown failed");
}
lane_flush_task.abort();
if matches!(stop_order, AdminStopOrder::AfterRouter)
&& let Some(tx) = admin_stop_tx.take()
&& tx.send(()).is_err()
{
tracing::error!(
"admin server exited while runtime was awaiting router.shutdown; \
`decdn node drain --wait` clients may have observed ECONNREFUSED \
before in-flight streams completed (false drain-complete)"
);
}
let cache_shutdown_err = cache.shutdown().await.err();
if let Some(err) = cache_shutdown_err.as_ref() {
tracing::error!(error = %err.display_chain(), "cache shutdown failed; store state may be inconsistent");
}
let watchdog_abort = rpc_watchdog_handle
.as_ref()
.map(tokio::task::JoinHandle::abort_handle);
let receipt_writer_abort = receipt_writer.abort_handle();
let warming_creditor_abort = warming_creditor.abort_handle();
let drain = async {
while let Some(result) = tasks.join_next().await {
log_join_result(result, "shutdown");
}
log_join_result(receipt_writer.shutdown().await, "receipt-writer-shutdown");
log_join_result(
warming_creditor.shutdown().await,
"warming-creditor-shutdown",
);
if let Some(handle) = rpc_watchdog_handle
&& let Err(err) = handle.await
&& !err.is_cancelled()
{
tracing::warn!(error = %err, "RPC watchdog task panicked during shutdown");
}
};
if tokio::time::timeout(SHUTDOWN_DEADLINE, drain).await.is_ok() {
tracing::info!("graceful shutdown complete");
} else {
tracing::warn!(
deadline = ?SHUTDOWN_DEADLINE,
out_of_joinset_aborts = usize::from(watchdog_abort.is_some()) + 2,
"graceful shutdown timed out; aborting remaining tasks",
);
tasks.abort_all();
if let Some(abort) = &watchdog_abort {
abort.abort();
}
tracing::warn!(
event = "receipt_writer_aborted",
"receipt writer aborted at shutdown deadline; enqueued audit receipts may be lost"
);
receipt_writer_abort.abort();
tracing::warn!(
event = "warming_creditor_aborted",
"warming-credit aggregator aborted at shutdown deadline; \
queued serve credits are lost"
);
warming_creditor_abort.abort();
while let Some(result) = tasks.join_next().await {
log_join_result(result, "abort");
}
}
tracing::info!("node stopped");
match cache_shutdown_err {
None => Ok(()),
Some(err) => Err(anyhow::Error::from(err).context("cache shutdown failed")),
}
}
async fn build_endpoint(
secret_key: &SecretKey,
bind_port: u16,
relay_urls: &[String],
discovery: &ResolvedDiscovery,
transport_config: QuicTransportConfig,
) -> anyhow::Result<Endpoint> {
let bind_v4 = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, bind_port);
let bind_v6 = SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, bind_port, 0, 0);
let mut builder = if discovery.is_empty() {
Endpoint::builder(presets::Minimal)
.address_lookup(PkarrPublisher::n0_dns().addr_filter(node_publish_filter()))
.address_lookup(DnsAddressLookup::n0_dns())
} else {
add_discovery_lookups(Endpoint::builder(presets::Minimal), discovery)?
};
builder = builder.relay_mode(RelayMode::Default);
builder = builder
.secret_key(secret_key.clone())
.transport_config(transport_config);
if !relay_urls.is_empty() {
let relays = parse_relay_urls(relay_urls)?;
builder = builder.relay_mode(RelayMode::Custom(RelayMap::from_iter(relays)));
}
let ep = builder
.bind_addr(bind_v4)
.map_err(|e| anyhow::anyhow!("invalid bind addr {bind_v4}: {e}"))?
.bind_addr_with_opts(bind_v6, BindOpts::default().set_is_required(false))
.map_err(|e| anyhow::anyhow!("invalid bind addr {bind_v6}: {e}"))?
.bind()
.await
.map_err(|e| anyhow::anyhow!("endpoint bind {bind_v4} + {bind_v6} failed: {e}"))?;
warn_if_no_ipv6_socket(&ep.bound_sockets(), bind_v6);
tokio::spawn(report_home_relay(ep.home_relay_status(), HOME_RELAY_GRACE));
Ok(ep)
}
const HOME_RELAY_GRACE: Duration = Duration::from_secs(15);
async fn report_home_relay(
mut status: impl iroh::Watcher<Value = Vec<iroh::endpoint::RelayStatus>>,
grace: Duration,
) -> bool {
let deadline = tokio::time::sleep(grace);
tokio::pin!(deadline);
let mut relays = status.get();
loop {
if let Some(up) = relays.iter().find(|relay| relay.is_connected()) {
tracing::info!(
relay = %redact_userinfo(up.url().as_str()),
"home relay connected"
);
return true;
}
tokio::select! {
() = &mut deadline => break,
next = status.updated() => match next {
Ok(value) => relays = value,
Err(_) => return false,
},
}
}
warn_no_home_relay(&relays, grace);
false
}
fn warn_no_home_relay(relays: &[iroh::endpoint::RelayStatus], grace: Duration) {
if relays.is_empty() {
tracing::warn!(
?grace,
"no home relay selected after bring-up; relay-assisted connectivity is down \
while iroh keeps retrying. Check network.relay_urls and outbound HTTPS"
);
}
for relay in relays {
let url = redact_userinfo(relay.url().as_str());
if let Some(reason) = relay.auth_denied_reason() {
tracing::warn!(
relay = %url,
reason = %sanitize_rpc_display(reason),
"home relay denied this node's authentication; retries will not recover \
until the relay's access policy changes"
);
} else {
tracing::warn!(
relay = %url,
?grace,
error = relay.last_error().map(|e| sanitize_error_sources(e)),
"home relay not connected after bring-up; iroh keeps retrying in the \
background. Check the relay URL and that the relay is up"
);
}
}
}
fn warn_if_no_ipv6_socket(bound: &[SocketAddr], bind_v6: SocketAddrV6) -> bool {
let has_v6 = bound.iter().any(SocketAddr::is_ipv6);
if !has_v6 {
tracing::warn!(
%bind_v6,
"IPv6 bind failed; the node listens on IPv4 only — an /ip6/ multiaddr does not \
reach it. Enable IPv6 on the host or free the port, or register /ip4/ \
multiaddrs only"
);
}
has_v6
}
fn add_discovery_lookups(
mut builder: iroh::endpoint::Builder,
discovery: &ResolvedDiscovery,
) -> anyhow::Result<iroh::endpoint::Builder> {
if let Some(pkarr) = &discovery.pkarr_url {
let url = pkarr.parse::<url::Url>().map_err(|e| {
anyhow::anyhow!(
"invalid network.discovery.pkarr_url {:?}: {e}",
redact_userinfo(pkarr)
)
})?;
builder =
builder.address_lookup(PkarrPublisher::builder(url).addr_filter(node_publish_filter()));
}
if let Some(origin) = &discovery.dns_origin {
builder = builder.address_lookup(DnsAddressLookup::builder(origin.clone()));
}
if !discovery.peers.is_empty() {
let mut infos = Vec::with_capacity(discovery.peers.len());
for peer in &discovery.peers {
let id = peer.node_id.parse::<PublicKey>().map_err(|e| {
anyhow::anyhow!("invalid network.discovery peer id {}: {e}", peer.node_id)
})?;
let mut addr = EndpointAddr::new(id);
if let Some(relay) = &peer.relay_url {
let relay_url = relay.parse::<RelayUrl>().map_err(|e| {
anyhow::anyhow!(
"invalid relay_url {:?} for network.discovery peer {}: {e}",
redact_userinfo(relay),
peer.node_id
)
})?;
addr = addr.with_relay_url(relay_url);
}
for a in &peer.addrs {
let sock = a.parse::<std::net::SocketAddr>().map_err(|e| {
anyhow::anyhow!(
"invalid addr {a:?} for network.discovery peer {}: {e}",
peer.node_id
)
})?;
addr = addr.with_ip_addr(sock);
}
infos.push(addr);
}
builder = builder.address_lookup(MemoryLookup::from_endpoint_info(infos));
}
Ok(builder)
}
fn parse_relay_urls(urls: &[String]) -> anyhow::Result<Vec<RelayUrl>> {
urls.iter()
.map(|u| {
u.parse::<RelayUrl>().map_err(|e| {
anyhow::anyhow!(
"invalid network.relay_urls entry {:?}: {e}",
redact_userinfo(u)
)
})
})
.collect()
}
async fn load_eth_signer(cfg: &ResolvedConfig) -> anyhow::Result<PrivateKeySigner> {
use alloy::signers::Signer;
let sources = eth_identity::standard_sources(
cfg.blockchain.keystore_password_file.clone(),
eth_identity::PasswordUse::Unlock,
);
let resolved = eth_identity::read_password(&sources, "eth keystore password")?;
for warning in resolved.warnings() {
tracing::warn!(%warning, "keystore password source ignored");
}
let password = resolved.into_secret();
let path = cfg.blockchain.eth_keystore.clone();
let signer = tokio::task::spawn_blocking(move || eth_identity::load_signer(&path, &password))
.await
.map_err(|e| anyhow::anyhow!("keystore decrypt task panicked: {e}"))??;
Ok(signer.with_chain_id(Some(cfg.blockchain.chain_id)))
}
fn log_join_result(result: Result<(), tokio::task::JoinError>, phase: &'static str) {
if let Err(err) = result {
if err.is_cancelled() {
tracing::debug!(phase, "task cancelled during shutdown");
} else {
tracing::warn!(phase, error = %err, "task failed during shutdown");
}
}
}
fn wire_cache_policies(
cache_cfg: &decdn_common::config::resolved::ResolvedCache,
cache: &CacheEngine,
) -> (
Arc<dyn decdn_cache::EvictionPolicy>,
Option<Arc<dyn decdn_cache::FrequencyEstimator>>,
) {
let want_estimator = cache_cfg.eviction_policy == "tinylfu"
|| cache_cfg.admission_policy == "tinylfu"
|| cache_cfg.serve_economics.policy == "margin";
let estimator: Option<Arc<dyn decdn_cache::FrequencyEstimator>> = want_estimator.then(|| {
Arc::new(decdn_cache::policy::tinylfu::TinyLfuEstimator::new(
cache_cfg.tinylfu.sketch_bytes,
)) as Arc<dyn decdn_cache::FrequencyEstimator>
});
if let Some(est) = &estimator {
cache.set_frequency_estimator(est.clone());
}
if cache_cfg.admission_policy == "tinylfu"
&& let Some(est) = &estimator
{
cache.set_admission_policy(Arc::new(decdn_cache::policy::tinylfu::ProbationAdmission {
freq: est.clone(),
promotion_threshold: cache_cfg.tinylfu.promotion_threshold,
}));
}
let eviction_policy: Arc<dyn decdn_cache::EvictionPolicy> =
match cache_cfg.eviction_policy.as_str() {
"tinylfu" => match &estimator {
Some(est) => Arc::new(decdn_cache::policy::tinylfu::TinyLfuEviction::new(
est.clone(),
cache_cfg.tinylfu.promotion_threshold,
cache_cfg.tinylfu.probation_target_pct,
)),
None => Arc::new(decdn_cache::policy::LruEviction),
},
_ => Arc::new(decdn_cache::policy::LruEviction),
};
if cache_cfg.admission_policy == "tinylfu" && cache_cfg.eviction_policy != "tinylfu" {
tracing::warn!(
admission_policy = %cache_cfg.admission_policy,
eviction_policy = %cache_cfg.eviction_policy,
"cache.admission_policy = \"tinylfu\" is inert unless cache.eviction_policy is \
also \"tinylfu\": probation admission relies on the tinylfu eviction policy to \
promote and cap probation members; under lru eviction the labels do nothing and \
the frequency estimator runs for no effect",
);
}
(eviction_policy, estimator)
}
async fn build_cache(
cfg: &ResolvedConfig,
node_metrics: Arc<metrics::Metrics>,
node_origin: Option<Arc<dyn Origin>>,
) -> anyhow::Result<CacheEngine> {
let mut origins: Vec<Arc<dyn Origin>> = Vec::with_capacity(cfg.cache.origins.len());
for (idx, resolved) in cfg.cache.origins.iter().enumerate() {
let backend: Arc<dyn Origin> = match resolved {
ResolvedOrigin::Http { url, decompress } => Arc::new(
HttpOrigin::new_with_user_agent(url.clone(), &cfg.cache.user_agent)
.with_context(|| {
format!("failed to build HTTP origin client for cache.origins[{idx}]")
})?
.with_decompress_mode(*decompress),
),
ResolvedOrigin::Fs { path } => {
Arc::new(FilesystemOrigin::new(path.clone()).await.with_context(|| {
format!("failed to open filesystem origin for cache.origins[{idx}]")
})?)
}
ResolvedOrigin::S3(s3_cfg) => Arc::new(
S3Origin::new(&s3_origin_config_from_resolved(s3_cfg))
.await
.with_context(|| {
format!("failed to construct S3 origin client for cache.origins[{idx}]")
})?
.with_decompress_mode(s3_cfg.decompress),
),
};
origins.push(backend);
}
if let Some(node_origin) = node_origin {
origins.push(node_origin);
}
let engine = CacheEngine::open_full(
&cfg.cache.cache_dir,
origins,
cfg.cache.max_blob_size_mb,
cfg.cache.pinned_hashes.clone(),
cfg.cache.origin_retry,
cfg.cache.circuit_breaker,
Some(node_metrics.cache_metrics()),
std::time::Duration::from_secs(cfg.cache.gc_interval_sec),
)
.await
.context("failed to open cache engine")?;
engine.set_max_probe_holds(cfg.cache.max_probe_holds);
engine.set_origin_read_budget(
std::time::Duration::from_secs(cfg.cache.node_pull_stall_window_sec),
cfg.cache.node_pull_min_throughput_bps,
);
engine.set_origin_probe_config(decdn_cache::origin_probe::OriginProbePolicy {
positive_ttl: std::time::Duration::from_secs(cfg.cache.origin_probe_ttl_sec),
negative_ttl: std::time::Duration::from_secs(cfg.cache.origin_probe_negative_ttl_sec),
fault_ttl: std::time::Duration::from_secs(cfg.cache.origin_probe_fault_ttl_sec),
timeout: std::time::Duration::from_millis(cfg.cache.origin_probe_timeout_ms),
capacity: usize::try_from(cfg.cache.origin_probe_memo_capacity).unwrap_or(usize::MAX),
});
Ok(engine)
}
fn s3_origin_config_from_resolved(cfg: &decdn_common::config::ResolvedS3Config) -> S3OriginConfig {
let credentials = cfg.credentials.as_ref().map(|c| match c {
ResolvedS3Credentials::Static {
access_key_id,
secret_access_key,
session_token,
} => S3Credentials::Static {
access_key_id: access_key_id.expose().to_string(),
secret_access_key: secret_access_key.expose().to_string(),
session_token: session_token.as_ref().map(|t| t.expose().to_string()),
},
ResolvedS3Credentials::DefaultChain { profile } => S3Credentials::DefaultChain {
profile: profile.clone(),
},
});
S3OriginConfig {
bucket: cfg.bucket.clone(),
region: cfg.region.clone(),
endpoint_url: cfg.endpoint_url.clone(),
path_style: cfg.path_style,
prefix: cfg.prefix.clone(),
credentials,
}
}
fn origin_kinds_label(origins: &[ResolvedOrigin]) -> String {
if origins.is_empty() {
return "none".to_string();
}
origins
.iter()
.map(|o| match o {
ResolvedOrigin::Http { .. } => "http",
ResolvedOrigin::Fs { .. } => "fs",
ResolvedOrigin::S3(_) => "s3",
})
.collect::<Vec<_>>()
.join(",")
}
#[derive(Debug, Clone, Copy)]
enum ShutdownSignal {
Sigint,
#[cfg(unix)]
Sigterm,
AdminDrain,
}
impl std::fmt::Display for ShutdownSignal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Sigint => f.write_str("SIGINT"),
#[cfg(unix)]
Self::Sigterm => f.write_str("SIGTERM"),
Self::AdminDrain => f.write_str("admin-drain"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AdminStopOrder {
Early,
AfterRouter,
}
fn admin_stop_order(signal: ShutdownSignal, drain_trigger: &admin::DrainTrigger) -> AdminStopOrder {
if matches!(signal, ShutdownSignal::AdminDrain) && drain_trigger.wait_admin() {
AdminStopOrder::AfterRouter
} else {
AdminStopOrder::Early
}
}
struct HupStream {
#[cfg(unix)]
hup: Option<tokio::signal::unix::Signal>,
}
impl HupStream {
fn install() -> Self {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let hup = match signal(SignalKind::hangup()) {
Ok(s) => Some(s),
Err(err) => {
tracing::warn!(error = %err, "failed to install SIGHUP handler; hot-reload disabled");
None
}
};
Self { hup }
}
#[cfg(not(unix))]
{
Self {}
}
}
async fn recv(&mut self) {
#[cfg(unix)]
{
if let Some(stream) = self.hup.as_mut() {
if stream.recv().await.is_some() {
return;
}
self.hup = None;
tracing::warn!("SIGHUP stream closed; hot-reload disabled for this process");
}
std::future::pending::<()>().await;
}
#[cfg(not(unix))]
{
std::future::pending::<()>().await
}
}
}
struct ShutdownStreams {
#[cfg(unix)]
term: Option<tokio::signal::unix::Signal>,
}
impl ShutdownStreams {
fn install() -> Self {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let term = match signal(SignalKind::terminate()) {
Ok(s) => Some(s),
Err(err) => {
tracing::warn!(
error = %err,
"failed to install SIGTERM handler; falling back to SIGINT only",
);
None
}
};
Self { term }
}
#[cfg(not(unix))]
{
Self {}
}
}
async fn recv(&mut self) -> ShutdownSignal {
#[cfg(unix)]
{
if let Some(term) = self.term.as_mut() {
let race = tokio::select! {
_ = tokio::signal::ctrl_c() => Some(ShutdownSignal::Sigint),
res = term.recv() => res.map(|()| ShutdownSignal::Sigterm),
};
if let Some(sig) = race {
return sig;
}
tracing::warn!("SIGTERM stream closed; falling back to SIGINT only");
self.term = None;
}
let _ = tokio::signal::ctrl_c().await;
ShutdownSignal::Sigint
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
ShutdownSignal::Sigint
}
}
}
enum RpcProbe {
Healthy,
Transient(String),
Fatal(String),
}
const DEPLOYMENT_PREFLIGHT_BUDGET: Duration = Duration::from_mins(1);
async fn check_deployment_preflight<P: Provider + Clone>(
provider: P,
deployment: crate::pool_store::Deployment,
retry: &BootRetry,
) -> anyhow::Result<()> {
let (rpc_chain_id, code_len) = retry
.run(
"deployment preflight (eth_chainId + PaymentPool code)",
|| {
let provider = provider.clone();
async move {
let chain_id = provider.get_chain_id().await.context("eth_chainId")?;
let code = provider
.get_code_at(deployment.payment_pool)
.await
.context("eth_getCode(payment_pool)")?;
Ok((chain_id, code.len()))
}
},
)
.await?;
anyhow::ensure!(
rpc_chain_id == deployment.chain_id,
"blockchain.chain_id is {} but the RPC endpoint serves chain {rpc_chain_id}; a \
mismatched chain id would rebind the lane store and drop its seller state — fix \
blockchain.chain_id or blockchain.rpc_url",
deployment.chain_id,
);
anyhow::ensure!(
code_len > 0,
"blockchain.payment_pool_address {} has no code on chain {rpc_chain_id}; a wrong \
address would rebind the lane store and drop its seller state — fix \
blockchain.payment_pool_address",
deployment.payment_pool,
);
let contract =
decdn_incentive::payment_pool::PaymentPool::new(deployment.payment_pool, provider);
retry
.run(
"deployment preflight (PaymentPool.getRateBounds())",
|| async {
contract.getRateBounds().call().await.with_context(|| {
format!(
"blockchain.payment_pool_address {} does not answer \
PaymentPool.getRateBounds() on chain {rpc_chain_id}; the address hosts \
some other contract, and a wrong address would rebind the lane store and \
drop its seller state — fix blockchain.payment_pool_address",
deployment.payment_pool,
)
})
},
)
.await?;
Ok(())
}
const RPC_PREFLIGHT_MAX_ATTEMPTS: u32 = 5;
async fn check_rpc_reachability(rpc_url: &str) -> anyhow::Result<()> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.context("failed to build HTTP client for RPC check")?;
preflight_retry(&client, rpc_url, Duration::from_secs(1)).await
}
async fn preflight_retry(
client: &reqwest::Client,
rpc_url: &str,
initial_backoff: Duration,
) -> anyhow::Result<()> {
let mut backoff = initial_backoff;
for attempt in 1..=RPC_PREFLIGHT_MAX_ATTEMPTS {
match probe_rpc_classified(client, rpc_url).await {
RpcProbe::Healthy => {
tracing::info!("RPC endpoint reachable");
return Ok(());
}
RpcProbe::Fatal(msg) => anyhow::bail!("blockchain.rpc_url {msg}"),
RpcProbe::Transient(msg) if attempt == RPC_PREFLIGHT_MAX_ATTEMPTS => {
anyhow::bail!(
"blockchain.rpc_url {msg}; still failing after {RPC_PREFLIGHT_MAX_ATTEMPTS} attempts"
);
}
RpcProbe::Transient(msg) => {
tracing::warn!(
attempt,
backoff_secs = backoff.as_secs(),
"RPC preflight transient failure ({msg}); retrying after backoff"
);
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(Duration::from_secs(8));
}
}
}
anyhow::bail!("blockchain.rpc_url preflight exhausted retries")
}
async fn probe_rpc_classified(client: &reqwest::Client, rpc_url: &str) -> RpcProbe {
let body = serde_json::json!({
"jsonrpc": "2.0",
"method": "net_version",
"params": [],
"id": 1
});
let resp = match client.post(rpc_url).json(&body).send().await {
Ok(resp) => resp,
Err(err) => {
return RpcProbe::Transient(format!(
"is not reachable (timeout or connection error): {}",
sanitize_error_sources(&err)
));
}
};
let status = resp.status();
if status.is_success() {
RpcProbe::Healthy
} else if status.as_u16() == 429 || status.is_server_error() {
RpcProbe::Transient(format!(
"returned status {status} (rate-limited or server error)"
))
} else {
RpcProbe::Fatal(format!(
"returned unexpected status {status}; verify the endpoint is a valid JSON-RPC server"
))
}
}
pub(crate) async fn probe_rpc(client: &reqwest::Client, rpc_url: &str) -> anyhow::Result<()> {
match probe_rpc_classified(client, rpc_url).await {
RpcProbe::Healthy => Ok(()),
RpcProbe::Transient(msg) | RpcProbe::Fatal(msg) => {
anyhow::bail!("blockchain.rpc_url {msg}")
}
}
}
fn spawn_rpc_watchdog(
client: reqwest::Client,
rpc_url: String,
interval: Duration,
metrics: Arc<metrics::Metrics>,
mut shutdown: oneshot::Receiver<()>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut prev_healthy = true;
loop {
tokio::select! {
biased;
_ = &mut shutdown => {
tracing::debug!("RPC watchdog shutdown signal received");
return;
}
() = tokio::time::sleep(interval) => {}
}
let now_healthy = match probe_rpc(&client, &rpc_url).await {
Ok(()) => {
if !prev_healthy {
tracing::info!("RPC endpoint healthy");
}
true
}
Err(err) => {
if prev_healthy {
tracing::warn!(error = %sanitize_err_chain(&err), "RPC endpoint unhealthy");
}
false
}
};
metrics.rpc_healthy(now_healthy);
prev_healthy = now_healthy;
}
})
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests;