Skip to main content

reth_engine_tree/
persistence.rs

1use crate::metrics::PersistenceMetrics;
2use alloy_eips::BlockNumHash;
3use crossbeam_channel::Sender as CrossbeamSender;
4use reth_errors::ProviderError;
5use reth_ethereum_primitives::EthPrimitives;
6use reth_primitives_traits::{FastInstant as Instant, NodePrimitives};
7use reth_provider::{
8    providers::ProviderNodeTypes, BalProvider, BlockExecutionWriter, BlockHashReader,
9    ChainStateBlockWriter, DBProvider, DatabaseProviderFactory, ProviderFactory, SaveBlocksInput,
10};
11use reth_prune::{PrunerError, PrunerWithFactory};
12use reth_stages_api::{MetricEvent, MetricEventsSender};
13use reth_tasks::spawn_os_thread;
14use std::{
15    sync::{
16        mpsc::{Receiver, SendError, Sender},
17        Arc,
18    },
19    thread::JoinHandle,
20    time::Duration,
21};
22use thiserror::Error;
23use tracing::{debug, error, instrument, warn};
24
25/// Unified result of any persistence operation.
26#[derive(Debug)]
27pub struct PersistenceResult {
28    /// The highest block whose non-state/trie outputs are persisted.
29    pub last_block: BlockNumHash,
30    /// The state/trie persistence frontier.
31    pub last_state_trie_block: BlockNumHash,
32    /// The commit duration, only available for save-blocks operations.
33    pub commit_duration: Option<Duration>,
34}
35
36/// Writes parts of reth's in memory tree state to the database and static files.
37///
38/// This is meant to be a spawned service that listens for various incoming persistence operations,
39/// performing those actions on disk, and returning the result in a channel.
40///
41/// This should be spawned in its own thread with [`std::thread::spawn`], since this performs
42/// blocking I/O operations in an endless loop.
43#[derive(Debug)]
44pub struct PersistenceService<N>
45where
46    N: ProviderNodeTypes,
47{
48    /// The provider factory to use
49    provider: ProviderFactory<N>,
50    /// Incoming requests
51    incoming: Receiver<PersistenceAction<N::Primitives>>,
52    /// The pruner
53    pruner: PrunerWithFactory<ProviderFactory<N>>,
54    /// metrics
55    metrics: PersistenceMetrics,
56    /// Sender for sync metrics - we only submit sync metrics for persisted blocks
57    sync_metrics_tx: MetricEventsSender,
58    /// Pending finalized block number to be committed with the next block save.
59    /// This avoids triggering a separate fsync for each finalized block update.
60    pending_finalized_block: Option<u64>,
61    /// Pending safe block number to be committed with the next block save.
62    /// This avoids triggering a separate fsync for each safe block update.
63    pending_safe_block: Option<u64>,
64}
65
66impl<N> PersistenceService<N>
67where
68    N: ProviderNodeTypes,
69{
70    /// Create a new persistence service
71    pub fn new(
72        provider: ProviderFactory<N>,
73        incoming: Receiver<PersistenceAction<N::Primitives>>,
74        pruner: PrunerWithFactory<ProviderFactory<N>>,
75        sync_metrics_tx: MetricEventsSender,
76    ) -> Self {
77        Self {
78            provider,
79            incoming,
80            pruner,
81            metrics: PersistenceMetrics::default(),
82            sync_metrics_tx,
83            pending_finalized_block: None,
84            pending_safe_block: None,
85        }
86    }
87}
88
89impl<N> PersistenceService<N>
90where
91    N: ProviderNodeTypes,
92{
93    /// This is the main loop, that will listen to database events and perform the requested
94    /// database actions
95    pub fn run(mut self) -> Result<(), PersistenceError> {
96        // If the receiver errors then senders have disconnected, so the loop should then end.
97        while let Ok(action) = self.incoming.recv() {
98            match action {
99                PersistenceAction::RemoveBlocksAbove(new_tip_num, sender) => {
100                    let result = self.on_remove_blocks_above(new_tip_num)?;
101                    // send new sync metrics based on removed blocks
102                    let _ =
103                        self.sync_metrics_tx.send(MetricEvent::SyncHeight { height: new_tip_num });
104                    let _ = sender.send(result);
105                }
106                PersistenceAction::SaveBlocks(input, sender) => {
107                    let new_db_tip = input.new_db_tip();
108                    let db_tip_advanced = input.prev_db_tip() < new_db_tip;
109                    let result = self.on_save_blocks(input)?;
110
111                    let _ = sender.send(result);
112
113                    if db_tip_advanced {
114                        // send new sync metrics based on saved blocks
115                        let _ = self
116                            .sync_metrics_tx
117                            .send(MetricEvent::SyncHeight { height: new_db_tip });
118                        self.maybe_run_pruner(new_db_tip)?;
119                    }
120                }
121                PersistenceAction::SaveFinalizedBlock(finalized_block) => {
122                    self.pending_finalized_block = Some(finalized_block);
123                }
124                PersistenceAction::SaveSafeBlock(safe_block) => {
125                    self.pending_safe_block = Some(safe_block);
126                }
127            }
128        }
129        Ok(())
130    }
131
132    #[instrument(level = "debug", target = "engine::persistence", skip_all, fields(%new_tip_num))]
133    fn on_remove_blocks_above(
134        &self,
135        new_tip_num: u64,
136    ) -> Result<PersistenceResult, PersistenceError> {
137        debug!(target: "engine::persistence", ?new_tip_num, "Removing blocks");
138        let start_time = Instant::now();
139        let provider_rw = self.provider.database_provider_rw()?;
140
141        let new_tip_hash = provider_rw
142            .block_hash(new_tip_num)?
143            .ok_or_else(|| ProviderError::HeaderNotFound(new_tip_num.into()))?;
144        let frontiers = provider_rw.remove_block_and_execution_above(new_tip_num)?;
145        debug_assert_eq!(frontiers.db_tip, new_tip_num);
146        let last_block = BlockNumHash::new(new_tip_num, new_tip_hash);
147        let last_state_trie_block = if frontiers.partial_state_trie == new_tip_num {
148            last_block
149        } else {
150            let hash = provider_rw.block_hash(frontiers.partial_state_trie)?.ok_or_else(|| {
151                ProviderError::HeaderNotFound(frontiers.partial_state_trie.into())
152            })?;
153            BlockNumHash::new(frontiers.partial_state_trie, hash)
154        };
155        provider_rw.commit()?;
156
157        debug!(target: "engine::persistence", ?new_tip_num, ?new_tip_hash, "Removed blocks from disk");
158        self.metrics.remove_blocks_above_duration_seconds.record(start_time.elapsed());
159        Ok(PersistenceResult { last_block, last_state_trie_block, commit_duration: None })
160    }
161
162    #[instrument(level = "debug", target = "engine::persistence", skip_all, fields(block_count = input.persist_rest_blocks().len()))]
163    fn on_save_blocks(
164        &mut self,
165        input: SaveBlocksInput<N::Primitives>,
166    ) -> Result<PersistenceResult, PersistenceError> {
167        let first_block =
168            input.first_persist_rest_block().map(|block| block.recovered_block().num_hash());
169        let last_block = input.last_block();
170        let block_count = input.persist_rest_blocks().len();
171
172        let pending_finalized = self.pending_finalized_block.take();
173        let pending_safe = self.pending_safe_block.take();
174
175        debug!(target: "engine::persistence", ?block_count, first=?first_block, last=?last_block, "Saving range of blocks");
176
177        let start_time = Instant::now();
178
179        let canonical_blocks = input
180            .persist_rest_blocks()
181            .iter()
182            .map(|block| block.recovered_block().num_hash())
183            .collect::<Vec<_>>();
184        let provider_rw = self.provider.database_provider_rw()?;
185        let last_state_trie_block = if let Some(block) = input.state_trie_blocks().last() {
186            // Newly written static-file headers are not readable until commit finalizes their
187            // index.
188            block.recovered_block().num_hash()
189        } else {
190            // If the state/trie frontier did not advance, its block is excluded from
191            // `state_trie_blocks()` and must be loaded from already-persisted storage.
192            let number = input.new_partial_state_trie();
193            let hash = provider_rw
194                .block_hash(number)?
195                .ok_or_else(|| ProviderError::HeaderNotFound(number.into()))?;
196            BlockNumHash::new(number, hash)
197        };
198        provider_rw.save_blocks(&input)?;
199
200        if let Some(finalized) = pending_finalized {
201            provider_rw.save_finalized_block_number(finalized.min(last_block.number))?;
202            if finalized > last_block.number {
203                self.pending_finalized_block = Some(finalized);
204            }
205        }
206        if let Some(safe) = pending_safe {
207            provider_rw.save_safe_block_number(safe.min(last_block.number))?;
208            if safe > last_block.number {
209                self.pending_safe_block = Some(safe);
210            }
211        }
212
213        provider_rw.commit()?;
214        // BALs live outside the main database and are intentionally flushed last.
215        let _ = self.provider.bal_store().flush(&canonical_blocks).inspect_err(|err| {
216            warn!(target: "engine::persistence", last=?last_block, ?err, "Failed to flush BAL store");
217        });
218        debug!(target: "engine::persistence", first=?first_block, last=?last_block, "Saved range of blocks");
219
220        let elapsed = start_time.elapsed();
221        self.metrics.save_blocks_batch_size.record(block_count as f64);
222        self.metrics.save_blocks_duration_seconds.record(elapsed);
223
224        Ok(PersistenceResult { last_block, last_state_trie_block, commit_duration: Some(elapsed) })
225    }
226
227    fn maybe_run_pruner(&mut self, block_number: u64) -> Result<(), PersistenceError> {
228        // The durable save is already committed at this point, so pruning can happen after we
229        // acknowledge the save without extending the synchronous persistence wait.
230        if self.pruner.is_pruning_needed(block_number) {
231            debug!(target: "engine::persistence", block_num=?block_number, "Running pruner");
232            let prune_start = Instant::now();
233            let provider_rw = self.provider.database_provider_rw()?;
234            let _ = self.pruner.run_with_provider(&provider_rw, block_number)?;
235            provider_rw.commit()?;
236            let pruned_bals = self
237                .provider
238                .bal_store()
239                .prune(block_number)
240                .inspect_err(|err| {
241                    warn!(target: "engine::persistence", tip=?block_number, ?err, "Failed to prune BAL store");
242                })
243                .unwrap_or_default();
244            debug!(target: "engine::persistence", tip=?block_number, pruned_bals, "Finished pruning after saving blocks");
245            self.metrics.prune_before_duration_seconds.record(prune_start.elapsed());
246        }
247
248        Ok(())
249    }
250}
251
252/// One of the errors that can happen when using the persistence service.
253#[derive(Debug, Error)]
254pub enum PersistenceError {
255    /// A pruner error
256    #[error(transparent)]
257    PrunerError(#[from] PrunerError),
258
259    /// A provider error
260    #[error(transparent)]
261    ProviderError(#[from] ProviderError),
262}
263
264/// A signal to the persistence service that part of the tree state can be persisted.
265#[derive(Debug)]
266pub enum PersistenceAction<N: NodePrimitives = EthPrimitives> {
267    /// Advances the block-data and state/trie persistence frontiers described by the input.
268    SaveBlocks(SaveBlocksInput<N>, CrossbeamSender<PersistenceResult>),
269
270    /// Removes block data above the given block number from the database.
271    ///
272    /// This will first update checkpoints from the database, then remove actual block data from
273    /// static files.
274    RemoveBlocksAbove(u64, CrossbeamSender<PersistenceResult>),
275
276    /// Update the persisted finalized block on disk
277    SaveFinalizedBlock(u64),
278
279    /// Update the persisted safe block on disk
280    SaveSafeBlock(u64),
281}
282
283/// A handle to the persistence service
284#[derive(Debug, Clone)]
285pub struct PersistenceHandle<N: NodePrimitives = EthPrimitives> {
286    /// The channel used to communicate with the persistence service
287    sender: Sender<PersistenceAction<N>>,
288    /// Guard that joins the service thread when all handles are dropped.
289    /// Uses `Arc` so the handle remains `Clone`.
290    _service_guard: Arc<ServiceGuard>,
291}
292
293impl<T: NodePrimitives> PersistenceHandle<T> {
294    /// Create a new [`PersistenceHandle`] from a [`Sender<PersistenceAction>`].
295    ///
296    /// This is intended for testing purposes where you want to mock the persistence service.
297    /// For production use, prefer [`spawn_service`](Self::spawn_service).
298    pub fn new(sender: Sender<PersistenceAction<T>>) -> Self {
299        Self { sender, _service_guard: Arc::new(ServiceGuard(None)) }
300    }
301
302    /// Create a new [`PersistenceHandle`], and spawn the persistence service.
303    ///
304    /// The returned handle can be cloned and shared. When all clones are dropped, the service
305    /// thread will be joined, ensuring graceful shutdown before resources (like `RocksDB`) are
306    /// released.
307    pub fn spawn_service<N>(
308        provider_factory: ProviderFactory<N>,
309        pruner: PrunerWithFactory<ProviderFactory<N>>,
310        sync_metrics_tx: MetricEventsSender,
311    ) -> PersistenceHandle<N::Primitives>
312    where
313        N: ProviderNodeTypes,
314    {
315        // create the initial channels
316        let (db_service_tx, db_service_rx) = std::sync::mpsc::channel();
317
318        // spawn the persistence service
319        let db_service =
320            PersistenceService::new(provider_factory, db_service_rx, pruner, sync_metrics_tx);
321        let join_handle = spawn_os_thread("persistence", || {
322            if let Err(err) = db_service.run() {
323                error!(target: "engine::persistence", ?err, "Persistence service failed");
324            }
325        });
326
327        PersistenceHandle {
328            sender: db_service_tx,
329            _service_guard: Arc::new(ServiceGuard(Some(join_handle))),
330        }
331    }
332
333    /// Sends a specific [`PersistenceAction`] in the contained channel. The caller is responsible
334    /// for creating any channels for the given action.
335    pub fn send_action(
336        &self,
337        action: PersistenceAction<T>,
338    ) -> Result<(), SendError<PersistenceAction<T>>> {
339        self.sender.send(action)
340    }
341
342    /// Tells the persistence service to advance its block-data and state/trie frontiers.
343    ///
344    /// This returns the latest hash that has been saved, allowing removal of that block and any
345    /// previous blocks from in-memory data structures. This value is returned in the receiver end
346    /// of the sender argument.
347    pub fn save_blocks(
348        &self,
349        input: SaveBlocksInput<T>,
350        tx: CrossbeamSender<PersistenceResult>,
351    ) -> Result<(), SendError<PersistenceAction<T>>> {
352        self.send_action(PersistenceAction::SaveBlocks(input, tx))
353    }
354
355    /// Queues the finalized block number to be persisted on disk.
356    ///
357    /// The update is deferred and will be committed together with the next [`Self::save_blocks`]
358    /// call to avoid triggering a separate fsync for each update.
359    pub fn save_finalized_block_number(
360        &self,
361        finalized_block: u64,
362    ) -> Result<(), SendError<PersistenceAction<T>>> {
363        self.send_action(PersistenceAction::SaveFinalizedBlock(finalized_block))
364    }
365
366    /// Queues the safe block number to be persisted on disk.
367    ///
368    /// The update is deferred and will be committed together with the next [`Self::save_blocks`]
369    /// call to avoid triggering a separate fsync for each update.
370    pub fn save_safe_block_number(
371        &self,
372        safe_block: u64,
373    ) -> Result<(), SendError<PersistenceAction<T>>> {
374        self.send_action(PersistenceAction::SaveSafeBlock(safe_block))
375    }
376
377    /// Tells the persistence service to remove blocks above a certain block number. The removed
378    /// blocks are returned by the service.
379    ///
380    /// When the operation completes, the new tip hash is returned in the receiver end of the sender
381    /// argument.
382    pub fn remove_blocks_above(
383        &self,
384        block_num: u64,
385        tx: CrossbeamSender<PersistenceResult>,
386    ) -> Result<(), SendError<PersistenceAction<T>>> {
387        self.send_action(PersistenceAction::RemoveBlocksAbove(block_num, tx))
388    }
389}
390
391/// Guard that joins the persistence service thread when dropped.
392///
393/// This ensures graceful shutdown - the service thread completes before resources like
394/// `RocksDB` are released. Stored in an `Arc` inside [`PersistenceHandle`] so the handle
395/// can be cloned while sharing the same guard.
396struct ServiceGuard(Option<JoinHandle<()>>);
397
398impl std::fmt::Debug for ServiceGuard {
399    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
400        f.debug_tuple("ServiceGuard").field(&self.0.as_ref().map(|_| "...")).finish()
401    }
402}
403
404impl Drop for ServiceGuard {
405    fn drop(&mut self) {
406        if let Some(join_handle) = self.0.take() {
407            let _ = join_handle.join();
408        }
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use alloy_eips::NumHash;
416    use alloy_primitives::{BlockHash, BlockNumber, Bytes, B256, U256};
417    use reth_chain_state::{test_utils::TestBlockBuilder, ExecutedBlock};
418    use reth_db_common::init::init_genesis;
419    use reth_exex_types::FinishedExExHeight;
420    use reth_provider::{
421        providers::{ProviderFactoryBuilder, ReadOnlyConfig},
422        test_utils::{create_test_provider_factory, MockNodeTypes},
423        AccountReader, BalConfig, BalNotificationStream, BalStore, BalStoreHandle,
424        ChainSpecProvider, HeaderProvider, InMemoryBalStore, ProviderError, ProviderResult, RawBal,
425        StorageSettingsCache, TryIntoHistoricalStateProvider,
426    };
427    use reth_prune::Pruner;
428    use reth_prune_types::PruneMode;
429    use tokio::sync::mpsc::unbounded_channel;
430
431    fn default_persistence_handle() -> PersistenceHandle<EthPrimitives> {
432        let provider = create_test_provider_factory();
433        init_genesis(&provider).unwrap();
434
435        let (_finished_exex_height_tx, finished_exex_height_rx) =
436            tokio::sync::watch::channel(FinishedExExHeight::NoExExs);
437
438        let pruner =
439            Pruner::new_with_factory(provider.clone(), vec![], 5, 0, None, finished_exex_height_rx);
440
441        let (sync_metrics_tx, _sync_metrics_rx) = unbounded_channel();
442        PersistenceHandle::<EthPrimitives>::spawn_service(provider, pruner, sync_metrics_tx)
443    }
444
445    fn full_save_input(
446        blocks: Vec<ExecutedBlock<EthPrimitives>>,
447    ) -> SaveBlocksInput<EthPrimitives> {
448        let prev_tip = blocks
449            .first()
450            .map(|block| block.recovered_block().number.saturating_sub(1))
451            .expect("save input must not be empty");
452        let new_tip =
453            blocks.last().map(|block| block.recovered_block().number).expect("checked non-empty");
454        SaveBlocksInput::new(blocks, prev_tip, prev_tip, new_tip, new_tip)
455    }
456
457    #[test]
458    fn test_pruner_prunes_bal_store() {
459        reth_tracing::init_test_tracing();
460
461        let old_hash = B256::random();
462        let retained_hash = B256::random();
463        let old_bal = Bytes::from_static(b"old");
464        let retained_bal = Bytes::from_static(b"retained");
465        let bal_store = BalStoreHandle::new(InMemoryBalStore::new(
466            BalConfig::with_in_memory_retention(PruneMode::Before(2)),
467        ));
468
469        bal_store.insert(NumHash::new(1, old_hash), RawBal::new(old_bal)).unwrap();
470        bal_store
471            .insert(NumHash::new(2, retained_hash), RawBal::new(retained_bal.clone()))
472            .unwrap();
473
474        let provider = create_test_provider_factory().with_bal_store(bal_store.clone());
475        let (_finished_exex_height_tx, finished_exex_height_rx) =
476            tokio::sync::watch::channel(FinishedExExHeight::NoExExs);
477        let pruner =
478            Pruner::new_with_factory(provider.clone(), vec![], 0, 0, None, finished_exex_height_rx);
479        let (_db_service_tx, db_service_rx) = std::sync::mpsc::channel();
480        let (sync_metrics_tx, _sync_metrics_rx) = unbounded_channel();
481        let mut service = PersistenceService::new(provider, db_service_rx, pruner, sync_metrics_tx);
482
483        service.maybe_run_pruner(2).unwrap();
484
485        assert_eq!(
486            bal_store.get_by_hashes(&[old_hash, retained_hash]).unwrap(),
487            vec![None, Some(retained_bal)]
488        );
489    }
490
491    #[test]
492    fn test_pruner_ignores_bal_store_prune_error() {
493        reth_tracing::init_test_tracing();
494
495        let provider = create_test_provider_factory()
496            .with_bal_store(BalStoreHandle::new(FailingPruneBalStore));
497        let (_finished_exex_height_tx, finished_exex_height_rx) =
498            tokio::sync::watch::channel(FinishedExExHeight::NoExExs);
499        let pruner =
500            Pruner::new_with_factory(provider.clone(), vec![], 0, 0, None, finished_exex_height_rx);
501        let (_db_service_tx, db_service_rx) = std::sync::mpsc::channel();
502        let (sync_metrics_tx, _sync_metrics_rx) = unbounded_channel();
503        let mut service = PersistenceService::new(provider, db_service_rx, pruner, sync_metrics_tx);
504
505        service.maybe_run_pruner(2).unwrap();
506    }
507
508    #[test]
509    fn test_remove_blocks_above_requires_tip_header() {
510        let provider = create_test_provider_factory();
511        init_genesis(&provider).unwrap();
512
513        let (_finished_exex_height_tx, finished_exex_height_rx) =
514            tokio::sync::watch::channel(FinishedExExHeight::NoExExs);
515        let pruner =
516            Pruner::new_with_factory(provider.clone(), vec![], 5, 0, None, finished_exex_height_rx);
517        let (_db_service_tx, db_service_rx) = std::sync::mpsc::channel();
518        let (sync_metrics_tx, _sync_metrics_rx) = unbounded_channel();
519        let service = PersistenceService::new(provider, db_service_rx, pruner, sync_metrics_tx);
520
521        assert!(matches!(
522            service.on_remove_blocks_above(1),
523            Err(PersistenceError::ProviderError(ProviderError::HeaderNotFound(_)))
524        ));
525    }
526
527    #[derive(Debug)]
528    struct FailingPruneBalStore;
529
530    impl BalStore for FailingPruneBalStore {
531        fn insert(&self, _num_hash: NumHash, _bal: RawBal) -> ProviderResult<()> {
532            Ok(())
533        }
534
535        fn prune(&self, _tip: BlockNumber) -> ProviderResult<usize> {
536            Err(ProviderError::other(std::io::Error::other("BAL store prune failed")))
537        }
538
539        fn get_by_hashes(&self, block_hashes: &[BlockHash]) -> ProviderResult<Vec<Option<Bytes>>> {
540            Ok(vec![None; block_hashes.len()])
541        }
542
543        fn bal_stream(&self) -> BalNotificationStream {
544            BalStoreHandle::noop().bal_stream()
545        }
546    }
547
548    #[test]
549    fn test_save_blocks_single_block() {
550        reth_tracing::init_test_tracing();
551        let handle = default_persistence_handle();
552        let block_number = 1;
553        let mut test_block_builder = TestBlockBuilder::eth();
554        let executed =
555            test_block_builder.get_executed_block_with_number(block_number, B256::random());
556        let block_hash = executed.recovered_block().hash();
557
558        let blocks = full_save_input(vec![executed]);
559        let (tx, rx) = crossbeam_channel::bounded(1);
560
561        handle.save_blocks(blocks, tx).unwrap();
562
563        let result = rx.recv_timeout(std::time::Duration::from_secs(10)).expect("test timed out");
564
565        assert_eq!(block_hash, result.last_block.hash);
566        assert_eq!(result.last_state_trie_block, result.last_block);
567    }
568
569    #[test]
570    fn test_save_blocks_flushes_bal_store() {
571        use reth_provider::{RocksDBBalStore, RocksDBProviderFactory};
572
573        reth_tracing::init_test_tracing();
574        let provider = create_test_provider_factory();
575        let bal_store = BalStoreHandle::new(RocksDBBalStore::new(provider.rocksdb_provider()));
576        let provider = provider.with_bal_store(bal_store);
577        init_genesis(&provider).unwrap();
578
579        let mut test_block_builder = TestBlockBuilder::eth();
580        let executed = test_block_builder.get_executed_block_with_number(1, B256::random());
581        let num_hash = executed.recovered_block().num_hash();
582        let raw_bal = Bytes::from_static(&[0xc0]);
583
584        provider.bal_store().insert(num_hash, RawBal::new(raw_bal.clone())).unwrap();
585
586        let (_finished_exex_height_tx, finished_exex_height_rx) =
587            tokio::sync::watch::channel(FinishedExExHeight::NoExExs);
588        let pruner =
589            Pruner::new_with_factory(provider.clone(), vec![], 5, 0, None, finished_exex_height_rx);
590        let (sync_metrics_tx, _sync_metrics_rx) = unbounded_channel();
591        let handle = PersistenceHandle::<EthPrimitives>::spawn_service(
592            provider.clone(),
593            pruner,
594            sync_metrics_tx,
595        );
596        let (tx, rx) = crossbeam_channel::bounded(1);
597
598        handle.save_blocks(full_save_input(vec![executed]), tx).unwrap();
599
600        let result = rx.recv_timeout(std::time::Duration::from_secs(10)).expect("test timed out");
601        assert_eq!(result.last_block, num_hash);
602        let persisted_store = RocksDBBalStore::new(provider.rocksdb_provider());
603        assert_eq!(persisted_store.get_by_hash(num_hash.hash).unwrap(), Some(raw_bal));
604    }
605
606    #[test]
607    fn test_save_blocks_multiple_blocks() {
608        reth_tracing::init_test_tracing();
609        let handle = default_persistence_handle();
610
611        let mut test_block_builder = TestBlockBuilder::eth();
612        let blocks = test_block_builder.get_executed_blocks(1..6).collect::<Vec<_>>();
613        let last_hash = blocks.last().unwrap().recovered_block().hash();
614        let (tx, rx) = crossbeam_channel::bounded(1);
615
616        handle.save_blocks(full_save_input(blocks), tx).unwrap();
617        let result = rx.recv().unwrap();
618        assert_eq!(last_hash, result.last_block.hash);
619    }
620
621    #[test]
622    fn test_save_blocks_multiple_calls() {
623        reth_tracing::init_test_tracing();
624        let handle = default_persistence_handle();
625
626        let ranges = [1..2, 2..3, 3..5, 5..6];
627        let mut test_block_builder = TestBlockBuilder::eth();
628        for range in ranges {
629            let blocks = test_block_builder.get_executed_blocks(range).collect::<Vec<_>>();
630            let last_hash = blocks.last().unwrap().recovered_block().hash();
631            let (tx, rx) = crossbeam_channel::bounded(1);
632
633            handle.save_blocks(full_save_input(blocks), tx).unwrap();
634
635            let result = rx.recv().unwrap();
636            assert_eq!(last_hash, result.last_block.hash);
637        }
638    }
639
640    /// Verifies that committing `save_blocks` history before running the pruner
641    /// prevents the pruner from overwriting new entries.
642    ///
643    /// Previously, both `save_blocks` and the pruner pushed `RocksDB` batches before
644    /// a single commit. Both read committed state, so the pruner didn't see the
645    /// new entries and its batch overwrote them. The fix commits `save_blocks`
646    /// first, then runs the pruner against committed state in a separate provider.
647    #[test]
648    fn test_save_blocks_then_prune_preserves_new_history() {
649        use reth_db::{models::ShardedKey, tables, BlockNumberList};
650        use reth_provider::RocksDBProviderFactory;
651
652        reth_tracing::init_test_tracing();
653
654        let provider_factory = create_test_provider_factory();
655        let tracked_addr = alloy_primitives::Address::from([0xBE; 20]);
656
657        // Phase 1: Establish baseline history for blocks 0..20.
658        let rocksdb = provider_factory.rocksdb_provider();
659        {
660            let mut batch = rocksdb.batch();
661            let initial_blocks: Vec<u64> = (0..20).collect();
662            let shard = BlockNumberList::new_pre_sorted(initial_blocks.iter().copied());
663            batch
664                .put::<tables::AccountsHistory>(ShardedKey::new(tracked_addr, u64::MAX), &shard)
665                .unwrap();
666            batch.commit().unwrap();
667        }
668
669        // Phase 2: Simulate the fixed on_save_blocks flow.
670        // Step 1: save_blocks appends new entries 20..25 and commits immediately.
671        let mut batch1 = rocksdb.batch();
672        batch1.append_account_history_shard(tracked_addr, 20..25u64).unwrap();
673        batch1.commit().unwrap();
674
675        // Step 2: Pruner runs AFTER commit, so it reads state that includes 20..25.
676        // Prunes entries ≤ 14, leaving [15..25).
677        let mut batch2 = rocksdb.batch();
678        batch2.prune_account_history_to(tracked_addr, 14).unwrap();
679        batch2.commit().unwrap();
680
681        // Verify new entries survived pruning.
682        let shards = rocksdb.account_history_shards(tracked_addr).unwrap();
683        let entries: Vec<u64> = shards.iter().flat_map(|(_, list)| list.iter()).collect();
684        let expected: Vec<u64> = (15..25).collect();
685        assert_eq!(entries, expected, "new entries 20..25 must survive pruning");
686    }
687
688    #[test]
689    fn test_read_only_consistency_across_reorg() {
690        reth_tracing::init_test_tracing();
691
692        // Allow opening the same MDBX env twice in-process
693        reth_db::test_utils::enable_legacy_multiopen();
694
695        let provider_factory = create_test_provider_factory();
696        provider_factory.set_storage_settings_cache(reth_provider::StorageSettings::v2());
697
698        // Open the secondary provider concurrently with the primary.
699        let secondary = ProviderFactoryBuilder::<MockNodeTypes>::default()
700            .open_read_only(
701                provider_factory.chain_spec(),
702                ReadOnlyConfig::from_datadir(provider_factory.db_ref().path()),
703                reth_tasks::Runtime::test(),
704            )
705            .expect("failed to open read-only provider factory");
706        secondary.set_storage_settings_cache(reth_provider::StorageSettings::v2());
707
708        // --- Phase 1: Write blocks 1 and 2 via the primary ---
709        let genesis_hash = init_genesis(&provider_factory).unwrap();
710        let mut test_block_builder = TestBlockBuilder::eth().with_state();
711        let signer = test_block_builder.signer;
712        let initial_balance = U256::from(10).pow(U256::from(18));
713        let block_a1 = test_block_builder.get_executed_block_with_number(1, genesis_hash);
714        let hash_a1 = block_a1.recovered_block().hash();
715        let block_a2 = test_block_builder.get_executed_block_with_number(2, hash_a1);
716        let hash_a2 = block_a2.recovered_block().hash();
717
718        // Compute expected signer state after block 1 from its transaction count.
719        let single_cost = TestBlockBuilder::<EthPrimitives>::single_tx_cost();
720        let txs_in_block1 = block_a1.recovered_block().body().transactions.len() as u64;
721
722        let balance_after_block1 = initial_balance - single_cost * U256::from(txs_in_block1);
723        let nonce_after_block1 = txs_in_block1;
724
725        let provider_rw = provider_factory.database_provider_rw().unwrap();
726        let input = SaveBlocksInput::new(vec![block_a1, block_a2], 0, 0, 2, 2);
727        provider_rw.save_blocks(&input).unwrap();
728        provider_rw.commit().unwrap();
729
730        // Secondary catches up and sees all 3 blocks.
731        // Hold this provider (and its MDBX RO tx) across the reorg to test snapshot isolation.
732        let pre_reorg_provider = secondary.provider().unwrap();
733        assert_eq!(
734            pre_reorg_provider.sealed_header(2).unwrap().as_ref().map(|h| h.hash()),
735            Some(hash_a2),
736            "secondary must see block 2 after initial append"
737        );
738
739        // Check the primary can read its own historical state.
740        {
741            let primary_state_at_1 = provider_factory.history_by_block_number(1).unwrap();
742            let primary_account = primary_state_at_1.basic_account(&signer).unwrap();
743            assert!(primary_account.is_some(), "primary: signer must exist at block 1");
744        }
745
746        // Verify historical state at block 1 is accessible via changesets on the secondary.
747        {
748            let state_at_1 = secondary.history_by_block_number(1).unwrap();
749            let account_at_1 = state_at_1.basic_account(&signer).unwrap();
750            assert!(account_at_1.is_some(), "signer account must exist at block 1");
751            let account_at_1 = account_at_1.unwrap();
752            assert_eq!(account_at_1.balance, balance_after_block1, "signer balance at block 1");
753            assert_eq!(account_at_1.nonce, nonce_after_block1, "signer nonce at block 1");
754        }
755
756        // --- Phase 2: Reorg — remove block 2 and append a different block 2 ---
757        // Build the reorg block before starting the commit so we can write it in the
758        // same thread after the unwind.
759        let block_b2 = test_block_builder.get_executed_block_with_number(2, hash_a1);
760        let hash_b2 = block_b2.recovered_block().hash();
761        let txs_in_block_b2 = block_b2.recovered_block().body().transactions.len() as u64;
762        assert_ne!(hash_a2, hash_b2, "reorg block must differ");
763
764        // Expected signer state after the reorged block 2.
765        let balance_after_reorg_block2 =
766            balance_after_block1 - single_cost * U256::from(txs_in_block_b2);
767        let nonce_after_reorg_block2 = nonce_after_block1 + txs_in_block_b2;
768
769        // Spawn the reorg on a background thread because `commit_unwind` calls
770        // `wait_for_pre_commit_readers()` which blocks until the secondary's held
771        // RO tx is dropped.
772        //
773        // We want to keep provider factory around, otherwise it's gonna drop mdbx env before the
774        // reorg thread is on
775        #[expect(clippy::redundant_clone)]
776        let pf = provider_factory.clone();
777        let reorg_handle = std::thread::spawn(move || {
778            let provider_rw = pf.database_provider_rw().unwrap();
779            let frontiers = provider_rw.remove_block_and_execution_above(1).unwrap();
780            assert_eq!(frontiers.partial_state_trie, 1);
781            provider_rw.commit().unwrap();
782
783            let provider_rw = pf.database_provider_rw().unwrap();
784            let input = SaveBlocksInput::new(vec![block_b2], 1, 1, 2, 2);
785            provider_rw.save_blocks(&input).unwrap();
786            provider_rw.commit().unwrap();
787        });
788
789        // Give the reorg thread time to start and block on wait_for_pre_commit_readers.
790        std::thread::sleep(std::time::Duration::from_millis(100));
791
792        // The pre-reorg provider still holds its MDBX snapshot — it must still see
793        // the OLD block 2 from before the reorg.
794        assert_eq!(
795            pre_reorg_provider.sealed_header(2).unwrap().as_ref().map(|h| h.hash()),
796            Some(hash_a2),
797            "pre-reorg provider must still see the original block 2"
798        );
799        assert_eq!(
800            pre_reorg_provider.sealed_header(1).unwrap().as_ref().map(|h| h.hash()),
801            Some(hash_a1),
802            "pre-reorg provider must still see block 1"
803        );
804
805        // The held RO tx must still be able to read historical state at block 1 via
806        // changesets, even though the reorg thread is about to rewrite block 2's data.
807        // Consuming pre_reorg_provider here also unblocks the reorg commit.
808        let state_at_1 = pre_reorg_provider.try_into_history_at_block(1).unwrap();
809        let account = state_at_1.basic_account(&signer).unwrap();
810        assert!(
811            account.is_some(),
812            "pre-reorg RO tx must still read signer at block 1 during reorg"
813        );
814        let account = account.unwrap();
815        assert_eq!(
816            account.balance, balance_after_block1,
817            "pre-reorg RO tx: signer balance at block 1 during reorg"
818        );
819        assert_eq!(
820            account.nonce, nonce_after_block1,
821            "pre-reorg RO tx: signer nonce at block 1 during reorg"
822        );
823        drop(state_at_1);
824        reorg_handle.join().expect("reorg thread panicked");
825
826        // A new provider catches up and sees the reorged chain.
827        let obs_header = secondary.provider().unwrap().sealed_header(2).unwrap();
828        assert_eq!(
829            obs_header.as_ref().map(|h| h.hash()),
830            Some(hash_b2),
831            "secondary must see the reorged block 2, not the old one"
832        );
833
834        // Block 1 should still be the original.
835        let obs_header = secondary.provider().unwrap().sealed_header(1).unwrap();
836        assert_eq!(
837            obs_header.as_ref().map(|h| h.hash()),
838            Some(hash_a1),
839            "secondary must still see block 1"
840        );
841
842        // Verify historical state at block 1 is still accessible after the reorg.
843        let state_at_1 = secondary.history_by_block_number(1).unwrap();
844        let account_at_1 = state_at_1.basic_account(&signer).unwrap();
845        assert!(account_at_1.is_some(), "signer account must exist at block 1 after reorg");
846        let account_at_1 = account_at_1.unwrap();
847        assert_eq!(
848            account_at_1.balance, balance_after_block1,
849            "signer balance at block 1 must survive reorg"
850        );
851        assert_eq!(
852            account_at_1.nonce, nonce_after_block1,
853            "signer nonce at block 1 must survive reorg"
854        );
855
856        // Verify the latest state (at block 2) reflects the reorged execution.
857        let state_at_2 = secondary.history_by_block_number(2).unwrap();
858        let account_at_2 = state_at_2.basic_account(&signer).unwrap();
859        assert!(account_at_2.is_some(), "signer account must exist at block 2 after reorg");
860        let account_at_2 = account_at_2.unwrap();
861        assert_eq!(
862            account_at_2.balance, balance_after_reorg_block2,
863            "signer balance at block 2 must reflect reorged execution"
864        );
865        assert_eq!(
866            account_at_2.nonce, nonce_after_reorg_block2,
867            "signer nonce at block 2 must reflect reorged execution"
868        );
869    }
870}