Skip to main content

reth_exex/wal/
mod.rs

1#![allow(dead_code)]
2
3mod cache;
4pub use cache::BlockCache;
5mod storage;
6use reth_ethereum_primitives::EthPrimitives;
7use reth_node_api::NodePrimitives;
8pub use storage::Storage;
9mod metrics;
10use metrics::Metrics;
11mod error;
12pub use error::{WalError, WalResult};
13
14use std::{
15    path::Path,
16    sync::{
17        atomic::{AtomicU32, Ordering},
18        Arc,
19    },
20};
21
22use alloy_eips::BlockNumHash;
23use alloy_primitives::B256;
24use parking_lot::{RwLock, RwLockReadGuard};
25use reth_exex_types::ExExNotification;
26use reth_tracing::tracing::{debug, instrument};
27
28/// WAL is a write-ahead log (WAL) that stores the notifications sent to ExExes.
29///
30/// WAL is backed by a directory of binary files represented by [`Storage`] and a block cache
31/// represented by [`BlockCache`]. The role of the block cache is to avoid walking the WAL directory
32/// and decoding notifications every time we want to iterate or finalize the WAL.
33///
34/// The expected mode of operation is as follows:
35/// 1. On every new canonical chain notification, call [`Wal::commit`].
36/// 2. When the chain is finalized, call [`Wal::finalize`] to prevent the infinite growth of the
37///    WAL.
38#[derive(Debug, Clone)]
39pub struct Wal<N: NodePrimitives = EthPrimitives> {
40    inner: Arc<WalInner<N>>,
41}
42
43impl<N> Wal<N>
44where
45    N: NodePrimitives,
46{
47    /// Creates a new instance of [`Wal`].
48    pub fn new(directory: impl AsRef<Path>) -> WalResult<Self> {
49        Ok(Self { inner: Arc::new(WalInner::new(directory)?) })
50    }
51
52    /// Returns a read-only handle to the WAL.
53    pub fn handle(&self) -> WalHandle<N> {
54        WalHandle { wal: self.inner.clone() }
55    }
56
57    /// Commits the notification to WAL.
58    pub fn commit(&self, notification: &ExExNotification<N>) -> WalResult<()> {
59        self.inner.commit(notification)
60    }
61
62    /// Finalizes the WAL up to the given canonical block, inclusive.
63    ///
64    /// The caller should check that all ExExes are on the canonical chain and will not need any
65    /// blocks from the WAL below the provided block, inclusive.
66    pub fn finalize(&self, to_block: BlockNumHash) -> WalResult<()> {
67        self.inner.finalize(to_block)
68    }
69
70    /// Returns an iterator over all notifications in the WAL.
71    pub fn iter_notifications(
72        &self,
73    ) -> WalResult<Box<dyn Iterator<Item = WalResult<ExExNotification<N>>> + '_>> {
74        self.inner.iter_notifications()
75    }
76
77    /// Returns the number of blocks in the WAL.
78    pub fn num_blocks(&self) -> usize {
79        self.inner.block_cache().num_blocks()
80    }
81}
82
83/// Inner type for the WAL.
84#[derive(Debug)]
85struct WalInner<N: NodePrimitives> {
86    next_file_id: AtomicU32,
87    /// The underlying WAL storage backed by a file.
88    storage: Storage<N>,
89    /// WAL block cache. See [`cache::BlockCache`] docs for more details.
90    block_cache: RwLock<BlockCache>,
91    metrics: Metrics,
92}
93
94impl<N> WalInner<N>
95where
96    N: NodePrimitives,
97{
98    fn new(directory: impl AsRef<Path>) -> WalResult<Self> {
99        let wal = Self {
100            next_file_id: AtomicU32::new(0),
101            storage: Storage::new(directory)?,
102            block_cache: RwLock::new(BlockCache::default()),
103            metrics: Metrics::default(),
104        };
105        wal.fill_block_cache()?;
106        Ok(wal)
107    }
108
109    fn block_cache(&self) -> RwLockReadGuard<'_, BlockCache> {
110        self.block_cache.read()
111    }
112
113    /// Fills the block cache with the notifications from the storage.
114    #[instrument(skip(self))]
115    fn fill_block_cache(&self) -> WalResult<()> {
116        let file_ids = self.storage.file_ids()?;
117        let Some(last_file_id) = file_ids.last() else { return Ok(()) };
118        self.next_file_id.store(last_file_id + 1, Ordering::Relaxed);
119
120        let mut block_cache = self.block_cache.write();
121        let mut notifications_size = 0;
122
123        for entry in self.storage.iter_notifications(file_ids) {
124            let (file_id, size, notification) = entry?;
125
126            notifications_size += size;
127
128            let committed_chain = notification.committed_chain();
129            let reverted_chain = notification.reverted_chain();
130
131            debug!(
132                target: "exex::wal",
133                ?file_id,
134                reverted_block_range = ?reverted_chain.as_ref().map(|chain| chain.range()),
135                committed_block_range = ?committed_chain.as_ref().map(|chain| chain.range()),
136                "Inserting block cache entries"
137            );
138
139            block_cache.insert_notification_blocks_with_file_id(file_id, &notification);
140        }
141
142        self.update_metrics(&block_cache, notifications_size as i64);
143
144        Ok(())
145    }
146
147    #[instrument(skip_all, fields(
148        reverted_block_range = ?notification.reverted_chain().as_ref().map(|chain| chain.range()),
149        committed_block_range = ?notification.committed_chain().as_ref().map(|chain| chain.range())
150    ))]
151    fn commit(&self, notification: &ExExNotification<N>) -> WalResult<()> {
152        let mut block_cache = self.block_cache.write();
153
154        let file_id = self.next_file_id.fetch_add(1, Ordering::Relaxed);
155        let size = self.storage.write_notification(file_id, notification)?;
156
157        debug!(target: "exex::wal", ?file_id, "Inserting notification blocks into the block cache");
158        block_cache.insert_notification_blocks_with_file_id(file_id, notification);
159
160        self.update_metrics(&block_cache, size as i64);
161
162        Ok(())
163    }
164
165    #[instrument(skip(self))]
166    fn finalize(&self, to_block: BlockNumHash) -> WalResult<()> {
167        let mut block_cache = self.block_cache.write();
168        let file_ids = block_cache.remove_before(to_block.number);
169
170        // Remove notifications from the storage.
171        if file_ids.is_empty() {
172            debug!(target: "exex::wal", "No notifications were finalized from the storage");
173            return Ok(())
174        }
175
176        let (removed_notifications, removed_size) = self.storage.remove_notifications(file_ids)?;
177        debug!(target: "exex::wal", ?removed_notifications, ?removed_size, "Storage was finalized");
178
179        self.update_metrics(&block_cache, -(removed_size as i64));
180
181        Ok(())
182    }
183
184    fn update_metrics(&self, block_cache: &BlockCache, size_delta: i64) {
185        self.metrics.size_bytes.increment(size_delta as f64);
186        self.metrics.notifications_count.set(block_cache.notification_max_blocks.len() as f64);
187        self.metrics.committed_blocks_count.set(block_cache.committed_blocks.len() as f64);
188
189        if let Some(lowest_committed_block_height) = block_cache.lowest_committed_block_height {
190            self.metrics.lowest_committed_block_height.set(lowest_committed_block_height as f64);
191        }
192
193        if let Some(highest_committed_block_height) = block_cache.highest_committed_block_height {
194            self.metrics.highest_committed_block_height.set(highest_committed_block_height as f64);
195        }
196    }
197
198    /// Returns an iterator over all notifications in the WAL.
199    fn iter_notifications(
200        &self,
201    ) -> WalResult<Box<dyn Iterator<Item = WalResult<ExExNotification<N>>> + '_>> {
202        Ok(Box::new(
203            self.storage.iter_notifications(self.storage.file_ids()?).map(|entry| Ok(entry?.2)),
204        ))
205    }
206}
207
208/// A read-only handle to the WAL that can be shared.
209#[derive(Debug)]
210pub struct WalHandle<N: NodePrimitives> {
211    wal: Arc<WalInner<N>>,
212}
213
214impl<N> WalHandle<N>
215where
216    N: NodePrimitives,
217{
218    /// Returns the notification for the given committed block hash if it exists.
219    pub fn get_committed_notification_by_block_hash(
220        &self,
221        block_hash: &B256,
222    ) -> WalResult<Option<ExExNotification<N>>> {
223        let Some(file_id) = self.wal.block_cache().get_file_id_by_committed_block_hash(block_hash)
224        else {
225            return Ok(None)
226        };
227
228        self.wal
229            .storage
230            .read_notification(file_id)
231            .map(|entry| entry.map(|(notification, _)| notification))
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use crate::wal::{cache::CachedBlock, error::WalResult, Wal};
238    use alloy_primitives::B256;
239    use itertools::Itertools;
240    use reth_ethereum_primitives::EthPrimitives;
241    use reth_exex_types::ExExNotification;
242    use reth_provider::Chain;
243    use reth_testing_utils::generators::{
244        self, random_block, random_block_range, BlockParams, BlockRangeParams,
245    };
246    use std::{collections::BTreeMap, sync::Arc};
247
248    fn read_notifications(wal: &Wal) -> WalResult<Vec<ExExNotification>> {
249        wal.inner
250            .storage
251            .iter_notifications(wal.inner.storage.file_ids()?)
252            .map(|entry| entry.map(|(_, _, n)| n))
253            .collect()
254    }
255
256    fn sort_committed_blocks(
257        committed_blocks: Vec<(B256, u32, CachedBlock)>,
258    ) -> Vec<(B256, u32, CachedBlock)> {
259        committed_blocks
260            .into_iter()
261            .sorted_by_key(|(_, _, block)| (block.block.number, block.block.hash))
262            .collect()
263    }
264
265    #[test]
266    fn test_wal() -> eyre::Result<()> {
267        reth_tracing::init_test_tracing();
268
269        let mut rng = generators::rng();
270
271        // Create an instance of the WAL in a temporary directory
272        let temp_dir = tempfile::tempdir()?;
273        let wal = Wal::new(&temp_dir)?;
274        assert!(wal.inner.block_cache().is_empty());
275
276        // Create 4 canonical blocks and one reorged block with number 2
277        let blocks = random_block_range(&mut rng, 0..=3, BlockRangeParams::default())
278            .into_iter()
279            .map(|block| block.try_recover())
280            .collect::<Result<Vec<_>, _>>()?;
281        let block_1_reorged = random_block(
282            &mut rng,
283            1,
284            BlockParams { parent: Some(blocks[0].hash()), ..Default::default() },
285        )
286        .try_recover()?;
287        let block_2_reorged = random_block(
288            &mut rng,
289            2,
290            BlockParams { parent: Some(blocks[1].hash()), ..Default::default() },
291        )
292        .try_recover()?;
293
294        // Create notifications for the above blocks.
295        // 1. Committed notification for blocks with number 0 and 1
296        // 2. Reverted notification for block with number 1
297        // 3. Committed notification for block with number 1 and 2
298        // 4. Reorged notification for block with number 2 that was reverted, and blocks with number
299        //    2 and 3 that were committed
300        let committed_notification_1 = ExExNotification::ChainCommitted {
301            new: Arc::new(Chain::new(
302                vec![blocks[0].clone(), blocks[1].clone()],
303                Default::default(),
304                BTreeMap::new(),
305            )),
306        };
307        let reverted_notification = ExExNotification::ChainReverted {
308            old: Arc::new(Chain::new(vec![blocks[1].clone()], Default::default(), BTreeMap::new())),
309        };
310        let committed_notification_2 = ExExNotification::ChainCommitted {
311            new: Arc::new(Chain::new(
312                vec![block_1_reorged.clone(), blocks[2].clone()],
313                Default::default(),
314                BTreeMap::new(),
315            )),
316        };
317        let reorged_notification = ExExNotification::ChainReorged {
318            old: Arc::new(Chain::new(vec![blocks[2].clone()], Default::default(), BTreeMap::new())),
319            new: Arc::new(Chain::new(
320                vec![block_2_reorged.clone(), blocks[3].clone()],
321                Default::default(),
322                BTreeMap::new(),
323            )),
324        };
325
326        // Commit notifications, verify that the block cache is updated and the notifications are
327        // written to WAL.
328
329        // First notification (commit block 0, 1)
330        let file_id = 0;
331        let committed_notification_1_cache_blocks = (blocks[1].number, file_id);
332        let committed_notification_1_cache_committed_blocks = vec![
333            (
334                blocks[0].hash(),
335                file_id,
336                CachedBlock {
337                    block: (blocks[0].number, blocks[0].hash()).into(),
338                    parent_hash: blocks[0].parent_hash,
339                },
340            ),
341            (
342                blocks[1].hash(),
343                file_id,
344                CachedBlock {
345                    block: (blocks[1].number, blocks[1].hash()).into(),
346                    parent_hash: blocks[1].parent_hash,
347                },
348            ),
349        ];
350        wal.commit(&committed_notification_1)?;
351        assert_eq!(
352            wal.inner.block_cache().blocks_sorted(),
353            [committed_notification_1_cache_blocks]
354        );
355        assert_eq!(
356            wal.inner.block_cache().committed_blocks_sorted(),
357            committed_notification_1_cache_committed_blocks
358        );
359        assert_eq!(read_notifications(&wal)?, vec![committed_notification_1.clone()]);
360
361        // Second notification (revert block 1)
362        wal.commit(&reverted_notification)?;
363        let file_id = 1;
364        let reverted_notification_cache_blocks = (blocks[1].number, file_id);
365        assert_eq!(
366            wal.inner.block_cache().blocks_sorted(),
367            [reverted_notification_cache_blocks, committed_notification_1_cache_blocks]
368        );
369        assert_eq!(
370            wal.inner.block_cache().committed_blocks_sorted(),
371            committed_notification_1_cache_committed_blocks
372        );
373        assert_eq!(
374            read_notifications(&wal)?,
375            vec![committed_notification_1.clone(), reverted_notification.clone()]
376        );
377
378        // Third notification (commit block 1, 2)
379        wal.commit(&committed_notification_2)?;
380        let file_id = 2;
381        let committed_notification_2_cache_blocks = (blocks[2].number, file_id);
382        let committed_notification_2_cache_committed_blocks = vec![
383            (
384                block_1_reorged.hash(),
385                file_id,
386                CachedBlock {
387                    block: (block_1_reorged.number, block_1_reorged.hash()).into(),
388                    parent_hash: block_1_reorged.parent_hash,
389                },
390            ),
391            (
392                blocks[2].hash(),
393                file_id,
394                CachedBlock {
395                    block: (blocks[2].number, blocks[2].hash()).into(),
396                    parent_hash: blocks[2].parent_hash,
397                },
398            ),
399        ];
400        assert_eq!(
401            wal.inner.block_cache().blocks_sorted(),
402            [
403                committed_notification_2_cache_blocks,
404                reverted_notification_cache_blocks,
405                committed_notification_1_cache_blocks,
406            ]
407        );
408        assert_eq!(
409            wal.inner.block_cache().committed_blocks_sorted(),
410            sort_committed_blocks(
411                [
412                    committed_notification_1_cache_committed_blocks.clone(),
413                    committed_notification_2_cache_committed_blocks.clone()
414                ]
415                .concat()
416            )
417        );
418        assert_eq!(
419            read_notifications(&wal)?,
420            vec![
421                committed_notification_1.clone(),
422                reverted_notification.clone(),
423                committed_notification_2.clone()
424            ]
425        );
426
427        // Fourth notification (revert block 2, commit block 2, 3)
428        wal.commit(&reorged_notification)?;
429        let file_id = 3;
430        let reorged_notification_cache_blocks = (blocks[3].number, file_id);
431        let reorged_notification_cache_committed_blocks = vec![
432            (
433                block_2_reorged.hash(),
434                file_id,
435                CachedBlock {
436                    block: (block_2_reorged.number, block_2_reorged.hash()).into(),
437                    parent_hash: block_2_reorged.parent_hash,
438                },
439            ),
440            (
441                blocks[3].hash(),
442                file_id,
443                CachedBlock {
444                    block: (blocks[3].number, blocks[3].hash()).into(),
445                    parent_hash: blocks[3].parent_hash,
446                },
447            ),
448        ];
449        assert_eq!(
450            wal.inner.block_cache().blocks_sorted(),
451            [
452                reorged_notification_cache_blocks,
453                committed_notification_2_cache_blocks,
454                reverted_notification_cache_blocks,
455                committed_notification_1_cache_blocks,
456            ]
457        );
458        assert_eq!(
459            wal.inner.block_cache().committed_blocks_sorted(),
460            sort_committed_blocks(
461                [
462                    committed_notification_1_cache_committed_blocks,
463                    committed_notification_2_cache_committed_blocks.clone(),
464                    reorged_notification_cache_committed_blocks.clone()
465                ]
466                .concat()
467            )
468        );
469        assert_eq!(
470            read_notifications(&wal)?,
471            vec![
472                committed_notification_1,
473                reverted_notification,
474                committed_notification_2.clone(),
475                reorged_notification.clone()
476            ]
477        );
478
479        // Now, finalize the WAL up to the block 1. Block 1 was in the third notification that also
480        // had block 2 committed. In this case, we can't split the notification into two parts, so
481        // we preserve the whole notification in both the block cache and the storage, and delete
482        // the notifications before it.
483        wal.finalize((block_1_reorged.number, block_1_reorged.hash()).into())?;
484        assert_eq!(
485            wal.inner.block_cache().blocks_sorted(),
486            [reorged_notification_cache_blocks, committed_notification_2_cache_blocks]
487        );
488        assert_eq!(
489            wal.inner.block_cache().committed_blocks_sorted(),
490            sort_committed_blocks(
491                [
492                    committed_notification_2_cache_committed_blocks.clone(),
493                    reorged_notification_cache_committed_blocks.clone()
494                ]
495                .concat()
496            )
497        );
498        assert_eq!(
499            read_notifications(&wal)?,
500            vec![committed_notification_2.clone(), reorged_notification.clone()]
501        );
502
503        // Re-open the WAL and verify that the cache population works correctly
504        let wal = Wal::new(&temp_dir)?;
505        assert_eq!(
506            wal.inner.block_cache().blocks_sorted(),
507            [reorged_notification_cache_blocks, committed_notification_2_cache_blocks]
508        );
509        assert_eq!(
510            wal.inner.block_cache().committed_blocks_sorted(),
511            sort_committed_blocks(
512                [
513                    committed_notification_2_cache_committed_blocks,
514                    reorged_notification_cache_committed_blocks
515                ]
516                .concat()
517            )
518        );
519        assert_eq!(read_notifications(&wal)?, vec![committed_notification_2, reorged_notification]);
520
521        Ok(())
522    }
523
524    #[test]
525    fn test_finalize_with_sparse_file_ids() -> eyre::Result<()> {
526        let mut rng = generators::rng();
527
528        let temp_dir = tempfile::tempdir()?;
529        let wal = Wal::<EthPrimitives>::new(&temp_dir)?;
530
531        let blocks = random_block_range(&mut rng, 0..=5, BlockRangeParams::default())
532            .into_iter()
533            .map(|block| block.try_recover())
534            .collect::<Result<Vec<_>, _>>()?;
535        let chain = |range: std::ops::RangeInclusive<usize>| {
536            Arc::new(Chain::new(blocks[range].to_vec(), Default::default(), BTreeMap::new()))
537        };
538
539        let commit_to_four = ExExNotification::ChainCommitted { new: chain(1..=4) };
540        let revert_to_two = ExExNotification::ChainReverted { old: chain(2..=4) };
541        let commit_two = ExExNotification::ChainCommitted { new: chain(2..=2) };
542        let commit_to_five = ExExNotification::ChainCommitted { new: chain(3..=5) };
543
544        wal.commit(&commit_to_four)?;
545        wal.commit(&revert_to_two)?;
546        wal.commit(&commit_two)?;
547        wal.commit(&commit_to_five)?;
548        assert_eq!(wal.inner.storage.file_ids()?, vec![0, 1, 2, 3]);
549
550        // File 2 has a lower maximum block than the surrounding notifications, so finalizing it
551        // leaves a gap in the file ID sequence.
552        wal.finalize((blocks[2].number, blocks[2].hash()).into())?;
553        assert_eq!(wal.inner.storage.file_ids()?, vec![0, 1, 3]);
554
555        let wal = Wal::<EthPrimitives>::new(&temp_dir)?;
556        assert_eq!(read_notifications(&wal)?, vec![commit_to_four, revert_to_two, commit_to_five]);
557
558        wal.commit(&commit_two)?;
559        assert_eq!(wal.inner.storage.file_ids()?, vec![0, 1, 3, 4]);
560
561        Ok(())
562    }
563}