Skip to main content

reth_exex/
notifications.rs

1use crate::{BackfillJobFactory, ExExNotification, StreamBackfillJob, WalHandle};
2use alloy_consensus::BlockHeader;
3use alloy_eips::BlockNumHash;
4use futures::{Stream, StreamExt};
5use reth_ethereum_primitives::EthPrimitives;
6use reth_evm::ConfigureEvm;
7use reth_exex_types::ExExHead;
8use reth_node_api::NodePrimitives;
9use reth_provider::{BlockNumReader, BlockReader, Chain, HeaderProvider, StateProviderFactory};
10use reth_stages_api::ExecutionStageThresholds;
11use reth_tracing::tracing::debug;
12use std::{
13    collections::VecDeque,
14    fmt::Debug,
15    pin::Pin,
16    sync::Arc,
17    task::{ready, Context, Poll},
18};
19use tokio::sync::mpsc::Receiver;
20
21/// A stream of [`ExExNotification`]s. The stream will emit notifications for all blocks. If the
22/// stream is configured with a head via [`ExExNotifications::set_with_head`] or
23/// [`ExExNotifications::with_head`], it will run backfill jobs to catch up to the node head.
24#[derive(Debug)]
25pub struct ExExNotifications<P, E>
26where
27    E: ConfigureEvm,
28{
29    inner: ExExNotificationsInner<P, E>,
30}
31
32/// A trait, that represents a stream of [`ExExNotification`]s. The stream will emit notifications
33/// for all blocks. If the stream is configured with a head via [`ExExNotifications::set_with_head`]
34/// or [`ExExNotifications::with_head`], it will run backfill jobs to catch up to the node head.
35pub trait ExExNotificationsStream<N: NodePrimitives = EthPrimitives>:
36    Stream<Item = eyre::Result<ExExNotification<N>>> + Unpin
37{
38    /// Sets [`ExExNotificationsStream`] to a stream of [`ExExNotification`]s without a head.
39    ///
40    /// It's a no-op if the stream has already been configured without a head.
41    ///
42    /// See the documentation of [`ExExNotificationsWithoutHead`] for more details.
43    fn set_without_head(&mut self);
44
45    /// Sets [`ExExNotificationsStream`] to a stream of [`ExExNotification`]s with the provided
46    /// head.
47    ///
48    /// It's a no-op if the stream has already been configured with a head.
49    ///
50    /// See the documentation of [`ExExNotificationsWithHead`] for more details.
51    fn set_with_head(&mut self, exex_head: ExExHead);
52
53    /// Returns a new [`ExExNotificationsStream`] without a head.
54    ///
55    /// See the documentation of [`ExExNotificationsWithoutHead`] for more details.
56    fn without_head(self) -> Self
57    where
58        Self: Sized;
59
60    /// Returns a new [`ExExNotificationsStream`] with the provided head.
61    ///
62    /// See the documentation of [`ExExNotificationsWithHead`] for more details.
63    fn with_head(self, exex_head: ExExHead) -> Self
64    where
65        Self: Sized;
66
67    /// Sets custom thresholds for the backfill job.
68    ///
69    /// These thresholds control how many blocks are included in each backfill notification.
70    /// Only takes effect when the stream is configured with a head.
71    ///
72    /// By default, the backfill job uses [`BackfillJobFactory`] defaults (up to 500,000 blocks
73    /// per batch, bounded by 30s execution time).
74    fn set_backfill_thresholds(&mut self, _thresholds: ExecutionStageThresholds) {}
75}
76
77#[derive(Debug)]
78enum ExExNotificationsInner<P, E>
79where
80    E: ConfigureEvm,
81{
82    /// A stream of [`ExExNotification`]s. The stream will emit notifications for all blocks.
83    WithoutHead(ExExNotificationsWithoutHead<P, E>),
84    /// A stream of [`ExExNotification`]s. The stream will only emit notifications for blocks that
85    /// are committed or reverted after the given head.
86    WithHead(Box<ExExNotificationsWithHead<P, E>>),
87    /// Internal state used when transitioning between [`ExExNotificationsInner::WithoutHead`] and
88    /// [`ExExNotificationsInner::WithHead`].
89    Invalid,
90}
91
92impl<P, E> ExExNotificationsInner<P, E>
93where
94    E: ConfigureEvm,
95{
96    /// Returns the provider of the underlying stream.
97    fn provider(&self) -> &P {
98        match self {
99            Self::WithoutHead(n) => &n.provider,
100            Self::WithHead(n) => &n.provider,
101            Self::Invalid => unreachable!(),
102        }
103    }
104}
105
106impl<P, E> ExExNotifications<P, E>
107where
108    E: ConfigureEvm,
109{
110    /// Creates a new stream of [`ExExNotifications`] without a head.
111    pub const fn new(
112        node_head: BlockNumHash,
113        provider: P,
114        evm_config: E,
115        notifications: Receiver<ExExNotification<E::Primitives>>,
116        wal_handle: WalHandle<E::Primitives>,
117    ) -> Self {
118        Self {
119            inner: ExExNotificationsInner::WithoutHead(ExExNotificationsWithoutHead::new(
120                node_head,
121                provider,
122                evm_config,
123                notifications,
124                wal_handle,
125            )),
126        }
127    }
128
129    /// As [`set_with_head`](ExExNotificationsStream::set_with_head), but backfills up to the
130    /// node's current canonical head rather than the head captured at construction.
131    pub fn catch_up_with_head(&mut self, exex_head: ExExHead) -> eyre::Result<()>
132    where
133        P: BlockNumReader,
134    {
135        // Resolve the current canonical head before tearing down the stream state, so a failed
136        // lookup leaves the stream untouched.
137        let local_head: BlockNumHash = self.inner.provider().chain_info()?.into();
138
139        let current = std::mem::replace(&mut self.inner, ExExNotificationsInner::Invalid);
140        let (provider, evm_config, notifications, wal_handle, backfill_thresholds) = match current {
141            ExExNotificationsInner::WithoutHead(n) => {
142                (n.provider, n.evm_config, n.notifications, n.wal_handle, None)
143            }
144            ExExNotificationsInner::WithHead(n) => {
145                (n.provider, n.evm_config, n.notifications, n.wal_handle, n.backfill_thresholds)
146            }
147            ExExNotificationsInner::Invalid => unreachable!(),
148        };
149        let mut with_head = ExExNotificationsWithHead::new(
150            local_head,
151            provider,
152            evm_config,
153            notifications,
154            wal_handle,
155            exex_head,
156        );
157        // Preserve any custom backfill thresholds so the catch-up backfill respects the limits
158        // the ExEx already configured.
159        with_head.backfill_thresholds = backfill_thresholds;
160        self.inner = ExExNotificationsInner::WithHead(Box::new(with_head));
161        Ok(())
162    }
163}
164
165impl<P, E> ExExNotificationsStream<E::Primitives> for ExExNotifications<P, E>
166where
167    P: BlockReader + HeaderProvider + StateProviderFactory + Clone + Unpin + 'static,
168    E: ConfigureEvm<Primitives: NodePrimitives<Block = P::Block>> + Clone + Unpin + 'static,
169{
170    fn set_without_head(&mut self) {
171        let current = std::mem::replace(&mut self.inner, ExExNotificationsInner::Invalid);
172        self.inner = ExExNotificationsInner::WithoutHead(match current {
173            ExExNotificationsInner::WithoutHead(notifications) => notifications,
174            ExExNotificationsInner::WithHead(notifications) => ExExNotificationsWithoutHead::new(
175                notifications.initial_local_head,
176                notifications.provider,
177                notifications.evm_config,
178                notifications.notifications,
179                notifications.wal_handle,
180            ),
181            ExExNotificationsInner::Invalid => unreachable!(),
182        });
183    }
184
185    fn set_with_head(&mut self, exex_head: ExExHead) {
186        let current = std::mem::replace(&mut self.inner, ExExNotificationsInner::Invalid);
187        self.inner = ExExNotificationsInner::WithHead(match current {
188            ExExNotificationsInner::WithoutHead(notifications) => {
189                Box::new(notifications.with_head(exex_head))
190            }
191            ExExNotificationsInner::WithHead(notifications) => {
192                Box::new(ExExNotificationsWithHead::new(
193                    notifications.initial_local_head,
194                    notifications.provider,
195                    notifications.evm_config,
196                    notifications.notifications,
197                    notifications.wal_handle,
198                    exex_head,
199                ))
200            }
201            ExExNotificationsInner::Invalid => unreachable!(),
202        });
203    }
204
205    fn without_head(mut self) -> Self {
206        self.set_without_head();
207        self
208    }
209
210    fn with_head(mut self, exex_head: ExExHead) -> Self {
211        self.set_with_head(exex_head);
212        self
213    }
214
215    fn set_backfill_thresholds(&mut self, thresholds: ExecutionStageThresholds) {
216        if let ExExNotificationsInner::WithHead(notifications) = &mut self.inner {
217            notifications.backfill_thresholds = Some(thresholds);
218        }
219    }
220}
221
222impl<P, E> Stream for ExExNotifications<P, E>
223where
224    P: BlockReader + HeaderProvider + StateProviderFactory + Clone + Unpin + 'static,
225    E: ConfigureEvm<Primitives: NodePrimitives<Block = P::Block>> + 'static,
226{
227    type Item = eyre::Result<ExExNotification<E::Primitives>>;
228
229    fn poll_next(
230        self: std::pin::Pin<&mut Self>,
231        cx: &mut std::task::Context<'_>,
232    ) -> std::task::Poll<Option<Self::Item>> {
233        match &mut self.get_mut().inner {
234            ExExNotificationsInner::WithoutHead(notifications) => {
235                notifications.poll_next_unpin(cx).map(|result| result.map(Ok))
236            }
237            ExExNotificationsInner::WithHead(notifications) => notifications.poll_next_unpin(cx),
238            ExExNotificationsInner::Invalid => unreachable!(),
239        }
240    }
241}
242
243/// A stream of [`ExExNotification`]s. The stream will emit notifications for all blocks.
244pub struct ExExNotificationsWithoutHead<P, E>
245where
246    E: ConfigureEvm,
247{
248    node_head: BlockNumHash,
249    provider: P,
250    evm_config: E,
251    notifications: Receiver<ExExNotification<E::Primitives>>,
252    wal_handle: WalHandle<E::Primitives>,
253}
254
255impl<P: Debug, E> Debug for ExExNotificationsWithoutHead<P, E>
256where
257    E: ConfigureEvm + Debug,
258{
259    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260        f.debug_struct("ExExNotifications")
261            .field("provider", &self.provider)
262            .field("evm_config", &self.evm_config)
263            .field("notifications", &self.notifications)
264            .finish()
265    }
266}
267
268impl<P, E> ExExNotificationsWithoutHead<P, E>
269where
270    E: ConfigureEvm,
271{
272    /// Creates a new instance of [`ExExNotificationsWithoutHead`].
273    const fn new(
274        node_head: BlockNumHash,
275        provider: P,
276        evm_config: E,
277        notifications: Receiver<ExExNotification<E::Primitives>>,
278        wal_handle: WalHandle<E::Primitives>,
279    ) -> Self {
280        Self { node_head, provider, evm_config, notifications, wal_handle }
281    }
282
283    /// Subscribe to notifications with the given head.
284    fn with_head(self, head: ExExHead) -> ExExNotificationsWithHead<P, E> {
285        ExExNotificationsWithHead::new(
286            self.node_head,
287            self.provider,
288            self.evm_config,
289            self.notifications,
290            self.wal_handle,
291            head,
292        )
293    }
294}
295
296impl<P: Unpin, E> Stream for ExExNotificationsWithoutHead<P, E>
297where
298    E: ConfigureEvm,
299{
300    type Item = ExExNotification<E::Primitives>;
301
302    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
303        self.get_mut().notifications.poll_recv(cx)
304    }
305}
306
307/// A stream of [`ExExNotification`]s. The stream will only emit notifications for blocks that are
308/// committed or reverted after the given head. The head is the ExEx's latest view of the host
309/// chain.
310///
311/// Notifications will be sent starting from the head, not inclusive. For example, if
312/// `exex_head.number == 10`, then the first notification will be with `block.number == 11`. An
313/// `exex_head.number` of 10 indicates that the ExEx has processed up to block 10, and is ready to
314/// process block 11.
315#[derive(Debug)]
316pub struct ExExNotificationsWithHead<P, E>
317where
318    E: ConfigureEvm,
319{
320    /// The node's local head at launch.
321    initial_local_head: BlockNumHash,
322    provider: P,
323    evm_config: E,
324    notifications: Receiver<ExExNotification<E::Primitives>>,
325    wal_handle: WalHandle<E::Primitives>,
326    /// The exex head at launch
327    initial_exex_head: ExExHead,
328
329    /// If true, then we need to check if the ExEx head is on the canonical chain and if not,
330    /// revert its head.
331    pending_check_canonical: bool,
332    /// If true, then we need to check if the ExEx head is behind the node head and if so, backfill
333    /// the missing blocks.
334    pending_check_backfill: bool,
335    /// The backfill job to run before consuming any notifications.
336    backfill_job: Option<StreamBackfillJob<E, P, Chain<E::Primitives>>>,
337    /// Custom thresholds for the backfill job, if set.
338    backfill_thresholds: Option<ExecutionStageThresholds>,
339    /// Notifications that arrived during backfill and need to be delivered after it completes.
340    /// These are notifications for blocks beyond the backfill range that we must not drop.
341    pending_notifications: VecDeque<ExExNotification<E::Primitives>>,
342}
343
344impl<P, E> ExExNotificationsWithHead<P, E>
345where
346    E: ConfigureEvm,
347{
348    /// Creates a new [`ExExNotificationsWithHead`].
349    const fn new(
350        node_head: BlockNumHash,
351        provider: P,
352        evm_config: E,
353        notifications: Receiver<ExExNotification<E::Primitives>>,
354        wal_handle: WalHandle<E::Primitives>,
355        exex_head: ExExHead,
356    ) -> Self {
357        Self {
358            initial_local_head: node_head,
359            provider,
360            evm_config,
361            notifications,
362            wal_handle,
363            initial_exex_head: exex_head,
364            pending_check_canonical: true,
365            pending_check_backfill: true,
366            backfill_job: None,
367            backfill_thresholds: None,
368            pending_notifications: VecDeque::new(),
369        }
370    }
371
372    /// Sets custom thresholds for the backfill job.
373    ///
374    /// These thresholds control how many blocks are included in each backfill notification.
375    /// By default, the backfill job uses [`BackfillJobFactory`] defaults (up to 500,000 blocks
376    /// per batch, bounded by 30s execution time).
377    ///
378    /// If your ExEx is memory-constrained, consider setting a lower `max_blocks` value to
379    /// reduce the size of each backfill notification.
380    pub const fn with_backfill_thresholds(mut self, thresholds: ExecutionStageThresholds) -> Self {
381        self.backfill_thresholds = Some(thresholds);
382        self
383    }
384}
385
386impl<P, E> ExExNotificationsWithHead<P, E>
387where
388    P: BlockReader + HeaderProvider + StateProviderFactory + Clone + Unpin + 'static,
389    E: ConfigureEvm<Primitives: NodePrimitives<Block = P::Block>> + Clone + Unpin + 'static,
390{
391    /// Checks if the ExEx head is on the canonical chain.
392    ///
393    /// If the head block is not found in the database or it's ahead of the node head, it means
394    /// we're not on the canonical chain and we need to revert the notification with the ExEx
395    /// head block.
396    fn check_canonical(&mut self) -> eyre::Result<Option<ExExNotification<E::Primitives>>> {
397        if self.provider.is_known(self.initial_exex_head.block.hash)? &&
398            self.initial_exex_head.block.number <= self.initial_local_head.number
399        {
400            // we have the targeted block and that block is below the current head
401            debug!(target: "exex::notifications", "ExEx head is on the canonical chain");
402            return Ok(None)
403        }
404
405        // If the head block is not found in the database, it means we're not on the canonical
406        // chain.
407
408        // Get the committed notification for the head block from the WAL.
409        let Some(notification) = self
410            .wal_handle
411            .get_committed_notification_by_block_hash(&self.initial_exex_head.block.hash)?
412        else {
413            // it's possible that the exex head is further ahead
414            if self.initial_exex_head.block.number > self.initial_local_head.number {
415                debug!(target: "exex::notifications", "ExEx head is ahead of the canonical chain");
416                return Ok(None);
417            }
418
419            return Err(eyre::eyre!(
420                "Could not find notification for block hash {:?} in the WAL",
421                self.initial_exex_head.block.hash
422            ))
423        };
424
425        // Update the head block hash to the parent hash of the first committed block.
426        let committed_chain = notification.committed_chain().unwrap();
427        let new_exex_head =
428            (committed_chain.first().parent_hash(), committed_chain.first().number() - 1).into();
429        debug!(target: "exex::notifications", old_exex_head = ?self.initial_exex_head.block, new_exex_head = ?new_exex_head, "ExEx head updated");
430        self.initial_exex_head.block = new_exex_head;
431
432        // Return an inverted notification. See the documentation for
433        // `ExExNotification::into_inverted`.
434        Ok(Some(notification.into_inverted()))
435    }
436
437    /// Compares the node head against the ExEx head, and backfills if needed.
438    ///
439    /// CAUTION: This method assumes that the ExEx head is <= the node head, and that it's on the
440    /// canonical chain.
441    ///
442    /// Possible situations are:
443    /// - ExEx is behind the node head (`exex_head.number < node_head.number`). Backfill from the
444    ///   node database.
445    /// - ExEx is at the same block number as the node head (`exex_head.number ==
446    ///   node_head.number`). Nothing to do.
447    fn check_backfill(&mut self) -> eyre::Result<()> {
448        let mut backfill_job_factory =
449            BackfillJobFactory::new(self.evm_config.clone(), self.provider.clone());
450        if let Some(thresholds) = self.backfill_thresholds.clone() {
451            backfill_job_factory = backfill_job_factory.with_thresholds(thresholds);
452        }
453        match self.initial_exex_head.block.number.cmp(&self.initial_local_head.number) {
454            std::cmp::Ordering::Less => {
455                // ExEx is behind the node head, start backfill
456                debug!(target: "exex::notifications", "ExEx is behind the node head and on the canonical chain, starting backfill");
457                let backfill = backfill_job_factory
458                    .backfill(
459                        self.initial_exex_head.block.number + 1..=self.initial_local_head.number,
460                    )
461                    .into_stream();
462                self.backfill_job = Some(backfill);
463            }
464            std::cmp::Ordering::Equal => {
465                debug!(target: "exex::notifications", "ExEx is at the node head");
466            }
467            std::cmp::Ordering::Greater => {
468                debug!(target: "exex::notifications", "ExEx is ahead of the node head");
469            }
470        };
471
472        Ok(())
473    }
474}
475
476impl<P, E> Stream for ExExNotificationsWithHead<P, E>
477where
478    P: BlockReader + HeaderProvider + StateProviderFactory + Clone + Unpin + 'static,
479    E: ConfigureEvm<Primitives: NodePrimitives<Block = P::Block>> + Clone + Unpin + 'static,
480{
481    type Item = eyre::Result<ExExNotification<E::Primitives>>;
482
483    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
484        let this = self.get_mut();
485
486        // 1. Check once whether we need to retrieve a notification gap from the WAL.
487        if this.pending_check_canonical {
488            if let Some(canonical_notification) = this.check_canonical()? {
489                return Poll::Ready(Some(Ok(canonical_notification)))
490            }
491
492            // ExEx head is on the canonical chain, we no longer need to check it
493            this.pending_check_canonical = false;
494        }
495
496        // 2. Check once whether we need to trigger backfill sync
497        if this.pending_check_backfill {
498            this.check_backfill()?;
499            this.pending_check_backfill = false;
500        }
501
502        // 3. If backfill is in progress yield new notifications
503        if let Some(backfill_job) = &mut this.backfill_job {
504            debug!(target: "exex::notifications", "Polling backfill job");
505
506            // Drain the notification channel to prevent backpressure from stalling the
507            // ExExManager. During backfill, the ExEx is not consuming from the channel,
508            // so the capacity-1 channel fills up, which blocks the manager's PollSender,
509            // which fills the manager's 1024-entry buffer, which blocks all upstream
510            // senders. Notifications for blocks covered by the backfill range are
511            // discarded (they'll be re-delivered by the backfill job), while
512            // notifications beyond the backfill range are buffered for delivery after the
513            // backfill completes.
514            while let Poll::Ready(Some(notification)) = this.notifications.poll_recv(cx) {
515                // Always buffer revert-containing notifications (ChainReverted,
516                // ChainReorged) because the backfill job only re-delivers
517                // ChainCommitted from the database. Discarding a reorg here would
518                // leave the ExEx unaware of the fork switch.
519                if notification.reverted_chain().is_some() {
520                    this.pending_notifications.push_back(notification);
521                    continue;
522                }
523                if let Some(committed) = notification.committed_chain() &&
524                    committed.tip().number() <= this.initial_local_head.number
525                {
526                    // Covered by backfill range, safe to discard
527                    continue;
528                }
529                // Beyond the backfill range — buffer for delivery after backfill
530                this.pending_notifications.push_back(notification);
531            }
532
533            if let Some(chain) = ready!(backfill_job.poll_next_unpin(cx)).transpose()? {
534                debug!(target: "exex::notifications", range = ?chain.range(), "Backfill job returned a chain");
535                return Poll::Ready(Some(Ok(ExExNotification::ChainCommitted {
536                    new: Arc::new(chain),
537                })))
538            }
539
540            // Backfill job is done, remove it
541            this.backfill_job = None;
542        }
543
544        // 4. Deliver any notifications that were buffered during backfill
545        if let Some(notification) = this.pending_notifications.pop_front() {
546            return Poll::Ready(Some(Ok(notification)))
547        }
548
549        // 5. Otherwise advance the regular event stream
550        loop {
551            let Some(notification) = ready!(this.notifications.poll_recv(cx)) else {
552                return Poll::Ready(None)
553            };
554
555            // 6. In case the exex is ahead of the new tip, we must skip it
556            if let Some(committed) = notification.committed_chain() {
557                // inclusive check because we should start with `exex.head + 1`
558                if this.initial_exex_head.block.number >= committed.tip().number() {
559                    continue
560                }
561            }
562
563            return Poll::Ready(Some(Ok(notification)))
564        }
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use crate::Wal;
572    use alloy_consensus::Header;
573    use alloy_eips::BlockNumHash;
574    use eyre::OptionExt;
575    use futures::StreamExt;
576    use reth_db_common::init::init_genesis;
577    use reth_ethereum_primitives::Block;
578    use reth_evm_ethereum::EthEvmConfig;
579    use reth_primitives_traits::Block as _;
580    use reth_provider::{
581        providers::BlockchainProvider, test_utils::create_test_provider_factory, BlockWriter,
582        Chain, DBProvider, DatabaseProviderFactory,
583    };
584    use reth_testing_utils::generators::{self, random_block, BlockParams};
585    use std::collections::BTreeMap;
586    use tokio::sync::mpsc;
587
588    #[tokio::test]
589    async fn exex_notifications_behind_head_canonical() -> eyre::Result<()> {
590        let mut rng = generators::rng();
591
592        let temp_dir = tempfile::tempdir().unwrap();
593        let wal = Wal::new(temp_dir.path()).unwrap();
594
595        let provider_factory = create_test_provider_factory();
596        let genesis_hash = init_genesis(&provider_factory)?;
597        let genesis_block = provider_factory
598            .block(genesis_hash.into())?
599            .ok_or_else(|| eyre::eyre!("genesis block not found"))?;
600
601        let provider = BlockchainProvider::new(provider_factory.clone())?;
602
603        let node_head_block = random_block(
604            &mut rng,
605            genesis_block.number + 1,
606            BlockParams { parent: Some(genesis_hash), tx_count: Some(0), ..Default::default() },
607        )
608        .try_recover()?;
609        let node_head = node_head_block.num_hash();
610        let provider_rw = provider_factory.provider_rw()?;
611        provider_rw.insert_block(&node_head_block)?;
612        provider_rw.commit()?;
613        let exex_head =
614            ExExHead { block: BlockNumHash { number: genesis_block.number, hash: genesis_hash } };
615
616        let notification = ExExNotification::ChainCommitted {
617            new: Arc::new(Chain::new(
618                vec![random_block(
619                    &mut rng,
620                    node_head.number + 1,
621                    BlockParams { parent: Some(node_head.hash), ..Default::default() },
622                )
623                .try_recover()?],
624                Default::default(),
625                BTreeMap::new(),
626            )),
627        };
628
629        let (notifications_tx, notifications_rx) = mpsc::channel(1);
630
631        notifications_tx.send(notification.clone()).await?;
632
633        let mut notifications = ExExNotificationsWithoutHead::new(
634            node_head,
635            provider,
636            EthEvmConfig::mainnet(),
637            notifications_rx,
638            wal.handle(),
639        )
640        .with_head(exex_head);
641
642        // First notification is the backfill of missing blocks from the canonical chain
643        assert_eq!(
644            notifications.next().await.transpose()?,
645            Some(ExExNotification::ChainCommitted {
646                new: Arc::new(
647                    BackfillJobFactory::new(
648                        notifications.evm_config.clone(),
649                        notifications.provider.clone()
650                    )
651                    .backfill(1..=1)
652                    .next()
653                    .ok_or_eyre("failed to backfill")??
654                )
655            })
656        );
657
658        // Second notification is the actual notification that we sent before
659        assert_eq!(notifications.next().await.transpose()?, Some(notification));
660
661        Ok(())
662    }
663
664    #[tokio::test]
665    async fn catch_up_with_head_after_pause_backfills_missed_blocks() -> eyre::Result<()> {
666        let mut rng = generators::rng();
667
668        let temp_dir = tempfile::tempdir().unwrap();
669        let wal = Wal::new(temp_dir.path()).unwrap();
670
671        let provider_factory = create_test_provider_factory();
672        let genesis_hash = init_genesis(&provider_factory)?;
673        let genesis_block = provider_factory
674            .block(genesis_hash.into())?
675            .ok_or_else(|| eyre::eyre!("genesis block not found"))?;
676        let provider = BlockchainProvider::new(provider_factory.clone())?;
677
678        let exex_head =
679            ExExHead { block: BlockNumHash { number: genesis_block.number, hash: genesis_hash } };
680        let (notifications_tx, notifications_rx) = mpsc::channel(1);
681
682        let evm_config = EthEvmConfig::mainnet();
683        let mut notifications = ExExNotifications::new(
684            BlockNumHash { number: genesis_block.number, hash: genesis_hash },
685            provider.clone(),
686            evm_config.clone(),
687            notifications_rx,
688            wal.handle(),
689        );
690        // The ExEx configures its head at launch, as usual.
691        notifications.set_with_head(exex_head);
692
693        // Block 1 is delivered live and consumed, but the ExEx fails to durably process it.
694        let node_head_block = random_block(
695            &mut rng,
696            genesis_block.number + 1,
697            BlockParams { parent: Some(genesis_hash), tx_count: Some(0), ..Default::default() },
698        )
699        .try_recover()?;
700        let node_head = node_head_block.num_hash();
701        let block_1_notification = ExExNotification::ChainCommitted {
702            new: Arc::new(Chain::new(
703                vec![node_head_block.clone()],
704                Default::default(),
705                BTreeMap::new(),
706            )),
707        };
708        notifications_tx.send(block_1_notification.clone()).await?;
709        assert_eq!(notifications.next().await.transpose()?, Some(block_1_notification));
710
711        // Meanwhile the node commits block 1 and advances its canonical head past the
712        // launch-time head.
713        let provider_rw = provider_factory.provider_rw()?;
714        provider_rw.insert_block(&node_head_block)?;
715        provider_rw.commit()?;
716        provider
717            .canonical_in_memory_state()
718            .set_canonical_head(node_head_block.clone_sealed_header());
719
720        // The ExEx recovers still at genesis and catches up, it needs block 1 again, but that
721        // notification is long gone from the channel.
722        notifications.catch_up_with_head(exex_head)?;
723
724        let block_2_notification = ExExNotification::ChainCommitted {
725            new: Arc::new(Chain::new(
726                vec![random_block(
727                    &mut rng,
728                    node_head.number + 1,
729                    BlockParams { parent: Some(node_head.hash), ..Default::default() },
730                )
731                .try_recover()?],
732                Default::default(),
733                BTreeMap::new(),
734            )),
735        };
736        notifications_tx.send(block_2_notification.clone()).await?;
737
738        // Backfill re-delivers block 1 up to the node's current head
739        assert_eq!(
740            notifications.next().await.transpose()?,
741            Some(ExExNotification::ChainCommitted {
742                new: Arc::new(
743                    BackfillJobFactory::new(evm_config, provider)
744                        .backfill(1..=1)
745                        .next()
746                        .ok_or_eyre("failed to backfill")??
747                )
748            })
749        );
750        // followed by the live notification for block 2.
751        assert_eq!(notifications.next().await.transpose()?, Some(block_2_notification));
752
753        Ok(())
754    }
755
756    #[tokio::test]
757    async fn exex_notifications_same_head_canonical() -> eyre::Result<()> {
758        let temp_dir = tempfile::tempdir().unwrap();
759        let wal = Wal::new(temp_dir.path()).unwrap();
760
761        let provider_factory = create_test_provider_factory();
762        let genesis_hash = init_genesis(&provider_factory)?;
763        let genesis_block = provider_factory
764            .block(genesis_hash.into())?
765            .ok_or_else(|| eyre::eyre!("genesis block not found"))?;
766
767        let provider = BlockchainProvider::new(provider_factory)?;
768
769        let node_head = BlockNumHash { number: genesis_block.number, hash: genesis_hash };
770        let exex_head = ExExHead { block: node_head };
771
772        let notification = ExExNotification::ChainCommitted {
773            new: Arc::new(Chain::new(
774                vec![Block {
775                    header: Header {
776                        parent_hash: node_head.hash,
777                        number: node_head.number + 1,
778                        ..Default::default()
779                    },
780                    ..Default::default()
781                }
782                .seal_slow()
783                .try_recover()?],
784                Default::default(),
785                BTreeMap::new(),
786            )),
787        };
788
789        let (notifications_tx, notifications_rx) = mpsc::channel(1);
790
791        notifications_tx.send(notification.clone()).await?;
792
793        let mut notifications = ExExNotificationsWithoutHead::new(
794            node_head,
795            provider,
796            EthEvmConfig::mainnet(),
797            notifications_rx,
798            wal.handle(),
799        )
800        .with_head(exex_head);
801
802        let new_notification = notifications.next().await.transpose()?;
803        assert_eq!(new_notification, Some(notification));
804
805        Ok(())
806    }
807
808    #[tokio::test]
809    async fn exex_notifications_same_head_non_canonical() -> eyre::Result<()> {
810        let mut rng = generators::rng();
811
812        let temp_dir = tempfile::tempdir().unwrap();
813        let wal = Wal::new(temp_dir.path()).unwrap();
814
815        let provider_factory = create_test_provider_factory();
816        let genesis_hash = init_genesis(&provider_factory)?;
817        let genesis_block = provider_factory
818            .block(genesis_hash.into())?
819            .ok_or_else(|| eyre::eyre!("genesis block not found"))?;
820
821        let provider = BlockchainProvider::new(provider_factory)?;
822
823        let node_head_block = random_block(
824            &mut rng,
825            genesis_block.number + 1,
826            BlockParams { parent: Some(genesis_hash), tx_count: Some(0), ..Default::default() },
827        )
828        .try_recover()?;
829        let node_head = node_head_block.num_hash();
830        let provider_rw = provider.database_provider_rw()?;
831        provider_rw.insert_block(&node_head_block)?;
832        provider_rw.commit()?;
833        let node_head_notification = ExExNotification::ChainCommitted {
834            new: Arc::new(
835                BackfillJobFactory::new(EthEvmConfig::mainnet(), provider.clone())
836                    .backfill(node_head.number..=node_head.number)
837                    .next()
838                    .ok_or_else(|| eyre::eyre!("failed to backfill"))??,
839            ),
840        };
841
842        let exex_head_block = random_block(
843            &mut rng,
844            genesis_block.number + 1,
845            BlockParams { parent: Some(genesis_hash), tx_count: Some(0), ..Default::default() },
846        );
847        let exex_head = ExExHead { block: exex_head_block.num_hash() };
848        let exex_head_notification = ExExNotification::ChainCommitted {
849            new: Arc::new(Chain::new(
850                vec![exex_head_block.clone().try_recover()?],
851                Default::default(),
852                BTreeMap::new(),
853            )),
854        };
855        wal.commit(&exex_head_notification)?;
856
857        let new_notification = ExExNotification::ChainCommitted {
858            new: Arc::new(Chain::new(
859                vec![random_block(
860                    &mut rng,
861                    node_head.number + 1,
862                    BlockParams { parent: Some(node_head.hash), ..Default::default() },
863                )
864                .try_recover()?],
865                Default::default(),
866                BTreeMap::new(),
867            )),
868        };
869
870        let (notifications_tx, notifications_rx) = mpsc::channel(1);
871
872        notifications_tx.send(new_notification.clone()).await?;
873
874        let mut notifications = ExExNotificationsWithoutHead::new(
875            node_head,
876            provider,
877            EthEvmConfig::mainnet(),
878            notifications_rx,
879            wal.handle(),
880        )
881        .with_head(exex_head);
882
883        // First notification is the revert of the ExEx head block to get back to the canonical
884        // chain
885        assert_eq!(
886            notifications.next().await.transpose()?,
887            Some(exex_head_notification.into_inverted())
888        );
889        // Second notification is the backfilled block from the canonical chain to get back to the
890        // canonical tip
891        assert_eq!(notifications.next().await.transpose()?, Some(node_head_notification));
892        // Third notification is the actual notification that we sent before
893        assert_eq!(notifications.next().await.transpose()?, Some(new_notification));
894
895        Ok(())
896    }
897
898    #[tokio::test]
899    async fn test_notifications_ahead_of_head() -> eyre::Result<()> {
900        reth_tracing::init_test_tracing();
901        let mut rng = generators::rng();
902
903        let temp_dir = tempfile::tempdir().unwrap();
904        let wal = Wal::new(temp_dir.path()).unwrap();
905
906        let provider_factory = create_test_provider_factory();
907        let genesis_hash = init_genesis(&provider_factory)?;
908        let genesis_block = provider_factory
909            .block(genesis_hash.into())?
910            .ok_or_else(|| eyre::eyre!("genesis block not found"))?;
911
912        let provider = BlockchainProvider::new(provider_factory)?;
913
914        let exex_head_block = random_block(
915            &mut rng,
916            genesis_block.number + 1,
917            BlockParams { parent: Some(genesis_hash), tx_count: Some(0), ..Default::default() },
918        );
919        let exex_head_notification = ExExNotification::ChainCommitted {
920            new: Arc::new(Chain::new(
921                vec![exex_head_block.clone().try_recover()?],
922                Default::default(),
923                BTreeMap::new(),
924            )),
925        };
926        wal.commit(&exex_head_notification)?;
927
928        let node_head = BlockNumHash { number: genesis_block.number, hash: genesis_hash };
929        let exex_head = ExExHead {
930            block: BlockNumHash { number: exex_head_block.number, hash: exex_head_block.hash() },
931        };
932
933        let new_notification = ExExNotification::ChainCommitted {
934            new: Arc::new(Chain::new(
935                vec![random_block(
936                    &mut rng,
937                    genesis_block.number + 1,
938                    BlockParams { parent: Some(genesis_hash), ..Default::default() },
939                )
940                .try_recover()?],
941                Default::default(),
942                BTreeMap::new(),
943            )),
944        };
945
946        let (notifications_tx, notifications_rx) = mpsc::channel(1);
947
948        notifications_tx.send(new_notification.clone()).await?;
949
950        let mut notifications = ExExNotificationsWithoutHead::new(
951            node_head,
952            provider,
953            EthEvmConfig::mainnet(),
954            notifications_rx,
955            wal.handle(),
956        )
957        .with_head(exex_head);
958
959        // First notification is the revert of the ExEx head block to get back to the canonical
960        // chain
961        assert_eq!(
962            notifications.next().await.transpose()?,
963            Some(exex_head_notification.into_inverted())
964        );
965
966        // Second notification is the actual notification that we sent before
967        assert_eq!(notifications.next().await.transpose()?, Some(new_notification));
968
969        Ok(())
970    }
971
972    /// Regression test for <https://github.com/paradigmxyz/reth/issues/19665>.
973    ///
974    /// During backfill, `poll_next` must drain the notification channel so that
975    /// the upstream `ExExManager` is never blocked by a full channel. Without
976    /// the drain loop the capacity-1 channel stays full for the entire backfill
977    /// duration, which stalls the manager's `PollSender` and eventually blocks
978    /// all upstream senders once the 1024-entry buffer fills up.
979    ///
980    /// The key assertion is the `try_send` after the first `poll_next`: it
981    /// proves the channel was drained during the backfill poll. Without the
982    /// fix this `try_send` fails because the notification is still sitting in
983    /// the channel.
984    #[tokio::test]
985    async fn exex_notifications_backfill_drains_channel() -> eyre::Result<()> {
986        let mut rng = generators::rng();
987
988        let temp_dir = tempfile::tempdir().unwrap();
989        let wal = Wal::new(temp_dir.path()).unwrap();
990
991        let provider_factory = create_test_provider_factory();
992        let genesis_hash = init_genesis(&provider_factory)?;
993        let genesis_block = provider_factory
994            .block(genesis_hash.into())?
995            .ok_or_else(|| eyre::eyre!("genesis block not found"))?;
996
997        let provider = BlockchainProvider::new(provider_factory.clone())?;
998
999        // Insert block 1 into the DB so there's something to backfill
1000        let node_head_block = random_block(
1001            &mut rng,
1002            genesis_block.number + 1,
1003            BlockParams { parent: Some(genesis_hash), tx_count: Some(0), ..Default::default() },
1004        )
1005        .try_recover()?;
1006        let node_head = node_head_block.num_hash();
1007        let provider_rw = provider_factory.provider_rw()?;
1008        provider_rw.insert_block(&node_head_block)?;
1009        provider_rw.commit()?;
1010
1011        // ExEx head is at genesis — backfill will run for block 1
1012        let exex_head =
1013            ExExHead { block: BlockNumHash { number: genesis_block.number, hash: genesis_hash } };
1014
1015        // Notification for a block AFTER the backfill range (block 2).
1016        let post_backfill_notification = ExExNotification::ChainCommitted {
1017            new: Arc::new(Chain::new(
1018                vec![random_block(
1019                    &mut rng,
1020                    node_head.number + 1,
1021                    BlockParams { parent: Some(node_head.hash), ..Default::default() },
1022                )
1023                .try_recover()?],
1024                Default::default(),
1025                BTreeMap::new(),
1026            )),
1027        };
1028
1029        // Another notification (block 3) used to probe channel capacity.
1030        let probe_notification = ExExNotification::ChainCommitted {
1031            new: Arc::new(Chain::new(
1032                vec![random_block(
1033                    &mut rng,
1034                    node_head.number + 2,
1035                    BlockParams { parent: None, ..Default::default() },
1036                )
1037                .try_recover()?],
1038                Default::default(),
1039                BTreeMap::new(),
1040            )),
1041        };
1042
1043        let (notifications_tx, notifications_rx) = mpsc::channel(1);
1044
1045        // Fill the capacity-1 channel.
1046        notifications_tx.send(post_backfill_notification.clone()).await?;
1047
1048        // Confirm the channel is full — this is the precondition that causes the
1049        // stall in production: the ExExManager's PollSender would block here.
1050        assert!(
1051            notifications_tx.try_send(probe_notification.clone()).is_err(),
1052            "channel should be full before backfill poll"
1053        );
1054
1055        let mut notifications = ExExNotificationsWithoutHead::new(
1056            node_head,
1057            provider,
1058            EthEvmConfig::mainnet(),
1059            notifications_rx,
1060            wal.handle(),
1061        )
1062        .with_head(exex_head);
1063
1064        // Poll once — this returns the backfill result for block 1. Crucially,
1065        // the drain loop in poll_next runs in this same call, consuming the
1066        // notification from the channel and buffering it.
1067        let backfill_result = notifications.next().await.transpose()?;
1068        assert_eq!(
1069            backfill_result,
1070            Some(ExExNotification::ChainCommitted {
1071                new: Arc::new(
1072                    BackfillJobFactory::new(
1073                        notifications.evm_config.clone(),
1074                        notifications.provider.clone()
1075                    )
1076                    .backfill(1..=1)
1077                    .next()
1078                    .ok_or_eyre("failed to backfill")??
1079                )
1080            })
1081        );
1082
1083        // KEY ASSERTION: the channel was drained during the backfill poll above.
1084        // Without the drain loop this try_send fails because the original
1085        // notification is still occupying the capacity-1 channel.
1086        assert!(
1087            notifications_tx.try_send(probe_notification.clone()).is_ok(),
1088            "channel should have been drained during backfill poll"
1089        );
1090
1091        // The first buffered notification (block 2) was drained from the channel
1092        // during backfill and is delivered now.
1093        let buffered = notifications.next().await.transpose()?;
1094        assert_eq!(buffered, Some(post_backfill_notification));
1095
1096        // The probe notification (block 3) that we just sent is delivered next.
1097        let probe = notifications.next().await.transpose()?;
1098        assert_eq!(probe, Some(probe_notification));
1099
1100        Ok(())
1101    }
1102}