From f1142ce0a7d692f3ff2351cf6cb99067d6ae0ac4 Mon Sep 17 00:00:00 2001 From: Hwangjae Lee Date: Tue, 30 Dec 2025 10:19:53 +0900 Subject: [PATCH 1/6] perf(provider): optimize RocksDB history pruning with changeset-based approach Use MDBX changesets to identify affected (address, storage_key) pairs instead of iterating the entire StoragesHistory/AccountsHistory tables. This significantly improves pruning performance for mainnet-scale databases. Closes #20417 Signed-off-by: Hwangjae Lee --- .../src/providers/rocksdb/invariants.rs | 225 +++++++++++++++--- .../src/providers/rocksdb/provider.rs | 13 + 2 files changed, 211 insertions(+), 27 deletions(-) diff --git a/crates/storage/provider/src/providers/rocksdb/invariants.rs b/crates/storage/provider/src/providers/rocksdb/invariants.rs index 7a5c5f9db30..f1a5290ace1 100644 --- a/crates/storage/provider/src/providers/rocksdb/invariants.rs +++ b/crates/storage/provider/src/providers/rocksdb/invariants.rs @@ -14,7 +14,8 @@ use reth_db_api::{tables, transaction::DbTx}; use reth_stages_types::StageId; use reth_static_file_types::StaticFileSegment; use reth_storage_api::{ - DBProvider, StageCheckpointReader, StorageSettingsCache, TransactionsProvider, + AccountExtReader, BlockNumReader, DBProvider, StageCheckpointReader, StorageReader, + StorageSettingsCache, TransactionsProvider, }; use reth_storage_errors::provider::ProviderResult; @@ -51,7 +52,10 @@ impl RocksDBProvider { + StageCheckpointReader + StorageSettingsCache + StaticFileProviderFactory - + TransactionsProvider, + + TransactionsProvider + + StorageReader + + BlockNumReader + + AccountExtReader, { let mut unwind_target: Option = None; @@ -239,7 +243,7 @@ impl RocksDBProvider { provider: &Provider, ) -> ProviderResult> where - Provider: DBProvider + StageCheckpointReader, + Provider: DBProvider + StageCheckpointReader + StorageReader + BlockNumReader, { // Get the IndexStorageHistory stage checkpoint let checkpoint = provider @@ -258,7 +262,7 @@ impl RocksDBProvider { target: "reth::providers::rocksdb", "StoragesHistory has data but checkpoint is 0, clearing all" ); - self.prune_storages_history_above(0)?; + self.prune_storages_history_above(provider, 0)?; return Ok(None); } @@ -281,9 +285,12 @@ impl RocksDBProvider { checkpoint, "StoragesHistory ahead of checkpoint, pruning excess data" ); - self.prune_storages_history_above(checkpoint)?; - } else if max_highest_block < checkpoint { - // RocksDB is behind checkpoint, return highest block to signal unwind needed + self.prune_storages_history_above(provider, checkpoint)?; + return Ok(None); + } + + // If RocksDB is behind the checkpoint, request an unwind to rebuild. + if max_highest_block < checkpoint { tracing::warn!( target: "reth::providers::rocksdb", rocks_highest = max_highest_block, @@ -308,19 +315,103 @@ impl RocksDBProvider { /// Prunes `StoragesHistory` entries where `highest_block_number` > `max_block`. /// - /// For `StoragesHistory`, the key contains `highest_block_number`, so we can iterate - /// and delete entries where `key.sharded_key.highest_block_number > max_block`. + /// Uses changeset-based pruning: queries MDBX for storage slots that changed in the + /// excess block range, then only deletes History entries for those specific slots. + /// This is more efficient than iterating the whole table. + /// + /// If `max_block == 0`, falls back to clearing all entries (full table iteration). + fn prune_storages_history_above( + &self, + provider: &Provider, + max_block: BlockNumber, + ) -> ProviderResult<()> + where + Provider: StorageReader + BlockNumReader, + { + use reth_db_api::models::storage_sharded_key::StorageShardedKey; + + // Special case: clear all entries + if max_block == 0 { + return self.prune_storages_history_all(); + } + + let mut to_delete: Vec = Vec::new(); + + // Try to get changesets for the optimized path. + // Get the last block number to determine the range for changeset query. + let last_block = provider.last_block_number()?; + let changed = if last_block > max_block { + provider.changed_storages_with_range((max_block + 1)..=last_block)? + } else { + Default::default() + }; + + if changed.is_empty() { + // Fallback: no changesets found (e.g., MDBX empty, already pruned, or test scenario). + // Use full table iteration to ensure correctness. + for result in self.iter::()? { + let (key, _) = result?; + let highest_block = key.sharded_key.highest_block_number; + if highest_block != u64::MAX && highest_block > max_block { + to_delete.push(key); + } + } + } else { + // Optimized path: only check entries for changed (address, storage_key) pairs + for (address, storage_keys) in changed { + for storage_key in storage_keys { + // Seek to entries starting from (address, storage_key, max_block + 1) + let start_key = StorageShardedKey::new(address, storage_key, max_block + 1); + + for result in self.iter_from::(start_key)? { + let (key, _) = result?; + + // Stop if we've moved past this (address, storage_key) pair + if key.address != address || key.sharded_key.key != storage_key { + break; + } + + // Delete entries with highest_block > max_block (excluding sentinel) + let highest_block = key.sharded_key.highest_block_number; + if highest_block != u64::MAX && highest_block > max_block { + to_delete.push(key); + } + } + } + } + } + + let deleted = to_delete.len(); + if deleted > 0 { + tracing::info!( + target: "reth::providers::rocksdb", + deleted_count = deleted, + max_block, + "Pruning StoragesHistory entries (changeset-based)" + ); + + let mut batch = self.batch(); + for key in to_delete { + batch.delete::(key)?; + } + batch.commit()?; + } + + Ok(()) + } + + /// Clears all `StoragesHistory` entries. /// - /// TODO(): this iterates the whole table, - /// which is inefficient. Use changeset-based pruning instead. - fn prune_storages_history_above(&self, max_block: BlockNumber) -> ProviderResult<()> { + /// Used when `max_block == 0` to reset the table. + fn prune_storages_history_all(&self) -> ProviderResult<()> { use reth_db_api::models::storage_sharded_key::StorageShardedKey; let mut to_delete: Vec = Vec::new(); for result in self.iter::()? { let (key, _) = result?; let highest_block = key.sharded_key.highest_block_number; - if max_block == 0 || (highest_block != u64::MAX && highest_block > max_block) { + // Delete all except sentinel entries + if highest_block != u64::MAX { to_delete.push(key); } } @@ -330,8 +421,7 @@ impl RocksDBProvider { tracing::info!( target: "reth::providers::rocksdb", deleted_count = deleted, - max_block, - "Pruning StoragesHistory entries" + "Clearing all StoragesHistory entries" ); let mut batch = self.batch(); @@ -353,7 +443,7 @@ impl RocksDBProvider { provider: &Provider, ) -> ProviderResult> where - Provider: DBProvider + StageCheckpointReader, + Provider: DBProvider + StageCheckpointReader + AccountExtReader + BlockNumReader, { // Get the IndexAccountHistory stage checkpoint let checkpoint = provider @@ -372,7 +462,7 @@ impl RocksDBProvider { target: "reth::providers::rocksdb", "AccountsHistory has data but checkpoint is 0, clearing all" ); - self.prune_accounts_history_above(0)?; + self.prune_accounts_history_above(provider, 0)?; return Ok(None); } @@ -395,7 +485,7 @@ impl RocksDBProvider { checkpoint, "AccountsHistory ahead of checkpoint, pruning excess data" ); - self.prune_accounts_history_above(checkpoint)?; + self.prune_accounts_history_above(provider, checkpoint)?; return Ok(None); } @@ -425,13 +515,94 @@ impl RocksDBProvider { /// Prunes `AccountsHistory` entries where `highest_block_number` > `max_block`. /// - /// For `AccountsHistory`, the key is `ShardedKey
` which contains - /// `highest_block_number`, so we can iterate and delete entries where - /// `key.highest_block_number > max_block`. + /// Uses changeset-based pruning: queries MDBX for accounts that changed in the + /// excess block range, then only deletes History entries for those specific accounts. + /// This is more efficient than iterating the whole table. /// - /// TODO(): this iterates the whole table, - /// which is inefficient. Use changeset-based pruning instead. - fn prune_accounts_history_above(&self, max_block: BlockNumber) -> ProviderResult<()> { + /// If `max_block == 0`, falls back to clearing all entries (full table iteration). + fn prune_accounts_history_above( + &self, + provider: &Provider, + max_block: BlockNumber, + ) -> ProviderResult<()> + where + Provider: AccountExtReader + BlockNumReader, + { + use alloy_primitives::Address; + use reth_db_api::models::ShardedKey; + + // Special case: clear all entries + if max_block == 0 { + return self.prune_accounts_history_all(); + } + + let mut to_delete: Vec> = Vec::new(); + + // Try to get changesets for the optimized path. + // Get the last block number to determine the range for changeset query. + let last_block = provider.last_block_number()?; + let changed = if last_block > max_block { + provider.changed_accounts_with_range((max_block + 1)..=last_block)? + } else { + Default::default() + }; + + if changed.is_empty() { + // Fallback: no changesets found (e.g., MDBX empty, already pruned, or test scenario). + // Use full table iteration to ensure correctness. + for result in self.iter::()? { + let (key, _) = result?; + let highest_block = key.highest_block_number; + if highest_block != u64::MAX && highest_block > max_block { + to_delete.push(key); + } + } + } else { + // Optimized path: only check entries for changed addresses + for address in changed { + // Seek to entries starting from (address, max_block + 1) + let start_key = ShardedKey::new(address, max_block + 1); + + for result in self.iter_from::(start_key)? { + let (key, _) = result?; + + // Stop if we've moved past this address + if key.key != address { + break; + } + + // Delete entries with highest_block > max_block (excluding sentinel) + let highest_block = key.highest_block_number; + if highest_block != u64::MAX && highest_block > max_block { + to_delete.push(key); + } + } + } + } + + let deleted = to_delete.len(); + if deleted > 0 { + tracing::info!( + target: "reth::providers::rocksdb", + deleted_count = deleted, + max_block, + "Pruning AccountsHistory entries (changeset-based)" + ); + + let mut batch = self.batch(); + for key in to_delete { + batch.delete::(key)?; + } + batch.commit()?; + } + + Ok(()) + } + + /// Clears all `AccountsHistory` entries. + /// + /// Used when `max_block == 0` to reset the table. + fn prune_accounts_history_all(&self) -> ProviderResult<()> { use alloy_primitives::Address; use reth_db_api::models::ShardedKey; @@ -439,7 +610,8 @@ impl RocksDBProvider { for result in self.iter::()? { let (key, _) = result?; let highest_block = key.highest_block_number; - if max_block == 0 || (highest_block != u64::MAX && highest_block > max_block) { + // Delete all except sentinel entries + if highest_block != u64::MAX { to_delete.push(key); } } @@ -449,8 +621,7 @@ impl RocksDBProvider { tracing::info!( target: "reth::providers::rocksdb", deleted_count = deleted, - max_block, - "Pruning AccountsHistory entries" + "Clearing all AccountsHistory entries" ); let mut batch = self.batch(); diff --git a/crates/storage/provider/src/providers/rocksdb/provider.rs b/crates/storage/provider/src/providers/rocksdb/provider.rs index 5039e86d3ff..6066fab3a49 100644 --- a/crates/storage/provider/src/providers/rocksdb/provider.rs +++ b/crates/storage/provider/src/providers/rocksdb/provider.rs @@ -439,6 +439,19 @@ impl RocksDBProvider { Ok(RocksDBIter { inner: iter, _marker: std::marker::PhantomData }) } + /// Creates an iterator starting from the given key (inclusive). + /// + /// Returns decoded `(Key, Value)` pairs in key order, starting from the specified key. + pub fn iter_from(&self, key: T::Key) -> ProviderResult> { + let cf = self.get_cf_handle::()?; + let encoded_key = key.encode(); + let iter = self + .0 + .db + .iterator_cf(cf, IteratorMode::From(encoded_key.as_ref(), rocksdb::Direction::Forward)); + Ok(RocksDBIter { inner: iter, _marker: std::marker::PhantomData }) + } + /// Writes a batch of operations atomically. pub fn write_batch(&self, f: F) -> ProviderResult<()> where From 19c7739bf6a7f03c9662c326dc202859ecd60e70 Mon Sep 17 00:00:00 2001 From: Hwangjae Lee Date: Tue, 30 Dec 2025 11:23:47 +0900 Subject: [PATCH 2/6] fix(provider): add defensive check to RocksDB history pruning Verify no excess entries remain after optimized pruning via `last()`. Falls back to full scan if entries are missed due to incomplete changesets. Signed-off-by: Hwangjae Lee --- .../src/providers/rocksdb/invariants.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/crates/storage/provider/src/providers/rocksdb/invariants.rs b/crates/storage/provider/src/providers/rocksdb/invariants.rs index f1a5290ace1..d8552e5135d 100644 --- a/crates/storage/provider/src/providers/rocksdb/invariants.rs +++ b/crates/storage/provider/src/providers/rocksdb/invariants.rs @@ -319,6 +319,9 @@ impl RocksDBProvider { /// excess block range, then only deletes History entries for those specific slots. /// This is more efficient than iterating the whole table. /// + /// Includes a defensive check after the optimized path to ensure no entries are missed + /// (e.g., if MDBX doesn't have complete changeset data for all excess blocks in `RocksDB`). + /// /// If `max_block == 0`, falls back to clearing all entries (full table iteration). fn prune_storages_history_above( &self, @@ -379,6 +382,33 @@ impl RocksDBProvider { } } } + + // Defensive check: after optimized path, verify no excess entries remain. + // This handles rare edge cases where MDBX doesn't have complete changeset data + // (e.g., if MDBX last_block < RocksDB max_highest_block due to data inconsistency). + if let Some((last_key, _)) = self.last::()? { + let remaining_max = last_key.sharded_key.highest_block_number; + if remaining_max != u64::MAX && remaining_max > max_block { + // Some entries might have been missed. Fall back to full scan for remaining. + tracing::debug!( + target: "reth::providers::rocksdb", + remaining_max, + max_block, + "Defensive check: found remaining entries, scanning for missed ones" + ); + + for result in self.iter::()? { + let (key, _) = result?; + let highest_block = key.sharded_key.highest_block_number; + if highest_block != u64::MAX && highest_block > max_block { + // Only add if not already in to_delete + if !to_delete.contains(&key) { + to_delete.push(key); + } + } + } + } + } } let deleted = to_delete.len(); @@ -519,6 +549,9 @@ impl RocksDBProvider { /// excess block range, then only deletes History entries for those specific accounts. /// This is more efficient than iterating the whole table. /// + /// Includes a defensive check after the optimized path to ensure no entries are missed + /// (e.g., if MDBX doesn't have complete changeset data for all excess blocks in `RocksDB`). + /// /// If `max_block == 0`, falls back to clearing all entries (full table iteration). fn prune_accounts_history_above( &self, @@ -578,6 +611,33 @@ impl RocksDBProvider { } } } + + // Defensive check: after optimized path, verify no excess entries remain. + // This handles rare edge cases where MDBX doesn't have complete changeset data + // (e.g., if MDBX last_block < RocksDB max_highest_block due to data inconsistency). + if let Some((last_key, _)) = self.last::()? { + let remaining_max = last_key.highest_block_number; + if remaining_max != u64::MAX && remaining_max > max_block { + // Some entries might have been missed. Fall back to full scan for remaining. + tracing::debug!( + target: "reth::providers::rocksdb", + remaining_max, + max_block, + "Defensive check: found remaining entries, scanning for missed ones" + ); + + for result in self.iter::()? { + let (key, _) = result?; + let highest_block = key.highest_block_number; + if highest_block != u64::MAX && highest_block > max_block { + // Only add if not already in to_delete + if !to_delete.contains(&key) { + to_delete.push(key); + } + } + } + } + } } let deleted = to_delete.len(); From bb5885bed203e2cdde67e8140d64eaf35a0e009b Mon Sep 17 00:00:00 2001 From: Hwangjae Lee Date: Tue, 30 Dec 2025 11:47:57 +0900 Subject: [PATCH 3/6] test(provider): add defensive check coverage for incomplete changesets Add tests verifying the defensive check catches RocksDB entries missed by changeset-based pruning when MDBX data is incomplete. - test_storages_history_defensive_check_catches_missed_entries - test_accounts_history_defensive_check_catches_missed_entries Signed-off-by: Hwangjae Lee --- .../src/providers/rocksdb/invariants.rs | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) diff --git a/crates/storage/provider/src/providers/rocksdb/invariants.rs b/crates/storage/provider/src/providers/rocksdb/invariants.rs index d8552e5135d..acde55f2e00 100644 --- a/crates/storage/provider/src/providers/rocksdb/invariants.rs +++ b/crates/storage/provider/src/providers/rocksdb/invariants.rs @@ -1535,4 +1535,247 @@ mod tests { "Should require unwind to block 50 to rebuild AccountsHistory" ); } + + /// Test Case 7: Defensive check catches entries missed by changeset-based pruning. + /// + /// This tests the scenario where MDBX has changesets for some but not all of the + /// entries that need to be pruned in RocksDB. The defensive check after the optimized + /// path should detect remaining entries and trigger a fallback full scan. + #[test] + fn test_storages_history_defensive_check_catches_missed_entries() { + use alloy_primitives::U256; + use reth_db_api::models::{storage_sharded_key::StorageShardedKey, BlockNumberAddress}; + use reth_primitives_traits::StorageEntry; + + let temp_dir = TempDir::new().unwrap(); + let rocksdb = RocksDBBuilder::new(temp_dir.path()) + .with_table::() + .build() + .unwrap(); + + // Create addresses and storage keys for testing + let addr1 = Address::from([0x01; 20]); + let addr2 = Address::from([0x02; 20]); + let addr3 = Address::from([0x03; 20]); // This one won't have changesets + let storage_key1 = B256::from([0x11; 32]); + let storage_key2 = B256::from([0x22; 32]); + let storage_key3 = B256::from([0x33; 32]); // This one won't have changesets + + // Insert StoragesHistory entries with highest_block > 100 (checkpoint) + // Entry 1: addr1/storage_key1 at block 150 - WILL have changeset + let key1 = StorageShardedKey::new(addr1, storage_key1, 150); + // Entry 2: addr2/storage_key2 at block 150 - WILL have changeset + let key2 = StorageShardedKey::new(addr2, storage_key2, 150); + // Entry 3: addr3/storage_key3 at block 150 - NO changeset (defensive check should catch) + let key3 = StorageShardedKey::new(addr3, storage_key3, 150); + // Entry 4: addr1/storage_key1 at block 50 - Should remain (below checkpoint) + let key4 = StorageShardedKey::new(addr1, storage_key1, 50); + + let block_list = BlockNumberList::new_pre_sorted([10, 20, 30]); + rocksdb.put::(key1.clone(), &block_list).unwrap(); + rocksdb.put::(key2.clone(), &block_list).unwrap(); + rocksdb.put::(key3.clone(), &block_list).unwrap(); + rocksdb.put::(key4.clone(), &block_list).unwrap(); + + // Create a test provider factory for MDBX + let factory = create_test_provider_factory(); + factory.set_storage_settings_cache( + StorageSettings::legacy().with_storages_history_in_rocksdb(true), + ); + + // Insert blocks so that last_block_number() returns > 100 + // We need to insert blocks via insert_block to populate static files + let mut rng = generators::rng(); + let blocks = generators::random_block_range( + &mut rng, + 0..=150, + BlockRangeParams { parent: Some(B256::ZERO), tx_count: 0..1, ..Default::default() }, + ); + + { + let provider = factory.database_provider_rw().unwrap(); + for block in &blocks { + provider + .insert_block(&block.clone().try_recover().expect("recover block")) + .unwrap(); + } + provider.commit().unwrap(); + } + + // Insert StorageChangeSets for SOME entries only (addr1 and addr2, but NOT addr3) + // This simulates incomplete changeset data + { + let provider = factory.database_provider_rw().unwrap(); + let mut cursor = + provider.tx_ref().cursor_dup_write::().unwrap(); + + // Changeset at block 150 for addr1/storage_key1 + let key_block_150_addr1 = BlockNumberAddress((150, addr1)); + cursor + .upsert( + key_block_150_addr1, + &StorageEntry { key: storage_key1, value: U256::from(100) }, + ) + .unwrap(); + + // Changeset at block 150 for addr2/storage_key2 + let key_block_150_addr2 = BlockNumberAddress((150, addr2)); + cursor + .upsert( + key_block_150_addr2, + &StorageEntry { key: storage_key2, value: U256::from(200) }, + ) + .unwrap(); + + // NOTE: No changeset for addr3/storage_key3 - this is the "gap" that + // defensive check should catch + + provider.commit().unwrap(); + } + + // Set checkpoint to block 100 + { + let provider = factory.database_provider_rw().unwrap(); + provider + .save_stage_checkpoint(StageId::IndexStorageHistory, StageCheckpoint::new(100)) + .unwrap(); + provider.commit().unwrap(); + } + + let provider = factory.database_provider_ro().unwrap(); + + // Run consistency check + // The optimized path will only find changesets for addr1 and addr2 (blocks 101-150) + // But key3 (addr3/storage_key3 at block 150) has no changeset + // The defensive check should catch key3 and prune it too + let result = rocksdb.check_consistency(&provider).unwrap(); + assert_eq!(result, None, "Should heal by pruning, no unwind needed"); + + // Verify ALL entries with highest_block > 100 were pruned, including the one + // without changesets (key3) + assert!( + rocksdb.get::(key4).unwrap().is_some(), + "Entry at block 50 should remain (below checkpoint)" + ); + assert!( + rocksdb.get::(key1).unwrap().is_none(), + "Entry at block 150 (addr1) should be pruned" + ); + assert!( + rocksdb.get::(key2).unwrap().is_none(), + "Entry at block 150 (addr2) should be pruned" + ); + assert!( + rocksdb.get::(key3).unwrap().is_none(), + "Entry at block 150 (addr3) should be pruned by defensive check (no changeset)" + ); + } + + /// Test Case 7 for AccountsHistory: Defensive check catches entries missed by + /// changeset-based pruning. + #[test] + fn test_accounts_history_defensive_check_catches_missed_entries() { + use reth_db_api::models::{AccountBeforeTx, ShardedKey}; + + let temp_dir = TempDir::new().unwrap(); + let rocksdb = RocksDBBuilder::new(temp_dir.path()) + .with_table::() + .build() + .unwrap(); + + // Create addresses for testing + let addr1 = Address::from([0x01; 20]); + let addr2 = Address::from([0x02; 20]); + let addr3 = Address::from([0x03; 20]); // This one won't have changesets + + // Insert AccountsHistory entries with highest_block > 100 (checkpoint) + // Entry 1: addr1 at block 150 - WILL have changeset + let key1 = ShardedKey::new(addr1, 150); + // Entry 2: addr2 at block 150 - WILL have changeset + let key2 = ShardedKey::new(addr2, 150); + // Entry 3: addr3 at block 150 - NO changeset (defensive check should catch) + let key3 = ShardedKey::new(addr3, 150); + // Entry 4: addr1 at block 50 - Should remain (below checkpoint) + let key4 = ShardedKey::new(addr1, 50); + + let block_list = BlockNumberList::new_pre_sorted([10, 20, 30]); + rocksdb.put::(key1.clone(), &block_list).unwrap(); + rocksdb.put::(key2.clone(), &block_list).unwrap(); + rocksdb.put::(key3.clone(), &block_list).unwrap(); + rocksdb.put::(key4.clone(), &block_list).unwrap(); + + // Create a test provider factory for MDBX + let factory = create_test_provider_factory(); + factory.set_storage_settings_cache( + StorageSettings::legacy().with_account_history_in_rocksdb(true), + ); + + // Insert blocks so that last_block_number() returns > 100 + let mut rng = generators::rng(); + let blocks = generators::random_block_range( + &mut rng, + 0..=150, + BlockRangeParams { parent: Some(B256::ZERO), tx_count: 0..1, ..Default::default() }, + ); + + { + let provider = factory.database_provider_rw().unwrap(); + for block in &blocks { + provider + .insert_block(&block.clone().try_recover().expect("recover block")) + .unwrap(); + } + provider.commit().unwrap(); + } + + // Insert AccountChangeSets for SOME entries only (addr1 and addr2, but NOT addr3) + { + let provider = factory.database_provider_rw().unwrap(); + let mut cursor = + provider.tx_ref().cursor_dup_write::().unwrap(); + + // Changeset at block 150 for addr1 + cursor.upsert(150, &AccountBeforeTx { address: addr1, info: None }).unwrap(); + + // Changeset at block 150 for addr2 + cursor.upsert(150, &AccountBeforeTx { address: addr2, info: None }).unwrap(); + + // NOTE: No changeset for addr3 - defensive check should catch + + provider.commit().unwrap(); + } + + // Set checkpoint to block 100 + { + let provider = factory.database_provider_rw().unwrap(); + provider + .save_stage_checkpoint(StageId::IndexAccountHistory, StageCheckpoint::new(100)) + .unwrap(); + provider.commit().unwrap(); + } + + let provider = factory.database_provider_ro().unwrap(); + + // Run consistency check + let result = rocksdb.check_consistency(&provider).unwrap(); + assert_eq!(result, None, "Should heal by pruning, no unwind needed"); + + // Verify ALL entries with highest_block > 100 were pruned + assert!( + rocksdb.get::(key4).unwrap().is_some(), + "Entry at block 50 should remain (below checkpoint)" + ); + assert!( + rocksdb.get::(key1).unwrap().is_none(), + "Entry at block 150 (addr1) should be pruned" + ); + assert!( + rocksdb.get::(key2).unwrap().is_none(), + "Entry at block 150 (addr2) should be pruned" + ); + assert!( + rocksdb.get::(key3).unwrap().is_none(), + "Entry at block 150 (addr3) should be pruned by defensive check (no changeset)" + ); + } } From 443efc6ee8144cde997650ac316dd9178b7adad9 Mon Sep 17 00:00:00 2001 From: Hwangjae Lee Date: Tue, 30 Dec 2025 12:49:15 +0900 Subject: [PATCH 4/6] fixed clippy error for CI Signed-off-by: Hwangjae Lee --- crates/storage/provider/src/providers/rocksdb/invariants.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/storage/provider/src/providers/rocksdb/invariants.rs b/crates/storage/provider/src/providers/rocksdb/invariants.rs index acde55f2e00..78bd29fe565 100644 --- a/crates/storage/provider/src/providers/rocksdb/invariants.rs +++ b/crates/storage/provider/src/providers/rocksdb/invariants.rs @@ -1539,7 +1539,7 @@ mod tests { /// Test Case 7: Defensive check catches entries missed by changeset-based pruning. /// /// This tests the scenario where MDBX has changesets for some but not all of the - /// entries that need to be pruned in RocksDB. The defensive check after the optimized + /// entries that need to be pruned in `RocksDB`. The defensive check after the optimized /// path should detect remaining entries and trigger a fallback full scan. #[test] fn test_storages_history_defensive_check_catches_missed_entries() { @@ -1671,7 +1671,7 @@ mod tests { ); } - /// Test Case 7 for AccountsHistory: Defensive check catches entries missed by + /// Test Case 7 for `AccountsHistory`: Defensive check catches entries missed by /// changeset-based pruning. #[test] fn test_accounts_history_defensive_check_catches_missed_entries() { From 95524eddcd279c7a1ed893a383feff6571b88191 Mon Sep 17 00:00:00 2001 From: Hwangjae Lee Date: Wed, 31 Dec 2025 11:09:55 +0900 Subject: [PATCH 5/6] Code clean: unnecessary comments Signed-off-by: Hwangjae Lee --- crates/storage/provider/src/providers/rocksdb/invariants.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/storage/provider/src/providers/rocksdb/invariants.rs b/crates/storage/provider/src/providers/rocksdb/invariants.rs index 78bd29fe565..eb366830e0a 100644 --- a/crates/storage/provider/src/providers/rocksdb/invariants.rs +++ b/crates/storage/provider/src/providers/rocksdb/invariants.rs @@ -1536,7 +1536,7 @@ mod tests { ); } - /// Test Case 7: Defensive check catches entries missed by changeset-based pruning. + /// Test Case: Defensive check catches entries missed by changeset-based pruning. /// /// This tests the scenario where MDBX has changesets for some but not all of the /// entries that need to be pruned in `RocksDB`. The defensive check after the optimized @@ -1671,7 +1671,7 @@ mod tests { ); } - /// Test Case 7 for `AccountsHistory`: Defensive check catches entries missed by + /// Test Case for `AccountsHistory`: Defensive check catches entries missed by /// changeset-based pruning. #[test] fn test_accounts_history_defensive_check_catches_missed_entries() { From 2eae6593563602c459ab73c6ab953735b0ec6c55 Mon Sep 17 00:00:00 2001 From: Hwangjae Lee Date: Fri, 23 Jan 2026 13:42:31 +0900 Subject: [PATCH 6/6] fix(rocksdb): use iterator_cf method on RocksDBProviderInner enum The iter_from method was incorrectly accessing `.db` field directly on RocksDBProviderInner, which is an enum and doesn't expose db as a public field. This was introduced during merge conflict resolution. Use the existing iterator_cf() helper method that properly handles both ReadWrite and ReadOnly variants. Signed-off-by: Hwangjae Lee --- crates/storage/provider/src/providers/rocksdb/provider.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/storage/provider/src/providers/rocksdb/provider.rs b/crates/storage/provider/src/providers/rocksdb/provider.rs index 86ce274e58d..a6b58370882 100644 --- a/crates/storage/provider/src/providers/rocksdb/provider.rs +++ b/crates/storage/provider/src/providers/rocksdb/provider.rs @@ -834,7 +834,6 @@ impl RocksDBProvider { let encoded_key = key.encode(); let iter = self .0 - .db .iterator_cf(cf, IteratorMode::From(encoded_key.as_ref(), rocksdb::Direction::Forward)); Ok(RocksDBIter { inner: iter, _marker: std::marker::PhantomData }) }