Skip to content

Commit 39b3949

Browse files
0xMars42gakonstampagent
authored andcommitted
fix(exex): drain notification channel during backfill to prevent stall (paradigmxyz#22168)
Co-authored-by: Georgios Konstantopoulos <me@gakonst.com> Co-authored-by: Amp <amp@ampcode.com>
1 parent d6324d6 commit 39b3949

1 file changed

Lines changed: 171 additions & 2 deletions

File tree

crates/exex/exex/src/notifications.rs

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use reth_provider::{BlockReader, Chain, HeaderProvider, StateProviderFactory};
1010
use reth_stages_api::ExecutionStageThresholds;
1111
use reth_tracing::tracing::debug;
1212
use std::{
13+
collections::VecDeque,
1314
fmt::Debug,
1415
pin::Pin,
1516
sync::Arc,
@@ -286,6 +287,9 @@ where
286287
backfill_job: Option<StreamBackfillJob<E, P, Chain<E::Primitives>>>,
287288
/// Custom thresholds for the backfill job, if set.
288289
backfill_thresholds: Option<ExecutionStageThresholds>,
290+
/// Notifications that arrived during backfill and need to be delivered after it completes.
291+
/// These are notifications for blocks beyond the backfill range that we must not drop.
292+
pending_notifications: VecDeque<ExExNotification<E::Primitives>>,
289293
}
290294

291295
impl<P, E> ExExNotificationsWithHead<P, E>
@@ -312,6 +316,7 @@ where
312316
pending_check_backfill: true,
313317
backfill_job: None,
314318
backfill_thresholds: None,
319+
pending_notifications: VecDeque::new(),
315320
}
316321
}
317322

