@@ -10,6 +10,7 @@ use reth_provider::{BlockReader, Chain, HeaderProvider, StateProviderFactory};
1010use reth_stages_api:: ExecutionStageThresholds ;
1111use reth_tracing:: tracing:: debug;
1212use 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
291295impl < 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