Skip to main content

reth_provider/providers/rocksdb/
invariants.rs

1//! Invariant checking for `RocksDB` tables.
2//!
3//! This module provides consistency checks for tables stored in `RocksDB`, similar to the
4//! consistency checks for static files. The goal is to detect and potentially heal
5//! inconsistencies between `RocksDB` data and MDBX checkpoints.
6
7use super::RocksDBProvider;
8use crate::StaticFileProviderFactory;
9use alloy_primitives::BlockNumber;
10use reth_chainspec::{ChainSpecProvider, EthChainSpec};
11use reth_db::models::{storage_sharded_key::StorageShardedKey, ShardedKey};
12use reth_db_api::{table::Value, tables};
13use reth_primitives_traits::NodePrimitives;
14use reth_stages_types::StageId;
15use reth_static_file_types::StaticFileSegment;
16use reth_storage_api::{
17    BlockBodyIndicesProvider, ChangeSetReader, DBProvider, StageCheckpointReader,
18    StorageChangeSetReader, StorageSettingsCache, TransactionsProviderExt,
19};
20use reth_storage_errors::provider::ProviderResult;
21use std::collections::HashSet;
22
23/// Batch size for changeset iteration during history healing.
24/// Balances memory usage against iteration overhead.
25const HEAL_HISTORY_BATCH_SIZE: u64 = 10_000;
26
27impl RocksDBProvider {
28    /// Checks consistency of `RocksDB` tables against MDBX stage checkpoints.
29    ///
30    /// Returns an unwind target block number if the pipeline needs to unwind to rebuild
31    /// `RocksDB` data. Returns `None` if all invariants pass or if inconsistencies were healed.
32    ///
33    /// # Invariants checked
34    ///
35    /// For `TransactionHashNumbers`:
36    /// - The maximum `TxNumber` value should not exceed what the `TransactionLookup` stage
37    ///   checkpoint indicates has been processed.
38    /// - If `RocksDB` is ahead, excess entries are pruned (healed).
39    /// - If `RocksDB` is behind, an unwind is required.
40    ///
41    /// For `StoragesHistory` and `AccountsHistory`:
42    /// - Uses changesets to heal stale entries when static file tip > checkpoint.
43    ///
44    /// # Requirements
45    ///
46    /// For pruning `TransactionHashNumbers`, the provider must be able to supply transaction
47    /// data (typically from static files) so that transaction hashes can be read. This
48    /// implies that static files should be ahead of or in sync with `RocksDB`.
49    pub fn check_consistency<Provider>(
50        &self,
51        provider: &Provider,
52    ) -> ProviderResult<Option<BlockNumber>>
53    where
54        Provider: DBProvider
55            + StageCheckpointReader
56            + StorageSettingsCache
57            + BlockBodyIndicesProvider
58            + StorageChangeSetReader
59            + ChangeSetReader
60            + ChainSpecProvider
61            + StaticFileProviderFactory<
62                Primitives: NodePrimitives<SignedTx: Value, Receipt: Value, BlockHeader: Value>,
63            >,
64    {
65        let mut unwind_target: Option<BlockNumber> = None;
66
67        // Heal TransactionHashNumbers if stored in RocksDB
68        if provider.cached_storage_settings().storage_v2 &&
69            let Some(target) = self.heal_transaction_hash_numbers(provider)?
70        {
71            unwind_target = Some(unwind_target.map_or(target, |t| t.min(target)));
72        }
73
74        // Heal StoragesHistory if stored in RocksDB
75        if provider.cached_storage_settings().storage_v2 &&
76            let Some(target) = self.heal_storages_history(provider)?
77        {
78            unwind_target = Some(unwind_target.map_or(target, |t| t.min(target)));
79        }
80
81        // Heal AccountsHistory if stored in RocksDB
82        if provider.cached_storage_settings().storage_v2 &&
83            let Some(target) = self.heal_accounts_history(provider)?
84        {
85            unwind_target = Some(unwind_target.map_or(target, |t| t.min(target)));
86        }
87
88        Ok(unwind_target)
89    }
90
91    /// Heals the `TransactionHashNumbers` table.
92    ///
93    /// - Fast path: if checkpoint == 0, clear any stale data and return
94    /// - If `sf_tip` < checkpoint, return unwind target (static files behind)
95    /// - If `sf_tip` == checkpoint, nothing to do
96    /// - If `sf_tip` > checkpoint, heal via transaction ranges in batches
97    fn heal_transaction_hash_numbers<Provider>(
98        &self,
99        provider: &Provider,
100    ) -> ProviderResult<Option<BlockNumber>>
101    where
102        Provider: DBProvider
103            + StageCheckpointReader
104            + BlockBodyIndicesProvider
105            + StaticFileProviderFactory<
106                Primitives: NodePrimitives<SignedTx: Value, Receipt: Value, BlockHeader: Value>,
107            >,
108    {
109        let checkpoint = provider
110            .get_stage_checkpoint(StageId::TransactionLookup)?
111            .map(|cp| cp.block_number)
112            .unwrap_or(0);
113
114        let sf_tip = provider
115            .static_file_provider()
116            .get_highest_static_file_block(StaticFileSegment::Transactions)
117            .unwrap_or(0);
118
119        // Fast path: clear any stale data and return.
120        if checkpoint == 0 {
121            if self.first::<tables::TransactionHashNumbers>()?.is_some() {
122                tracing::info!(
123                    target: "reth::providers::rocksdb",
124                    "TransactionHashNumbers: checkpoint is 0, clearing stale data"
125                );
126                self.clear::<tables::TransactionHashNumbers>()?;
127            }
128
129            return Ok(None);
130        }
131
132        if sf_tip < checkpoint {
133            // This should never happen in normal operation - static files are always committed
134            // before RocksDB. If we get here, something is seriously wrong. The unwind is a
135            // best-effort attempt but is probably futile.
136            tracing::warn!(
137                target: "reth::providers::rocksdb",
138                sf_tip,
139                checkpoint,
140                "TransactionHashNumbers: static file tip behind checkpoint, unwind needed"
141            );
142            return Ok(Some(sf_tip));
143        }
144
145        // sf_tip == checkpoint - nothing to do
146        if sf_tip == checkpoint {
147            return Ok(None);
148        }
149
150        // Get end tx from static files (authoritative for sf_tip)
151        let sf_tip_end_tx = provider
152            .static_file_provider()
153            .get_highest_static_file_tx(StaticFileSegment::Transactions)
154            .unwrap_or(0);
155
156        // Get the first tx after the checkpoint block from MDBX (authoritative up to checkpoint)
157        let checkpoint_next_tx = provider
158            .block_body_indices(checkpoint)?
159            .map(|indices| indices.next_tx_num())
160            .unwrap_or(0);
161
162        if sf_tip_end_tx < checkpoint_next_tx {
163            // This should never happen in normal operation - static files should have all
164            // transactions up to sf_tip. If we get here, something is seriously wrong.
165            // The unwind is a best-effort attempt but is probably futile.
166            tracing::warn!(
167                target: "reth::providers::rocksdb",
168                sf_tip_end_tx,
169                checkpoint_next_tx,
170                checkpoint,
171                sf_tip,
172                "TransactionHashNumbers: static file tx tip behind checkpoint, unwind needed"
173            );
174            return Ok(Some(sf_tip));
175        }
176
177        tracing::info!(
178            target: "reth::providers::rocksdb",
179            checkpoint,
180            sf_tip,
181            checkpoint_next_tx,
182            sf_tip_end_tx,
183            "TransactionHashNumbers: healing via transaction ranges"
184        );
185
186        const BATCH_SIZE: u64 = 10_000;
187        let mut batch_start = checkpoint_next_tx;
188
189        while batch_start <= sf_tip_end_tx {
190            let batch_end = batch_start.saturating_add(BATCH_SIZE - 1).min(sf_tip_end_tx);
191
192            tracing::debug!(
193                target: "reth::providers::rocksdb",
194                batch_start,
195                batch_end,
196                "Pruning TransactionHashNumbers batch"
197            );
198
199            self.prune_transaction_hash_numbers_in_range(provider, batch_start..=batch_end)?;
200
201            batch_start = batch_end.saturating_add(1);
202        }
203
204        Ok(None)
205    }
206
207    /// Prunes `TransactionHashNumbers` entries for transactions in the given range.
208    ///
209    /// This fetches transaction hashes from the provider and deletes the corresponding
210    /// entries from `RocksDB` by key. This approach is more
211    /// scalable than iterating all rows because it only processes the transactions that
212    /// need to be pruned.
213    ///
214    /// # Requirements
215    ///
216    /// The provider must be able to supply transaction data (typically from static files)
217    /// so that transaction hashes can be read. This implies that static files should
218    /// be ahead of or in sync with `RocksDB`.
219    fn prune_transaction_hash_numbers_in_range<Provider>(
220        &self,
221        provider: &Provider,
222        tx_range: std::ops::RangeInclusive<u64>,
223    ) -> ProviderResult<()>
224    where
225        Provider: StaticFileProviderFactory<
226            Primitives: NodePrimitives<SignedTx: Value, Receipt: Value, BlockHeader: Value>,
227        >,
228    {
229        if tx_range.is_empty() {
230            return Ok(());
231        }
232
233        let hashes = provider
234            .static_file_provider()
235            .transaction_hashes_by_range(*tx_range.start()..tx_range.end().saturating_add(1))?;
236
237        if !hashes.is_empty() {
238            tracing::info!(
239                target: "reth::providers::rocksdb",
240                deleted_count = hashes.len(),
241                tx_range_start = *tx_range.start(),
242                tx_range_end = *tx_range.end(),
243                "Pruning TransactionHashNumbers entries by tx range"
244            );
245
246            let mut batch = self.batch();
247            for (hash, _) in hashes {
248                batch.delete::<tables::TransactionHashNumbers>(hash)?;
249            }
250            batch.commit()?;
251        }
252
253        Ok(())
254    }
255
256    /// Heals the `StoragesHistory` table by removing stale entries.
257    ///
258    /// Returns an unwind target if static file tip is behind checkpoint (cannot heal).
259    /// Otherwise iterates changesets in batches to identify and unwind affected keys.
260    fn heal_storages_history<Provider>(
261        &self,
262        provider: &Provider,
263    ) -> ProviderResult<Option<BlockNumber>>
264    where
265        Provider: DBProvider
266            + StageCheckpointReader
267            + StaticFileProviderFactory
268            + StorageChangeSetReader
269            + ChainSpecProvider,
270    {
271        let checkpoint = provider
272            .get_stage_checkpoint(StageId::IndexStorageHistory)?
273            .map(|cp| cp.block_number)
274            .unwrap_or(0);
275
276        let sf_tip = provider
277            .static_file_provider()
278            .get_highest_static_file_block(StaticFileSegment::StorageChangeSets)
279            .unwrap_or(0);
280
281        if sf_tip < checkpoint {
282            // This should never happen in normal operation - static files are always
283            // committed before RocksDB. If we get here, something is seriously wrong.
284            // The unwind is a best-effort attempt but is probably futile.
285            tracing::warn!(
286                target: "reth::providers::rocksdb",
287                sf_tip,
288                checkpoint,
289                "StoragesHistory: static file tip behind checkpoint, unwind needed"
290            );
291            return Ok(Some(sf_tip));
292        }
293
294        if sf_tip == checkpoint {
295            return Ok(None);
296        }
297
298        // Fast path: clear and re-insert genesis history.
299        if checkpoint == 0 {
300            tracing::info!(
301                target: "reth::providers::rocksdb",
302                "StoragesHistory: checkpoint is 0, clearing stale data"
303            );
304            self.clear::<tables::StoragesHistory>()?;
305
306            let chain_spec = provider.chain_spec();
307            let genesis = chain_spec.genesis();
308            let list = tables::BlockNumberList::new([0]).expect("single block always fits");
309            for (addr, account) in &genesis.alloc {
310                if let Some(storage) = &account.storage {
311                    for key in storage.keys() {
312                        self.put::<tables::StoragesHistory>(
313                            StorageShardedKey::last(*addr, *key),
314                            &list,
315                        )?;
316                    }
317                }
318            }
319
320            return Ok(None);
321        }
322
323        let total_blocks = sf_tip - checkpoint;
324        tracing::info!(
325            target: "reth::providers::rocksdb",
326            checkpoint,
327            sf_tip,
328            total_blocks,
329            "StoragesHistory: healing via changesets"
330        );
331
332        let mut batch_start = checkpoint + 1;
333        let mut batch_num = 0u64;
334        let total_batches = total_blocks.div_ceil(HEAL_HISTORY_BATCH_SIZE);
335
336        while batch_start <= sf_tip {
337            let batch_end = (batch_start + HEAL_HISTORY_BATCH_SIZE - 1).min(sf_tip);
338            batch_num += 1;
339
340            let changesets = provider.storage_changesets_range(batch_start..=batch_end)?;
341
342            let unique_keys: HashSet<_> = changesets
343                .into_iter()
344                .map(|(block_addr, entry)| (block_addr.address(), entry.key, checkpoint + 1))
345                .collect();
346            let indices: Vec<_> = unique_keys.into_iter().collect();
347
348            if !indices.is_empty() {
349                tracing::info!(
350                    target: "reth::providers::rocksdb",
351                    batch_num,
352                    total_batches,
353                    batch_start,
354                    batch_end,
355                    indices_count = indices.len(),
356                    "StoragesHistory: unwinding batch"
357                );
358
359                let batch = self.unwind_storage_history_indices(&indices)?;
360                self.commit_batch(batch)?;
361            }
362
363            batch_start = batch_end + 1;
364        }
365
366        Ok(None)
367    }
368
369    /// Heals the `AccountsHistory` table by removing stale entries.
370    ///
371    /// Returns an unwind target if static file tip is behind checkpoint (cannot heal).
372    /// Otherwise iterates changesets in batches to identify and unwind affected keys.
373    fn heal_accounts_history<Provider>(
374        &self,
375        provider: &Provider,
376    ) -> ProviderResult<Option<BlockNumber>>
377    where
378        Provider: DBProvider
379            + StageCheckpointReader
380            + StaticFileProviderFactory
381            + ChangeSetReader
382            + ChainSpecProvider,
383    {
384        let checkpoint = provider
385            .get_stage_checkpoint(StageId::IndexAccountHistory)?
386            .map(|cp| cp.block_number)
387            .unwrap_or(0);
388
389        let sf_tip = provider
390            .static_file_provider()
391            .get_highest_static_file_block(StaticFileSegment::AccountChangeSets)
392            .unwrap_or(0);
393
394        if sf_tip < checkpoint {
395            // This should never happen in normal operation - static files are always
396            // committed before RocksDB. If we get here, something is seriously wrong.
397            // The unwind is a best-effort attempt but is probably futile.
398            tracing::warn!(
399                target: "reth::providers::rocksdb",
400                sf_tip,
401                checkpoint,
402                "AccountsHistory: static file tip behind checkpoint, unwind needed"
403            );
404            return Ok(Some(sf_tip));
405        }
406
407        if sf_tip == checkpoint {
408            return Ok(None);
409        }
410
411        // Fast path: clear and re-insert genesis history.
412        if checkpoint == 0 {
413            tracing::info!(
414                target: "reth::providers::rocksdb",
415                "AccountsHistory: checkpoint is 0, clearing stale data"
416            );
417            self.clear::<tables::AccountsHistory>()?;
418
419            let chain_spec = provider.chain_spec();
420            let genesis = chain_spec.genesis();
421            let list = tables::BlockNumberList::new([0]).expect("single block always fits");
422            for addr in genesis.alloc.keys() {
423                self.put::<tables::AccountsHistory>(ShardedKey::last(*addr), &list)?;
424            }
425
426            return Ok(None);
427        }
428
429        let total_blocks = sf_tip - checkpoint;
430        tracing::info!(
431            target: "reth::providers::rocksdb",
432            checkpoint,
433            sf_tip,
434            total_blocks,
435            "AccountsHistory: healing via changesets"
436        );
437
438        let mut batch_start = checkpoint + 1;
439        let mut batch_num = 0u64;
440        let total_batches = total_blocks.div_ceil(HEAL_HISTORY_BATCH_SIZE);
441
442        while batch_start <= sf_tip {
443            let batch_end = (batch_start + HEAL_HISTORY_BATCH_SIZE - 1).min(sf_tip);
444            batch_num += 1;
445
446            let changesets = provider.account_changesets_range(batch_start..=batch_end)?;
447
448            let mut addresses = HashSet::with_capacity(changesets.len());
449            addresses.extend(changesets.iter().map(|(_, cs)| cs.address));
450            let unwind_from = checkpoint + 1;
451            let indices: Vec<_> = addresses.into_iter().map(|addr| (addr, unwind_from)).collect();
452
453            if !indices.is_empty() {
454                tracing::info!(
455                    target: "reth::providers::rocksdb",
456                    batch_num,
457                    total_batches,
458                    batch_start,
459                    batch_end,
460                    indices_count = indices.len(),
461                    "AccountsHistory: unwinding batch"
462                );
463
464                let batch = self.unwind_account_history_indices(&indices)?;
465                self.commit_batch(batch)?;
466            }
467
468            batch_start = batch_end + 1;
469        }
470
471        Ok(None)
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use std::sync::Arc;
478
479    use super::*;
480    use crate::{
481        init::insert_genesis_history,
482        providers::{rocksdb::RocksDBBuilder, static_file::StaticFileWriter},
483        test_utils::{create_test_provider_factory, create_test_provider_factory_with_chain_spec},
484        BlockWriter, DatabaseProviderFactory, RocksDBProviderFactory, StageCheckpointWriter,
485        TransactionsProvider,
486    };
487    use alloy_primitives::{Address, B256, U256};
488    use reth_chainspec::MAINNET;
489    use reth_db::{
490        cursor::{DbCursorRO, DbCursorRW},
491        models::AccountBeforeTx,
492    };
493    use reth_db_api::{
494        models::{
495            storage_sharded_key::StorageShardedKey, ShardedKey, StorageBeforeTx, StorageSettings,
496        },
497        tables::{self, BlockNumberList},
498        transaction::DbTxMut,
499    };
500    use reth_stages_types::StageCheckpoint;
501    use reth_static_file_types::StaticFileSegment;
502    use reth_testing_utils::generators::{self, BlockRangeParams};
503    use tempfile::TempDir;
504
505    #[test]
506    fn test_first_last_empty_rocksdb() {
507        let temp_dir = TempDir::new().unwrap();
508        let provider = RocksDBBuilder::new(temp_dir.path())
509            .with_table::<tables::TransactionHashNumbers>()
510            .with_table::<tables::StoragesHistory>()
511            .build()
512            .unwrap();
513
514        // Empty RocksDB, no checkpoints - should be consistent
515        let first = provider.first::<tables::TransactionHashNumbers>().unwrap();
516        let last = provider.last::<tables::TransactionHashNumbers>().unwrap();
517
518        assert!(first.is_none());
519        assert!(last.is_none());
520    }
521
522    #[test]
523    fn test_first_last_with_data() {
524        let temp_dir = TempDir::new().unwrap();
525        let provider = RocksDBBuilder::new(temp_dir.path())
526            .with_table::<tables::TransactionHashNumbers>()
527            .build()
528            .unwrap();
529
530        // Insert some data
531        let tx_hash = B256::repeat_byte(1u8);
532        provider.put::<tables::TransactionHashNumbers>(tx_hash, &100).unwrap();
533
534        // RocksDB has data
535        let last = provider.last::<tables::TransactionHashNumbers>().unwrap();
536        assert!(last.is_some());
537        assert_eq!(last.unwrap().1, 100);
538    }
539
540    #[test]
541    fn test_check_consistency_empty_rocksdb_no_checkpoint_is_ok() {
542        let temp_dir = TempDir::new().unwrap();
543        let rocksdb = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap();
544
545        // Create a test provider factory for MDBX
546        let factory = create_test_provider_factory();
547        factory.set_storage_settings_cache(StorageSettings::v2());
548
549        let provider = factory.database_provider_ro().unwrap();
550
551        // Empty RocksDB and no checkpoints - should be consistent (None = no unwind needed)
552        let result = rocksdb.check_consistency(&provider).unwrap();
553        assert_eq!(result, None);
554    }
555
556    /// Tests that `checkpoint=0` with empty `RocksDB` returns early without attempting
557    /// an expensive healing loop. Previously, when `sf_tip` > `checkpoint=0`, the healer
558    /// would iterate billions of transactions from static files for no effect, causing
559    /// the node to hang on startup with MDBX read transaction timeouts.
560    #[test]
561    fn test_check_consistency_checkpoint_zero_empty_rocksdb_returns_early() {
562        let temp_dir = TempDir::new().unwrap();
563        let rocksdb = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap();
564
565        let factory = create_test_provider_factory();
566        factory.set_storage_settings_cache(StorageSettings::v2());
567
568        // No checkpoints set — all default to 0 via unwrap_or(0).
569        // RocksDB tables are empty.
570        let provider = factory.database_provider_ro().unwrap();
571
572        let result = rocksdb.heal_transaction_hash_numbers(&provider).unwrap();
573        assert_eq!(result, None, "TransactionHashNumbers should return early at checkpoint 0");
574        assert!(rocksdb.first::<tables::TransactionHashNumbers>().unwrap().is_none());
575
576        let result = rocksdb.heal_storages_history(&provider).unwrap();
577        assert_eq!(result, None, "StoragesHistory should return early at checkpoint 0");
578
579        let result = rocksdb.heal_accounts_history(&provider).unwrap();
580        assert_eq!(result, None, "AccountsHistory should return early at checkpoint 0");
581        assert_eq!(rocksdb.iter::<tables::AccountsHistory>().unwrap().count(), 0);
582    }
583
584    #[test]
585    fn test_check_consistency_empty_rocksdb_with_checkpoint_is_first_run() {
586        let temp_dir = TempDir::new().unwrap();
587        let rocksdb = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap();
588
589        // Create a test provider factory for MDBX
590        let factory = create_test_provider_factory();
591        factory.set_storage_settings_cache(StorageSettings::v2());
592
593        // Set a checkpoint indicating we should have processed up to block 100
594        {
595            let provider = factory.database_provider_rw().unwrap();
596            provider
597                .save_stage_checkpoint(StageId::TransactionLookup, StageCheckpoint::new(100))
598                .unwrap();
599            provider.commit().unwrap();
600        }
601
602        let provider = factory.database_provider_ro().unwrap();
603
604        // RocksDB is empty but checkpoint says block 100 was processed.
605        // Since static file tip defaults to 0 when None, and 0 < 100, an unwind is triggered.
606        let result = rocksdb.check_consistency(&provider).unwrap();
607        assert_eq!(result, Some(0), "Static file tip (0) behind checkpoint (100) triggers unwind");
608    }
609
610    /// Tests that when checkpoint=0 and `RocksDB` has data, all entries are pruned.
611    /// This simulates a crash recovery scenario where the checkpoint was lost.
612    #[test]
613    fn test_check_consistency_checkpoint_zero_with_rocksdb_data_prunes_all() {
614        let temp_dir = TempDir::new().unwrap();
615        let rocksdb = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap();
616
617        let factory = create_test_provider_factory();
618        factory.set_storage_settings_cache(StorageSettings::v2());
619
620        // Generate blocks with real transactions and insert them
621        let mut rng = generators::rng();
622        let blocks = generators::random_block_range(
623            &mut rng,
624            0..=2,
625            BlockRangeParams { parent: Some(B256::ZERO), tx_count: 2..3, ..Default::default() },
626        );
627
628        let mut tx_hashes = Vec::new();
629        {
630            let provider = factory.database_provider_rw().unwrap();
631            let mut tx_count = 0u64;
632            for block in &blocks {
633                provider
634                    .insert_block(&block.clone().try_recover().expect("recover block"))
635                    .unwrap();
636                for tx in &block.body().transactions {
637                    let hash = *tx.tx_hash();
638                    tx_hashes.push(hash);
639                    rocksdb.put::<tables::TransactionHashNumbers>(hash, &tx_count).unwrap();
640                    tx_count += 1;
641                }
642            }
643            provider.commit().unwrap();
644        }
645
646        // Explicitly clear the checkpoints to simulate crash recovery
647        {
648            let provider = factory.database_provider_rw().unwrap();
649            provider
650                .save_stage_checkpoint(StageId::TransactionLookup, StageCheckpoint::new(0))
651                .unwrap();
652            provider
653                .save_stage_checkpoint(StageId::IndexStorageHistory, StageCheckpoint::new(0))
654                .unwrap();
655            provider
656                .save_stage_checkpoint(StageId::IndexAccountHistory, StageCheckpoint::new(0))
657                .unwrap();
658            provider.commit().unwrap();
659        }
660
661        // Verify RocksDB data exists
662        assert!(rocksdb.last::<tables::TransactionHashNumbers>().unwrap().is_some());
663
664        let provider = factory.database_provider_ro().unwrap();
665
666        // checkpoint = 0 but RocksDB has data.
667        // This means RocksDB has stale data that should be cleared.
668        let result = rocksdb.check_consistency(&provider).unwrap();
669        assert_eq!(result, None, "Should heal by clearing, no unwind needed");
670
671        // Verify data was cleared
672        for hash in &tx_hashes {
673            assert!(
674                rocksdb.get::<tables::TransactionHashNumbers>(*hash).unwrap().is_none(),
675                "RocksDB should be empty after pruning"
676            );
677        }
678    }
679
680    #[test]
681    fn test_check_consistency_storages_history_empty_with_checkpoint_is_first_run() {
682        let temp_dir = TempDir::new().unwrap();
683        let rocksdb = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap();
684
685        // Create a test provider factory for MDBX
686        let factory = create_test_provider_factory();
687        factory.set_storage_settings_cache(StorageSettings::v2());
688
689        // Set a checkpoint indicating we should have processed up to block 100
690        {
691            let provider = factory.database_provider_rw().unwrap();
692            provider
693                .save_stage_checkpoint(StageId::IndexStorageHistory, StageCheckpoint::new(100))
694                .unwrap();
695            provider.commit().unwrap();
696        }
697
698        let provider = factory.database_provider_ro().unwrap();
699
700        // RocksDB is empty but checkpoint says block 100 was processed.
701        // Since sf_tip=0 < checkpoint=100, we return unwind target of 0.
702        // This should never happen in normal operation.
703        let result = rocksdb.check_consistency(&provider).unwrap();
704        assert_eq!(result, Some(0), "sf_tip=0 < checkpoint=100 returns unwind target");
705    }
706
707    #[test]
708    fn test_check_consistency_storages_history_preserves_genesis_entries_at_checkpoint_zero(
709    ) -> eyre::Result<()> {
710        // Modify mainnet chainspec to include a single genesis storage slot
711        let mut chain_spec = MAINNET.clone();
712        Arc::make_mut(&mut chain_spec).genesis.alloc.first_entry().unwrap().get_mut().storage =
713            Some(From::from([(B256::random(), B256::random())]));
714
715        // Create a test provider factory for MDBX with NO checkpoint
716        let factory = create_test_provider_factory_with_chain_spec(chain_spec);
717        let rocksdb = factory.rocksdb_provider();
718        factory.set_storage_settings_cache(StorageSettings::v2());
719
720        // Insert genesis history into RocksDB
721        let provider_rw = factory.database_provider_rw().unwrap();
722        insert_genesis_history(&provider_rw, factory.chain_spec().genesis.alloc.iter())?;
723        provider_rw.commit()?;
724
725        let provider = factory.database_provider_ro().unwrap();
726
727        // This should not prune anything because only genesis entries are present
728        let result = rocksdb.heal_storages_history(&provider).unwrap();
729        assert_eq!(result, None, "Should skip healing when only genesis entries present");
730
731        // Verify data was NOT deleted
732        assert!(
733            rocksdb.iter::<tables::StoragesHistory>().unwrap().count() > 0,
734            "Genesis entries should be preserved"
735        );
736
737        Ok(())
738    }
739
740    #[test]
741    fn test_check_consistency_mdbx_behind_checkpoint_needs_unwind() {
742        let temp_dir = TempDir::new().unwrap();
743        let rocksdb = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap();
744
745        let factory = create_test_provider_factory();
746        factory.set_storage_settings_cache(StorageSettings::v2());
747
748        // Generate blocks with real transactions (blocks 0-2, 6 transactions total)
749        let mut rng = generators::rng();
750        let blocks = generators::random_block_range(
751            &mut rng,
752            0..=2,
753            BlockRangeParams { parent: Some(B256::ZERO), tx_count: 2..3, ..Default::default() },
754        );
755
756        {
757            let provider = factory.database_provider_rw().unwrap();
758            let mut tx_count = 0u64;
759            for block in &blocks {
760                provider
761                    .insert_block(&block.clone().try_recover().expect("recover block"))
762                    .unwrap();
763                for tx in &block.body().transactions {
764                    let hash = *tx.tx_hash();
765                    rocksdb.put::<tables::TransactionHashNumbers>(hash, &tx_count).unwrap();
766                    tx_count += 1;
767                }
768            }
769            provider.commit().unwrap();
770        }
771
772        // Set checkpoint to block 10 (beyond our actual data at block 2)
773        // sf_tip is at block 2, checkpoint is at block 10
774        // Since sf_tip < checkpoint, we need to unwind to sf_tip
775        {
776            let provider = factory.database_provider_rw().unwrap();
777            provider
778                .save_stage_checkpoint(StageId::TransactionLookup, StageCheckpoint::new(10))
779                .unwrap();
780            // Reset history checkpoints so they don't interfere
781            provider
782                .save_stage_checkpoint(StageId::IndexStorageHistory, StageCheckpoint::new(0))
783                .unwrap();
784            provider
785                .save_stage_checkpoint(StageId::IndexAccountHistory, StageCheckpoint::new(0))
786                .unwrap();
787            provider.commit().unwrap();
788        }
789
790        let provider = factory.database_provider_ro().unwrap();
791
792        // sf_tip (2) < checkpoint (10), so unwind to sf_tip is needed
793        let result = rocksdb.check_consistency(&provider).unwrap();
794        assert_eq!(result, Some(2), "sf_tip < checkpoint requires unwind to sf_tip");
795    }
796
797    #[test]
798    fn test_check_consistency_rocksdb_ahead_of_checkpoint_prunes_excess() {
799        let temp_dir = TempDir::new().unwrap();
800        let rocksdb = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap();
801
802        // Create a test provider factory for MDBX
803        let factory = create_test_provider_factory();
804        factory.set_storage_settings_cache(StorageSettings::v2());
805
806        // Generate blocks with real transactions:
807        // Blocks 0-5, each with 2 transactions = 12 total transactions (0-11)
808        let mut rng = generators::rng();
809        let blocks = generators::random_block_range(
810            &mut rng,
811            0..=5,
812            BlockRangeParams { parent: Some(B256::ZERO), tx_count: 2..3, ..Default::default() },
813        );
814
815        // Track which hashes belong to which blocks
816        let mut tx_hashes = Vec::new();
817        let mut tx_count = 0u64;
818        {
819            let provider = factory.database_provider_rw().unwrap();
820            // Insert ALL blocks (0-5) to write transactions to static files
821            for block in &blocks {
822                provider
823                    .insert_block(&block.clone().try_recover().expect("recover block"))
824                    .unwrap();
825                for tx in &block.body().transactions {
826                    let hash = *tx.tx_hash();
827                    tx_hashes.push(hash);
828                    rocksdb.put::<tables::TransactionHashNumbers>(hash, &tx_count).unwrap();
829                    tx_count += 1;
830                }
831            }
832            provider.commit().unwrap();
833        }
834
835        // Simulate crash recovery scenario:
836        // MDBX was unwound to block 2, but RocksDB and static files still have more data.
837        // Remove TransactionBlocks entries for blocks 3-5 to simulate MDBX unwind.
838        {
839            let provider = factory.database_provider_rw().unwrap();
840            // Delete TransactionBlocks entries for tx > 5 (i.e., for blocks 3-5)
841            // TransactionBlocks maps last_tx_in_block -> block_number
842            // After unwind, only entries for blocks 0-2 should remain (tx 5 -> block 2)
843            let mut cursor = provider.tx_ref().cursor_write::<tables::TransactionBlocks>().unwrap();
844            // Walk and delete entries where block > 2
845            let mut to_delete = Vec::new();
846            let mut walker = cursor.walk(Some(0)).unwrap();
847            while let Some((tx_num, block_num)) = walker.next().transpose().unwrap() {
848                if block_num > 2 {
849                    to_delete.push(tx_num);
850                }
851            }
852            drop(walker);
853            for tx_num in to_delete {
854                cursor.seek_exact(tx_num).unwrap();
855                cursor.delete_current().unwrap();
856            }
857
858            // Set checkpoint to block 2
859            provider
860                .save_stage_checkpoint(StageId::TransactionLookup, StageCheckpoint::new(2))
861                .unwrap();
862            // Reset history checkpoints so they don't interfere
863            provider
864                .save_stage_checkpoint(StageId::IndexStorageHistory, StageCheckpoint::new(0))
865                .unwrap();
866            provider
867                .save_stage_checkpoint(StageId::IndexAccountHistory, StageCheckpoint::new(0))
868                .unwrap();
869            provider.commit().unwrap();
870        }
871
872        let provider = factory.database_provider_ro().unwrap();
873
874        // RocksDB has tx hashes for all blocks (0-5)
875        // MDBX TransactionBlocks only goes up to tx 5 (block 2)
876        // Static files have data for all txs (0-11)
877        // This means RocksDB is ahead and should prune entries for tx 6-11
878        let result = rocksdb.check_consistency(&provider).unwrap();
879        assert_eq!(result, None, "Should heal by pruning, no unwind needed");
880
881        // Verify: hashes for blocks 0-2 (tx 0-5) should remain, blocks 3-5 (tx 6-11) should be
882        // pruned First 6 hashes should remain
883        for (i, hash) in tx_hashes.iter().take(6).enumerate() {
884            assert!(
885                rocksdb.get::<tables::TransactionHashNumbers>(*hash).unwrap().is_some(),
886                "tx {} should remain",
887                i
888            );
889        }
890        // Last 6 hashes should be pruned
891        for (i, hash) in tx_hashes.iter().skip(6).enumerate() {
892            assert!(
893                rocksdb.get::<tables::TransactionHashNumbers>(*hash).unwrap().is_none(),
894                "tx {} should be pruned",
895                i + 6
896            );
897        }
898    }
899
900    #[test]
901    fn test_check_consistency_storages_history_sentinel_only_with_checkpoint_is_first_run() {
902        let temp_dir = TempDir::new().unwrap();
903        let rocksdb = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap();
904
905        // Insert ONLY sentinel entries (highest_block_number = u64::MAX)
906        // This simulates a scenario where history tracking started but no shards were completed
907        let key_sentinel_1 = StorageShardedKey::new(Address::ZERO, B256::ZERO, u64::MAX);
908        let key_sentinel_2 = StorageShardedKey::new(Address::random(), B256::random(), u64::MAX);
909        let block_list = BlockNumberList::new_pre_sorted([10, 20, 30]);
910        rocksdb.put::<tables::StoragesHistory>(key_sentinel_1, &block_list).unwrap();
911        rocksdb.put::<tables::StoragesHistory>(key_sentinel_2, &block_list).unwrap();
912
913        // Verify entries exist (not empty table)
914        assert!(rocksdb.first::<tables::StoragesHistory>().unwrap().is_some());
915
916        // Create a test provider factory for MDBX
917        let factory = create_test_provider_factory();
918        factory.set_storage_settings_cache(StorageSettings::v2());
919
920        // Set a checkpoint indicating we should have processed up to block 100
921        {
922            let provider = factory.database_provider_rw().unwrap();
923            provider
924                .save_stage_checkpoint(StageId::IndexStorageHistory, StageCheckpoint::new(100))
925                .unwrap();
926            provider.commit().unwrap();
927        }
928
929        let provider = factory.database_provider_ro().unwrap();
930
931        // RocksDB has only sentinel entries but checkpoint is set.
932        // Since sf_tip=0 < checkpoint=100, we return unwind target of 0.
933        // This should never happen in normal operation.
934        let result = rocksdb.check_consistency(&provider).unwrap();
935        assert_eq!(result, Some(0), "sf_tip=0 < checkpoint=100 returns unwind target");
936    }
937
938    #[test]
939    fn test_check_consistency_accounts_history_sentinel_only_with_checkpoint_is_first_run() {
940        let temp_dir = TempDir::new().unwrap();
941        let rocksdb = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap();
942
943        // Insert ONLY sentinel entries (highest_block_number = u64::MAX)
944        let key_sentinel_1 = ShardedKey::new(Address::ZERO, u64::MAX);
945        let key_sentinel_2 = ShardedKey::new(Address::random(), u64::MAX);
946        let block_list = BlockNumberList::new_pre_sorted([10, 20, 30]);
947        rocksdb.put::<tables::AccountsHistory>(key_sentinel_1, &block_list).unwrap();
948        rocksdb.put::<tables::AccountsHistory>(key_sentinel_2, &block_list).unwrap();
949
950        // Verify entries exist (not empty table)
951        assert!(rocksdb.first::<tables::AccountsHistory>().unwrap().is_some());
952
953        // Create a test provider factory for MDBX
954        let factory = create_test_provider_factory();
955        factory.set_storage_settings_cache(StorageSettings::v2());
956
957        // Set a checkpoint indicating we should have processed up to block 100
958        {
959            let provider = factory.database_provider_rw().unwrap();
960            provider
961                .save_stage_checkpoint(StageId::IndexAccountHistory, StageCheckpoint::new(100))
962                .unwrap();
963            provider.commit().unwrap();
964        }
965
966        let provider = factory.database_provider_ro().unwrap();
967
968        // RocksDB has only sentinel entries but checkpoint is set.
969        // Since sf_tip=0 < checkpoint=100, we return unwind target of 0.
970        // This should never happen in normal operation.
971        let result = rocksdb.check_consistency(&provider).unwrap();
972        assert_eq!(result, Some(0), "sf_tip=0 < checkpoint=100 returns unwind target");
973    }
974
975    /// Test that pruning works by fetching transactions and reading their hashes,
976    /// rather than iterating all rows. This test uses random blocks with unique
977    /// transactions so we can verify the correct entries are pruned.
978    #[test]
979    fn test_prune_transaction_hash_numbers_by_range() {
980        let temp_dir = TempDir::new().unwrap();
981        let rocksdb = RocksDBBuilder::new(temp_dir.path())
982            .with_table::<tables::TransactionHashNumbers>()
983            .build()
984            .unwrap();
985
986        // Create a test provider factory for MDBX
987        let factory = create_test_provider_factory();
988        factory.set_storage_settings_cache(StorageSettings::v2());
989
990        // Generate random blocks with unique transactions
991        // Block 0 (genesis) has no transactions
992        // Blocks 1-5 each have 2 transactions = 10 transactions total
993        let mut rng = generators::rng();
994        let blocks = generators::random_block_range(
995            &mut rng,
996            0..=5,
997            BlockRangeParams { parent: Some(B256::ZERO), tx_count: 2..3, ..Default::default() },
998        );
999
1000        // Insert blocks into the database
1001        let mut tx_count = 0u64;
1002        let mut tx_hashes = Vec::new();
1003        {
1004            let provider = factory.database_provider_rw().unwrap();
1005
1006            for block in &blocks {
1007                provider
1008                    .insert_block(&block.clone().try_recover().expect("recover block"))
1009                    .unwrap();
1010
1011                // Store transaction hash -> tx_number mappings in RocksDB
1012                for tx in &block.body().transactions {
1013                    let hash = *tx.tx_hash();
1014                    tx_hashes.push(hash);
1015                    rocksdb.put::<tables::TransactionHashNumbers>(hash, &tx_count).unwrap();
1016                    tx_count += 1;
1017                }
1018            }
1019
1020            // Set checkpoint to block 2 (meaning we should only have tx hashes for blocks 0-2)
1021            // Blocks 0, 1, 2 have 6 transactions (2 each), so tx 0-5 should remain
1022            provider
1023                .save_stage_checkpoint(StageId::TransactionLookup, StageCheckpoint::new(2))
1024                .unwrap();
1025            provider.commit().unwrap();
1026        }
1027
1028        // At this point:
1029        // - RocksDB has tx hashes for blocks 0-5 (10 total: 2 per block)
1030        // - Checkpoint says we only processed up to block 2
1031        // - We need to prune tx hashes for blocks 3, 4, 5 (tx 6-9)
1032
1033        // Verify RocksDB has the expected number of entries before pruning
1034        let rocksdb_count_before: usize =
1035            rocksdb.iter::<tables::TransactionHashNumbers>().unwrap().count();
1036        assert_eq!(
1037            rocksdb_count_before, tx_count as usize,
1038            "RocksDB should have all {} transaction hashes before pruning",
1039            tx_count
1040        );
1041
1042        let provider = factory.database_provider_ro().unwrap();
1043
1044        // Verify we can fetch transactions by tx range
1045        let all_txs = provider.transactions_by_tx_range(0..tx_count).unwrap();
1046        assert_eq!(all_txs.len(), tx_count as usize, "Should be able to fetch all transactions");
1047
1048        // Verify the hashes match between what we stored and what we read from fetched txs.
1049        for (i, tx) in all_txs.iter().enumerate() {
1050            let fetched_hash = *tx.tx_hash();
1051            assert_eq!(
1052                fetched_hash, tx_hashes[i],
1053                "Hash mismatch for tx {}: stored {:?} vs fetched {:?}",
1054                i, tx_hashes[i], fetched_hash
1055            );
1056        }
1057
1058        // Blocks 0, 1, 2 have 2 tx each = 6 tx total (indices 0-5)
1059        // We want to keep tx 0-5, prune tx 6-9
1060        let max_tx_to_keep = 5u64;
1061        let tx_to_prune_start = max_tx_to_keep + 1;
1062
1063        // Prune transactions 6-9 (blocks 3-5)
1064        rocksdb
1065            .prune_transaction_hash_numbers_in_range(&provider, tx_to_prune_start..=(tx_count - 1))
1066            .expect("prune should succeed");
1067
1068        // Verify: transactions 0-5 should remain, 6-9 should be pruned
1069        let mut remaining_count = 0;
1070        for result in rocksdb.iter::<tables::TransactionHashNumbers>().unwrap() {
1071            let (_hash, tx_num) = result.unwrap();
1072            assert!(
1073                tx_num <= max_tx_to_keep,
1074                "Transaction {} should have been pruned (> {})",
1075                tx_num,
1076                max_tx_to_keep
1077            );
1078            remaining_count += 1;
1079        }
1080        assert_eq!(
1081            remaining_count,
1082            (max_tx_to_keep + 1) as usize,
1083            "Should have {} transactions (0-{})",
1084            max_tx_to_keep + 1,
1085            max_tx_to_keep
1086        );
1087    }
1088
1089    #[test]
1090    fn test_check_consistency_accounts_history_empty_with_checkpoint_is_first_run() {
1091        let temp_dir = TempDir::new().unwrap();
1092        let rocksdb = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap();
1093
1094        // Create a test provider factory for MDBX
1095        let factory = create_test_provider_factory();
1096        factory.set_storage_settings_cache(StorageSettings::v2());
1097
1098        // Set a checkpoint indicating we should have processed up to block 100
1099        {
1100            let provider = factory.database_provider_rw().unwrap();
1101            provider
1102                .save_stage_checkpoint(StageId::IndexAccountHistory, StageCheckpoint::new(100))
1103                .unwrap();
1104            provider.commit().unwrap();
1105        }
1106
1107        let provider = factory.database_provider_ro().unwrap();
1108
1109        // RocksDB is empty but checkpoint says block 100 was processed.
1110        // Since sf_tip=0 < checkpoint=100, we return unwind target of 0.
1111        // This should never happen in normal operation.
1112        let result = rocksdb.check_consistency(&provider).unwrap();
1113        assert_eq!(result, Some(0), "sf_tip=0 < checkpoint=100 returns unwind target");
1114    }
1115
1116    #[test]
1117    fn test_check_consistency_accounts_history_preserves_genesis_entries_at_checkpoint_zero(
1118    ) -> eyre::Result<()> {
1119        // Create a test provider factory for MDBX with NO checkpoint
1120        let factory = create_test_provider_factory();
1121        factory.set_storage_settings_cache(StorageSettings::v2());
1122        let rocksdb = factory.rocksdb_provider();
1123
1124        // Insert genesis history into RocksDB
1125        let provider_rw = factory.database_provider_rw().unwrap();
1126        insert_genesis_history(&provider_rw, factory.chain_spec().genesis.alloc.iter())?;
1127        provider_rw.commit()?;
1128
1129        let provider = factory.database_provider_ro().unwrap();
1130
1131        // This should not prune anything because only genesis entries are present
1132        let result = rocksdb.check_consistency(&provider).unwrap();
1133        assert_eq!(result, None, "Should heal by pruning, no unwind needed");
1134
1135        // Verify data was NOT deleted
1136        assert!(
1137            rocksdb.iter::<tables::AccountsHistory>().unwrap().count() > 0,
1138            "Genesis entries should be preserved"
1139        );
1140
1141        Ok(())
1142    }
1143
1144    #[test]
1145    fn test_check_consistency_accounts_history_sf_tip_equals_checkpoint_no_action() {
1146        let temp_dir = TempDir::new().unwrap();
1147        let rocksdb = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap();
1148
1149        // Insert some AccountsHistory entries with various highest_block_numbers
1150        let key1 = ShardedKey::new(Address::ZERO, 50);
1151        let key2 = ShardedKey::new(Address::random(), 75);
1152        let key3 = ShardedKey::new(Address::random(), u64::MAX); // sentinel
1153        let block_list1 = BlockNumberList::new_pre_sorted([10, 20, 30, 50]);
1154        let block_list2 = BlockNumberList::new_pre_sorted([40, 60, 75]);
1155        let block_list3 = BlockNumberList::new_pre_sorted([80, 90, 100]);
1156        rocksdb.put::<tables::AccountsHistory>(key1, &block_list1).unwrap();
1157        rocksdb.put::<tables::AccountsHistory>(key2, &block_list2).unwrap();
1158        rocksdb.put::<tables::AccountsHistory>(key3, &block_list3).unwrap();
1159
1160        // Capture RocksDB state before consistency check
1161        let entries_before: Vec<_> =
1162            rocksdb.iter::<tables::AccountsHistory>().unwrap().map(|r| r.unwrap()).collect();
1163        assert_eq!(entries_before.len(), 3, "Should have 3 entries before check");
1164
1165        // Create a test provider factory for MDBX
1166        let factory = create_test_provider_factory();
1167        factory.set_storage_settings_cache(StorageSettings::v2());
1168
1169        // Write account changesets to static files for blocks 0-100
1170        {
1171            let sf_provider = factory.static_file_provider();
1172            let mut writer =
1173                sf_provider.latest_writer(StaticFileSegment::AccountChangeSets).unwrap();
1174
1175            for block_num in 0..=100 {
1176                let changeset = vec![AccountBeforeTx { address: Address::random(), info: None }];
1177                writer.append_account_changeset(changeset, block_num).unwrap();
1178            }
1179
1180            writer.commit().unwrap();
1181        }
1182
1183        // Set IndexAccountHistory checkpoint to block 100 (same as sf_tip)
1184        {
1185            let provider = factory.database_provider_rw().unwrap();
1186            provider
1187                .save_stage_checkpoint(StageId::IndexAccountHistory, StageCheckpoint::new(100))
1188                .unwrap();
1189            provider.commit().unwrap();
1190        }
1191
1192        let provider = factory.database_provider_ro().unwrap();
1193
1194        // Verify sf_tip equals checkpoint (both at 100)
1195        let sf_tip = provider
1196            .static_file_provider()
1197            .get_highest_static_file_block(StaticFileSegment::AccountChangeSets)
1198            .unwrap();
1199        assert_eq!(sf_tip, 100, "Static file tip should be 100");
1200
1201        // Run check_consistency - should return None (no unwind needed)
1202        let result = rocksdb.check_consistency(&provider).unwrap();
1203        assert_eq!(result, None, "sf_tip == checkpoint should not require unwind");
1204
1205        // Verify NO entries are deleted - RocksDB state unchanged
1206        let entries_after: Vec<_> =
1207            rocksdb.iter::<tables::AccountsHistory>().unwrap().map(|r| r.unwrap()).collect();
1208
1209        assert_eq!(
1210            entries_after.len(),
1211            entries_before.len(),
1212            "RocksDB entry count should be unchanged when sf_tip == checkpoint"
1213        );
1214
1215        // Verify exact entries are preserved
1216        for (before, after) in entries_before.iter().zip(entries_after.iter()) {
1217            assert_eq!(before.0.key, after.0.key, "Entry key should be unchanged");
1218            assert_eq!(
1219                before.0.highest_block_number, after.0.highest_block_number,
1220                "Entry highest_block_number should be unchanged"
1221            );
1222            assert_eq!(before.1, after.1, "Entry block list should be unchanged");
1223        }
1224    }
1225
1226    /// Tests `StoragesHistory` changeset-based healing with enough blocks to trigger batching.
1227    ///
1228    /// Scenario:
1229    /// 1. Generate 15,000 blocks worth of storage changeset data (to exceed the 10k batch size)
1230    /// 2. Each block has 1 storage change (address + slot + value)
1231    /// 3. Write storage changesets to static files for all 15k blocks
1232    /// 4. Set `IndexStorageHistory` checkpoint to block 5000
1233    /// 5. Insert stale `StoragesHistory` entries in `RocksDB` for (address, slot) pairs that
1234    ///    changed in blocks 5001-15000
1235    /// 6. Run `check_consistency`
1236    /// 7. Verify stale entries for blocks > 5000 are pruned and batching worked
1237    #[test]
1238    fn test_check_consistency_storages_history_heals_via_changesets_large_range() {
1239        const TOTAL_BLOCKS: u64 = 15_000;
1240        const CHECKPOINT_BLOCK: u64 = 5_000;
1241
1242        let temp_dir = TempDir::new().unwrap();
1243        let rocksdb = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap();
1244
1245        let factory = create_test_provider_factory();
1246        factory.set_storage_settings_cache(StorageSettings::v2());
1247
1248        // Helper to generate address from block number (reuses stack arrays)
1249        #[inline]
1250        fn make_address(block_num: u64) -> Address {
1251            let mut addr_bytes = [0u8; 20];
1252            addr_bytes[0..8].copy_from_slice(&block_num.to_le_bytes());
1253            Address::from(addr_bytes)
1254        }
1255
1256        // Helper to generate slot from block number (reuses stack arrays)
1257        #[inline]
1258        fn make_slot(block_num: u64) -> B256 {
1259            let mut slot_bytes = [0u8; 32];
1260            slot_bytes[0..8].copy_from_slice(&block_num.to_le_bytes());
1261            B256::from(slot_bytes)
1262        }
1263
1264        // Write storage changesets to static files for 15k blocks.
1265        // Each block has 1 storage change with a unique (address, slot) pair.
1266        {
1267            let sf_provider = factory.static_file_provider();
1268            let mut writer =
1269                sf_provider.latest_writer(StaticFileSegment::StorageChangeSets).unwrap();
1270
1271            // Reuse changeset vec to avoid repeated allocations
1272            let mut changeset = Vec::with_capacity(1);
1273
1274            for block_num in 0..TOTAL_BLOCKS {
1275                changeset.clear();
1276                changeset.push(StorageBeforeTx {
1277                    address: make_address(block_num),
1278                    key: make_slot(block_num),
1279                    value: U256::from(block_num),
1280                });
1281
1282                writer.append_storage_changeset(changeset.clone(), block_num).unwrap();
1283            }
1284
1285            writer.commit().unwrap();
1286        }
1287
1288        // Verify static files have data up to block 14999
1289        {
1290            let sf_provider = factory.static_file_provider();
1291            let highest = sf_provider
1292                .get_highest_static_file_block(StaticFileSegment::StorageChangeSets)
1293                .unwrap();
1294            assert_eq!(highest, TOTAL_BLOCKS - 1, "Static files should have blocks 0..14999");
1295        }
1296
1297        // Set IndexStorageHistory checkpoint to block 5000
1298        {
1299            let provider = factory.database_provider_rw().unwrap();
1300            provider
1301                .save_stage_checkpoint(
1302                    StageId::IndexStorageHistory,
1303                    StageCheckpoint::new(CHECKPOINT_BLOCK),
1304                )
1305                .unwrap();
1306            provider.commit().unwrap();
1307        }
1308
1309        // Insert stale StoragesHistory entries for blocks 5001-14999
1310        // These are (address, slot) pairs that changed after the checkpoint
1311        for block_num in (CHECKPOINT_BLOCK + 1)..TOTAL_BLOCKS {
1312            let key =
1313                StorageShardedKey::new(make_address(block_num), make_slot(block_num), block_num);
1314            let block_list = BlockNumberList::new_pre_sorted([block_num]);
1315            rocksdb.put::<tables::StoragesHistory>(key, &block_list).unwrap();
1316        }
1317
1318        // Verify RocksDB has stale entries before healing
1319        let count_before: usize = rocksdb.iter::<tables::StoragesHistory>().unwrap().count();
1320        assert_eq!(
1321            count_before,
1322            (TOTAL_BLOCKS - CHECKPOINT_BLOCK - 1) as usize,
1323            "Should have {} stale entries before healing",
1324            TOTAL_BLOCKS - CHECKPOINT_BLOCK - 1
1325        );
1326
1327        // Run check_consistency - this should heal by pruning stale entries
1328        let provider = factory.database_provider_ro().unwrap();
1329        let result = rocksdb.check_consistency(&provider).unwrap();
1330        assert_eq!(result, None, "Should heal via changesets, no unwind needed");
1331
1332        // Verify all stale entries were pruned
1333        // After healing, entries with highest_block_number > checkpoint should be gone
1334        let mut remaining_stale = 0;
1335        for result in rocksdb.iter::<tables::StoragesHistory>().unwrap() {
1336            let (key, _) = result.unwrap();
1337            if key.sharded_key.highest_block_number > CHECKPOINT_BLOCK {
1338                remaining_stale += 1;
1339            }
1340        }
1341        assert_eq!(
1342            remaining_stale, 0,
1343            "All stale entries (block > {}) should be pruned",
1344            CHECKPOINT_BLOCK
1345        );
1346    }
1347
1348    /// Tests that healing preserves entries at exactly the checkpoint block.
1349    ///
1350    /// This catches off-by-one bugs where checkpoint block data is incorrectly deleted.
1351    #[test]
1352    fn test_check_consistency_storages_history_preserves_checkpoint_block() {
1353        const CHECKPOINT_BLOCK: u64 = 100;
1354        const SF_TIP: u64 = 200;
1355
1356        let temp_dir = TempDir::new().unwrap();
1357        let rocksdb = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap();
1358
1359        let factory = create_test_provider_factory();
1360        factory.set_storage_settings_cache(StorageSettings::v2());
1361
1362        let checkpoint_addr = Address::repeat_byte(0xAA);
1363        let checkpoint_slot = B256::repeat_byte(0xBB);
1364        let stale_addr = Address::repeat_byte(0xCC);
1365        let stale_slot = B256::repeat_byte(0xDD);
1366
1367        // Write storage changesets to static files
1368        {
1369            let sf_provider = factory.static_file_provider();
1370            let mut writer =
1371                sf_provider.latest_writer(StaticFileSegment::StorageChangeSets).unwrap();
1372
1373            for block_num in 0..=SF_TIP {
1374                let changeset = if block_num == CHECKPOINT_BLOCK {
1375                    vec![StorageBeforeTx {
1376                        address: checkpoint_addr,
1377                        key: checkpoint_slot,
1378                        value: U256::from(block_num),
1379                    }]
1380                } else if block_num > CHECKPOINT_BLOCK {
1381                    vec![StorageBeforeTx {
1382                        address: stale_addr,
1383                        key: stale_slot,
1384                        value: U256::from(block_num),
1385                    }]
1386                } else {
1387                    vec![StorageBeforeTx {
1388                        address: Address::ZERO,
1389                        key: B256::ZERO,
1390                        value: U256::ZERO,
1391                    }]
1392                };
1393                writer.append_storage_changeset(changeset, block_num).unwrap();
1394            }
1395            writer.commit().unwrap();
1396        }
1397
1398        // Set checkpoint
1399        {
1400            let provider = factory.database_provider_rw().unwrap();
1401            provider
1402                .save_stage_checkpoint(
1403                    StageId::IndexStorageHistory,
1404                    StageCheckpoint::new(CHECKPOINT_BLOCK),
1405                )
1406                .unwrap();
1407            provider.commit().unwrap();
1408        }
1409
1410        // Insert entry AT the checkpoint block (should be preserved)
1411        let checkpoint_key =
1412            StorageShardedKey::new(checkpoint_addr, checkpoint_slot, CHECKPOINT_BLOCK);
1413        let checkpoint_list = BlockNumberList::new_pre_sorted([CHECKPOINT_BLOCK]);
1414        rocksdb.put::<tables::StoragesHistory>(checkpoint_key.clone(), &checkpoint_list).unwrap();
1415
1416        // Insert stale entry AFTER the checkpoint (should be removed)
1417        let stale_key = StorageShardedKey::new(stale_addr, stale_slot, SF_TIP);
1418        let stale_list = BlockNumberList::new_pre_sorted([CHECKPOINT_BLOCK + 1, SF_TIP]);
1419        rocksdb.put::<tables::StoragesHistory>(stale_key.clone(), &stale_list).unwrap();
1420
1421        // Run healing
1422        let provider = factory.database_provider_ro().unwrap();
1423        let result = rocksdb.check_consistency(&provider).unwrap();
1424        assert_eq!(result, None, "Should heal without unwind");
1425
1426        // Verify checkpoint block entry is PRESERVED
1427        let preserved = rocksdb.get::<tables::StoragesHistory>(checkpoint_key).unwrap();
1428        assert!(preserved.is_some(), "Entry at checkpoint block should be preserved, not deleted");
1429
1430        // Verify stale entry is removed or unwound
1431        let stale = rocksdb.get::<tables::StoragesHistory>(stale_key).unwrap();
1432        assert!(stale.is_none(), "Stale entry after checkpoint should be removed");
1433    }
1434
1435    /// Tests `AccountsHistory` changeset-based healing with enough blocks to trigger batching.
1436    ///
1437    /// Scenario:
1438    /// 1. Generate 15,000 blocks worth of account changeset data (to exceed the 10k batch size)
1439    /// 2. Each block has 1 account change (simple - just random addresses)
1440    /// 3. Write account changesets to static files for all 15k blocks
1441    /// 4. Set `IndexAccountHistory` checkpoint to block 5000
1442    /// 5. Insert stale `AccountsHistory` entries in `RocksDB` for addresses that changed in blocks
1443    ///    5001-15000
1444    /// 6. Run `check_consistency`
1445    /// 7. Verify:
1446    ///    - Stale entries for blocks > 5000 are pruned
1447    ///    - The batching worked (no OOM, completed successfully)
1448    #[test]
1449    fn test_check_consistency_accounts_history_heals_via_changesets_large_range() {
1450        let temp_dir = TempDir::new().unwrap();
1451        let rocksdb = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap();
1452
1453        // Create test provider factory
1454        let factory = create_test_provider_factory();
1455        factory.set_storage_settings_cache(StorageSettings::v2());
1456
1457        const TOTAL_BLOCKS: u64 = 15_000;
1458        const CHECKPOINT_BLOCK: u64 = 5_000;
1459
1460        // Helper to generate address from block number (avoids pre-allocating 15k addresses)
1461        #[inline]
1462        fn make_address(block_num: u64) -> Address {
1463            let mut addr = Address::ZERO;
1464            addr.0[0..8].copy_from_slice(&block_num.to_le_bytes());
1465            addr
1466        }
1467
1468        // Write account changesets to static files for all 15k blocks
1469        {
1470            let sf_provider = factory.static_file_provider();
1471            let mut writer =
1472                sf_provider.latest_writer(StaticFileSegment::AccountChangeSets).unwrap();
1473
1474            // Reuse changeset vec to avoid repeated allocations
1475            let mut changeset = Vec::with_capacity(1);
1476
1477            for block_num in 0..TOTAL_BLOCKS {
1478                changeset.clear();
1479                changeset.push(AccountBeforeTx { address: make_address(block_num), info: None });
1480                writer.append_account_changeset(changeset.clone(), block_num).unwrap();
1481            }
1482
1483            writer.commit().unwrap();
1484        }
1485
1486        // Insert stale AccountsHistory entries in RocksDB for addresses that changed
1487        // in blocks 5001-15000 (i.e., blocks after the checkpoint)
1488        // These should be pruned by check_consistency
1489        for block_num in (CHECKPOINT_BLOCK + 1)..TOTAL_BLOCKS {
1490            let key = ShardedKey::new(make_address(block_num), block_num);
1491            let block_list = BlockNumberList::new_pre_sorted([block_num]);
1492            rocksdb.put::<tables::AccountsHistory>(key, &block_list).unwrap();
1493        }
1494
1495        // Also insert some valid entries for blocks <= 5000 that should NOT be pruned
1496        for block_num in [100u64, 500, 1000, 2500, 5000] {
1497            let key = ShardedKey::new(make_address(block_num), block_num);
1498            let block_list = BlockNumberList::new_pre_sorted([block_num]);
1499            rocksdb.put::<tables::AccountsHistory>(key, &block_list).unwrap();
1500        }
1501
1502        // Verify we have entries before healing
1503        let entries_before: usize = rocksdb.iter::<tables::AccountsHistory>().unwrap().count();
1504        let stale_count = (TOTAL_BLOCKS - CHECKPOINT_BLOCK - 1) as usize;
1505        let valid_count = 5usize;
1506        assert_eq!(
1507            entries_before,
1508            stale_count + valid_count,
1509            "Should have {} stale + {} valid entries before healing",
1510            stale_count,
1511            valid_count
1512        );
1513
1514        // Set IndexAccountHistory checkpoint to block 5000
1515        {
1516            let provider = factory.database_provider_rw().unwrap();
1517            provider
1518                .save_stage_checkpoint(
1519                    StageId::IndexAccountHistory,
1520                    StageCheckpoint::new(CHECKPOINT_BLOCK),
1521                )
1522                .unwrap();
1523            provider.commit().unwrap();
1524        }
1525
1526        let provider = factory.database_provider_ro().unwrap();
1527
1528        // Verify sf_tip > checkpoint
1529        let sf_tip = provider
1530            .static_file_provider()
1531            .get_highest_static_file_block(StaticFileSegment::AccountChangeSets)
1532            .unwrap();
1533        assert_eq!(sf_tip, TOTAL_BLOCKS - 1, "Static file tip should be 14999");
1534        assert!(sf_tip > CHECKPOINT_BLOCK, "sf_tip should be > checkpoint to trigger healing");
1535
1536        // Run check_consistency - this should trigger batched changeset-based healing
1537        let result = rocksdb.check_consistency(&provider).unwrap();
1538        assert_eq!(result, None, "Healing should succeed without requiring unwind");
1539
1540        // Verify: all stale entries for blocks > 5000 should be pruned
1541        // Count remaining entries with highest_block_number > checkpoint
1542        let mut remaining_stale = 0;
1543        for result in rocksdb.iter::<tables::AccountsHistory>().unwrap() {
1544            let (key, _) = result.unwrap();
1545            if key.highest_block_number > CHECKPOINT_BLOCK && key.highest_block_number != u64::MAX {
1546                remaining_stale += 1;
1547            }
1548        }
1549        assert_eq!(
1550            remaining_stale, 0,
1551            "All stale entries (block > {}) should be pruned",
1552            CHECKPOINT_BLOCK
1553        );
1554    }
1555
1556    /// Tests that accounts history healing preserves entries at exactly the checkpoint block.
1557    #[test]
1558    fn test_check_consistency_accounts_history_preserves_checkpoint_block() {
1559        const CHECKPOINT_BLOCK: u64 = 100;
1560        const SF_TIP: u64 = 200;
1561
1562        let temp_dir = TempDir::new().unwrap();
1563        let rocksdb = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap();
1564
1565        let factory = create_test_provider_factory();
1566        factory.set_storage_settings_cache(StorageSettings::v2());
1567
1568        let checkpoint_addr = Address::repeat_byte(0xAA);
1569        let stale_addr = Address::repeat_byte(0xCC);
1570
1571        // Write account changesets to static files
1572        {
1573            let sf_provider = factory.static_file_provider();
1574            let mut writer =
1575                sf_provider.latest_writer(StaticFileSegment::AccountChangeSets).unwrap();
1576
1577            for block_num in 0..=SF_TIP {
1578                let changeset = if block_num == CHECKPOINT_BLOCK {
1579                    vec![AccountBeforeTx { address: checkpoint_addr, info: None }]
1580                } else if block_num > CHECKPOINT_BLOCK {
1581                    vec![AccountBeforeTx { address: stale_addr, info: None }]
1582                } else {
1583                    vec![AccountBeforeTx { address: Address::ZERO, info: None }]
1584                };
1585                writer.append_account_changeset(changeset, block_num).unwrap();
1586            }
1587            writer.commit().unwrap();
1588        }
1589
1590        // Set checkpoint
1591        {
1592            let provider = factory.database_provider_rw().unwrap();
1593            provider
1594                .save_stage_checkpoint(
1595                    StageId::IndexAccountHistory,
1596                    StageCheckpoint::new(CHECKPOINT_BLOCK),
1597                )
1598                .unwrap();
1599            provider.commit().unwrap();
1600        }
1601
1602        // Insert entry AT the checkpoint block (should be preserved)
1603        let checkpoint_key = ShardedKey::new(checkpoint_addr, CHECKPOINT_BLOCK);
1604        let checkpoint_list = BlockNumberList::new_pre_sorted([CHECKPOINT_BLOCK]);
1605        rocksdb.put::<tables::AccountsHistory>(checkpoint_key.clone(), &checkpoint_list).unwrap();
1606
1607        // Insert stale entry AFTER the checkpoint (should be removed)
1608        let stale_key = ShardedKey::new(stale_addr, SF_TIP);
1609        let stale_list = BlockNumberList::new_pre_sorted([CHECKPOINT_BLOCK + 1, SF_TIP]);
1610        rocksdb.put::<tables::AccountsHistory>(stale_key.clone(), &stale_list).unwrap();
1611
1612        // Run healing
1613        let provider = factory.database_provider_ro().unwrap();
1614        let result = rocksdb.check_consistency(&provider).unwrap();
1615        assert_eq!(result, None, "Should heal without unwind");
1616
1617        // Verify checkpoint block entry is PRESERVED
1618        let preserved = rocksdb.get::<tables::AccountsHistory>(checkpoint_key).unwrap();
1619        assert!(preserved.is_some(), "Entry at checkpoint block should be preserved, not deleted");
1620
1621        // Verify stale entry is removed or unwound
1622        let stale = rocksdb.get::<tables::AccountsHistory>(stale_key).unwrap();
1623        assert!(stale.is_none(), "Stale entry after checkpoint should be removed");
1624    }
1625
1626    #[test]
1627    fn test_check_consistency_storages_history_sf_tip_equals_checkpoint_no_action() {
1628        let temp_dir = TempDir::new().unwrap();
1629        let rocksdb = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap();
1630
1631        // Insert StoragesHistory entries into RocksDB
1632        let key1 = StorageShardedKey::new(Address::ZERO, B256::ZERO, 50);
1633        let key2 = StorageShardedKey::new(Address::random(), B256::random(), 80);
1634        let block_list1 = BlockNumberList::new_pre_sorted([10, 20, 30, 50]);
1635        let block_list2 = BlockNumberList::new_pre_sorted([40, 60, 80]);
1636        rocksdb.put::<tables::StoragesHistory>(key1, &block_list1).unwrap();
1637        rocksdb.put::<tables::StoragesHistory>(key2, &block_list2).unwrap();
1638
1639        // Capture entries before consistency check
1640        let entries_before: Vec<_> =
1641            rocksdb.iter::<tables::StoragesHistory>().unwrap().map(|r| r.unwrap()).collect();
1642
1643        // Create a test provider factory
1644        let factory = create_test_provider_factory();
1645        factory.set_storage_settings_cache(StorageSettings::v2());
1646
1647        // Write storage changesets to static files for blocks 0-100
1648        {
1649            let sf_provider = factory.static_file_provider();
1650            let mut writer =
1651                sf_provider.latest_writer(StaticFileSegment::StorageChangeSets).unwrap();
1652
1653            for block_num in 0..=100u64 {
1654                let changeset = vec![StorageBeforeTx {
1655                    address: Address::ZERO,
1656                    key: B256::with_last_byte(block_num as u8),
1657                    value: U256::from(block_num),
1658                }];
1659                writer.append_storage_changeset(changeset, block_num).unwrap();
1660            }
1661            writer.commit().unwrap();
1662        }
1663
1664        // Set IndexStorageHistory checkpoint to block 100 (same as sf_tip)
1665        {
1666            let provider = factory.database_provider_rw().unwrap();
1667            provider
1668                .save_stage_checkpoint(StageId::IndexStorageHistory, StageCheckpoint::new(100))
1669                .unwrap();
1670            provider.commit().unwrap();
1671        }
1672
1673        let provider = factory.database_provider_ro().unwrap();
1674
1675        // Verify sf_tip equals checkpoint (both at 100)
1676        let sf_tip = provider
1677            .static_file_provider()
1678            .get_highest_static_file_block(StaticFileSegment::StorageChangeSets)
1679            .unwrap();
1680        assert_eq!(sf_tip, 100, "Static file tip should be 100");
1681
1682        // Run check_consistency - should return None (no unwind needed)
1683        let result = rocksdb.check_consistency(&provider).unwrap();
1684        assert_eq!(result, None, "sf_tip == checkpoint should not require unwind");
1685
1686        // Verify NO entries are deleted - RocksDB state unchanged
1687        let entries_after: Vec<_> =
1688            rocksdb.iter::<tables::StoragesHistory>().unwrap().map(|r| r.unwrap()).collect();
1689
1690        assert_eq!(
1691            entries_after.len(),
1692            entries_before.len(),
1693            "RocksDB entry count should be unchanged when sf_tip == checkpoint"
1694        );
1695
1696        // Verify exact entries are preserved
1697        for (before, after) in entries_before.iter().zip(entries_after.iter()) {
1698            assert_eq!(before.0, after.0, "Entry key should be unchanged");
1699            assert_eq!(before.1, after.1, "Entry block list should be unchanged");
1700        }
1701    }
1702}