@@ -448,6 +453,34 @@ where
448453
// 3. If backfill is in progress yield new notifications
449454
if let Some(backfill_job) = &mut this.backfill_job {
450455
debug!(target: "exex::notifications", "Polling backfill job");
456+
457+
// Drain the notification channel to prevent backpressure from stalling the
458+
// ExExManager. During backfill, the ExEx is not consuming from the channel,
459+
// so the capacity-1 channel fills up, which blocks the manager's PollSender,
460+
// which fills the manager's 1024-entry buffer, which blocks all upstream
461+
// senders. Notifications for blocks covered by the backfill range are
462+
// discarded (they'll be re-delivered by the backfill job), while
463+
// notifications beyond the backfill range are buffered for delivery after the
464+
// backfill completes.
465+
while let Poll::Ready(Some(notification)) = this.notifications.poll_recv(cx) {
466+
// Always buffer revert-containing notifications (ChainReverted,
467+
// ChainReorged) because the backfill job only re-delivers
468+
// ChainCommitted from the database. Discarding a reorg here would
469+
// leave the ExEx unaware of the fork switch.
470+
if notification.reverted_chain().is_some() {
471+
this.pending_notifications.push_back(notification);
472+
continue;
473+
}
474+
if let Some(committed) = notification.committed_chain() &&
475+
committed.tip().number() <= this.initial_local_head.number
476+
{
477+
// Covered by backfill range, safe to discard
478+
continue;
479+
}
480+
// Beyond the backfill range — buffer for delivery after backfill
481+
this.pending_notifications.push_back(notification);
482+
}
483+
451484
if let Some(chain) = ready!(backfill_job.poll_next_unpin(cx)).transpose()? {
452485
debug!(target: "exex::notifications", range = ?chain.range(), "Backfill job returned a chain");
453486
return Poll::Ready(Some(Ok(ExExNotification::ChainCommitted {
@@ -459,13 +492,18 @@ where
459492
this.backfill_job = None;
460493
}
461494

462-
// 4. Otherwise advance the regular event stream
495+
// 4. Deliver any notifications that were buffered during backfill
496+
if let Some(notification) = this.pending_notifications.pop_front() {
497+
return Poll::Ready(Some(Ok(notification)))
498+
}
499+
500+
// 5. Otherwise advance the regular event stream
463501
loop {
464502
let Some(notification) = ready!(this.notifications.poll_recv(cx)) else {
465503
return Poll::Ready(None)
466504
};
467505

468-
// 5. In case the exex is ahead of the new tip, we must skip it
506+
// 6. In case the exex is ahead of the new tip, we must skip it
469507
if let Some(committed) = notification.committed_chain() {
470508
// inclusive check because we should start with `exex.head + 1`
471509
if this.initial_exex_head.block.number >= committed.tip().number() {
@@ -789,4 +827,135 @@ mod tests {
789827

790828
Ok(())
791829
}
830+
831+
/// Regression test for <https://github.com/paradigmxyz/reth/issues/19665>.
832+
///
833+
/// During backfill, `poll_next` must drain the notification channel so that
834+
/// the upstream `ExExManager` is never blocked by a full channel. Without
835+
/// the drain loop the capacity-1 channel stays full for the entire backfill
836+
/// duration, which stalls the manager's `PollSender` and eventually blocks
837+
/// all upstream senders once the 1024-entry buffer fills up.
838+
///
839+
/// The key assertion is the `try_send` after the first `poll_next`: it
840+
/// proves the channel was drained during the backfill poll. Without the
841+
/// fix this `try_send` fails because the notification is still sitting in
842+
/// the channel.
843+
#[tokio::test]
844+
async fn exex_notifications_backfill_drains_channel() -> eyre::Result<()> {
845+
let mut rng = generators::rng();
846+
847+
let temp_dir = tempfile::tempdir().unwrap();
848+
let wal = Wal::new(temp_dir.path()).unwrap();
849+
850+
let provider_factory = create_test_provider_factory();
851+
let genesis_hash = init_genesis(&provider_factory)?;
852+
let genesis_block = provider_factory
853+
.block(genesis_hash.into())?
854+
.ok_or_else(|| eyre::eyre!("genesis block not found"))?;
855+
856+
let provider = BlockchainProvider::new(provider_factory.clone())?;
857+
858+
// Insert block 1 into the DB so there's something to backfill
859+
let node_head_block = random_block(
860+
&mut rng,
861+
genesis_block.number + 1,
862+
BlockParams { parent: Some(genesis_hash), tx_count: Some(0), ..Default::default() },
863+
)
864+
.try_recover()?;
865+
let node_head = node_head_block.num_hash();
866+
let provider_rw = provider_factory.provider_rw()?;
867+
provider_rw.insert_block(&node_head_block)?;
868+
provider_rw.commit()?;
869+
870+
// ExEx head is at genesis — backfill will run for block 1
871+
let exex_head =
872+
ExExHead { block: BlockNumHash { number: genesis_block.number, hash: genesis_hash } };
873+
874+
// Notification for a block AFTER the backfill range (block 2).
875+
let post_backfill_notification = ExExNotification::ChainCommitted {
876+
new: Arc::new(Chain::new(
877+
vec![random_block(
878+
&mut rng,
879+
node_head.number + 1,
880+
BlockParams { parent: Some(node_head.hash), ..Default::default() },
881+
)
882+
.try_recover()?],
883+
Default::default(),
884+
BTreeMap::new(),
885+
)),
886+
};
887+
888+
// Another notification (block 3) used to probe channel capacity.
889+
let probe_notification = ExExNotification::ChainCommitted {
890+
new: Arc::new(Chain::new(
891+
vec![random_block(
892+
&mut rng,
893+
node_head.number + 2,
894+
BlockParams { parent: None, ..Default::default() },
895+
)
896+
.try_recover()?],
897+
Default::default(),
898+
BTreeMap::new(),
899+
)),
900+
};
901+
902+
let (notifications_tx, notifications_rx) = mpsc::channel(1);
903+
904+
// Fill the capacity-1 channel.
905+
notifications_tx.send(post_backfill_notification.clone()).await?;
906+
907+
// Confirm the channel is full — this is the precondition that causes the
908+
// stall in production: the ExExManager's PollSender would block here.
909+
assert!(
910+
notifications_tx.try_send(probe_notification.clone()).is_err(),
911+
"channel should be full before backfill poll"
912+
);
913+
914+
let mut notifications = ExExNotificationsWithoutHead::new(
915+
node_head,
916+
provider,
917+
EthEvmConfig::mainnet(),
918+
notifications_rx,
919+
wal.handle(),
920+
)
921+
.with_head(exex_head);
922+
923+
// Poll once — this returns the backfill result for block 1. Crucially,
924+
// the drain loop in poll_next runs in this same call, consuming the
925+
// notification from the channel and buffering it.
926+
let backfill_result = notifications.next().await.transpose()?;
927+
assert_eq!(
928+
backfill_result,
929+
Some(ExExNotification::ChainCommitted {
930+
new: Arc::new(
931+
BackfillJobFactory::new(
932+
notifications.evm_config.clone(),
933+
notifications.provider.clone()
934+
)
935+
.backfill(1..=1)
936+
.next()
937+
.ok_or_eyre("failed to backfill")??
938+
)
939+
})
940+
);
941+
942+
// KEY ASSERTION: the channel was drained during the backfill poll above.
943+
// Without the drain loop this try_send fails because the original
944+
// notification is still occupying the capacity-1 channel.
945+
assert!(
946+
notifications_tx.try_send(probe_notification.clone()).is_ok(),
947+
"channel should have been drained during backfill poll"
948+
);
949+
950+
// The first buffered notification (block 2) was drained from the channel
951+
// during backfill and is delivered now.
952+
let buffered = notifications.next().await.transpose()?;
953+
assert_eq!(buffered, Some(post_backfill_notification));
954+
955+
// The probe notification (block 3) that we just sent is delivered next.
956+
let probe = notifications.next().await.transpose()?;
957+
assert_eq!(probe, Some(probe_notification));
958+
959+
Ok(())
960+
}
792961
}

0 commit comments

Comments
 (0)