use super::*;
use crate::chain_events::resumable_watcher::{Checkpoint, ColdStart};
use crate::chain_events::shared_head::SharedHead;
use alloy::primitives::{Bytes, U64, address, b256};
use alloy::providers::ProviderBuilder;
use decdn_incentive::{CheckpointKey, KeyedCheckpointStore, StoreError};
use std::sync::atomic::{AtomicUsize, Ordering};
fn log_at(addr: Address, topic0: B256, block: u64) -> Log {
Log {
inner: alloy::primitives::Log::new_unchecked(addr, vec![topic0], Bytes::new()),
block_number: Some(block),
..Default::default()
}
}
#[derive(Default)]
struct ScriptedSink {
applied: Vec<(Address, B256, Option<u64>)>,
fail_apply_on: Option<usize>,
fail_tick_complete: bool,
tick_completes: usize,
recovered_at: Vec<usize>,
}
impl LogSink for ScriptedSink {
async fn apply(&mut self, log: Log) -> Result<()> {
if self.fail_apply_on == Some(self.applied.len()) {
anyhow::bail!("scripted sink failure");
}
let topic0 = log.topic0().copied().unwrap_or_default();
self.applied.push((log.address(), topic0, log.block_number));
Ok(())
}
async fn on_tick_complete(&mut self) -> Result<()> {
self.tick_completes += 1;
if self.fail_tick_complete {
anyhow::bail!("scripted reconcile failure");
}
Ok(())
}
fn on_recovered(&mut self) {
self.recovered_at.push(self.tick_completes);
}
}
fn no_shutdown() -> CancellationToken {
CancellationToken::new()
}
fn assert_built(built: Result<MultiplexedPoller>) -> Result<MultiplexedPoller> {
assert!(
built.is_ok(),
"build must succeed: {}",
built
.as_ref()
.err()
.map(|e| format!("{e:#}"))
.unwrap_or_default()
);
built
}
const ADDR_A: Address = address!("0x1111111111111111111111111111111111111111");
const ADDR_B: Address = address!("0x2222222222222222222222222222222222222222");
const TOPIC_A: B256 = b256!("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
const TOPIC_B: B256 = b256!("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
fn head_route(
label: &'static str,
addr: Address,
topic0: B256,
) -> (Route, Arc<std::sync::Mutex<ScriptedSink>>) {
seeded_route(
label,
addr,
topic0,
CursorStart::HeadMinusWindow { window_blocks: 0 },
)
}
struct MutexSink(Arc<std::sync::Mutex<ScriptedSink>>);
impl LogSink for MutexSink {
async fn apply(&mut self, log: Log) -> Result<()> {
let mut guard = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let fail = guard.fail_apply_on == Some(guard.applied.len());
if fail {
anyhow::bail!("scripted sink failure");
}
let topic0 = log.topic0().copied().unwrap_or_default();
guard
.applied
.push((log.address(), topic0, log.block_number));
Ok(())
}
async fn on_tick_complete(&mut self) -> Result<()> {
let mut guard = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard.tick_completes += 1;
if guard.fail_tick_complete {
anyhow::bail!("scripted reconcile failure");
}
Ok(())
}
fn on_recovered(&mut self) {
let mut guard = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let seen = guard.tick_completes;
guard.recovered_at.push(seen);
}
}
fn seeded_route(
label: &'static str,
addr: Address,
topic0: B256,
start: CursorStart,
) -> (Route, Arc<std::sync::Mutex<ScriptedSink>>) {
let recorded = Arc::new(std::sync::Mutex::new(ScriptedSink::default()));
let route = Route {
addresses: vec![addr],
topic0s: vec![topic0],
start,
sink: SinkSource::Ready(Box::new(MutexSink(Arc::clone(&recorded)))),
label,
on_established: None,
on_backoff: None,
on_tick_success: None,
on_task_panic: None,
};
(route, recorded)
}
#[tokio::test]
async fn erased_sink_forwards_apply_and_tick_complete() {
let mut sink: Box<dyn ErasedSink> = Box::new(ScriptedSink::default());
let log = log_at(ADDR_A, TOPIC_A, 1);
assert!(sink.apply(log).await.is_ok());
assert!(sink.on_tick_complete().await.is_ok());
let recorded = Arc::new(std::sync::Mutex::new(ScriptedSink::default()));
let mut erased: Box<dyn ErasedSink> = Box::new(MutexSink(Arc::clone(&recorded)));
let applied = erased.apply(log_at(ADDR_A, TOPIC_A, 1)).await;
assert!(
applied.is_ok(),
"apply must forward through the blanket impl: {applied:?}"
);
let completed = erased.on_tick_complete().await;
assert!(
completed.is_ok(),
"on_tick_complete must forward through the blanket impl: {completed:?}"
);
let guard = recorded
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(
guard.applied.len(),
1,
"apply must forward through the blanket impl"
);
assert_eq!(
guard.tick_completes, 1,
"on_tick_complete must forward through the blanket impl"
);
drop(guard);
erased.on_recovered();
let guard = recorded
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(
guard.recovered_at.len(),
1,
"on_recovered must forward through the blanket impl"
);
}
#[test]
fn duplicate_route_key_is_a_build_error() {
let head: Arc<dyn HeadSource> = Arc::new(StaticHead(100));
let (route_a, _) = head_route("a", ADDR_A, TOPIC_A);
let (route_b, _) = head_route("b", ADDR_A, TOPIC_A);
let built = MultiplexedPollerBuilder::new(head, Duration::from_secs(1))
.route(route_a)
.route(route_b)
.build();
assert!(
built.is_err(),
"two routes claiming the same (address, topic0) must fail to build"
);
}
#[test]
fn distinct_route_keys_build_successfully() {
let head: Arc<dyn HeadSource> = Arc::new(StaticHead(100));
let (route_a, _) = head_route("a", ADDR_A, TOPIC_A);
let (route_b, _) = head_route("b", ADDR_A, TOPIC_B);
let built = MultiplexedPollerBuilder::new(head, Duration::from_secs(1))
.route(route_a)
.route(route_b)
.build();
assert!(
built.is_ok(),
"distinct (address, topic0) keys must build: {}",
built.err().map(|e| format!("{e:#}")).unwrap_or_default()
);
}
struct StaticHead(u64);
#[async_trait]
impl HeadSource for StaticHead {
async fn head(&self) -> Result<u64> {
Ok(self.0)
}
}
fn shared_head<P: Provider + 'static>(provider: P) -> Arc<dyn HeadSource> {
Arc::new(SharedHead::with_ttl(provider, Duration::ZERO, None))
}
#[tokio::test]
async fn demux_routes_logs_by_address_and_topic0() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let (route_a, sink_a) = head_route("a", ADDR_A, TOPIC_A);
let (route_b, sink_b) = head_route("b", ADDR_A, TOPIC_B);
let built =
MultiplexedPollerBuilder::new(shared_head(provider.clone()), Duration::from_secs(1))
.max_backfill_span(10)
.route(route_a)
.route(route_b)
.build();
let Ok(mut poller) = assert_built(built) else {
return;
};
asserter.push_success(&U64::from(1));
asserter.push_success(&vec![
log_at(ADDR_A, TOPIC_A, 1),
log_at(ADDR_A, TOPIC_B, 1),
]);
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(result.is_ok(), "tick must succeed: {result:?}");
let a = sink_a
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let b = sink_b
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(
a.applied,
vec![(ADDR_A, TOPIC_A, Some(1))],
"route A gets only topic A"
);
assert_eq!(
b.applied,
vec![(ADDR_A, TOPIC_B, Some(1))],
"route B gets only topic B"
);
}
#[tokio::test]
async fn route_below_its_floor_ignores_old_logs() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let (route_a, sink_a) = head_route("a", ADDR_A, TOPIC_A);
let (route_b, sink_b) = seeded_route("b", ADDR_B, TOPIC_B, CursorStart::Seeded { at: 0 });
let built =
MultiplexedPollerBuilder::new(shared_head(provider.clone()), Duration::from_secs(1))
.max_backfill_span(10)
.route(route_a)
.route(route_b)
.build();
let Ok(mut poller) = assert_built(built) else {
return;
};
asserter.push_success(&U64::from(5));
asserter.push_success(&vec![log_at(ADDR_A, TOPIC_A, 2)]);
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(result.is_ok(), "tick must succeed: {result:?}");
let a = sink_a
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let b = sink_b
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert!(
a.applied.is_empty(),
"route A must gate out a log below its own floor"
);
assert!(
b.applied.is_empty(),
"route B saw no matching log this tick"
);
drop(a);
drop(b);
assert_eq!(
poller.routes.first().and_then(|r| r.cursor),
Some(6),
"route A's cursor still advances past the window despite gating the log"
);
}
#[tokio::test]
async fn route_error_isolates_and_siblings_advance() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let (route_a, sink_a) = head_route("a", ADDR_A, TOPIC_A);
let (route_b, sink_b) = head_route("b", ADDR_B, TOPIC_B);
sink_a
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.fail_apply_on = Some(0);
let built =
MultiplexedPollerBuilder::new(shared_head(provider.clone()), Duration::from_secs(1))
.max_backfill_span(10)
.route(route_a)
.route(route_b)
.build();
let Ok(mut poller) = assert_built(built) else {
return;
};
asserter.push_success(&U64::from(1));
asserter.push_success(&vec![
log_at(ADDR_A, TOPIC_A, 1),
log_at(ADDR_B, TOPIC_B, 1),
]);
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(
result.is_err(),
"a route error must fail the tick (drives loop backoff)"
);
assert_eq!(
poller.routes.first().and_then(|r| r.cursor),
Some(1),
"route A holds its cursor at its floor after erroring"
);
assert_eq!(
poller.routes.get(1).and_then(|r| r.cursor),
Some(2),
"route B advances past the window despite A's error"
);
assert!(
sink_a
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.applied
.is_empty(),
"route A's failed apply is not recorded"
);
assert_eq!(
sink_b
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.applied
.len(),
1,
"route B's log applied despite A's sibling error"
);
sink_a
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.fail_apply_on = None;
asserter.push_success(&U64::from(1));
asserter.push_success(&vec![
log_at(ADDR_A, TOPIC_A, 1),
log_at(ADDR_B, TOPIC_B, 1),
]);
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(result.is_ok(), "retry tick should complete: {result:?}");
assert_eq!(
sink_a
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.applied
.len(),
1,
"route A re-applies the window's log on retry"
);
assert_eq!(
sink_b
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.applied
.len(),
1,
"route B does not re-apply — its floor already covers this range"
);
}
#[tokio::test]
async fn a_whole_tick_failure_arms_the_recovery_edge_for_every_route() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let (route_a, sink_a) = head_route("a", ADDR_A, TOPIC_A);
let (route_b, sink_b) = head_route("b", ADDR_B, TOPIC_B);
let built =
MultiplexedPollerBuilder::new(shared_head(provider.clone()), Duration::from_secs(1))
.route(route_a)
.route(route_b)
.build();
let Ok(mut poller) = assert_built(built) else {
return;
};
asserter.push_failure_msg("head is down");
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(result.is_err(), "a head-read failure must fail the tick");
asserter.push_success(&U64::from(1));
asserter.push_success(&Vec::<Log>::new());
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(result.is_ok(), "recovery tick should complete: {result:?}");
for (label, sink) in [("a", &sink_a), ("b", &sink_b)] {
let guard = sink
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(
guard.recovered_at.len(),
1,
"route {label} must be told it recovered from a whole-tick failure, \
or a cadence-gated sink waits out its full interval after the \
outage that made it stale"
);
assert_eq!(
guard.recovered_at.first().copied(),
Some(0),
"route {label}'s recovery must land before that tick's reconcile"
);
}
}
#[tokio::test]
async fn a_recovered_route_is_notified_before_its_reconcile() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let (route_a, sink_a) = head_route("a", ADDR_A, TOPIC_A);
let (route_b, sink_b) = head_route("b", ADDR_B, TOPIC_B);
sink_a
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.fail_apply_on = Some(0);
let built =
MultiplexedPollerBuilder::new(shared_head(provider.clone()), Duration::from_secs(1))
.max_backfill_span(10)
.route(route_a)
.route(route_b)
.build();
let Ok(mut poller) = assert_built(built) else {
return;
};
asserter.push_success(&U64::from(1));
asserter.push_success(&vec![
log_at(ADDR_A, TOPIC_A, 1),
log_at(ADDR_B, TOPIC_B, 1),
]);
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(result.is_err(), "a route error must fail the tick");
assert!(
sink_a
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.recovered_at
.is_empty(),
"an errored route has not recovered yet"
);
sink_a
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.fail_apply_on = None;
asserter.push_success(&U64::from(1));
asserter.push_success(&vec![
log_at(ADDR_A, TOPIC_A, 1),
log_at(ADDR_B, TOPIC_B, 1),
]);
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(result.is_ok(), "retry tick should complete: {result:?}");
{
let guard_a = sink_a
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(
guard_a.recovered_at.len(),
1,
"the recovery edge fires exactly once"
);
assert_eq!(guard_a.tick_completes, 1);
assert_eq!(
guard_a.recovered_at.first().copied(),
Some(0),
"on_recovered must run before this tick's reconcile, so the sink can \
force it to re-read now rather than a cadence later"
);
}
assert!(
sink_b
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.recovered_at
.is_empty(),
"a route that never errored must not be told it recovered"
);
asserter.push_success(&U64::from(1));
asserter.push_success(&Vec::<Log>::new());
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(result.is_ok(), "third tick should complete: {result:?}");
assert_eq!(
sink_a
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.recovered_at
.len(),
1,
"the edge is consumed, not re-fired on every healthy tick"
);
}
struct CountingRpc {
head_hits: Arc<AtomicUsize>,
getlogs_hits: Arc<AtomicUsize>,
}
impl wiremock::Respond for CountingRpc {
fn respond(&self, req: &wiremock::Request) -> wiremock::ResponseTemplate {
let body: serde_json::Value =
serde_json::from_slice(&req.body).unwrap_or(serde_json::Value::Null);
let id = body.get("id").cloned().unwrap_or(serde_json::json!(0));
let result = match body.get("method").and_then(serde_json::Value::as_str) {
Some("eth_blockNumber") => {
let n = self.head_hits.fetch_add(1, Ordering::SeqCst);
serde_json::json!(format!("0x{:x}", 100 + n))
}
Some("eth_getLogs") => {
self.getlogs_hits.fetch_add(1, Ordering::SeqCst);
serde_json::json!([])
}
_ => serde_json::json!("0x1"),
};
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"jsonrpc": "2.0", "id": id, "result": result,
}))
}
}
#[tokio::test(flavor = "multi_thread")]
async fn n_routes_share_one_getlogs_per_tick() {
const ROUTES: usize = 4;
const INTERVAL: Duration = Duration::from_millis(100);
const RUN_FOR: Duration = Duration::from_millis(350);
let head_hits = Arc::new(AtomicUsize::new(0));
let getlogs_hits = Arc::new(AtomicUsize::new(0));
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.respond_with(CountingRpc {
head_hits: Arc::clone(&head_hits),
getlogs_hits: Arc::clone(&getlogs_hits),
})
.mount(&server)
.await;
let parsed_url = server.uri().parse();
assert!(
parsed_url.is_ok(),
"mock server uri must parse: {}",
parsed_url
.as_ref()
.err()
.map(ToString::to_string)
.unwrap_or_default()
);
let Ok(url) = parsed_url else { return };
decdn_common::tls::install_crypto_provider();
let provider = ProviderBuilder::new().connect_http(url);
let head: Arc<dyn HeadSource> = Arc::new(SharedHead::new(provider.clone(), INTERVAL));
let mut builder = MultiplexedPollerBuilder::new(head, INTERVAL).max_backfill_span(10_000);
for (i, (addr, topic0)) in [
(ADDR_A, TOPIC_A),
(ADDR_A, TOPIC_B),
(ADDR_B, TOPIC_A),
(ADDR_B, TOPIC_B),
]
.into_iter()
.enumerate()
{
let (route, _) = head_route(
Box::leak(format!("route-{i}").into_boxed_str()),
addr,
topic0,
);
builder = builder.route(route);
}
let Ok(poller) = assert_built(builder.build()) else {
return;
};
let shutdown = CancellationToken::new();
let handle = tokio::spawn(run(provider, poller, shutdown.clone()));
tokio::time::sleep(RUN_FOR).await;
shutdown.cancel();
let _ = handle.await;
let getlogs = getlogs_hits.load(Ordering::SeqCst);
let heads = head_hits.load(Ordering::SeqCst);
let per_ms = |d: Duration| d.as_millis().max(1);
let ticks = (per_ms(RUN_FOR) / per_ms(INTERVAL)) as usize + 2;
assert!(
getlogs <= ticks + 2,
"merged polling must track ~1 get_logs per tick, not ROUTES per tick \
(getlogs={getlogs}, ticks~={ticks}, heads={heads}, unmerged would approach {})",
ROUTES * ticks
);
assert!(
getlogs >= 1,
"the poller must have issued at least one get_logs"
);
}
#[tokio::test]
async fn idle_tick_reconciles_all_routes() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let (route_a, sink_a) = seeded_route("a", ADDR_A, TOPIC_A, CursorStart::Seeded { at: 100 });
let (route_b, sink_b) = seeded_route("b", ADDR_B, TOPIC_B, CursorStart::Seeded { at: 100 });
let built =
MultiplexedPollerBuilder::new(shared_head(provider.clone()), Duration::from_secs(1))
.route(route_a)
.route(route_b)
.build();
let Ok(mut poller) = assert_built(built) else {
return;
};
asserter.push_success(&U64::from(5)); let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(result.is_ok(), "idle tick must succeed: {result:?}");
assert_eq!(
sink_a
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.tick_completes,
1,
"route A's on_tick_complete must fire on an idle tick"
);
assert_eq!(
sink_b
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.tick_completes,
1,
"route B's on_tick_complete must fire on an idle tick"
);
}
#[derive(Default)]
struct FlushCountingStore {
flushes: AtomicUsize,
}
impl KeyedCheckpointStore for FlushCountingStore {
fn load_checkpoint(&self, _key: CheckpointKey) -> std::result::Result<Option<u64>, StoreError> {
Ok(None)
}
fn record_checkpoint(
&self,
_key: CheckpointKey,
_block: u64,
) -> std::result::Result<(), StoreError> {
Ok(())
}
fn flush_checkpoint(&self, _key: CheckpointKey) -> std::result::Result<(), StoreError> {
self.flushes.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
#[tokio::test(start_paused = true)]
async fn cancelling_shutdown_flushes_every_persisting_route() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let store = Arc::new(FlushCountingStore::default());
let (persisting, _) = seeded_route(
"persisting",
ADDR_A,
TOPIC_A,
CursorStart::FromCheckpoint {
checkpoint: Checkpoint {
store: Arc::clone(&store) as Arc<dyn KeyedCheckpointStore>,
key: CheckpointKey::PoolOpened,
},
reorg_margin: 0,
cold_start: ColdStart::Head,
},
);
let (ephemeral, _) = head_route("ephemeral", ADDR_B, TOPIC_B); let built =
MultiplexedPollerBuilder::new(shared_head(provider.clone()), Duration::from_secs(1))
.route(persisting)
.route(ephemeral)
.build();
let Ok(poller) = assert_built(built) else {
return;
};
let shutdown = CancellationToken::new();
shutdown.cancel();
let task = tokio::spawn(run(provider, poller, shutdown));
assert!(task.await.is_ok(), "run must return, not hang or panic");
assert_eq!(
store.flushes.load(Ordering::SeqCst),
1,
"the persisting route must flush its checkpoint exactly once"
);
}
#[tokio::test]
async fn head_read_failure_fails_the_tick_and_backs_off_every_route() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let backoff_a = Arc::new(AtomicUsize::new(0));
let backoff_b = Arc::new(AtomicUsize::new(0));
let (mut route_a, _) = head_route("a", ADDR_A, TOPIC_A);
let (mut route_b, _) = head_route("b", ADDR_B, TOPIC_B);
{
let counter = Arc::clone(&backoff_a);
route_a.on_backoff = Some(Box::new(move || {
counter.fetch_add(1, Ordering::SeqCst);
}));
}
{
let counter = Arc::clone(&backoff_b);
route_b.on_backoff = Some(Box::new(move || {
counter.fetch_add(1, Ordering::SeqCst);
}));
}
let built =
MultiplexedPollerBuilder::new(shared_head(provider.clone()), Duration::from_secs(1))
.route(route_a)
.route(route_b)
.build();
let Ok(mut poller) = assert_built(built) else {
return;
};
asserter.push_failure_msg("head is down");
let err = run_tick(&provider, &mut poller, &no_shutdown())
.await
.err()
.map(|e| format!("{e:#}"));
assert!(
err.as_ref().is_some_and(|e| e.contains("read head block")),
"head failure must fail the tick with its context: {err:?}"
);
assert_eq!(
backoff_a.load(Ordering::SeqCst),
1,
"route A must fire on_backoff"
);
assert_eq!(
backoff_b.load(Ordering::SeqCst),
1,
"route B must fire on_backoff"
);
}
#[tokio::test]
#[allow(clippy::panic)] async fn run_task_panic_fires_every_routes_panic_hook() {
struct PanicOnApply;
impl LogSink for PanicOnApply {
async fn apply(&mut self, _log: Log) -> Result<()> {
panic!("intentional tick panic");
}
}
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let panicked_a = Arc::new(AtomicUsize::new(0));
let panicked_b = Arc::new(AtomicUsize::new(0));
let route_a = {
let counter = Arc::clone(&panicked_a);
Route {
addresses: vec![ADDR_A],
topic0s: vec![TOPIC_A],
start: CursorStart::HeadMinusWindow { window_blocks: 0 },
sink: SinkSource::Ready(Box::new(PanicOnApply)),
label: "a",
on_established: None,
on_backoff: None,
on_tick_success: None,
on_task_panic: Some(Box::new(move || {
counter.fetch_add(1, Ordering::SeqCst);
})),
}
};
let (route_b, _) = head_route("b", ADDR_B, TOPIC_B);
let route_b = Route {
on_task_panic: Some({
let counter = Arc::clone(&panicked_b);
Box::new(move || {
counter.fetch_add(1, Ordering::SeqCst);
})
}),
..route_b
};
let built =
MultiplexedPollerBuilder::new(shared_head(provider.clone()), Duration::from_secs(1))
.route(route_a)
.route(route_b)
.build();
let Ok(poller) = assert_built(built) else {
return;
};
asserter.push_success(&U64::from(1));
asserter.push_success(&vec![log_at(ADDR_A, TOPIC_A, 1)]);
let shutdown = CancellationToken::new();
let joined = tokio::spawn(run(provider, poller, shutdown)).await;
assert!(joined.is_err(), "the poller task must have panicked");
assert_eq!(
panicked_a.load(Ordering::SeqCst),
1,
"the panicking route's own on_task_panic must fire"
);
assert_eq!(
panicked_b.load(Ordering::SeqCst),
1,
"a sibling route's on_task_panic must ALSO fire — the whole task died"
);
}
#[tokio::test]
async fn established_edge_fires_on_first_healthy_tick_when_not_cancelled() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let established = Arc::new(AtomicUsize::new(0));
let tick_success = Arc::new(AtomicUsize::new(0));
let (mut route_a, _) = head_route("a", ADDR_A, TOPIC_A);
{
let counter = Arc::clone(&established);
route_a.on_established = Some(Box::new(move || {
counter.fetch_add(1, Ordering::SeqCst);
}));
}
{
let counter = Arc::clone(&tick_success);
route_a.on_tick_success = Some(Box::new(move || {
counter.fetch_add(1, Ordering::SeqCst);
}));
}
let built =
MultiplexedPollerBuilder::new(shared_head(provider.clone()), Duration::from_secs(1))
.max_backfill_span(10)
.route(route_a)
.build();
let Ok(mut poller) = assert_built(built) else {
return;
};
asserter.push_success(&U64::from(1));
asserter.push_success(&Vec::<Log>::new());
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(result.is_ok(), "tick must succeed: {result:?}");
assert_eq!(
established.load(Ordering::SeqCst),
1,
"on_established must fire once on an uncancelled first healthy tick"
);
assert_eq!(
tick_success.load(Ordering::SeqCst),
1,
"on_tick_success must fire on the same tick"
);
}
struct CancelInReconcile {
shutdown: CancellationToken,
bail: bool,
}
impl LogSink for CancelInReconcile {
async fn apply(&mut self, _log: Log) -> Result<()> {
Ok(())
}
async fn on_tick_complete(&mut self) -> Result<()> {
self.shutdown.cancel();
if self.bail {
anyhow::bail!("cancelled mid-tick reconcile");
}
Ok(())
}
}
#[tokio::test]
async fn shutdown_during_first_tick_suppresses_established_edge() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let established = Arc::new(AtomicUsize::new(0));
let tick_success = Arc::new(AtomicUsize::new(0));
let shutdown = CancellationToken::new(); let route_a = {
let counter = Arc::clone(&established);
let tick_counter = Arc::clone(&tick_success);
Route {
addresses: vec![ADDR_A],
topic0s: vec![TOPIC_A],
start: CursorStart::HeadMinusWindow { window_blocks: 0 },
sink: SinkSource::Ready(Box::new(CancelInReconcile {
shutdown: shutdown.clone(),
bail: false,
})),
label: "a",
on_established: Some(Box::new(move || {
counter.fetch_add(1, Ordering::SeqCst);
})),
on_backoff: None,
on_tick_success: Some(Box::new(move || {
tick_counter.fetch_add(1, Ordering::SeqCst);
})),
on_task_panic: None,
}
};
let built =
MultiplexedPollerBuilder::new(shared_head(provider.clone()), Duration::from_secs(1))
.max_backfill_span(10)
.route(route_a)
.build();
let Ok(mut poller) = assert_built(built) else {
return;
};
asserter.push_success(&U64::from(1));
asserter.push_success(&Vec::<Log>::new());
let result = run_tick(&provider, &mut poller, &shutdown).await;
assert!(result.is_ok(), "the tick itself still succeeds: {result:?}");
assert_eq!(
established.load(Ordering::SeqCst),
0,
"on_established must be suppressed once shutdown is cancelled \
mid-tick, even though the route was never established before"
);
assert_eq!(
tick_success.load(Ordering::SeqCst),
1,
"on_tick_success still fires — liveness is unconditional"
);
}
#[tokio::test]
async fn backoff_edge_fires_on_a_failing_tick_when_not_cancelled() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let backoff = Arc::new(AtomicUsize::new(0));
let (mut route_a, sink_a) = head_route("a", ADDR_A, TOPIC_A);
sink_a
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.fail_apply_on = Some(0);
{
let counter = Arc::clone(&backoff);
route_a.on_backoff = Some(Box::new(move || {
counter.fetch_add(1, Ordering::SeqCst);
}));
}
let built =
MultiplexedPollerBuilder::new(shared_head(provider.clone()), Duration::from_secs(1))
.max_backfill_span(10)
.route(route_a)
.build();
let Ok(mut poller) = assert_built(built) else {
return;
};
asserter.push_success(&U64::from(1));
asserter.push_success(&vec![log_at(ADDR_A, TOPIC_A, 1)]);
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(result.is_err(), "the route's sink error must fail the tick");
assert_eq!(
backoff.load(Ordering::SeqCst),
1,
"on_backoff must fire once on an uncancelled failing tick"
);
}
#[tokio::test]
async fn shutdown_during_failing_tick_suppresses_backoff_edge() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let backoff = Arc::new(AtomicUsize::new(0));
let shutdown = CancellationToken::new(); let route_a = {
let counter = Arc::clone(&backoff);
Route {
addresses: vec![ADDR_A],
topic0s: vec![TOPIC_A],
start: CursorStart::HeadMinusWindow { window_blocks: 0 },
sink: SinkSource::Ready(Box::new(CancelInReconcile {
shutdown: shutdown.clone(),
bail: true,
})),
label: "a",
on_established: None,
on_backoff: Some(Box::new(move || {
counter.fetch_add(1, Ordering::SeqCst);
})),
on_tick_success: None,
on_task_panic: None,
}
};
let built =
MultiplexedPollerBuilder::new(shared_head(provider.clone()), Duration::from_secs(1))
.max_backfill_span(10)
.route(route_a)
.build();
let Ok(mut poller) = assert_built(built) else {
return;
};
asserter.push_success(&U64::from(1));
asserter.push_success(&Vec::<Log>::new());
let result = run_tick(&provider, &mut poller, &shutdown).await;
assert!(
result.is_err(),
"the route still errors this tick — shutdown suppresses only the \
hook, not the tick's Err outcome: {result:?}"
);
assert_eq!(
backoff.load(Ordering::SeqCst),
0,
"on_backoff must be suppressed once shutdown is cancelled mid-tick, \
even though the route genuinely errored"
);
}
#[tokio::test]
async fn none_block_number_log_is_applied_not_gated() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let (route_a, sink_a) = head_route("a", ADDR_A, TOPIC_A);
let built =
MultiplexedPollerBuilder::new(shared_head(provider.clone()), Duration::from_secs(1))
.max_backfill_span(10)
.route(route_a)
.build();
let Ok(mut poller) = assert_built(built) else {
return;
};
let mut log = log_at(ADDR_A, TOPIC_A, 1);
log.block_number = None;
asserter.push_success(&U64::from(1));
asserter.push_success(&vec![log]);
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(result.is_ok(), "tick must succeed: {result:?}");
let a = sink_a
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(
a.applied,
vec![(ADDR_A, TOPIC_A, None)],
"a log with no block_number must be applied, not silently dropped by the floor gate"
);
}
#[derive(Default)]
struct MemoryCheckpointStore {
stored: std::sync::Mutex<std::collections::HashMap<CheckpointKey, u64>>,
}
impl KeyedCheckpointStore for MemoryCheckpointStore {
fn load_checkpoint(&self, key: CheckpointKey) -> std::result::Result<Option<u64>, StoreError> {
Ok(self
.stored
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&key)
.copied())
}
fn record_checkpoint(
&self,
key: CheckpointKey,
block: u64,
) -> std::result::Result<(), StoreError> {
self.stored
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(key, block);
Ok(())
}
}
#[tokio::test]
async fn multi_window_error_holds_cursor_at_failed_windows_start() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let store = Arc::new(MemoryCheckpointStore::default());
assert!(
store
.record_checkpoint(CheckpointKey::PoolOpened, 1)
.is_ok()
);
let (route_a, sink_a) = seeded_route(
"a",
ADDR_A,
TOPIC_A,
CursorStart::FromCheckpoint {
checkpoint: Checkpoint {
store: Arc::clone(&store) as Arc<dyn KeyedCheckpointStore>,
key: CheckpointKey::PoolOpened,
},
reorg_margin: 0,
cold_start: ColdStart::Head,
},
);
sink_a
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.fail_apply_on = Some(1);
let built =
MultiplexedPollerBuilder::new(shared_head(provider.clone()), Duration::from_secs(1))
.max_backfill_span(1)
.route(route_a)
.build();
let Ok(mut poller) = assert_built(built) else {
return;
};
asserter.push_success(&U64::from(3));
asserter.push_success(&vec![log_at(ADDR_A, TOPIC_A, 1)]);
asserter.push_success(&vec![log_at(ADDR_A, TOPIC_A, 2)]);
asserter.push_success(&vec![log_at(ADDR_A, TOPIC_A, 3)]);
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(result.is_err(), "the failed window must fail the tick");
assert_eq!(
poller.routes.first().and_then(|r| r.cursor),
Some(2),
"cursor holds at the failed window's start (2): past the completed \
window [1,1] but not into the un-scanned window [3,3]"
);
let persisted = store
.load_checkpoint(CheckpointKey::PoolOpened)
.ok()
.flatten();
assert_eq!(
persisted,
Some(1),
"only the completed window [1,1] is durably persisted"
);
let applied = sink_a
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.applied
.len();
assert_eq!(
applied, 1,
"only window [1,1]'s log applied; the failed and un-scanned windows did not"
);
}
fn error_payload(code: i64, message: &str) -> Option<alloy_json_rpc::ErrorPayload> {
serde_json::from_value(serde_json::json!({ "code": code, "message": message })).ok()
}
fn rpc_error(code: i64, message: &str) -> anyhow::Error {
error_payload(code, message).map_or_else(
|| anyhow::anyhow!("unbuildable error payload"),
|payload| {
anyhow::Error::new(TransportError::ErrorResp(payload))
.context("multiplexed get_logs [1, 20]")
},
)
}
#[test]
fn range_rejection_matches_provider_range_and_result_caps() {
assert!(is_range_rejection(&rpc_error(
35,
"ranges over 10000 blocks are not supported on free plan"
)));
assert!(is_range_rejection(&rpc_error(
-32600,
"Under the Free tier plan, you can make eth_getLogs requests with up to a 10 \
block range."
)));
assert!(is_range_rejection(&rpc_error(
-32005,
"query returned more than 10000 results"
)));
assert!(is_range_rejection(&rpc_error(
-32600,
"block range is too wide"
)));
assert!(is_range_rejection(&rpc_error(
-32000,
"Block range limit exceeded"
)));
assert!(is_range_rejection(&rpc_error(
-32000,
"query exceeds max results 20000"
)));
assert!(is_range_rejection(&rpc_error(
-32614,
"eth_getLogs is limited to a 10,000 range"
)));
}
#[test]
fn range_rejection_ignores_every_other_failure() {
assert!(!is_range_rejection(&rpc_error(429, "Too Many Requests")));
assert!(!is_range_rejection(&rpc_error(
-32005,
"project ID request rate exceeded"
)));
assert!(!is_range_rejection(&rpc_error(
-32000,
"block number is out of range"
)));
assert!(!is_range_rejection(&rpc_error(-32000, "unknown block")));
assert!(!is_range_rejection(&rpc_error(-32603, "internal error")));
assert!(!is_range_rejection(&rpc_error(
-32000,
"block range extends beyond current head block"
)));
assert!(!is_range_rejection(&rpc_error(
-32602,
"invalid block range params"
)));
assert!(!is_range_rejection(&rpc_error(
-32000,
"failed to marshal results"
)));
assert!(!is_range_rejection(&anyhow::Error::new(
alloy::transports::TransportErrorKind::custom_str("connection reset")
)));
assert!(!is_range_rejection(&anyhow::anyhow!(
"get_logs timed out after 10s"
)));
}
fn checkpoint_route(
from: u64,
) -> (
Route,
Arc<std::sync::Mutex<ScriptedSink>>,
Arc<MemoryCheckpointStore>,
) {
let store = Arc::new(MemoryCheckpointStore::default());
assert!(
store
.record_checkpoint(CheckpointKey::PoolOpened, from)
.is_ok()
);
let (route, sink) = seeded_route(
"a",
ADDR_A,
TOPIC_A,
CursorStart::FromCheckpoint {
checkpoint: Checkpoint {
store: Arc::clone(&store) as Arc<dyn KeyedCheckpointStore>,
key: CheckpointKey::PoolOpened,
},
reorg_margin: 0,
cold_start: ColdStart::Head,
},
);
(route, sink, store)
}
#[tokio::test]
async fn a_range_rejection_halves_the_window_and_the_tick_completes() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let (route, _sink, store) = checkpoint_route(1);
let built =
MultiplexedPollerBuilder::new(shared_head(provider.clone()), Duration::from_secs(1))
.route(route)
.build();
let Ok(mut poller) = assert_built(built) else {
return;
};
asserter.push_success(&U64::from(20));
if let Some(payload) = error_payload(
-32600,
"you can make eth_getLogs requests with up to a 10 block range",
) {
asserter.push_failure(payload);
}
asserter.push_success(&Vec::<Log>::new());
asserter.push_success(&Vec::<Log>::new());
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(
result.is_ok(),
"the halved windows must complete the tick: {result:?}"
);
assert_eq!(
poller.span.current(),
10,
"the span is halved from the 20-block window"
);
assert_eq!(poller.routes.first().and_then(|r| r.cursor), Some(21));
assert_eq!(
store
.load_checkpoint(CheckpointKey::PoolOpened)
.ok()
.flatten(),
Some(20)
);
}
#[tokio::test]
async fn a_mid_backfill_rejection_retries_from_the_rejected_window() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let (route, sink, store) = checkpoint_route(1);
let built =
MultiplexedPollerBuilder::new(shared_head(provider.clone()), Duration::from_secs(1))
.max_backfill_span(10)
.route(route)
.build();
let Ok(mut poller) = assert_built(built) else {
return;
};
asserter.push_success(&U64::from(30));
asserter.push_success(&vec![log_at(ADDR_A, TOPIC_A, 5)]);
if let Some(payload) = error_payload(-32005, "query returned more than 10000 results") {
asserter.push_failure(payload);
}
asserter.push_success(&vec![log_at(ADDR_A, TOPIC_A, 12)]);
asserter.push_success(&vec![log_at(ADDR_A, TOPIC_A, 18)]);
asserter.push_success(&Vec::<Log>::new());
asserter.push_success(&Vec::<Log>::new());
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(
result.is_ok(),
"the retried windows must complete the tick: {result:?}"
);
assert_eq!(poller.span.current(), 5);
let applied: Vec<Option<u64>> = sink
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.applied
.iter()
.map(|(_, _, block)| *block)
.collect();
assert_eq!(
applied,
vec![Some(5), Some(12), Some(18)],
"each log applied exactly once, in order"
);
assert_eq!(poller.routes.first().and_then(|r| r.cursor), Some(31));
assert_eq!(
store
.load_checkpoint(CheckpointKey::PoolOpened)
.ok()
.flatten(),
Some(30)
);
}
#[tokio::test]
async fn span_hooks_report_the_shrink_and_the_regrow() {
use crate::chain_events::backfill::SPAN_REGROW_AFTER_WINDOWS;
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let (route, _sink, _store) = checkpoint_route(1);
let spans = Arc::new(std::sync::Mutex::new(Vec::new()));
let rejections = Arc::new(AtomicUsize::new(0));
let spans_hook = Arc::clone(&spans);
let rejections_hook = Arc::clone(&rejections);
let built =
MultiplexedPollerBuilder::new(shared_head(provider.clone()), Duration::from_secs(1))
.max_backfill_span(20)
.on_span_change(Box::new(move |span| {
spans_hook
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(span);
}))
.on_range_rejection(Box::new(move || {
rejections_hook.fetch_add(1, Ordering::SeqCst);
}))
.route(route)
.build();
let Ok(mut poller) = assert_built(built) else {
return;
};
let windows = u64::from(SPAN_REGROW_AFTER_WINDOWS);
asserter.push_success(&U64::from(windows * 10));
if let Some(payload) = error_payload(35, "ranges over 10000 blocks are not supported") {
asserter.push_failure(payload);
}
for _ in 0..windows {
asserter.push_success(&Vec::<Log>::new());
}
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(result.is_ok(), "the tick must complete: {result:?}");
assert_eq!(rejections.load(Ordering::SeqCst), 1);
assert_eq!(
spans
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone(),
vec![10, 20],
"one shrink to 10, then one regrow back to the ceiling"
);
assert_eq!(poller.span.current(), 20);
asserter.push_success(&U64::from(windows * 10 + 40));
if let Some(payload) = error_payload(35, "ranges over 10000 blocks are not supported") {
asserter.push_failure(payload);
}
for _ in 0..4 {
asserter.push_success(&Vec::<Log>::new());
}
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(result.is_ok(), "the second tick must complete: {result:?}");
assert_eq!(rejections.load(Ordering::SeqCst), 2);
assert_eq!(
spans
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone(),
vec![10, 20, 10],
);
}
#[tokio::test]
async fn run_reports_the_starting_span() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let spans = Arc::new(std::sync::Mutex::new(Vec::new()));
let spans_hook = Arc::clone(&spans);
let built =
MultiplexedPollerBuilder::new(shared_head(provider.clone()), Duration::from_secs(1))
.max_backfill_span(150)
.on_span_change(Box::new(move |span| {
spans_hook
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(span);
}))
.build();
let Ok(poller) = assert_built(built) else {
return;
};
let shutdown = CancellationToken::new();
shutdown.cancel();
run(provider, poller, shutdown).await;
assert_eq!(
spans
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone(),
vec![150]
);
}
#[tokio::test]
async fn a_rate_limit_backs_off_and_keeps_the_window() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let Some(RetryPoller {
mut poller,
backoff,
retries,
..
}) = retry_poller(provider.clone(), DEFAULT_GET_LOGS_MAX_BLOCK_SPAN)
else {
return;
};
asserter.push_success(&U64::from(20));
push_rpc_failure(&asserter, 429, "Too Many Requests");
asserter.push_success(&Vec::<Log>::new());
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(
result.is_err(),
"a rate limit fails the tick into the backoff path"
);
assert_eq!(retries.load(Ordering::SeqCst), 0);
assert_eq!(backoff.load(Ordering::SeqCst), 1);
assert_eq!(poller.span.current(), DEFAULT_GET_LOGS_MAX_BLOCK_SPAN);
assert_eq!(
poller.routes.first().and_then(|r| r.cursor),
Some(1),
"cursor held"
);
}
#[tokio::test]
async fn a_rejected_one_block_window_fails_the_tick_instead_of_looping() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let (route, _sink, _store) = checkpoint_route(1);
let rejections = Arc::new(AtomicUsize::new(0));
let rejections_hook = Arc::clone(&rejections);
let built =
MultiplexedPollerBuilder::new(shared_head(provider.clone()), Duration::from_secs(1))
.max_backfill_span(1)
.on_range_rejection(Box::new(move || {
rejections_hook.fetch_add(1, Ordering::SeqCst);
}))
.route(route)
.build();
let Ok(mut poller) = assert_built(built) else {
return;
};
asserter.push_success(&U64::from(1));
if let Some(payload) = error_payload(35, "ranges over 10000 blocks are not supported") {
asserter.push_failure(payload);
}
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(
result.is_err(),
"a one-block window cannot shrink: back off"
);
assert_eq!(poller.span.current(), 1);
assert_eq!(
rejections.load(Ordering::SeqCst),
1,
"a provider that rejects even one block is still counted"
);
}
struct RetryPoller {
poller: MultiplexedPoller,
backoff: Arc<AtomicUsize>,
ticks: Arc<AtomicUsize>,
retries: Arc<AtomicUsize>,
deferred: Arc<AtomicUsize>,
rejections: Arc<AtomicUsize>,
store: Arc<MemoryCheckpointStore>,
}
fn counting_hook(counter: &Arc<AtomicUsize>) -> WatcherHook {
let counter = Arc::clone(counter);
Box::new(move || {
counter.fetch_add(1, Ordering::SeqCst);
})
}
fn retry_poller<P: Provider + Clone + 'static>(provider: P, span: u64) -> Option<RetryPoller> {
let (mut route, _sink, store) = checkpoint_route(1);
let backoff = Arc::new(AtomicUsize::new(0));
let ticks = Arc::new(AtomicUsize::new(0));
let retries = Arc::new(AtomicUsize::new(0));
let deferred = Arc::new(AtomicUsize::new(0));
let rejections = Arc::new(AtomicUsize::new(0));
route.on_backoff = Some(counting_hook(&backoff));
route.on_tick_success = Some(counting_hook(&ticks));
let built = MultiplexedPollerBuilder::new(shared_head(provider), Duration::from_secs(1))
.max_backfill_span(span)
.on_window_retry(counting_hook(&retries))
.on_window_deferred(counting_hook(&deferred))
.on_range_rejection(counting_hook(&rejections))
.route(route)
.build();
assert_built(built).ok().map(|poller| RetryPoller {
poller,
backoff,
ticks,
retries,
deferred,
rejections,
store,
})
}
fn push_rpc_failure(asserter: &alloy::providers::mock::Asserter, code: i64, message: &str) {
if let Some(payload) = error_payload(code, message) {
asserter.push_failure(payload);
}
}
#[test]
fn transient_window_errors_exclude_range_permanent_and_rate_limit() {
assert!(is_transient_window_error(&rpc_error(
19,
"Temporary internal error. Please retry"
)));
assert!(is_transient_window_error(&rpc_error(
30,
"Request timeout on the free plan"
)));
assert!(is_transient_window_error(&rpc_error(
-32603,
"internal error"
)));
assert!(is_transient_window_error(&anyhow::Error::new(
alloy::transports::TransportErrorKind::custom_str("connection reset")
)));
assert!(is_transient_window_error(&anyhow::anyhow!(
"get_logs timed out after 10s"
)));
assert!(is_transient_window_error(&rpc_error(
-32000,
"block range extends beyond current head block"
)));
assert!(is_transient_window_error(&rpc_error(
-32000,
"block number is out of range"
)));
assert!(is_transient_window_error(&anyhow::Error::new(
alloy::transports::TransportErrorKind::http_error(503, String::new())
)));
assert!(!is_transient_window_error(&rpc_error(
35,
"ranges over 10000 blocks are not supported on free plan"
)));
assert!(!is_transient_window_error(&rpc_error(
-32602,
"invalid params"
)));
assert!(!is_transient_window_error(&rpc_error(
-32601,
"the method eth_getLogs does not exist"
)));
assert!(!is_transient_window_error(&anyhow::Error::new(
alloy::transports::TransportErrorKind::http_error(401, "invalid API key".into())
)));
assert!(!is_transient_window_error(&rpc_error(
429,
"Too Many Requests"
)));
assert!(!is_transient_window_error(&rpc_error(
-32005,
"project ID request rate exceeded"
)));
assert!(!is_transient_window_error(&anyhow::Error::new(
alloy::transports::TransportErrorKind::http_error(429, String::new())
)));
assert!(!is_transient_window_error(&anyhow::Error::new(
alloy::transports::TransportErrorKind::http_error_with_retry_after(
503,
String::new(),
Some(Duration::from_secs(5)),
)
)));
}
#[tokio::test(start_paused = true)]
async fn a_transient_window_error_retries_and_the_tick_succeeds() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let Some(RetryPoller {
mut poller,
backoff,
retries,
store,
..
}) = retry_poller(provider.clone(), DEFAULT_GET_LOGS_MAX_BLOCK_SPAN)
else {
return;
};
asserter.push_success(&U64::from(20));
push_rpc_failure(&asserter, 19, "Temporary internal error. Please retry");
asserter.push_success(&Vec::<Log>::new());
let started = tokio::time::Instant::now();
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(
result.is_ok(),
"the retried window completes the tick: {result:?}"
);
assert!(
started.elapsed() >= GET_LOGS_WINDOW_RETRY_DELAY,
"the retry waits out the delay first"
);
assert_eq!(retries.load(Ordering::SeqCst), 1);
assert_eq!(
backoff.load(Ordering::SeqCst),
0,
"a retried window is not downtime"
);
assert_eq!(poller.routes.first().and_then(|r| r.cursor), Some(21));
assert_eq!(
store
.load_checkpoint(CheckpointKey::PoolOpened)
.ok()
.flatten(),
Some(20)
);
assert!(
poller.routes.iter().all(|r| r.established && !r.recovering),
"a retried window leaves the route established"
);
}
#[tokio::test(start_paused = true)]
async fn a_window_that_keeps_failing_defers_the_tick() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let Some(RetryPoller {
mut poller,
backoff,
ticks,
retries,
deferred,
store,
..
}) = retry_poller(provider.clone(), DEFAULT_GET_LOGS_MAX_BLOCK_SPAN)
else {
return;
};
asserter.push_success(&U64::from(20));
for _ in 0..3 {
push_rpc_failure(&asserter, 30, "Request timeout on the free plan");
}
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(
result.is_ok(),
"a deferred window does not fail the tick: {result:?}"
);
assert_eq!(retries.load(Ordering::SeqCst), 2);
assert_eq!(deferred.load(Ordering::SeqCst), 1);
assert_eq!(
backoff.load(Ordering::SeqCst),
0,
"a deferral is not downtime"
);
assert_eq!(ticks.load(Ordering::SeqCst), 0, "no window completed");
assert_eq!(
poller.routes.first().and_then(|r| r.cursor),
Some(1),
"the cursor holds at the deferred window's start"
);
assert_eq!(
store
.load_checkpoint(CheckpointKey::PoolOpened)
.ok()
.flatten(),
Some(1),
"the checkpoint holds where it was"
);
assert!(
poller.routes.iter().all(|r| !r.recovering),
"no route is marked down"
);
assert!(poller.stalled_since.is_some(), "the stall clock starts");
}
#[tokio::test(start_paused = true)]
async fn a_stall_past_the_budget_fails_the_tick() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let Some(RetryPoller {
mut poller,
backoff,
deferred,
..
}) = retry_poller(provider.clone(), DEFAULT_GET_LOGS_MAX_BLOCK_SPAN)
else {
return;
};
for _ in 0..3 {
asserter.push_success(&U64::from(20));
for _ in 0..3 {
push_rpc_failure(&asserter, 19, "Temporary internal error. Please retry");
}
}
asserter.push_success(&U64::from(20));
asserter.push_success(&Vec::<Log>::new());
let first = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(first.is_ok(), "the first stalled tick defers: {first:?}");
tokio::time::advance(GET_LOGS_STALL_BUDGET).await;
let second = run_tick(&provider, &mut poller, &no_shutdown()).await;
let err = second.err().map(|e| format!("{e:#}"));
assert!(
err.as_ref()
.is_some_and(|e| e.contains("no poll tick reached head")),
"a stall past the budget fails the tick: {err:?}"
);
assert_eq!(deferred.load(Ordering::SeqCst), 2);
assert_eq!(backoff.load(Ordering::SeqCst), 1);
assert_eq!(poller.routes.first().and_then(|r| r.cursor), Some(1));
assert!(
poller.routes.iter().all(|r| r.recovering),
"every route is marked down"
);
tokio::time::advance(Duration::from_secs(1)).await;
let third = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(third.is_err(), "the next deferral fails at once: {third:?}");
assert_eq!(backoff.load(Ordering::SeqCst), 2);
tokio::time::advance(Duration::from_secs(1)).await;
let fourth = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(
fourth.is_ok(),
"a tick that reaches head succeeds: {fourth:?}"
);
assert!(
poller.stalled_since.is_none(),
"reaching head clears the stall"
);
assert_eq!(poller.routes.first().and_then(|r| r.cursor), Some(21));
assert!(
poller.routes.iter().all(|r| r.established && !r.recovering),
"every route recovers"
);
}
#[tokio::test(start_paused = true)]
async fn progress_short_of_head_does_not_restart_the_stall_clock() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let Some(RetryPoller {
mut poller,
backoff,
store,
..
}) = retry_poller(provider.clone(), 10)
else {
return;
};
asserter.push_success(&U64::from(20));
asserter.push_success(&Vec::<Log>::new());
for _ in 0..3 {
push_rpc_failure(&asserter, 19, "Temporary internal error. Please retry");
}
asserter.push_success(&U64::from(40));
asserter.push_success(&Vec::<Log>::new());
for _ in 0..3 {
push_rpc_failure(&asserter, 19, "Temporary internal error. Please retry");
}
let first = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(first.is_ok(), "tick 1 defers after progress: {first:?}");
tokio::time::advance(GET_LOGS_STALL_BUDGET).await;
let second = run_tick(&provider, &mut poller, &no_shutdown()).await;
let err = second.err().map(|e| format!("{e:#}"));
assert!(
err.as_ref()
.is_some_and(|e| e.contains("no poll tick reached head")),
"progress short of head still fails past the budget: {err:?}"
);
assert_eq!(backoff.load(Ordering::SeqCst), 1);
assert_eq!(
poller.routes.first().and_then(|r| r.cursor),
Some(21),
"the window completed before the failure stays applied"
);
assert_eq!(
store
.load_checkpoint(CheckpointKey::PoolOpened)
.ok()
.flatten(),
Some(20)
);
}
#[tokio::test(start_paused = true)]
async fn reaching_head_restarts_the_stall_clock() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let Some(RetryPoller {
mut poller,
backoff,
deferred,
..
}) = retry_poller(provider.clone(), DEFAULT_GET_LOGS_MAX_BLOCK_SPAN)
else {
return;
};
asserter.push_success(&U64::from(20));
for _ in 0..3 {
push_rpc_failure(&asserter, 19, "Temporary internal error. Please retry");
}
asserter.push_success(&U64::from(20));
asserter.push_success(&Vec::<Log>::new());
asserter.push_success(&U64::from(40));
for _ in 0..3 {
push_rpc_failure(&asserter, 19, "Temporary internal error. Please retry");
}
let first = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(first.is_ok(), "tick 1 defers: {first:?}");
tokio::time::advance(Duration::from_secs(1)).await;
let second = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(second.is_ok(), "tick 2 completes: {second:?}");
assert_eq!(poller.routes.first().and_then(|r| r.cursor), Some(21));
assert!(
poller.stalled_since.is_none(),
"reaching head clears the stall clock"
);
tokio::time::advance(GET_LOGS_STALL_BUDGET).await;
let third = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(third.is_ok(), "tick 3 starts a fresh budget: {third:?}");
assert_eq!(deferred.load(Ordering::SeqCst), 2);
assert_eq!(backoff.load(Ordering::SeqCst), 0);
}
#[tokio::test(start_paused = true)]
async fn a_permanent_window_error_is_not_retried() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let Some(RetryPoller {
mut poller,
backoff,
retries,
..
}) = retry_poller(provider.clone(), DEFAULT_GET_LOGS_MAX_BLOCK_SPAN)
else {
return;
};
asserter.push_success(&U64::from(20));
push_rpc_failure(&asserter, -32602, "invalid params");
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
let err = result.err().map(|e| format!("{e:#}"));
assert!(
err.as_ref().is_some_and(|e| !e.contains("in-tick retries")),
"a permanent error fails the tick without a retry: {err:?}"
);
assert_eq!(retries.load(Ordering::SeqCst), 0);
assert_eq!(backoff.load(Ordering::SeqCst), 1);
}
#[tokio::test(start_paused = true)]
async fn a_range_rejection_is_not_retried() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let Some(RetryPoller {
mut poller,
retries,
..
}) = retry_poller(provider.clone(), DEFAULT_GET_LOGS_MAX_BLOCK_SPAN)
else {
return;
};
asserter.push_success(&U64::from(20));
push_rpc_failure(
&asserter,
35,
"ranges over 10000 blocks are not supported on free plan",
);
asserter.push_success(&Vec::<Log>::new());
asserter.push_success(&Vec::<Log>::new());
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(
result.is_ok(),
"the halved windows complete the tick: {result:?}"
);
assert_eq!(retries.load(Ordering::SeqCst), 0);
assert_eq!(poller.span.current(), 10);
}
#[tokio::test(start_paused = true)]
async fn shutdown_during_a_retry_sleep_ends_the_tick() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let Some(RetryPoller {
mut poller,
backoff,
retries,
..
}) = retry_poller(provider.clone(), DEFAULT_GET_LOGS_MAX_BLOCK_SPAN)
else {
return;
};
asserter.push_success(&U64::from(20));
push_rpc_failure(&asserter, 19, "Temporary internal error. Please retry");
asserter.push_success(&Vec::<Log>::new());
let shutdown = CancellationToken::new();
let cancel = async {
tokio::time::sleep(GET_LOGS_WINDOW_RETRY_DELAY / 2).await;
shutdown.cancel();
};
let started = tokio::time::Instant::now();
let (result, ()) = tokio::join!(run_tick(&provider, &mut poller, &shutdown), cancel);
assert!(result.is_ok(), "shutdown ends the tick cleanly: {result:?}");
assert!(
started.elapsed() < GET_LOGS_WINDOW_RETRY_DELAY,
"the retry sleep does not outlast shutdown"
);
assert_eq!(retries.load(Ordering::SeqCst), 0);
assert_eq!(backoff.load(Ordering::SeqCst), 0);
assert_eq!(poller.routes.first().and_then(|r| r.cursor), Some(1));
}
#[tokio::test(start_paused = true)]
async fn each_window_gets_its_own_retry_budget() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let Some(RetryPoller {
mut poller,
backoff,
retries,
..
}) = retry_poller(provider.clone(), 10)
else {
return;
};
asserter.push_success(&U64::from(20));
for _ in 0..2 {
push_rpc_failure(&asserter, 19, "Temporary internal error. Please retry");
push_rpc_failure(&asserter, 19, "Temporary internal error. Please retry");
asserter.push_success(&Vec::<Log>::new());
}
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(result.is_ok(), "both windows complete the tick: {result:?}");
assert_eq!(retries.load(Ordering::SeqCst), 4);
assert_eq!(backoff.load(Ordering::SeqCst), 0);
assert_eq!(poller.routes.first().and_then(|r| r.cursor), Some(21));
}
#[tokio::test(start_paused = true)]
async fn a_later_window_that_spends_its_retries_defers_and_keeps_earlier_progress() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let Some(RetryPoller {
mut poller,
backoff,
ticks,
retries,
deferred,
store,
..
}) = retry_poller(provider.clone(), 10)
else {
return;
};
asserter.push_success(&U64::from(20));
push_rpc_failure(&asserter, 19, "Temporary internal error. Please retry");
push_rpc_failure(&asserter, 19, "Temporary internal error. Please retry");
asserter.push_success(&Vec::<Log>::new());
for _ in 0..3 {
push_rpc_failure(&asserter, 30, "Request timeout on the free plan");
}
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(
result.is_ok(),
"the exhausted window is deferred: {result:?}"
);
assert_eq!(retries.load(Ordering::SeqCst), 4);
assert_eq!(deferred.load(Ordering::SeqCst), 1);
assert_eq!(backoff.load(Ordering::SeqCst), 0);
assert_eq!(
ticks.load(Ordering::SeqCst),
1,
"a completed window earns the stamp"
);
assert!(
poller.stalled_since.is_some(),
"a tick that stops short of head starts the stall clock"
);
assert!(
poller.routes.iter().all(|r| r.established),
"the route stays established"
);
assert_eq!(
poller.routes.first().and_then(|r| r.cursor),
Some(11),
"the cursor holds at the deferred window's start"
);
assert_eq!(
store
.load_checkpoint(CheckpointKey::PoolOpened)
.ok()
.flatten(),
Some(10),
"the completed window [1,10] stays persisted"
);
}
#[tokio::test(start_paused = true)]
async fn a_retried_window_still_shrinks_on_a_range_rejection() {
let asserter = alloy::providers::mock::Asserter::new();
let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
let Some(RetryPoller {
mut poller,
backoff,
retries,
rejections,
..
}) = retry_poller(provider.clone(), DEFAULT_GET_LOGS_MAX_BLOCK_SPAN)
else {
return;
};
asserter.push_success(&U64::from(20));
push_rpc_failure(&asserter, 19, "Temporary internal error. Please retry");
push_rpc_failure(
&asserter,
35,
"ranges over 10000 blocks are not supported on free plan",
);
asserter.push_success(&Vec::<Log>::new());
asserter.push_success(&Vec::<Log>::new());
let result = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(
result.is_ok(),
"the halved windows complete the tick: {result:?}"
);
assert_eq!(retries.load(Ordering::SeqCst), 1);
assert_eq!(rejections.load(Ordering::SeqCst), 1);
assert_eq!(backoff.load(Ordering::SeqCst), 0);
assert_eq!(poller.span.current(), 10);
assert_eq!(poller.routes.first().and_then(|r| r.cursor), Some(21));
}
#[test]
fn a_zero_span_is_a_build_error() {
let head: Arc<dyn HeadSource> = Arc::new(StaticHead(0));
let built = MultiplexedPollerBuilder::new(head, Duration::from_secs(1))
.max_backfill_span(0)
.build();
assert!(built.is_err(), "a zero span would scan no blocks");
}
struct MovingHead(Arc<std::sync::atomic::AtomicU64>);
#[async_trait]
impl HeadSource for MovingHead {
async fn head(&self) -> Result<u64> {
Ok(self.0.load(Ordering::SeqCst))
}
}
struct CappedGetLogsRpc {
cap: u64,
windows: Arc<std::sync::Mutex<Vec<(u64, u64, bool)>>>,
}
fn hex_block(value: Option<&serde_json::Value>) -> Option<u64> {
let text = value?.as_str()?.strip_prefix("0x")?;
u64::from_str_radix(text, 16).ok()
}
impl wiremock::Respond for CappedGetLogsRpc {
fn respond(&self, req: &wiremock::Request) -> wiremock::ResponseTemplate {
let body: serde_json::Value =
serde_json::from_slice(&req.body).unwrap_or(serde_json::Value::Null);
let id = body.get("id").cloned().unwrap_or(serde_json::json!(0));
let filter = body.get("params").and_then(|p| p.get(0));
let from = hex_block(filter.and_then(|f| f.get("fromBlock")));
let to = hex_block(filter.and_then(|f| f.get("toBlock")));
let (Some(from), Some(to)) = (from, to) else {
return wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"jsonrpc": "2.0", "id": id,
"error": { "code": -32602, "message": "missing block bounds" },
}));
};
let accepted = to - from < self.cap;
self.windows
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push((from, to, accepted));
let payload = if accepted {
serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": [] })
} else {
serde_json::json!({
"jsonrpc": "2.0", "id": id,
"error": {
"code": 35,
"message": "ranges over 10000 blocks are not supported on free plan",
},
})
};
wiremock::ResponseTemplate::new(200).set_body_json(payload)
}
}
#[tokio::test]
async fn a_capped_provider_backfill_recovers_and_the_span_sticks() {
const CAP: u64 = 150;
let windows = Arc::new(std::sync::Mutex::new(Vec::new()));
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.respond_with(CappedGetLogsRpc {
cap: CAP,
windows: Arc::clone(&windows),
})
.mount(&server)
.await;
let parsed_url = server.uri().parse();
assert!(parsed_url.is_ok(), "mock server uri must parse");
let Ok(url) = parsed_url else { return };
decdn_common::tls::install_crypto_provider();
let provider = ProviderBuilder::new().connect_http(url);
let head_block = Arc::new(std::sync::atomic::AtomicU64::new(2_000));
let head: Arc<dyn HeadSource> = Arc::new(MovingHead(Arc::clone(&head_block)));
let (route, _sink, store) = checkpoint_route(350);
let built = MultiplexedPollerBuilder::new(head, Duration::from_secs(1))
.route(route)
.build();
let Ok(mut poller) = assert_built(built) else {
return;
};
let first = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(
first.is_ok(),
"the capped backfill must complete: {first:?}"
);
assert!(
poller.span.current() <= CAP,
"span {} over the cap",
poller.span.current()
);
assert_eq!(poller.routes.first().and_then(|r| r.cursor), Some(2_001));
assert_eq!(
store
.load_checkpoint(CheckpointKey::PoolOpened)
.ok()
.flatten(),
Some(2_000)
);
let recorded = windows
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
let accepted: Vec<(u64, u64)> = recorded
.iter()
.filter(|w| w.2)
.map(|w| (w.0, w.1))
.collect();
let mut next = 350;
for (from, to) in &accepted {
assert_eq!(
*from, next,
"accepted windows must be contiguous: {accepted:?}"
);
next = to + 1;
}
assert_eq!(next, 2_001, "accepted windows must cover the whole gap");
head_block.store(2_100, Ordering::SeqCst);
let rejected_before = recorded.iter().filter(|w| !w.2).count();
let second = run_tick(&provider, &mut poller, &no_shutdown()).await;
assert!(second.is_ok(), "the live tail must complete: {second:?}");
let rejected_after = windows
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.iter()
.filter(|w| !w.2)
.count();
assert_eq!(
rejected_after, rejected_before,
"the learned span must stick"
);
assert_eq!(poller.routes.first().and_then(|r| r.cursor), Some(2_101));
}