Skip to main content

reth_prune/segments/user/
transaction_lookup.rs

1use crate::{
2    db_ext::DbTxPruneExt,
3    segments::{PruneInput, Segment, SegmentOutput},
4    PrunerError,
5};
6use alloy_primitives::TxNumber;
7use reth_db_api::{table::Value, tables, transaction::DbTxMut};
8use reth_primitives_traits::{NodePrimitives, SignedTransaction};
9use reth_provider::{
10    BlockReader, DBProvider, PruneCheckpointReader, RocksDBProviderFactory,
11    StaticFileProviderFactory, TransactionsProviderExt,
12};
13use reth_prune_types::{
14    PruneCheckpoint, PruneMode, PruneProgress, PrunePurpose, PruneSegment, SegmentOutputCheckpoint,
15};
16use reth_static_file_types::StaticFileSegment;
17use reth_storage_api::StorageSettingsCache;
18use tracing::{debug, instrument, trace};
19
20#[derive(Debug)]
21pub struct TransactionLookup {
22    mode: PruneMode,
23}
24
25impl TransactionLookup {
26    pub const fn new(mode: PruneMode) -> Self {
27        Self { mode }
28    }
29}
30
31impl<Provider> Segment<Provider> for TransactionLookup
32where
33    Provider: DBProvider<Tx: DbTxMut>
34        + BlockReader<Transaction: SignedTransaction>
35        + PruneCheckpointReader
36        + StorageSettingsCache
37        + RocksDBProviderFactory
38        + StaticFileProviderFactory<
39            Primitives: NodePrimitives<SignedTx: Value, Receipt: Value, BlockHeader: Value>,
40        >,
41{
42    fn segment(&self) -> PruneSegment {
43        PruneSegment::TransactionLookup
44    }
45
46    fn mode(&self) -> Option<PruneMode> {
47        Some(self.mode)
48    }
49
50    fn purpose(&self) -> PrunePurpose {
51        PrunePurpose::User
52    }
53
54    #[instrument(
55        name = "TransactionLookup::prune",
56        target = "pruner",
57        skip(self, provider),
58        ret(level = "trace")
59    )]
60    fn prune(
61        &self,
62        provider: &Provider,
63        mut input: PruneInput,
64    ) -> Result<SegmentOutput, PrunerError> {
65        // It is not possible to prune TransactionLookup data for which we don't have transaction
66        // data. If the TransactionLookup checkpoint is lagging behind (which can happen e.g. when
67        // pre-merge history is dropped and then later tx lookup pruning is enabled) then we can
68        // only prune from the lowest static file.
69        if let Some(lowest_range) =
70            provider.static_file_provider().get_lowest_range(StaticFileSegment::Transactions) &&
71            input
72                .previous_checkpoint
73                .is_none_or(|checkpoint| checkpoint.block_number < Some(lowest_range.start()))
74        {
75            let new_checkpoint = lowest_range.start().saturating_sub(1);
76            if let Some(body_indices) = provider.block_body_indices(new_checkpoint)? {
77                input.previous_checkpoint = Some(PruneCheckpoint {
78                    block_number: Some(new_checkpoint),
79                    tx_number: Some(body_indices.last_tx_num()),
80                    prune_mode: self.mode,
81                });
82                debug!(
83                    target: "pruner",
84                    static_file_checkpoint = ?input.previous_checkpoint,
85                    "Using static file transaction checkpoint as TransactionLookup starting point"
86                );
87            }
88        }
89
90        let (start, end) = match input.get_next_tx_num_range(provider)? {
91            Some(range) => range,
92            None => {
93                trace!(target: "pruner", "No transaction lookup entries to prune");
94                return Ok(SegmentOutput::done())
95            }
96        }
97        .into_inner();
98
99        // Check where transaction hash numbers are stored
100        if provider.cached_storage_settings().storage_v2 {
101            return self.prune_rocksdb(provider, input, start, end);
102        }
103
104        // For PruneMode::Full, clear the entire table in one operation
105        if self.mode.is_full() {
106            let pruned = provider.tx_ref().clear_table::<tables::TransactionHashNumbers>()?;
107            trace!(target: "pruner", %pruned, "Cleared transaction lookup table");
108
109            let last_pruned_block = provider
110                .block_by_transaction_id(end)?
111                .ok_or(PrunerError::InconsistentData("Block for transaction is not found"))?;
112
113            return Ok(SegmentOutput {
114                progress: PruneProgress::Finished,
115                pruned,
116                checkpoint: Some(SegmentOutputCheckpoint {
117                    block_number: Some(last_pruned_block),
118                    tx_number: Some(end),
119                }),
120            });
121        }
122
123        let tx_range = start..=
124            Some(end)
125                .min(
126                    input
127                        .limiter
128                        .deleted_entries_limit_left()
129                        // Use saturating addition here to avoid panicking on
130                        // `deleted_entries_limit == usize::MAX`
131                        .map(|left| start.saturating_add(left as u64) - 1),
132                )
133                .unwrap();
134        let tx_range_end = *tx_range.end();
135
136        let mut hashes = provider
137            .static_file_provider()
138            .transaction_hashes_by_range(
139                *tx_range.start()..
140                    tx_range_end
141                        .checked_add(1)
142                        .ok_or(PrunerError::InconsistentData("Transaction range end overflow"))?,
143            )?
144            .into_iter()
145            .map(|(hash, _)| hash)
146            .collect::<Vec<_>>();
147
148        // Sort hashes to enable efficient cursor traversal through the TransactionHashNumbers
149        // table, which is keyed by hash. Without sorting, each seek would be O(log n) random
150        // access; with sorting, the cursor advances sequentially through the B+tree.
151        hashes.sort_unstable();
152
153        // Number of transactions retrieved from the database should match the tx range count
154        let tx_count = tx_range.count();
155        if hashes.len() != tx_count {
156            return Err(PrunerError::InconsistentData(
157                "Unexpected number of transaction hashes retrieved by transaction number range",
158            ))
159        }
160
161        let mut limiter = input.limiter;
162
163        let mut last_pruned_transaction = None;
164        let (pruned, done) =
165            provider.tx_ref().prune_table_with_iterator::<tables::TransactionHashNumbers>(
166                hashes,
167                &mut limiter,
168                |row| {
169                    last_pruned_transaction =
170                        Some(last_pruned_transaction.unwrap_or(row.1).max(row.1))
171                },
172            )?;
173
174        let done = done && tx_range_end == end;
175        trace!(target: "pruner", %pruned, %done, "Pruned transaction lookup");
176
177        let last_pruned_transaction = last_pruned_transaction.unwrap_or(tx_range_end);
178
179        let last_pruned_block = provider
180            .block_by_transaction_id(last_pruned_transaction)?
181            .ok_or(PrunerError::InconsistentData("Block for transaction is not found"))?
182            // If there's more transaction lookup entries to prune, set the checkpoint block number
183            // to previous, so we could finish pruning its transaction lookup entries on the next
184            // run.
185            .checked_sub(if done { 0 } else { 1 });
186
187        let progress = limiter.progress(done);
188
189        Ok(SegmentOutput {
190            progress,
191            pruned,
192            checkpoint: Some(SegmentOutputCheckpoint {
193                block_number: last_pruned_block,
194                tx_number: Some(last_pruned_transaction),
195            }),
196        })
197    }
198}
199
200impl TransactionLookup {
201    /// Prunes transaction lookup when indices are stored in `RocksDB`.
202    ///
203    /// Reads transactions from static files and deletes corresponding entries
204    /// from the `RocksDB` `TransactionHashNumbers` table.
205    fn prune_rocksdb<Provider>(
206        &self,
207        provider: &Provider,
208        input: PruneInput,
209        start: TxNumber,
210        end: TxNumber,
211    ) -> Result<SegmentOutput, PrunerError>
212    where
213        Provider: DBProvider
214            + BlockReader<Transaction: SignedTransaction>
215            + RocksDBProviderFactory
216            + StaticFileProviderFactory<
217                Primitives: NodePrimitives<SignedTx: Value, Receipt: Value, BlockHeader: Value>,
218            >,
219    {
220        // For PruneMode::Full, clear the entire RocksDB table in one operation
221        if self.mode.is_full() {
222            let rocksdb = provider.rocksdb_provider();
223            rocksdb.clear::<tables::TransactionHashNumbers>()?;
224            trace!(target: "pruner", "Cleared transaction lookup table (RocksDB)");
225
226            let last_pruned_block = provider
227                .block_by_transaction_id(end)?
228                .ok_or(PrunerError::InconsistentData("Block for transaction is not found"))?;
229
230            return Ok(SegmentOutput {
231                progress: PruneProgress::Finished,
232                pruned: 0, // RocksDB clear doesn't return count
233                checkpoint: Some(SegmentOutputCheckpoint {
234                    block_number: Some(last_pruned_block),
235                    tx_number: Some(end),
236                }),
237            });
238        }
239
240        let tx_range_end = input
241            .limiter
242            .deleted_entries_limit_left()
243            .map(|left| start.saturating_add(left as u64).saturating_sub(1))
244            .map_or(end, |limited| limited.min(end));
245        let tx_range = start..=tx_range_end;
246
247        let hashes = provider.static_file_provider().transaction_hashes_by_range(
248            *tx_range.start()..
249                tx_range_end
250                    .checked_add(1)
251                    .ok_or(PrunerError::InconsistentData("Transaction range end overflow"))?,
252        )?;
253
254        // Number of transactions retrieved from the database should match the tx range count
255        let tx_count = tx_range.count();
256        if hashes.len() != tx_count {
257            return Err(PrunerError::InconsistentData(
258                "Unexpected number of transaction hashes retrieved by transaction number range",
259            ))
260        }
261
262        let mut limiter = input.limiter;
263
264        // Delete transaction hash -> number mappings from RocksDB
265        let mut deleted = 0usize;
266        provider.with_rocksdb_batch(|mut batch| {
267            for (hash, _) in &hashes {
268                if limiter.is_limit_reached() {
269                    break;
270                }
271                batch.delete::<tables::TransactionHashNumbers>(*hash)?;
272                limiter.increment_deleted_entries_count();
273                deleted += 1;
274            }
275            Ok(((), Some(batch.into_inner())))
276        })?;
277
278        let done = deleted == hashes.len() && tx_range_end == end;
279        trace!(target: "pruner", %deleted, %done, "Pruned transaction lookup (RocksDB)");
280
281        let last_pruned_transaction =
282            if deleted > 0 { start + deleted as u64 - 1 } else { tx_range_end };
283
284        let last_pruned_block = provider
285            .block_by_transaction_id(last_pruned_transaction)?
286            .ok_or(PrunerError::InconsistentData("Block for transaction is not found"))?
287            .checked_sub(if done { 0 } else { 1 });
288
289        let progress = limiter.progress(done);
290
291        Ok(SegmentOutput {
292            progress,
293            pruned: deleted,
294            checkpoint: Some(SegmentOutputCheckpoint {
295                block_number: last_pruned_block,
296                tx_number: Some(last_pruned_transaction),
297            }),
298        })
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use crate::segments::{PruneInput, PruneLimiter, Segment, SegmentOutput, TransactionLookup};
305    use alloy_primitives::{BlockNumber, TxNumber, B256};
306    use assert_matches::assert_matches;
307    use itertools::{
308        FoldWhile::{Continue, Done},
309        Itertools,
310    };
311    use reth_db_api::tables;
312    use reth_provider::{DBProvider, DatabaseProviderFactory, PruneCheckpointReader};
313    use reth_prune_types::{
314        PruneCheckpoint, PruneInterruptReason, PruneMode, PruneProgress, PruneSegment,
315    };
316    use reth_stages::test_utils::{StorageKind, TestStageDB};
317    use reth_testing_utils::generators::{self, random_block_range, BlockRangeParams};
318    use std::ops::Sub;
319
320    #[test]
321    fn prune() {
322        let db = TestStageDB::default();
323        let mut rng = generators::rng();
324
325        let blocks = random_block_range(
326            &mut rng,
327            1..=10,
328            BlockRangeParams { parent: Some(B256::ZERO), tx_count: 2..3, ..Default::default() },
329        );
330        db.insert_blocks(blocks.iter(), StorageKind::Static).expect("insert blocks");
331
332        let mut tx_hash_numbers = Vec::new();
333        for block in &blocks {
334            tx_hash_numbers.reserve_exact(block.transaction_count());
335            for transaction in &block.body().transactions {
336                tx_hash_numbers.push((*transaction.tx_hash(), tx_hash_numbers.len() as u64));
337            }
338        }
339        let tx_hash_numbers_len = tx_hash_numbers.len();
340        db.insert_tx_hash_numbers(tx_hash_numbers).expect("insert tx hash numbers");
341
342        assert_eq!(
343            db.count_entries::<tables::Transactions>().unwrap(),
344            blocks.iter().map(|block| block.transaction_count()).sum::<usize>()
345        );
346        assert_eq!(
347            db.count_entries::<tables::Transactions>().unwrap(),
348            db.table::<tables::TransactionHashNumbers>().unwrap().len()
349        );
350
351        let test_prune = |to_block: BlockNumber, expected_result: (PruneProgress, usize)| {
352            let prune_mode = PruneMode::Before(to_block);
353            let segment = TransactionLookup::new(prune_mode);
354            let mut limiter = PruneLimiter::default().set_deleted_entries_limit(10);
355            let input = PruneInput {
356                previous_checkpoint: db
357                    .factory
358                    .provider()
359                    .unwrap()
360                    .get_prune_checkpoint(PruneSegment::TransactionLookup)
361                    .unwrap(),
362                to_block,
363                limiter: limiter.clone(),
364            };
365
366            let next_tx_number_to_prune = db
367                .factory
368                .provider()
369                .unwrap()
370                .get_prune_checkpoint(PruneSegment::TransactionLookup)
371                .unwrap()
372                .and_then(|checkpoint| checkpoint.tx_number)
373                .map(|tx_number| tx_number + 1)
374                .unwrap_or_default();
375
376            let last_pruned_tx_number = blocks
377                .iter()
378                .take(to_block as usize)
379                .map(|block| block.transaction_count())
380                .sum::<usize>()
381                .min(
382                    next_tx_number_to_prune as usize +
383                        input.limiter.deleted_entries_limit().unwrap(),
384                )
385                .sub(1);
386
387            let last_pruned_block_number = blocks
388                .iter()
389                .fold_while((0, 0), |(_, mut tx_count), block| {
390                    tx_count += block.transaction_count();
391
392                    if tx_count > last_pruned_tx_number {
393                        Done((block.number, tx_count))
394                    } else {
395                        Continue((block.number, tx_count))
396                    }
397                })
398                .into_inner()
399                .0;
400
401            let provider = db.factory.database_provider_rw().unwrap();
402            let result = segment.prune(&provider, input).unwrap();
403            limiter.increment_deleted_entries_count_by(result.pruned);
404
405            assert_matches!(
406                result,
407                SegmentOutput {progress, pruned, checkpoint: Some(_)}
408                    if (progress, pruned) == expected_result
409            );
410
411            segment
412                .save_checkpoint(
413                    &provider,
414                    result.checkpoint.unwrap().as_prune_checkpoint(prune_mode),
415                )
416                .unwrap();
417            provider.commit().expect("commit");
418
419            let last_pruned_block_number = last_pruned_block_number
420                .checked_sub(if result.progress.is_finished() { 0 } else { 1 });
421
422            assert_eq!(
423                db.table::<tables::TransactionHashNumbers>().unwrap().len(),
424                tx_hash_numbers_len - (last_pruned_tx_number + 1)
425            );
426            assert_eq!(
427                db.factory
428                    .provider()
429                    .unwrap()
430                    .get_prune_checkpoint(PruneSegment::TransactionLookup)
431                    .unwrap(),
432                Some(PruneCheckpoint {
433                    block_number: last_pruned_block_number,
434                    tx_number: Some(last_pruned_tx_number as TxNumber),
435                    prune_mode
436                })
437            );
438        };
439
440        test_prune(
441            6,
442            (PruneProgress::HasMoreData(PruneInterruptReason::DeletedEntriesLimitReached), 10),
443        );
444        test_prune(6, (PruneProgress::Finished, 2));
445        test_prune(10, (PruneProgress::Finished, 8));
446    }
447
448    #[test]
449    fn prune_rocksdb() {
450        use reth_db_api::models::StorageSettings;
451        use reth_provider::RocksDBProviderFactory;
452        use reth_storage_api::StorageSettingsCache;
453
454        let db = TestStageDB::default();
455        let mut rng = generators::rng();
456
457        let blocks = random_block_range(
458            &mut rng,
459            1..=10,
460            BlockRangeParams { parent: Some(B256::ZERO), tx_count: 2..3, ..Default::default() },
461        );
462        db.insert_blocks(blocks.iter(), StorageKind::Static).expect("insert blocks");
463
464        // Collect transaction hashes and their tx numbers
465        let mut tx_hash_numbers = Vec::new();
466        for block in &blocks {
467            tx_hash_numbers.reserve_exact(block.transaction_count());
468            for transaction in &block.body().transactions {
469                tx_hash_numbers.push((*transaction.tx_hash(), tx_hash_numbers.len() as u64));
470            }
471        }
472        let tx_hash_numbers_len = tx_hash_numbers.len();
473
474        // Insert into RocksDB instead of MDBX
475        {
476            let rocksdb = db.factory.rocksdb_provider();
477            let mut batch = rocksdb.batch();
478            for (hash, tx_num) in &tx_hash_numbers {
479                batch.put::<tables::TransactionHashNumbers>(*hash, tx_num).unwrap();
480            }
481            batch.commit().expect("commit rocksdb batch");
482        }
483
484        // Verify RocksDB has all entries
485        {
486            let rocksdb = db.factory.rocksdb_provider();
487            for (hash, expected_tx_num) in &tx_hash_numbers {
488                let actual = rocksdb.get::<tables::TransactionHashNumbers>(*hash).unwrap();
489                assert_eq!(actual, Some(*expected_tx_num));
490            }
491        }
492
493        let to_block: BlockNumber = 6;
494        let prune_mode = PruneMode::Before(to_block);
495        let input =
496            PruneInput { previous_checkpoint: None, to_block, limiter: PruneLimiter::default() };
497        let segment = TransactionLookup::new(prune_mode);
498
499        // Enable RocksDB storage for transaction hash numbers
500        db.factory.set_storage_settings_cache(StorageSettings::v2());
501
502        let provider = db.factory.database_provider_rw().unwrap();
503        let result = segment.prune(&provider, input).unwrap();
504        provider.commit().expect("commit");
505
506        assert_matches!(
507            result,
508            SegmentOutput { progress: PruneProgress::Finished, pruned, checkpoint: Some(_) }
509                if pruned > 0
510        );
511
512        // Calculate expected: blocks 1-6 should have their tx hashes pruned
513        let txs_up_to_block_6: usize = blocks.iter().take(6).map(|b| b.transaction_count()).sum();
514
515        // Verify RocksDB entries: first `txs_up_to_block_6` should be gone
516        {
517            let rocksdb = db.factory.rocksdb_provider();
518            for (i, (hash, _)) in tx_hash_numbers.iter().enumerate() {
519                let entry = rocksdb.get::<tables::TransactionHashNumbers>(*hash).unwrap();
520                if i < txs_up_to_block_6 {
521                    assert!(entry.is_none(), "Entry {} (hash {:?}) should be pruned", i, hash);
522                } else {
523                    assert!(entry.is_some(), "Entry {} (hash {:?}) should still exist", i, hash);
524                }
525            }
526        }
527
528        // Verify remaining count
529        {
530            let rocksdb = db.factory.rocksdb_provider();
531            let remaining: Vec<_> =
532                rocksdb.iter::<tables::TransactionHashNumbers>().unwrap().collect();
533            assert_eq!(
534                remaining.len(),
535                tx_hash_numbers_len - txs_up_to_block_6,
536                "Remaining RocksDB entries should match expected"
537            );
538        }
539    }
540
541    /// Tests that when `RocksDB` prune deletes nothing (limit exhausted), checkpoint doesn't
542    /// advance.
543    ///
544    /// This test simulates a scenario where:
545    /// 1. Some transactions have already been pruned (checkpoint at tx 5)
546    /// 2. The deleted entries limit is exhausted before any new deletions
547    /// 3. The checkpoint should NOT advance to the next start position
548    #[test]
549    fn prune_rocksdb_zero_deleted_checkpoint() {
550        use reth_db_api::models::StorageSettings;
551        use reth_provider::RocksDBProviderFactory;
552        use reth_storage_api::StorageSettingsCache;
553
554        let db = TestStageDB::default();
555        let mut rng = generators::rng();
556
557        let blocks = random_block_range(
558            &mut rng,
559            1..=10,
560            BlockRangeParams { parent: Some(B256::ZERO), tx_count: 2..3, ..Default::default() },
561        );
562        db.insert_blocks(blocks.iter(), StorageKind::Static).expect("insert blocks");
563
564        // Collect transaction hashes and their tx numbers
565        let mut tx_hash_numbers = Vec::new();
566        for block in &blocks {
567            tx_hash_numbers.reserve_exact(block.transaction_count());
568            for transaction in &block.body().transactions {
569                tx_hash_numbers.push((*transaction.tx_hash(), tx_hash_numbers.len() as u64));
570            }
571        }
572
573        // Insert into RocksDB
574        {
575            let rocksdb = db.factory.rocksdb_provider();
576            let mut batch = rocksdb.batch();
577            for (hash, tx_num) in &tx_hash_numbers {
578                batch.put::<tables::TransactionHashNumbers>(*hash, tx_num).unwrap();
579            }
580            batch.commit().expect("commit rocksdb batch");
581        }
582
583        // Enable RocksDB storage for transaction hash numbers
584        db.factory.set_storage_settings_cache(StorageSettings::v2());
585
586        let to_block: BlockNumber = 6;
587        let prune_mode = PruneMode::Before(to_block);
588
589        // Simulate that we've already pruned up to tx 5, so start will be tx 6
590        let previous_checkpoint =
591            Some(PruneCheckpoint { block_number: Some(2), tx_number: Some(5), prune_mode });
592
593        // Create a limiter with limit of 1, but exhaust it before pruning
594        // This means deleted_entries_limit_left() = Some(0)
595        let mut limiter = PruneLimiter::default().set_deleted_entries_limit(1);
596        limiter.increment_deleted_entries_count(); // Exhaust the limit
597
598        let input = PruneInput { previous_checkpoint, to_block, limiter };
599        let segment = TransactionLookup::new(prune_mode);
600
601        let provider = db.factory.database_provider_rw().unwrap();
602        let result = segment.prune(&provider, input).unwrap();
603        provider.commit().expect("commit");
604
605        // With an exhausted limit, nothing should be deleted
606        assert_eq!(result.pruned, 0, "Nothing should be pruned with exhausted limit");
607
608        // The checkpoint tx_number should NOT advance to 6 (start)
609        // With the bug: checkpoint.tx_number = start = 6 (WRONG - claims tx 6 was pruned)
610        // With the fix: checkpoint.tx_number = tx_range_end = 5 (correct - no advancement)
611        if let Some(checkpoint) = &result.checkpoint {
612            assert_eq!(
613                checkpoint.tx_number,
614                Some(5),
615                "Checkpoint should stay at 5 (previous), not advance to 6 (start)"
616            );
617        }
618
619        // All RocksDB entries should still exist (nothing was actually deleted)
620        {
621            let rocksdb = db.factory.rocksdb_provider();
622            let remaining: Vec<_> =
623                rocksdb.iter::<tables::TransactionHashNumbers>().unwrap().collect();
624            assert_eq!(
625                remaining.len(),
626                tx_hash_numbers.len(),
627                "All RocksDB entries should still exist"
628            );
629        }
630    }
631}