#![allow(rustdoc::private_intra_doc_links)]
use std::collections::HashMap;
use std::num::NonZeroU64;
use std::sync::Arc;
use std::time::Duration;
use alloy::primitives::{Address, B256};
use alloy::providers::Provider;
use alloy::rpc::types::{Filter, Log};
use alloy::transports::TransportError;
use anyhow::{Context, Result};
use async_trait::async_trait;
use decdn_client::provider::is_permanent_rpc_error;
use decdn_common::config::DEFAULT_GET_LOGS_MAX_BLOCK_SPAN;
use decdn_common::redact::sanitize_err_chain;
use tokio_util::sync::CancellationToken;
use tokio_util::task::AbortOnDropHandle;
use tracing::{debug, error, info, warn};
use crate::rpc_metrics::is_rate_limit;
use super::resumable_watcher::{CursorStart, LogSink, WatcherHandle, WatcherHook, fire};
use super::shared_head::HeadSource;
use super::{Shrunk, WindowSpan, timed, window_end};
use super::{WATCHER_INITIAL_BACKOFF, WATCHER_MAX_BACKOFF};
#[async_trait]
pub(crate) trait ErasedSink: Send {
async fn apply(&mut self, log: Log) -> Result<()>;
async fn on_tick_complete(&mut self) -> Result<()>;
fn on_recovered(&mut self);
}
#[async_trait]
impl<S: LogSink> ErasedSink for S {
async fn apply(&mut self, log: Log) -> Result<()> {
LogSink::apply(self, log).await
}
async fn on_tick_complete(&mut self) -> Result<()> {
LogSink::on_tick_complete(self).await
}
fn on_recovered(&mut self) {
LogSink::on_recovered(self);
}
}
pub(crate) enum SinkSource {
Ready(Box<dyn ErasedSink>),
Factory(SinkFactory),
}
pub(crate) type SinkFactory = Box<dyn FnOnce(&CancellationToken) -> Box<dyn ErasedSink> + Send>;
impl SinkSource {
fn resolved(self, shutdown: &CancellationToken) -> Self {
match self {
Self::Factory(make) => Self::Ready(make(shutdown)),
ready @ Self::Ready(_) => ready,
}
}
fn as_erased_mut(&mut self) -> Option<&mut (dyn ErasedSink + 'static)> {
match self {
Self::Ready(sink) => Some(sink.as_mut()),
Self::Factory(_) => None,
}
}
}
pub struct Route {
pub(crate) addresses: Vec<Address>,
pub(crate) topic0s: Vec<B256>,
pub(crate) start: CursorStart,
pub(crate) sink: SinkSource,
pub(crate) label: &'static str,
pub(crate) on_established: Option<WatcherHook>,
pub(crate) on_backoff: Option<WatcherHook>,
pub(crate) on_tick_success: Option<WatcherHook>,
pub(crate) on_task_panic: Option<WatcherHook>,
}
impl std::fmt::Debug for Route {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Route")
.field("label", &self.label)
.field("addresses", &self.addresses)
.field("topic0s", &self.topic0s)
.finish_non_exhaustive()
}
}
struct RouteState {
start: CursorStart,
sink: SinkSource,
label: &'static str,
on_established: Option<WatcherHook>,
on_backoff: Option<WatcherHook>,
on_tick_success: Option<WatcherHook>,
cursor: Option<u64>,
tick_floor: u64,
errored: bool,
established: bool,
recovering: bool,
}
pub struct MultiplexedPoller {
head: Arc<dyn HeadSource>,
routes: Vec<RouteState>,
key_index: HashMap<(Address, B256), usize>,
base_filter: Filter,
from_block: u64,
poll_interval: Duration,
span: WindowSpan,
on_span_change: Option<SpanHook>,
on_range_rejection: Option<WatcherHook>,
on_window_retry: Option<WatcherHook>,
on_window_deferred: Option<WatcherHook>,
stalled_since: Option<tokio::time::Instant>,
initial_backoff: Duration,
max_backoff: Duration,
rpc_call_timeout: Option<Duration>,
panic_hooks: Vec<(&'static str, Option<WatcherHook>)>,
}
impl std::fmt::Debug for MultiplexedPoller {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MultiplexedPoller")
.field("routes", &self.routes.len())
.field("poll_interval", &self.poll_interval)
.finish_non_exhaustive()
}
}
pub(crate) type SpanHook = Box<dyn Fn(u64) + Send + Sync>;
pub struct MultiplexedPollerBuilder {
head: Arc<dyn HeadSource>,
routes: Vec<Route>,
from_block: u64,
poll_interval: Duration,
max_backfill_span: u64,
on_span_change: Option<SpanHook>,
on_range_rejection: Option<WatcherHook>,
on_window_retry: Option<WatcherHook>,
on_window_deferred: Option<WatcherHook>,
initial_backoff: Duration,
max_backoff: Duration,
rpc_call_timeout: Option<Duration>,
}
impl std::fmt::Debug for MultiplexedPollerBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MultiplexedPollerBuilder")
.field("routes", &self.routes.len())
.field("poll_interval", &self.poll_interval)
.finish_non_exhaustive()
}
}
impl MultiplexedPollerBuilder {
#[must_use]
pub fn new(head: Arc<dyn HeadSource>, poll_interval: Duration) -> Self {
Self {
head,
routes: Vec::new(),
from_block: 0,
poll_interval,
max_backfill_span: DEFAULT_GET_LOGS_MAX_BLOCK_SPAN,
on_span_change: None,
on_range_rejection: None,
on_window_retry: None,
on_window_deferred: None,
initial_backoff: WATCHER_INITIAL_BACKOFF,
max_backoff: WATCHER_MAX_BACKOFF,
rpc_call_timeout: None,
}
}
#[must_use]
pub fn route(mut self, route: Route) -> Self {
self.routes.push(route);
self
}
#[must_use]
pub const fn max_backfill_span(mut self, span: u64) -> Self {
self.max_backfill_span = span;
self
}
#[cfg(test)]
pub(crate) const fn span_ceiling(&self) -> u64 {
self.max_backfill_span
}
#[must_use]
pub(crate) fn on_span_change(mut self, hook: SpanHook) -> Self {
self.on_span_change = Some(hook);
self
}
#[must_use]
pub(crate) fn on_range_rejection(mut self, hook: WatcherHook) -> Self {
self.on_range_rejection = Some(hook);
self
}
#[must_use]
pub(crate) fn on_window_retry(mut self, hook: WatcherHook) -> Self {
self.on_window_retry = Some(hook);
self
}
#[must_use]
pub(crate) fn on_window_deferred(mut self, hook: WatcherHook) -> Self {
self.on_window_deferred = Some(hook);
self
}
pub fn build(self) -> Result<MultiplexedPoller> {
let span_ceiling = NonZeroU64::new(self.max_backfill_span)
.context("multiplexed poller max_backfill_span must be at least 1 block")?;
let mut key_index = HashMap::new();
let mut all_addresses = Vec::new();
let mut all_topic0s = Vec::new();
for (idx, route) in self.routes.iter().enumerate() {
for &address in &route.addresses {
for &topic0 in &route.topic0s {
if let Some(&existing) = key_index.get(&(address, topic0)) {
anyhow::bail!(
"duplicate multiplexed poller route key (address={address}, \
topic0={topic0}): routes {existing} and {idx} ({} and {}) both \
claim it",
self.routes.get(existing).map_or("?", |r: &Route| r.label),
route.label,
);
}
key_index.insert((address, topic0), idx);
}
}
all_addresses.extend(route.addresses.iter().copied());
all_topic0s.extend(route.topic0s.iter().copied());
}
all_addresses.sort_unstable();
all_addresses.dedup();
all_topic0s.sort_unstable();
all_topic0s.dedup();
let base_filter = Filter::new()
.address(all_addresses)
.event_signature(all_topic0s);
let mut routes = Vec::with_capacity(self.routes.len());
let mut panic_hooks = Vec::with_capacity(self.routes.len());
for route in self.routes {
panic_hooks.push((route.label, route.on_task_panic));
routes.push(RouteState {
start: route.start,
sink: route.sink,
label: route.label,
on_established: route.on_established,
on_backoff: route.on_backoff,
on_tick_success: route.on_tick_success,
cursor: None,
tick_floor: 0,
errored: false,
established: false,
recovering: false,
});
}
Ok(MultiplexedPoller {
head: self.head,
routes,
key_index,
base_filter,
from_block: self.from_block,
poll_interval: self.poll_interval,
span: WindowSpan::new(span_ceiling),
on_span_change: self.on_span_change,
on_range_rejection: self.on_range_rejection,
on_window_retry: self.on_window_retry,
on_window_deferred: self.on_window_deferred,
stalled_since: None,
initial_backoff: self.initial_backoff,
max_backoff: self.max_backoff,
rpc_call_timeout: self.rpc_call_timeout,
panic_hooks,
})
}
}
fn fail_whole_tick(
poller: &mut MultiplexedPoller,
shutdown: &CancellationToken,
err: anyhow::Error,
) -> anyhow::Error {
if !shutdown.is_cancelled() {
for r in &poller.routes {
fire(r.on_backoff.as_ref());
}
}
for r in &mut poller.routes {
r.established = false;
r.recovering = true;
}
err
}
fn resolve_route_floors(poller: &mut MultiplexedPoller, to: u64) -> Result<u64> {
for r in &mut poller.routes {
if r.cursor.is_none() {
let resolved = match r.start.seed() {
Some(seed) => seed,
None => r.start.initial_from(poller.from_block, to)?,
};
r.cursor = Some(resolved);
}
r.tick_floor = r.cursor.unwrap_or(poller.from_block);
r.errored = false;
}
Ok(poller
.routes
.iter()
.filter_map(|r| r.cursor)
.min()
.unwrap_or(to))
}
async fn demux_window_logs(poller: &mut MultiplexedPoller, logs: Vec<Log>) {
for log in logs {
let Some(t0) = log.topic0().copied() else {
continue;
};
let Some(&idx) = poller.key_index.get(&(log.address(), t0)) else {
continue; };
let Some(route) = poller.routes.get_mut(idx) else {
continue;
};
if route.errored {
continue; }
if log.block_number.is_some_and(|b| b < route.tick_floor) {
continue;
}
let applied = if let Some(sink) = route.sink.as_erased_mut() {
sink.apply(log).await
} else {
debug_assert!(
false,
"route {} has an unresolved SinkSource::Factory at demux time; \
spawn() must resolve every factory before run()",
route.label
);
continue;
};
if let Err(err) = applied {
route.errored = true;
warn!(
label = route.label,
error = %sanitize_err_chain(&err),
"route sink error; isolating and re-scanning this route"
);
}
}
}
fn advance_routes(poller: &mut MultiplexedPoller, end: u64) {
for r in &mut poller.routes {
if !r.errored && r.tick_floor <= end {
r.cursor = Some(end.saturating_add(1));
r.start.persist(end);
}
}
}
async fn reconcile_routes(poller: &mut MultiplexedPoller) {
for r in &mut poller.routes {
if r.errored {
continue;
}
let reconciled = if let Some(sink) = r.sink.as_erased_mut() {
sink.on_tick_complete().await
} else {
debug_assert!(
false,
"route {} has an unresolved SinkSource::Factory at reconcile time; \
spawn() must resolve every factory before run()",
r.label
);
continue;
};
if let Err(err) = reconciled {
r.errored = true;
warn!(label = r.label, error = %sanitize_err_chain(&err), "route reconcile error");
}
}
}
fn notify_recovered_routes(poller: &mut MultiplexedPoller, shutdown: &CancellationToken) {
if shutdown.is_cancelled() {
return;
}
for r in &mut poller.routes {
if r.errored || !r.recovering {
continue;
}
let Some(sink) = r.sink.as_erased_mut() else {
debug_assert!(
false,
"route {} has an unresolved SinkSource::Factory at recovery time; \
spawn() must resolve every factory before run()",
r.label
);
continue;
};
r.recovering = false;
sink.on_recovered();
}
}
fn fire_route_hooks(poller: &mut MultiplexedPoller, shutdown: &CancellationToken) -> bool {
let any_errored = poller.routes.iter().any(|r| r.errored);
for r in &mut poller.routes {
if r.errored {
if !shutdown.is_cancelled() {
fire(r.on_backoff.as_ref());
}
r.established = false;
r.recovering = true;
} else {
fire(r.on_tick_success.as_ref());
if !r.established && !shutdown.is_cancelled() {
r.established = true;
fire(r.on_established.as_ref());
}
}
}
any_errored
}
fn is_range_rejection(err: &anyhow::Error) -> bool {
err.chain()
.find_map(|cause| cause.downcast_ref::<TransportError>())
.and_then(TransportError::as_error_resp)
.is_some_and(|resp| {
let message = resp.message.to_ascii_lowercase();
(message.contains("range") || message.contains("results"))
&& !message.contains("out of range")
&& LIMIT_WORDS.iter().any(|word| message.contains(word))
})
}
const LIMIT_WORDS: [&str; 10] = [
"limit",
"max",
"exceed",
"up to",
" over ",
"more than",
"too many",
"too wide",
"too large",
"not supported",
];
const GET_LOGS_WINDOW_RETRIES: u32 = 2;
const GET_LOGS_WINDOW_RETRY_DELAY: Duration = Duration::from_secs(2);
const GET_LOGS_STALL_BUDGET: Duration = Duration::from_mins(2);
fn is_transient_window_error(err: &anyhow::Error) -> bool {
!is_range_rejection(err)
&& err
.chain()
.find_map(|cause| cause.downcast_ref::<TransportError>())
.is_none_or(|cause| !is_permanent_rpc_error(cause) && !is_rate_limit(cause))
}
fn shrink_on_range_rejection(
poller: &mut MultiplexedPoller,
start: u64,
end: u64,
err: anyhow::Error,
) -> Option<anyhow::Error> {
if !is_range_rejection(&err) {
return Some(err);
}
fire(poller.on_range_rejection.as_ref());
let Some(Shrunk {
span,
rejected_probe,
}) = poller.span.shrink(start, end)
else {
return Some(err.context(
"the RPC provider rejects even a one-block eth_getLogs window; \
it cannot serve the chain watchers — use another provider",
));
};
let rejected_span = (end - start).saturating_add(1);
if rejected_probe {
debug!(
error = %sanitize_err_chain(&err),
rejected_span,
span,
"RPC provider rejected a regrown eth_getLogs window; shrinking again"
);
} else {
warn!(
error = %sanitize_err_chain(&err),
rejected_span,
span,
"RPC provider rejected the eth_getLogs block range; \
shrinking the poll window (set blockchain.get_logs_max_block_span \
to the lowest span logged while rejections keep coming)"
);
}
report_span(poller, span);
None
}
impl MultiplexedPoller {
#[cfg(test)]
pub(crate) fn fire_range_rejection_for_test(&self) {
fire(self.on_range_rejection.as_ref());
}
#[cfg(test)]
pub(crate) fn fire_window_retry_for_test(&self) {
fire(self.on_window_retry.as_ref());
}
#[cfg(test)]
pub(crate) fn fire_window_deferred_for_test(&self) {
fire(self.on_window_deferred.as_ref());
}
}
fn report_span(poller: &MultiplexedPoller, span: u64) {
if let Some(hook) = poller.on_span_change.as_ref() {
hook(span);
}
}
enum WindowFetch {
Logs(Vec<Log>),
Exhausted(anyhow::Error),
Failed(anyhow::Error),
}
async fn fetch_window_logs<P: Provider + Clone>(
provider: &P,
base_filter: &Filter,
rpc_call_timeout: Option<Duration>,
on_window_retry: Option<&WatcherHook>,
start: u64,
end: u64,
shutdown: &CancellationToken,
) -> Option<WindowFetch> {
let filter = base_filter.clone().from_block(start).to_block(end);
let mut retries = 0;
loop {
let err = match timed(rpc_call_timeout, "get_logs", provider.get_logs(&filter))
.await
.with_context(|| format!("multiplexed get_logs [{start}, {end}]"))
{
Ok(logs) => return Some(WindowFetch::Logs(logs)),
Err(err) => err,
};
if !is_transient_window_error(&err) {
return Some(WindowFetch::Failed(err));
}
if retries == GET_LOGS_WINDOW_RETRIES {
return Some(WindowFetch::Exhausted(err.context(format!(
"gave up after {GET_LOGS_WINDOW_RETRIES} in-tick retries"
))));
}
retries += 1;
info!(
error = %sanitize_err_chain(&err),
start,
end,
retry = retries,
"eth_getLogs window failed; retrying the same window"
);
tokio::select! {
biased;
() = shutdown.cancelled() => return None,
() = tokio::time::sleep(GET_LOGS_WINDOW_RETRY_DELAY) => {}
}
fire(on_window_retry);
}
}
async fn run_tick<P: Provider + Clone>(
provider: &P,
poller: &mut MultiplexedPoller,
shutdown: &CancellationToken,
) -> Result<()> {
let to = match poller.head.head().await.context("read head block") {
Ok(to) => to,
Err(err) => return Err(fail_whole_tick(poller, shutdown, err)),
};
let from = match resolve_route_floors(poller, to) {
Ok(from) => from,
Err(err) => return Err(fail_whole_tick(poller, shutdown, err)),
};
let mut start = from;
let mut progressed = false;
let mut deferred: Option<anyhow::Error> = None;
while start <= to {
if shutdown.is_cancelled() {
return Ok(());
}
let end = window_end(start, to, poller.span.current());
let fetched = fetch_window_logs(
provider,
&poller.base_filter,
poller.rpc_call_timeout,
poller.on_window_retry.as_ref(),
start,
end,
shutdown,
)
.await;
let Some(fetched) = fetched else {
return Ok(());
};
let logs = match fetched {
WindowFetch::Logs(logs) => logs,
WindowFetch::Exhausted(err) => {
fire(poller.on_window_deferred.as_ref());
info!(
error = %sanitize_err_chain(&err),
start,
end,
"eth_getLogs window failed on every retry; deferring it to the next tick"
);
deferred = Some(err);
break;
}
WindowFetch::Failed(err) => match shrink_on_range_rejection(poller, start, end, err) {
None => continue,
Some(err) => return Err(fail_whole_tick(poller, shutdown, err)),
},
};
demux_window_logs(poller, logs).await;
advance_routes(poller, end);
progressed = true;
if let Some(span) = poller.span.record_success() {
debug!(
span,
"eth_getLogs windows accepted; growing the poll window"
);
report_span(poller, span);
}
if end >= to {
break;
}
start = end + 1;
}
if let Some(err) = deferred {
let since = *poller
.stalled_since
.get_or_insert_with(tokio::time::Instant::now);
if since.elapsed() >= GET_LOGS_STALL_BUDGET {
let err = err.context(format!(
"no poll tick reached head for {} s",
GET_LOGS_STALL_BUDGET.as_secs()
));
return Err(fail_whole_tick(poller, shutdown, err));
}
if !progressed {
return Ok(());
}
} else {
poller.stalled_since = None;
}
notify_recovered_routes(poller, shutdown);
reconcile_routes(poller).await;
if fire_route_hooks(poller, shutdown) {
anyhow::bail!("one or more routes errored this tick"); }
Ok(())
}
struct PanicGuard<'a> {
hooks: &'a [(&'static str, Option<WatcherHook>)],
}
impl Drop for PanicGuard<'_> {
fn drop(&mut self) {
if std::thread::panicking() {
for (label, hook) in self.hooks {
let hook_ref = hook.as_ref();
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fire(hook_ref)));
error!(
label = *label,
"multiplexed poller task PANICKED; nothing awaits this task, so this is its \
only trace for this route"
);
}
}
}
}
pub(crate) async fn run<P>(provider: P, mut poller: MultiplexedPoller, shutdown: CancellationToken)
where
P: Provider + Clone,
{
let panic_hooks = std::mem::take(&mut poller.panic_hooks);
let _panic_guard = PanicGuard {
hooks: &panic_hooks,
};
report_span(&poller, poller.span.current());
let mut backoff = poller.initial_backoff;
let mut ticker = tokio::time::interval(poller.poll_interval);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
biased;
() = shutdown.cancelled() => {
for r in &poller.routes {
r.start.flush();
}
return;
}
_ = ticker.tick() => {}
}
match run_tick(&provider, &mut poller, &shutdown).await {
Ok(()) => {
backoff = poller.initial_backoff;
}
Err(err) => {
warn!(
error = %sanitize_err_chain(&err),
backoff_secs = backoff.as_secs(),
"multiplexed poller tick error; restarting after backoff"
);
tokio::select! {
biased;
() = shutdown.cancelled() => {
for r in &poller.routes {
r.start.flush();
}
return;
}
() = tokio::time::sleep(backoff) => {}
}
backoff = (backoff * 2).min(poller.max_backoff);
}
}
}
}
pub fn spawn<P>(provider: P, mut poller: MultiplexedPoller) -> WatcherHandle
where
P: Provider + Clone + 'static,
{
let shutdown = CancellationToken::new();
let routes = std::mem::take(&mut poller.routes);
poller.routes = routes
.into_iter()
.map(|rs| RouteState {
sink: rs.sink.resolved(&shutdown),
..rs
})
.collect();
let task = AbortOnDropHandle::new(tokio::spawn(run(provider, poller, shutdown.clone())));
WatcherHandle::new(shutdown, task)
}
#[cfg(test)]
mod tests;