Skip to main content

reth_prune/segments/user/
storage_history.rs

1use crate::{
2    db_ext::DbTxPruneExt,
3    segments::{
4        user::history::{finalize_history_prune, HistoryPruneResult},
5        PruneInput, Segment,
6    },
7    PrunerError,
8};
9use alloy_primitives::{map::HashMap, Address, BlockNumber, B256};
10use reth_db_api::{
11    models::{storage_sharded_key::StorageShardedKey, BlockNumberAddress},
12    tables,
13    transaction::DbTxMut,
14};
15use reth_provider::{DBProvider, EitherWriter, RocksDBProviderFactory, StaticFileProviderFactory};
16use reth_prune_types::{
17    PruneMode, PrunePurpose, PruneSegment, SegmentOutput, SegmentOutputCheckpoint,
18};
19use reth_static_file_types::StaticFileSegment;
20use reth_storage_api::{StorageChangeSetReader, StorageSettingsCache};
21use tracing::{instrument, trace};
22
23/// Number of storage history tables to prune in one step.
24///
25/// Storage History consists of two tables: [`tables::StorageChangeSets`] and
26/// [`tables::StoragesHistory`]. We want to prune them to the same block number.
27const STORAGE_HISTORY_TABLES_TO_PRUNE: usize = 2;
28
29#[derive(Debug)]
30pub struct StorageHistory {
31    mode: PruneMode,
32}
33
34impl StorageHistory {
35    pub const fn new(mode: PruneMode) -> Self {
36        Self { mode }
37    }
38}
39
40impl<Provider> Segment<Provider> for StorageHistory
41where
42    Provider: DBProvider<Tx: DbTxMut>
43        + StaticFileProviderFactory
44        + StorageChangeSetReader
45        + StorageSettingsCache
46        + RocksDBProviderFactory,
47{
48    fn segment(&self) -> PruneSegment {
49        PruneSegment::StorageHistory
50    }
51
52    fn mode(&self) -> Option<PruneMode> {
53        Some(self.mode)
54    }
55
56    fn purpose(&self) -> PrunePurpose {
57        PrunePurpose::User
58    }
59
60    #[instrument(
61        name = "StorageHistory::prune",
62        target = "pruner",
63        skip(self, provider),
64        ret(level = "trace")
65    )]
66    fn prune(&self, provider: &Provider, input: PruneInput) -> Result<SegmentOutput, PrunerError> {
67        let range = match input.get_next_block_range() {
68            Some(range) => range,
69            None => {
70                trace!(target: "pruner", "No storage history to prune");
71                return Ok(SegmentOutput::done())
72            }
73        };
74        let range_end = *range.end();
75
76        // Check where storage history indices are stored
77        if provider.cached_storage_settings().storage_v2 {
78            return self.prune_rocksdb(provider, input, range, range_end);
79        }
80
81        // Check where storage changesets are stored (MDBX path)
82        if EitherWriter::storage_changesets_destination(provider).is_static_file() {
83            self.prune_static_files(provider, input, range, range_end)
84        } else {
85            self.prune_database(provider, input, range, range_end)
86        }
87    }
88}
89
90impl StorageHistory {
91    /// Prunes storage history when changesets are stored in static files.
92    fn prune_static_files<Provider>(
93        &self,
94        provider: &Provider,
95        input: PruneInput,
96        range: std::ops::RangeInclusive<BlockNumber>,
97        range_end: BlockNumber,
98    ) -> Result<SegmentOutput, PrunerError>
99    where
100        Provider: DBProvider<Tx: DbTxMut> + StaticFileProviderFactory,
101    {
102        let mut limiter = if let Some(limit) = input.limiter.deleted_entries_limit() {
103            input.limiter.set_deleted_entries_limit(limit / STORAGE_HISTORY_TABLES_TO_PRUNE)
104        } else {
105            input.limiter
106        };
107
108        // The limiter may already be exhausted from a previous segment in the same prune run.
109        // Early exit avoids unnecessary iteration when no budget remains.
110        if limiter.is_limit_reached() {
111            return Ok(SegmentOutput::not_done(
112                limiter.interrupt_reason(),
113                input.previous_checkpoint.map(SegmentOutputCheckpoint::from_prune_checkpoint),
114            ))
115        }
116
117        // The size of this map is limited by `prune_delete_limit * blocks_since_last_run /
118        // STORAGE_HISTORY_TABLES_TO_PRUNE`, and with current defaults it's usually `3500 * 5
119        // / 2`, so 8750 entries. Each entry is `160 bit + 256 bit + 64 bit`, so the total
120        // size should be up to ~0.5MB + some hashmap overhead. `blocks_since_last_run` is
121        // additionally limited by the `max_reorg_depth`, so no OOM is expected here.
122        let mut highest_deleted_storages = HashMap::default();
123        let mut last_changeset_pruned_block = None;
124        let mut pruned_changesets = 0;
125        let mut done = true;
126
127        let walker = provider.static_file_provider().walk_storage_changeset_range(range);
128        for result in walker {
129            let (block_address, entry) = result?;
130            let block_number = block_address.block_number();
131            let address = block_address.address();
132            // The walk itself deletes nothing, so an interrupted block cannot be resumed: giving
133            // up the budget inside block N reports checkpoint N-1 and the next run rereads the
134            // same entries, forever. Stop on block boundaries only, overshooting the budget by at
135            // most the rest of one block.
136            if limiter.is_limit_reached() &&
137                last_changeset_pruned_block.is_some_and(|last| last != block_number)
138            {
139                done = false;
140                break;
141            }
142            highest_deleted_storages.insert((address, entry.key), block_number);
143            last_changeset_pruned_block = Some(block_number);
144            pruned_changesets += 1;
145            limiter.increment_deleted_entries_count();
146        }
147
148        // Delete static file jars only when fully processed
149        if done && let Some(last_block) = last_changeset_pruned_block {
150            provider
151                .static_file_provider()
152                .delete_segment_below_block(StaticFileSegment::StorageChangeSets, last_block + 1)?;
153        }
154        trace!(target: "pruner", pruned = %pruned_changesets, %done, "Pruned storage history (changesets from static files)");
155
156        let result = HistoryPruneResult {
157            highest_deleted: highest_deleted_storages,
158            last_pruned_block: last_changeset_pruned_block,
159            pruned_count: pruned_changesets,
160            done,
161        };
162        finalize_history_prune::<_, tables::StoragesHistory, (Address, B256), _>(
163            provider,
164            result,
165            range_end,
166            &limiter,
167            |(address, storage_key), block_number| {
168                StorageShardedKey::new(address, storage_key, block_number)
169            },
170            |a, b| a.address == b.address && a.sharded_key.key == b.sharded_key.key,
171        )
172        .map_err(Into::into)
173    }
174
175    fn prune_database<Provider>(
176        &self,
177        provider: &Provider,
178        input: PruneInput,
179        range: std::ops::RangeInclusive<BlockNumber>,
180        range_end: BlockNumber,
181    ) -> Result<SegmentOutput, PrunerError>
182    where
183        Provider: DBProvider<Tx: DbTxMut>,
184    {
185        let mut limiter = if let Some(limit) = input.limiter.deleted_entries_limit() {
186            input.limiter.set_deleted_entries_limit(limit / STORAGE_HISTORY_TABLES_TO_PRUNE)
187        } else {
188            input.limiter
189        };
190
191        if limiter.is_limit_reached() {
192            return Ok(SegmentOutput::not_done(
193                limiter.interrupt_reason(),
194                input.previous_checkpoint.map(SegmentOutputCheckpoint::from_prune_checkpoint),
195            ))
196        }
197
198        // Deleted storage changeset keys (account addresses and storage slots) with the highest
199        // block number deleted for that key.
200        //
201        // The size of this map is limited by `prune_delete_limit * blocks_since_last_run /
202        // STORAGE_HISTORY_TABLES_TO_PRUNE`, and with current defaults it's usually `3500 * 5
203        // / 2`, so 8750 entries. Each entry is `160 bit + 256 bit + 64 bit`, so the total
204        // size should be up to ~0.5MB + some hashmap overhead. `blocks_since_last_run` is
205        // additionally limited by the `max_reorg_depth`, so no OOM is expected here.
206        let mut last_changeset_pruned_block = None;
207        let mut highest_deleted_storages = HashMap::default();
208        let (pruned_changesets, done) =
209            provider.tx_ref().prune_table_with_range::<tables::StorageChangeSets>(
210                BlockNumberAddress::range(range),
211                &mut limiter,
212                |_| false,
213                |(BlockNumberAddress((block_number, address)), entry)| {
214                    highest_deleted_storages.insert((address, entry.key), block_number);
215                    last_changeset_pruned_block = Some(block_number);
216                },
217            )?;
218        trace!(target: "pruner", deleted = %pruned_changesets, %done, "Pruned storage history (changesets)");
219
220        // The table walk can stop in the middle of a block, so the interrupted block has to be
221        // pruned again on the next run.
222        let last_pruned_block = last_changeset_pruned_block.map(|block_number| {
223            if done {
224                block_number
225            } else {
226                block_number.saturating_sub(1)
227            }
228        });
229
230        let result = HistoryPruneResult {
231            highest_deleted: highest_deleted_storages,
232            last_pruned_block,
233            pruned_count: pruned_changesets,
234            done,
235        };
236        finalize_history_prune::<_, tables::StoragesHistory, (Address, B256), _>(
237            provider,
238            result,
239            range_end,
240            &limiter,
241            |(address, storage_key), block_number| {
242                StorageShardedKey::new(address, storage_key, block_number)
243            },
244            |a, b| a.address == b.address && a.sharded_key.key == b.sharded_key.key,
245        )
246        .map_err(Into::into)
247    }
248
249    /// Prunes storage history when indices are stored in `RocksDB`.
250    ///
251    /// Reads storage changesets from static files and prunes the corresponding
252    /// `RocksDB` history shards.
253    fn prune_rocksdb<Provider>(
254        &self,
255        provider: &Provider,
256        input: PruneInput,
257        range: std::ops::RangeInclusive<BlockNumber>,
258        range_end: BlockNumber,
259    ) -> Result<SegmentOutput, PrunerError>
260    where
261        Provider: DBProvider + StaticFileProviderFactory + RocksDBProviderFactory,
262    {
263        let mut limiter = input.limiter;
264
265        if limiter.is_limit_reached() {
266            return Ok(SegmentOutput::not_done(
267                limiter.interrupt_reason(),
268                input.previous_checkpoint.map(SegmentOutputCheckpoint::from_prune_checkpoint),
269            ))
270        }
271
272        let mut highest_deleted_storages = HashMap::<_, _>::default();
273        let mut last_changeset_pruned_block = None;
274        let mut changesets_processed = 0usize;
275        let mut done = true;
276
277        // Walk storage changesets from static files using a streaming iterator.
278        // For each changeset, track the highest block number seen for each (address, storage_key)
279        // pair to determine which history shard entries need pruning.
280        let walker = provider.static_file_provider().walk_storage_changeset_range(range);
281        for result in walker {
282            let (block_address, entry) = result?;
283            let block_number = block_address.block_number();
284            let address = block_address.address();
285            // Static file changesets are not deleted here, so an interrupted block cannot be
286            // resumed: giving up the budget inside block N reports checkpoint N-1 and the next run
287            // rereads the same entries, forever. Stop on block boundaries only, overshooting the
288            // budget by at most the rest of one block.
289            if limiter.is_limit_reached() &&
290                last_changeset_pruned_block.is_some_and(|last| last != block_number)
291            {
292                done = false;
293                break;
294            }
295            highest_deleted_storages.insert((address, entry.key), block_number);
296            last_changeset_pruned_block = Some(block_number);
297            changesets_processed += 1;
298            limiter.increment_deleted_entries_count();
299        }
300
301        trace!(target: "pruner", processed = %changesets_processed, %done, "Scanned storage changesets from static files");
302
303        let last_changeset_pruned_block = last_changeset_pruned_block.unwrap_or(range_end);
304
305        // Prune RocksDB history shards for affected storage slots
306        let mut deleted_shards = 0usize;
307        let mut updated_shards = 0usize;
308
309        // Sort by (address, storage_key) for better RocksDB cache locality
310        let mut sorted_storages: Vec<_> = highest_deleted_storages.into_iter().collect();
311        sorted_storages.sort_unstable_by_key(|((addr, key), _)| (*addr, *key));
312
313        provider.with_rocksdb_batch(|mut batch| {
314            let targets: Vec<_> = sorted_storages
315                .iter()
316                .map(|((addr, key), highest)| {
317                    ((*addr, *key), (*highest).min(last_changeset_pruned_block))
318                })
319                .collect();
320
321            let outcomes = batch.prune_storage_history_batch(&targets)?;
322            deleted_shards = outcomes.deleted;
323            updated_shards = outcomes.updated;
324
325            Ok(((), Some(batch.into_inner())))
326        })?;
327
328        trace!(target: "pruner", deleted = deleted_shards, updated = updated_shards, %done, "Pruned storage history (RocksDB indices)");
329
330        // Delete static file jars only when fully processed. During provider.commit(), RocksDB
331        // batch is committed before the MDBX checkpoint. If crash occurs after RocksDB commit
332        // but before MDBX commit, on restart the pruner checkpoint indicates data needs
333        // re-pruning, but the RocksDB shards are already pruned - this is safe because pruning
334        // is idempotent (re-pruning already-pruned shards is a no-op).
335        if done {
336            provider.static_file_provider().delete_segment_below_block(
337                StaticFileSegment::StorageChangeSets,
338                last_changeset_pruned_block + 1,
339            )?;
340        }
341
342        let progress = limiter.progress(done);
343
344        Ok(SegmentOutput {
345            progress,
346            pruned: changesets_processed + deleted_shards + updated_shards,
347            checkpoint: Some(SegmentOutputCheckpoint {
348                block_number: Some(last_changeset_pruned_block),
349                tx_number: None,
350            }),
351        })
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::STORAGE_HISTORY_TABLES_TO_PRUNE;
358    use crate::segments::{PruneInput, PruneLimiter, Segment, SegmentOutput, StorageHistory};
359    use alloy_primitives::{Address, BlockNumber, B256, U256};
360    use assert_matches::assert_matches;
361    use reth_db_api::{
362        models::{storage_sharded_key::StorageShardedKey, StorageSettings},
363        tables, BlockNumberList,
364    };
365    use reth_primitives_traits::{Account, StorageEntry};
366    use reth_provider::{
367        DBProvider, DatabaseProviderFactory, PruneCheckpointReader, RocksDBProviderFactory,
368    };
369    use reth_prune_types::{
370        PruneCheckpoint, PruneInterruptReason, PruneMode, PruneProgress, PruneSegment,
371    };
372    use reth_stages::test_utils::{StorageKind, TestStageDB};
373    use reth_storage_api::StorageSettingsCache;
374    use reth_testing_utils::generators::{
375        self, random_block_range, random_changeset_range, random_eoa_accounts, BlockRangeParams,
376        ChangeSet,
377    };
378    use std::{collections::BTreeMap, ops::AddAssign};
379
380    #[test]
381    fn prune_legacy() {
382        let db = TestStageDB::default();
383        let mut rng = generators::rng();
384
385        let blocks = random_block_range(
386            &mut rng,
387            0..=5000,
388            BlockRangeParams { parent: Some(B256::ZERO), tx_count: 0..1, ..Default::default() },
389        );
390        db.insert_blocks(blocks.iter(), StorageKind::Database(None)).expect("insert blocks");
391
392        let accounts = random_eoa_accounts(&mut rng, 2).into_iter().collect::<BTreeMap<_, _>>();
393
394        let (changesets, _) = random_changeset_range(
395            &mut rng,
396            blocks.iter(),
397            accounts.into_iter().map(|(addr, acc)| (addr, (acc, Vec::new()))),
398            1..2,
399            1..2,
400        );
401        db.insert_changesets(changesets.clone(), None).expect("insert changesets");
402        db.insert_history(changesets.clone(), None).expect("insert history");
403
404        let storage_occurrences = db.table::<tables::StoragesHistory>().unwrap().into_iter().fold(
405            BTreeMap::<_, usize>::new(),
406            |mut map, (key, _)| {
407                map.entry((key.address, key.sharded_key.key)).or_default().add_assign(1);
408                map
409            },
410        );
411        assert!(storage_occurrences.into_iter().any(|(_, occurrences)| occurrences > 1));
412
413        assert_eq!(
414            db.table::<tables::StorageChangeSets>().unwrap().len(),
415            changesets.iter().flatten().flat_map(|(_, _, entries)| entries).count()
416        );
417
418        let original_shards = db.table::<tables::StoragesHistory>().unwrap();
419
420        let test_prune = |to_block: BlockNumber,
421                          run: usize,
422                          expected_result: (PruneProgress, usize)| {
423            let prune_mode = PruneMode::Before(to_block);
424            let deleted_entries_limit = 1000;
425            let mut limiter =
426                PruneLimiter::default().set_deleted_entries_limit(deleted_entries_limit);
427            let input = PruneInput {
428                previous_checkpoint: db
429                    .factory
430                    .provider()
431                    .unwrap()
432                    .get_prune_checkpoint(PruneSegment::StorageHistory)
433                    .unwrap(),
434                to_block,
435                limiter: limiter.clone(),
436            };
437            let segment = StorageHistory::new(prune_mode);
438
439            let provider = db.factory.database_provider_rw().unwrap();
440            provider.set_storage_settings_cache(StorageSettings::v1());
441            let result = segment.prune(&provider, input).unwrap();
442            limiter.increment_deleted_entries_count_by(result.pruned);
443
444            assert_matches!(
445                result,
446                SegmentOutput {progress, pruned, checkpoint: Some(_)}
447                    if (progress, pruned) == expected_result
448            );
449
450            segment
451                .save_checkpoint(
452                    &provider,
453                    result.checkpoint.unwrap().as_prune_checkpoint(prune_mode),
454                )
455                .unwrap();
456            provider.commit().expect("commit");
457
458            let changesets = changesets
459                .iter()
460                .enumerate()
461                .flat_map(|(block_number, changeset)| {
462                    changeset.iter().flat_map(move |(address, _, entries)| {
463                        entries.iter().map(move |entry| (block_number, address, entry))
464                    })
465                })
466                .collect::<Vec<_>>();
467
468            #[expect(clippy::skip_while_next)]
469            let pruned = changesets
470                .iter()
471                .enumerate()
472                .skip_while(|(i, (block_number, _, _))| {
473                    *i < deleted_entries_limit / STORAGE_HISTORY_TABLES_TO_PRUNE * run &&
474                        *block_number <= to_block as usize
475                })
476                .next()
477                .map(|(i, _)| i)
478                .unwrap_or_default();
479
480            // Skip what we've pruned so far, subtracting one to get last pruned block number
481            // further down
482            let mut pruned_changesets = changesets.iter().skip(pruned.saturating_sub(1));
483
484            let last_pruned_block_number = pruned_changesets
485                .next()
486                .map(|(block_number, _, _)| {
487                    (if result.progress.is_finished() {
488                        *block_number
489                    } else {
490                        block_number.saturating_sub(1)
491                    }) as BlockNumber
492                })
493                .unwrap_or(to_block);
494
495            let pruned_changesets = pruned_changesets.fold(
496                BTreeMap::<_, Vec<_>>::new(),
497                |mut acc, (block_number, address, entry)| {
498                    acc.entry((block_number, address)).or_default().push(entry);
499                    acc
500                },
501            );
502
503            assert_eq!(
504                db.table::<tables::StorageChangeSets>().unwrap().len(),
505                pruned_changesets.values().flatten().count()
506            );
507
508            let actual_shards = db.table::<tables::StoragesHistory>().unwrap();
509
510            let expected_shards = original_shards
511                .iter()
512                .filter(|(key, _)| key.sharded_key.highest_block_number > last_pruned_block_number)
513                .map(|(key, blocks)| {
514                    let new_blocks =
515                        blocks.iter().skip_while(|block| *block <= last_pruned_block_number);
516                    (key.clone(), BlockNumberList::new_pre_sorted(new_blocks))
517                })
518                .collect::<Vec<_>>();
519
520            assert_eq!(actual_shards, expected_shards);
521
522            assert_eq!(
523                db.factory
524                    .provider()
525                    .unwrap()
526                    .get_prune_checkpoint(PruneSegment::StorageHistory)
527                    .unwrap(),
528                Some(PruneCheckpoint {
529                    block_number: Some(last_pruned_block_number),
530                    tx_number: None,
531                    prune_mode
532                })
533            );
534        };
535
536        test_prune(
537            998,
538            1,
539            (PruneProgress::HasMoreData(PruneInterruptReason::DeletedEntriesLimitReached), 500),
540        );
541        test_prune(998, 2, (PruneProgress::Finished, 499));
542        test_prune(1200, 3, (PruneProgress::Finished, 202));
543    }
544
545    /// Tests that when a limiter stops mid-block (with multiple storage changes for the same
546    /// block), the checkpoint is set to `block_number - 1` to avoid dangling index entries.
547    #[test]
548    #[allow(clippy::clone_on_copy)]
549    fn prune_partial_progress_mid_block() {
550        let db = TestStageDB::default();
551        let mut rng = generators::rng();
552
553        // Create blocks 0..=10
554        let blocks = random_block_range(
555            &mut rng,
556            0..=10,
557            BlockRangeParams { parent: Some(B256::ZERO), tx_count: 0..1, ..Default::default() },
558        );
559        db.insert_blocks(blocks.iter(), StorageKind::Database(None)).expect("insert blocks");
560
561        // Create specific changesets where block 5 has 4 storage changes
562        let addr1 = Address::with_last_byte(1);
563        let addr2 = Address::with_last_byte(2);
564
565        let account = Account { nonce: 1, balance: U256::from(100), ..Default::default() };
566
567        // Create storage entries
568        let storage_entry = |key: u8| reth_primitives_traits::StorageEntry {
569            key: B256::with_last_byte(key),
570            value: U256::from(100),
571        };
572
573        // Build changesets: blocks 0-4 have 1 storage change each, block 5 has 4 changes, block 6
574        // has 1. Entries within each account must be sorted by key.
575        let changesets: Vec<ChangeSet> = vec![
576            vec![(addr1, account.clone(), vec![storage_entry(1)])], // block 0
577            vec![(addr1, account.clone(), vec![storage_entry(1)])], // block 1
578            vec![(addr1, account.clone(), vec![storage_entry(1)])], // block 2
579            vec![(addr1, account.clone(), vec![storage_entry(1)])], // block 3
580            vec![(addr1, account.clone(), vec![storage_entry(1)])], // block 4
581            // block 5: 4 different storage changes (2 addresses, each with 2 storage slots)
582            // Sorted by address, then by storage key within each address
583            vec![
584                (addr1, account.clone(), vec![storage_entry(1), storage_entry(2)]),
585                (addr2, account.clone(), vec![storage_entry(1), storage_entry(2)]),
586            ],
587            vec![(addr1, account, vec![storage_entry(3)])], // block 6
588        ];
589
590        db.insert_changesets(changesets.clone(), None).expect("insert changesets");
591        db.insert_history(changesets.clone(), None).expect("insert history");
592
593        // Total storage changesets
594        let total_storage_entries: usize =
595            changesets.iter().flat_map(|c| c.iter()).map(|(_, _, entries)| entries.len()).sum();
596        assert_eq!(db.table::<tables::StorageChangeSets>().unwrap().len(), total_storage_entries);
597
598        let prune_mode = PruneMode::Before(10);
599
600        // Set limiter to stop mid-block 5
601        // With STORAGE_HISTORY_TABLES_TO_PRUNE=2, limit=14 gives us 7 storage entries before limit
602        // Blocks 0-4 use 5 slots, leaving 2 for block 5 (which has 4), so we stop mid-block 5
603        let deleted_entries_limit = 14; // 14/2 = 7 storage entries before limit
604        let limiter = PruneLimiter::default().set_deleted_entries_limit(deleted_entries_limit);
605
606        let input = PruneInput { previous_checkpoint: None, to_block: 10, limiter };
607        let segment = StorageHistory::new(prune_mode);
608
609        let provider = db.factory.database_provider_rw().unwrap();
610        provider.set_storage_settings_cache(StorageSettings::v1());
611        let result = segment.prune(&provider, input).unwrap();
612
613        // Should report that there's more data
614        assert!(!result.progress.is_finished(), "Expected HasMoreData since we stopped mid-block");
615
616        // Save checkpoint and commit
617        segment
618            .save_checkpoint(&provider, result.checkpoint.unwrap().as_prune_checkpoint(prune_mode))
619            .unwrap();
620        provider.commit().expect("commit");
621
622        // Verify checkpoint is set to block 4 (not 5), since block 5 is incomplete
623        let checkpoint = db
624            .factory
625            .provider()
626            .unwrap()
627            .get_prune_checkpoint(PruneSegment::StorageHistory)
628            .unwrap()
629            .expect("checkpoint should exist");
630
631        assert_eq!(
632            checkpoint.block_number,
633            Some(4),
634            "Checkpoint should be block 4 (block before incomplete block 5)"
635        );
636
637        // Verify remaining changesets
638        let remaining_changesets = db.table::<tables::StorageChangeSets>().unwrap();
639        assert!(
640            !remaining_changesets.is_empty(),
641            "Should have remaining changesets for blocks 5-6"
642        );
643
644        // Verify no dangling history indices for blocks that weren't fully pruned
645        let history = db.table::<tables::StoragesHistory>().unwrap();
646        for (key, _blocks) in &history {
647            assert!(
648                key.sharded_key.highest_block_number > 4,
649                "Found stale history shard with highest_block_number {} <= checkpoint 4",
650                key.sharded_key.highest_block_number
651            );
652        }
653
654        // Run prune again to complete - should finish processing block 5 and 6
655        let input2 = PruneInput {
656            previous_checkpoint: Some(checkpoint),
657            to_block: 10,
658            limiter: PruneLimiter::default().set_deleted_entries_limit(100), // high limit
659        };
660
661        let provider2 = db.factory.database_provider_rw().unwrap();
662        provider2.set_storage_settings_cache(StorageSettings::v1());
663        let result2 = segment.prune(&provider2, input2).unwrap();
664
665        assert!(result2.progress.is_finished(), "Second run should complete");
666
667        segment
668            .save_checkpoint(
669                &provider2,
670                result2.checkpoint.unwrap().as_prune_checkpoint(prune_mode),
671            )
672            .unwrap();
673        provider2.commit().expect("commit");
674
675        // Verify final checkpoint
676        let final_checkpoint = db
677            .factory
678            .provider()
679            .unwrap()
680            .get_prune_checkpoint(PruneSegment::StorageHistory)
681            .unwrap()
682            .expect("checkpoint should exist");
683
684        // Should now be at block 6 (the last block with changesets)
685        assert_eq!(final_checkpoint.block_number, Some(6), "Final checkpoint should be at block 6");
686
687        // All changesets should be pruned
688        let final_changesets = db.table::<tables::StorageChangeSets>().unwrap();
689        assert!(final_changesets.is_empty(), "All changesets up to block 10 should be pruned");
690    }
691
692    #[test]
693    fn prune_rocksdb() {
694        use reth_storage_api::StorageSettings;
695
696        let db = TestStageDB::default();
697        let mut rng = generators::rng();
698
699        let blocks = random_block_range(
700            &mut rng,
701            0..=100,
702            BlockRangeParams { parent: Some(B256::ZERO), tx_count: 0..1, ..Default::default() },
703        );
704        db.insert_blocks(blocks.iter(), StorageKind::Database(None)).expect("insert blocks");
705
706        let accounts = random_eoa_accounts(&mut rng, 2).into_iter().collect::<BTreeMap<_, _>>();
707
708        let (changesets, _) = random_changeset_range(
709            &mut rng,
710            blocks.iter(),
711            accounts.into_iter().map(|(addr, acc)| (addr, (acc, Vec::new()))),
712            1..2,
713            1..2,
714        );
715
716        db.insert_changesets_to_static_files(changesets.clone(), None)
717            .expect("insert changesets to static files");
718
719        let mut storage_indices: BTreeMap<(alloy_primitives::Address, B256), Vec<u64>> =
720            BTreeMap::new();
721        for (block, changeset) in changesets.iter().enumerate() {
722            for (address, _, storage_entries) in changeset {
723                for entry in storage_entries {
724                    storage_indices.entry((*address, entry.key)).or_default().push(block as u64);
725                }
726            }
727        }
728
729        {
730            let rocksdb = db.factory.rocksdb_provider();
731            let mut batch = rocksdb.batch();
732            for ((address, storage_key), block_numbers) in &storage_indices {
733                let shard = BlockNumberList::new_pre_sorted(block_numbers.clone());
734                batch
735                    .put::<tables::StoragesHistory>(
736                        StorageShardedKey::last(*address, *storage_key),
737                        &shard,
738                    )
739                    .expect("insert storage history shard");
740            }
741            batch.commit().expect("commit rocksdb batch");
742        }
743
744        {
745            let rocksdb = db.factory.rocksdb_provider();
746            for (address, storage_key) in storage_indices.keys() {
747                let shards = rocksdb.storage_history_shards(*address, *storage_key).unwrap();
748                assert!(!shards.is_empty(), "RocksDB should contain storage history before prune");
749            }
750        }
751
752        let to_block = 50u64;
753        let prune_mode = PruneMode::Before(to_block);
754        let input =
755            PruneInput { previous_checkpoint: None, to_block, limiter: PruneLimiter::default() };
756        let segment = StorageHistory::new(prune_mode);
757
758        let provider = db.factory.database_provider_rw().unwrap();
759        provider.set_storage_settings_cache(StorageSettings::v2());
760        let result = segment.prune(&provider, input).unwrap();
761        provider.commit().expect("commit");
762
763        assert_matches!(
764            result,
765            SegmentOutput { progress: PruneProgress::Finished, checkpoint: Some(_), .. }
766        );
767
768        {
769            let rocksdb = db.factory.rocksdb_provider();
770            for ((address, storage_key), block_numbers) in &storage_indices {
771                let shards = rocksdb.storage_history_shards(*address, *storage_key).unwrap();
772
773                let remaining_blocks: Vec<u64> =
774                    block_numbers.iter().copied().filter(|&b| b > to_block).collect();
775
776                if remaining_blocks.is_empty() {
777                    assert!(
778                        shards.is_empty(),
779                        "Shard for {:?}/{:?} should be deleted when all blocks pruned",
780                        address,
781                        storage_key
782                    );
783                } else {
784                    assert!(!shards.is_empty(), "Shard should exist with remaining blocks");
785                    let actual_blocks: Vec<u64> =
786                        shards.iter().flat_map(|(_, list)| list.iter()).collect();
787                    assert_eq!(
788                        actual_blocks, remaining_blocks,
789                        "RocksDB shard should only contain blocks > {}",
790                        to_block
791                    );
792                }
793            }
794        }
795    }
796
797    /// A block holding at least a whole run's budget of changesets must not stall pruning: the
798    /// walk deletes no changesets, so a checkpoint rewound below such a block would make every
799    /// later run reread it and never advance.
800    #[test]
801    #[allow(clippy::clone_on_copy)]
802    fn dense_block_advances_rocksdb_checkpoint() {
803        use reth_storage_api::StorageSettings;
804
805        let db = TestStageDB::default();
806        let mut rng = generators::rng();
807
808        let blocks = random_block_range(
809            &mut rng,
810            0..=20,
811            BlockRangeParams { parent: Some(B256::ZERO), tx_count: 0..1, ..Default::default() },
812        );
813        db.insert_blocks(blocks.iter(), StorageKind::Database(None)).expect("insert blocks");
814
815        // Two storage changesets per block, so a budget of two makes every block "dense".
816        const ENTRIES_PER_BLOCK: usize = 2;
817        let (address, account) = random_eoa_accounts(&mut rng, 1).into_iter().next().unwrap();
818        let keys = [B256::with_last_byte(1), B256::with_last_byte(2)];
819        let changesets = (0..=20)
820            .map(|_| {
821                vec![(
822                    address,
823                    account.clone(),
824                    keys.iter().map(|key| StorageEntry { key: *key, value: U256::ONE }).collect(),
825                )]
826            })
827            .collect::<Vec<_>>();
828        db.insert_changesets_to_static_files(changesets, None)
829            .expect("insert changesets to static files");
830
831        {
832            let rocksdb = db.factory.rocksdb_provider();
833            let mut batch = rocksdb.batch();
834            for key in keys {
835                batch
836                    .put::<tables::StoragesHistory>(
837                        StorageShardedKey::last(address, key),
838                        &BlockNumberList::new_pre_sorted(0..=20),
839                    )
840                    .expect("insert storage history shard");
841            }
842            batch.commit().expect("commit rocksdb batch");
843        }
844
845        let to_block = 15u64;
846        let prune_mode = PruneMode::Before(to_block);
847        let segment = StorageHistory::new(prune_mode);
848
849        // Start from a checkpoint in the middle so a rewind can't be masked by block 0.
850        let mut checkpoint = PruneCheckpoint { block_number: Some(4), tx_number: None, prune_mode };
851
852        let run_prune = |checkpoint: PruneCheckpoint, limit: usize| {
853            let input = PruneInput {
854                previous_checkpoint: Some(checkpoint),
855                to_block,
856                limiter: PruneLimiter::default().set_deleted_entries_limit(limit),
857            };
858
859            let provider = db.factory.database_provider_rw().unwrap();
860            provider.set_storage_settings_cache(StorageSettings::v2());
861            let result = segment.prune(&provider, input).unwrap();
862            segment
863                .save_checkpoint(
864                    &provider,
865                    result.checkpoint.unwrap().as_prune_checkpoint(prune_mode),
866                )
867                .unwrap();
868            provider.commit().expect("commit");
869
870            let checkpoint = db
871                .factory
872                .provider()
873                .unwrap()
874                .get_prune_checkpoint(PruneSegment::StorageHistory)
875                .unwrap()
876                .unwrap();
877            (result, checkpoint)
878        };
879
880        // The RocksDB path does not halve the limit, so this budget is exactly one dense block.
881        for _ in 0..3 {
882            let previous = checkpoint.block_number;
883            let (result, next) = run_prune(checkpoint, ENTRIES_PER_BLOCK);
884            checkpoint = next;
885
886            assert!(
887                !result.progress.is_finished(),
888                "the range is longer than one run's budget allows"
889            );
890            assert!(
891                checkpoint.block_number > previous,
892                "checkpoint must advance past the dense block, got {:?} after {previous:?}",
893                checkpoint.block_number
894            );
895        }
896        assert_eq!(checkpoint.block_number, Some(7), "one dense block cleared per run");
897
898        // With enough budget the remainder of the range completes in one run.
899        let (result, checkpoint) = run_prune(checkpoint, 1000);
900        assert!(result.progress.is_finished());
901        assert_eq!(checkpoint.block_number, Some(to_block));
902    }
903}