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            let Some(previous_block) = lowest_range.start().checked_sub(1) &&
75            let Some(body_indices) = provider.block_body_indices(previous_block)? &&
76            let Some(tx_number) = body_indices.next_tx_num().checked_sub(1)
77        {
78            // Only skip earlier transactions if any exist.
79            input.previous_checkpoint = Some(PruneCheckpoint {
80                block_number: Some(previous_block),
81                tx_number: Some(tx_number),
82                prune_mode: self.mode,
83            });
84            debug!(
85                target: "pruner",
86                static_file_checkpoint = ?input.previous_checkpoint,
87                "Using static file transaction checkpoint as TransactionLookup starting point"
88            );
89        }
90
91        let (start, end) = match input.get_next_tx_num_range(provider)? {
92            Some(range) => range,
93            None => {
94                trace!(target: "pruner", "No transaction lookup entries to prune");
95                return Ok(SegmentOutput::done())
96            }
97        }
98        .into_inner();
99
100        // Check where transaction hash numbers are stored
101        if provider.cached_storage_settings().storage_v2 {
102            return self.prune_rocksdb(provider, input, start, end);
103        }
104
105        // For PruneMode::Full, clear the entire table in one operation
106        if self.mode.is_full() {
107            let pruned = provider.tx_ref().clear_table::<tables::TransactionHashNumbers>()?;
108            trace!(target: "pruner", %pruned, "Cleared transaction lookup table");
109
110            let last_pruned_block = provider
111                .block_by_transaction_id(end)?
112                .ok_or(PrunerError::InconsistentData("Block for transaction is not found"))?;
113
114            return Ok(SegmentOutput {
115                progress: PruneProgress::Finished,
116                pruned,
117                checkpoint: Some(SegmentOutputCheckpoint {
118                    block_number: Some(last_pruned_block),
119                    tx_number: Some(end),
120                }),
121            });
122        }
123
124        let tx_range = start..=
125            Some(end)
126                .min(
127                    input
128                        .limiter
129                        .deleted_entries_limit_left()
130                        // Use saturating addition here to avoid panicking on
131                        // `deleted_entries_limit == usize::MAX`
132                        .map(|left| start.saturating_add(left as u64) - 1),
133                )
134                .unwrap();
135        let tx_range_end = *tx_range.end();
136
137        let mut hashes = provider
138            .static_file_provider()
139            .transaction_hashes_by_range(
140                *tx_range.start()..
141                    tx_range_end
142                        .checked_add(1)
143                        .ok_or(PrunerError::InconsistentData("Transaction range end overflow"))?,
144            )?
145            .into_iter()
146            .map(|(hash, _)| hash)
147            .collect::<Vec<_>>();
148
149        // Sort hashes to enable efficient cursor traversal through the TransactionHashNumbers
150        // table, which is keyed by hash. Without sorting, each seek would be O(log n) random
151        // access; with sorting, the cursor advances sequentially through the B+tree.
152        hashes.sort_unstable();
153
154        // Number of transactions retrieved from the database should match the tx range count
155        let tx_count = tx_range.count();
156        if hashes.len() != tx_count {
157            return Err(PrunerError::InconsistentData(
158                "Unexpected number of transaction hashes retrieved by transaction number range",
159            ))
160        }
161
162        let mut limiter = input.limiter;
163
164        let mut last_pruned_transaction = None;
165        let (pruned, done) =
166            provider.tx_ref().prune_table_with_iterator::<tables::TransactionHashNumbers>(
167                hashes,
168                &mut limiter,
169                |row| {
170                    last_pruned_transaction =
171                        Some(last_pruned_transaction.unwrap_or(row.1).max(row.1))
172                },
173            )?;
174
175        let done = done && tx_range_end == end;
176        trace!(target: "pruner", %pruned, %done, "Pruned transaction lookup");
177
178        let last_pruned_transaction = last_pruned_transaction.unwrap_or(tx_range_end);
179
180        let last_pruned_block = provider
181            .block_by_transaction_id(last_pruned_transaction)?
182            .ok_or(PrunerError::InconsistentData("Block for transaction is not found"))?
183            // If there's more transaction lookup entries to prune, set the checkpoint block number
184            // to previous, so we could finish pruning its transaction lookup entries on the next
185            // run.
186            .checked_sub(if done { 0 } else { 1 });
187
188        let progress = limiter.progress(done);
189
190        Ok(SegmentOutput {
191            progress,
192            pruned,
193            checkpoint: Some(SegmentOutputCheckpoint {
194                block_number: last_pruned_block,
195                tx_number: Some(last_pruned_transaction),
196            }),
197        })
198    }
199}
200
201impl TransactionLookup {
202    /// Prunes transaction lookup when indices are stored in `RocksDB`.
203    ///
204    /// Reads transactions from static files and deletes corresponding entries
205    /// from the `RocksDB` `TransactionHashNumbers` table.
206    fn prune_rocksdb<Provider>(
207        &self,
208        provider: &Provider,
209        input: PruneInput,
210        start: TxNumber,
211        end: TxNumber,
212    ) -> Result<SegmentOutput, PrunerError>
213    where
214        Provider: DBProvider
215            + BlockReader<Transaction: SignedTransaction>
216            + RocksDBProviderFactory
217            + StaticFileProviderFactory<
218                Primitives: NodePrimitives<SignedTx: Value, Receipt: Value, BlockHeader: Value>,
219            >,
220    {
221        // For PruneMode::Full, clear the entire RocksDB table in one operation
222        if self.mode.is_full() {
223            let rocksdb = provider.rocksdb_provider();
224            rocksdb.clear::<tables::TransactionHashNumbers>()?;
225            trace!(target: "pruner", "Cleared transaction lookup table (RocksDB)");
226
227            let last_pruned_block = provider
228                .block_by_transaction_id(end)?
229                .ok_or(PrunerError::InconsistentData("Block for transaction is not found"))?;
230
231            return Ok(SegmentOutput {
232                progress: PruneProgress::Finished,
233                pruned: 0, // RocksDB clear doesn't return count
234                checkpoint: Some(SegmentOutputCheckpoint {
235                    block_number: Some(last_pruned_block),
236                    tx_number: Some(end),
237                }),
238            });
239        }
240
241        let tx_range_end = input
242            .limiter
243            .deleted_entries_limit_left()
244            .map(|left| start.saturating_add(left as u64).saturating_sub(1))
245            .map_or(end, |limited| limited.min(end));
246        let tx_range = start..=tx_range_end;
247
248        let hashes = provider.static_file_provider().transaction_hashes_by_range(
249            *tx_range.start()..
250                tx_range_end
251                    .checked_add(1)
252                    .ok_or(PrunerError::InconsistentData("Transaction range end overflow"))?,
253        )?;
254
255        // Number of transactions retrieved from the database should match the tx range count
256        let tx_count = tx_range.count();
257        if hashes.len() != tx_count {
258            return Err(PrunerError::InconsistentData(
259                "Unexpected number of transaction hashes retrieved by transaction number range",
260            ))
261        }
262
263        let mut limiter = input.limiter;
264
265        // Delete transaction hash -> number mappings from RocksDB
266        let mut deleted = 0usize;
267        provider.with_rocksdb_batch(|mut batch| {
268            for (hash, _) in &hashes {
269                if limiter.is_limit_reached() {
270                    break;
271                }
272                batch.delete::<tables::TransactionHashNumbers>(*hash)?;
273                limiter.increment_deleted_entries_count();
274                deleted += 1;
275            }
276            Ok(((), Some(batch.into_inner())))
277        })?;
278
279        let done = deleted == hashes.len() && tx_range_end == end;
280        trace!(target: "pruner", %deleted, %done, "Pruned transaction lookup (RocksDB)");
281
282        let last_pruned_transaction =
283            if deleted > 0 { start + deleted as u64 - 1 } else { tx_range_end };
284
285        let last_pruned_block = provider
286            .block_by_transaction_id(last_pruned_transaction)?
287            .ok_or(PrunerError::InconsistentData("Block for transaction is not found"))?
288            .checked_sub(if done { 0 } else { 1 });
289
290        let progress = limiter.progress(done);
291
292        Ok(SegmentOutput {
293            progress,
294            pruned: deleted,
295            checkpoint: Some(SegmentOutputCheckpoint {
296                block_number: last_pruned_block,
297                tx_number: Some(last_pruned_transaction),
298            }),
299        })
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use crate::segments::{PruneInput, PruneLimiter, Segment, SegmentOutput, TransactionLookup};
306    use alloy_primitives::{BlockNumber, TxNumber, B256};
307    use assert_matches::assert_matches;
308    use itertools::{
309        FoldWhile::{Continue, Done},
310        Itertools,
311    };
312    use reth_db_api::tables;
313    use reth_provider::{DBProvider, DatabaseProviderFactory, PruneCheckpointReader};
314    use reth_prune_types::{
315        PruneCheckpoint, PruneInterruptReason, PruneMode, PruneProgress, PruneSegment,
316    };
317    use reth_stages::test_utils::{StorageKind, TestStageDB};
318    use reth_testing_utils::generators::{self, random_block_range, BlockRangeParams};
319    use std::ops::Sub;
320
321    #[test]
322    fn prune_transaction_zero_after_empty_genesis() {
323        let db = TestStageDB::default();
324        let mut rng = generators::rng();
325        let blocks = [
326            random_block_range(
327                &mut rng,
328                0..=0,
329                BlockRangeParams { tx_count: 0..1, ..Default::default() },
330            ),
331            random_block_range(
332                &mut rng,
333                1..=2,
334                BlockRangeParams { tx_count: 1..2, ..Default::default() },
335            ),
336        ]
337        .concat();
338        db.insert_blocks(blocks.iter(), StorageKind::Static).unwrap();
339        let hashes = blocks
340            .iter()
341            .flat_map(|block| &block.body().transactions)
342            .map(|tx| *tx.tx_hash())
343            .collect::<Vec<_>>();
344        db.insert_tx_hash_numbers(hashes.iter().enumerate().map(|(id, hash)| (*hash, id as u64)))
345            .unwrap();
346
347        let provider = db.factory.database_provider_rw().unwrap();
348        let result = TransactionLookup::new(PruneMode::Before(2))
349            .prune(
350                &provider,
351                PruneInput {
352                    previous_checkpoint: None,
353                    to_block: 1,
354                    limiter: PruneLimiter::default().set_deleted_entries_limit(10),
355                },
356            )
357            .unwrap();
358        assert_eq!(result.pruned, 1);
359        assert_eq!(result.checkpoint.unwrap().tx_number, Some(0));
360        provider.commit().unwrap();
361        assert_eq!(db.table::<tables::TransactionHashNumbers>().unwrap(), vec![(hashes[1], 1)]);
362    }
363
364    #[test]
365    fn prune_transaction_zero_when_earlier_blocks_are_empty() {
366        use reth_provider::{StaticFileProviderFactory, StaticFileWriter};
367        use reth_static_file_types::{StaticFileSegment, DEFAULT_BLOCKS_PER_STATIC_FILE};
368
369        let db = TestStageDB::default();
370        let mut rng = generators::rng();
371        let first_retained_block = DEFAULT_BLOCKS_PER_STATIC_FILE;
372        let blocks = [
373            random_block_range(
374                &mut rng,
375                first_retained_block - 1..=first_retained_block - 1,
376                BlockRangeParams { tx_count: 0..1, ..Default::default() },
377            ),
378            random_block_range(
379                &mut rng,
380                first_retained_block..=first_retained_block + 1,
381                BlockRangeParams { tx_count: 1..2, ..Default::default() },
382            ),
383        ]
384        .concat();
385        db.insert_blocks(blocks.iter(), StorageKind::Database(None)).unwrap();
386
387        // Earlier blocks were empty, so this file contains transaction zero.
388        let static_files = db.factory.static_file_provider();
389        {
390            let mut writer = static_files
391                .get_writer(first_retained_block, StaticFileSegment::Transactions)
392                .unwrap();
393            for (tx_number, block) in blocks[1..].iter().enumerate() {
394                writer.append_transaction(tx_number as u64, &block.body().transactions[0]).unwrap();
395                writer.increment_block(block.number).unwrap();
396            }
397            writer.commit().unwrap();
398        }
399        assert_eq!(
400            static_files.get_lowest_range(StaticFileSegment::Transactions).unwrap().start(),
401            first_retained_block,
402        );
403
404        let hashes = blocks[1..]
405            .iter()
406            .map(|block| *block.body().transactions[0].tx_hash())
407            .collect::<Vec<_>>();
408        db.insert_tx_hash_numbers(hashes.iter().enumerate().map(|(id, hash)| (*hash, id as u64)))
409            .unwrap();
410
411        let provider = db.factory.database_provider_rw().unwrap();
412        let result = TransactionLookup::new(PruneMode::Before(first_retained_block + 1))
413            .prune(
414                &provider,
415                PruneInput {
416                    previous_checkpoint: None,
417                    to_block: first_retained_block,
418                    limiter: PruneLimiter::default().set_deleted_entries_limit(10),
419                },
420            )
421            .unwrap();
422        assert!(result.progress.is_finished());
423        assert_eq!(result.pruned, 1);
424        let checkpoint = result.checkpoint.unwrap();
425        assert_eq!(checkpoint.block_number, Some(first_retained_block));
426        assert_eq!(checkpoint.tx_number, Some(0));
427        provider.commit().unwrap();
428        assert_eq!(db.table::<tables::TransactionHashNumbers>().unwrap(), vec![(hashes[1], 1)]);
429    }
430
431    #[test]
432    fn prune() {
433        let db = TestStageDB::default();
434        let mut rng = generators::rng();
435
436        let blocks = random_block_range(
437            &mut rng,
438            1..=10,
439            BlockRangeParams { parent: Some(B256::ZERO), tx_count: 2..3, ..Default::default() },
440        );
441        db.insert_blocks(blocks.iter(), StorageKind::Static).expect("insert blocks");
442
443        let mut tx_hash_numbers = Vec::new();
444        for block in &blocks {
445            tx_hash_numbers.reserve_exact(block.transaction_count());
446            for transaction in &block.body().transactions {
447                tx_hash_numbers.push((*transaction.tx_hash(), tx_hash_numbers.len() as u64));
448            }
449        }
450        let tx_hash_numbers_len = tx_hash_numbers.len();
451        db.insert_tx_hash_numbers(tx_hash_numbers).expect("insert tx hash numbers");
452
453        assert_eq!(
454            db.count_entries::<tables::Transactions>().unwrap(),
455            blocks.iter().map(|block| block.transaction_count()).sum::<usize>()
456        );
457        assert_eq!(
458            db.count_entries::<tables::Transactions>().unwrap(),
459            db.table::<tables::TransactionHashNumbers>().unwrap().len()
460        );
461
462        let test_prune = |to_block: BlockNumber, expected_result: (PruneProgress, usize)| {
463            let prune_mode = PruneMode::Before(to_block);
464            let segment = TransactionLookup::new(prune_mode);
465            let mut limiter = PruneLimiter::default().set_deleted_entries_limit(10);
466            let input = PruneInput {
467                previous_checkpoint: db
468                    .factory
469                    .provider()
470                    .unwrap()
471                    .get_prune_checkpoint(PruneSegment::TransactionLookup)
472                    .unwrap(),
473                to_block,
474                limiter: limiter.clone(),
475            };
476
477            let next_tx_number_to_prune = db
478                .factory
479                .provider()
480                .unwrap()
481                .get_prune_checkpoint(PruneSegment::TransactionLookup)
482                .unwrap()
483                .and_then(|checkpoint| checkpoint.tx_number)
484                .map(|tx_number| tx_number + 1)
485                .unwrap_or_default();
486
487            let last_pruned_tx_number = blocks
488                .iter()
489                .take(to_block as usize)
490                .map(|block| block.transaction_count())
491                .sum::<usize>()
492                .min(
493                    next_tx_number_to_prune as usize +
494                        input.limiter.deleted_entries_limit().unwrap(),
495                )
496                .sub(1);
497
498            let last_pruned_block_number = blocks
499                .iter()
500                .fold_while((0, 0), |(_, mut tx_count), block| {
501                    tx_count += block.transaction_count();
502
503                    if tx_count > last_pruned_tx_number {
504                        Done((block.number, tx_count))
505                    } else {
506                        Continue((block.number, tx_count))
507                    }
508                })
509                .into_inner()
510                .0;
511
512            let provider = db.factory.database_provider_rw().unwrap();
513            let result = segment.prune(&provider, input).unwrap();
514            limiter.increment_deleted_entries_count_by(result.pruned);
515
516            assert_matches!(
517                result,
518                SegmentOutput {progress, pruned, checkpoint: Some(_)}
519                    if (progress, pruned) == expected_result
520            );
521
522            segment
523                .save_checkpoint(
524                    &provider,
525                    result.checkpoint.unwrap().as_prune_checkpoint(prune_mode),
526                )
527                .unwrap();
528            provider.commit().expect("commit");
529
530            let last_pruned_block_number = last_pruned_block_number
531                .checked_sub(if result.progress.is_finished() { 0 } else { 1 });
532
533            assert_eq!(
534                db.table::<tables::TransactionHashNumbers>().unwrap().len(),
535                tx_hash_numbers_len - (last_pruned_tx_number + 1)
536            );
537            assert_eq!(
538                db.factory
539                    .provider()
540                    .unwrap()
541                    .get_prune_checkpoint(PruneSegment::TransactionLookup)
542                    .unwrap(),
543                Some(PruneCheckpoint {
544                    block_number: last_pruned_block_number,
545                    tx_number: Some(last_pruned_tx_number as TxNumber),
546                    prune_mode
547                })
548            );
549        };
550
551        test_prune(
552            6,
553            (PruneProgress::HasMoreData(PruneInterruptReason::DeletedEntriesLimitReached), 10),
554        );
555        test_prune(6, (PruneProgress::Finished, 2));
556        test_prune(10, (PruneProgress::Finished, 8));
557    }
558
559    #[test]
560    fn prune_rocksdb() {
561        use reth_db_api::models::StorageSettings;
562        use reth_provider::RocksDBProviderFactory;
563        use reth_storage_api::StorageSettingsCache;
564
565        let db = TestStageDB::default();
566        let mut rng = generators::rng();
567
568        let blocks = random_block_range(
569            &mut rng,
570            1..=10,
571            BlockRangeParams { parent: Some(B256::ZERO), tx_count: 2..3, ..Default::default() },
572        );
573        db.insert_blocks(blocks.iter(), StorageKind::Static).expect("insert blocks");
574
575        // Collect transaction hashes and their tx numbers
576        let mut tx_hash_numbers = Vec::new();
577        for block in &blocks {
578            tx_hash_numbers.reserve_exact(block.transaction_count());
579            for transaction in &block.body().transactions {
580                tx_hash_numbers.push((*transaction.tx_hash(), tx_hash_numbers.len() as u64));
581            }
582        }
583        let tx_hash_numbers_len = tx_hash_numbers.len();
584
585        // Insert into RocksDB instead of MDBX
586        {
587            let rocksdb = db.factory.rocksdb_provider();
588            let mut batch = rocksdb.batch();
589            for (hash, tx_num) in &tx_hash_numbers {
590                batch.put::<tables::TransactionHashNumbers>(*hash, tx_num).unwrap();
591            }
592            batch.commit().expect("commit rocksdb batch");
593        }
594
595        // Verify RocksDB has all entries
596        {
597            let rocksdb = db.factory.rocksdb_provider();
598            for (hash, expected_tx_num) in &tx_hash_numbers {
599                let actual = rocksdb.get::<tables::TransactionHashNumbers>(*hash).unwrap();
600                assert_eq!(actual, Some(*expected_tx_num));
601            }
602        }
603
604        let to_block: BlockNumber = 6;
605        let prune_mode = PruneMode::Before(to_block);
606        let input =
607            PruneInput { previous_checkpoint: None, to_block, limiter: PruneLimiter::default() };
608        let segment = TransactionLookup::new(prune_mode);
609
610        // Enable RocksDB storage for transaction hash numbers
611        db.factory.set_storage_settings_cache(StorageSettings::v2());
612
613        let provider = db.factory.database_provider_rw().unwrap();
614        let result = segment.prune(&provider, input).unwrap();
615        provider.commit().expect("commit");
616
617        assert_matches!(
618            result,
619            SegmentOutput { progress: PruneProgress::Finished, pruned, checkpoint: Some(_) }
620                if pruned > 0
621        );
622
623        // Calculate expected: blocks 1-6 should have their tx hashes pruned
624        let txs_up_to_block_6: usize = blocks.iter().take(6).map(|b| b.transaction_count()).sum();
625
626        // Verify RocksDB entries: first `txs_up_to_block_6` should be gone
627        {
628            let rocksdb = db.factory.rocksdb_provider();
629            for (i, (hash, _)) in tx_hash_numbers.iter().enumerate() {
630                let entry = rocksdb.get::<tables::TransactionHashNumbers>(*hash).unwrap();
631                if i < txs_up_to_block_6 {
632                    assert!(entry.is_none(), "Entry {} (hash {:?}) should be pruned", i, hash);
633                } else {
634                    assert!(entry.is_some(), "Entry {} (hash {:?}) should still exist", i, hash);
635                }
636            }
637        }
638
639        // Verify remaining count
640        {
641            let rocksdb = db.factory.rocksdb_provider();
642            let remaining: Vec<_> =
643                rocksdb.iter::<tables::TransactionHashNumbers>().unwrap().collect();
644            assert_eq!(
645                remaining.len(),
646                tx_hash_numbers_len - txs_up_to_block_6,
647                "Remaining RocksDB entries should match expected"
648            );
649        }
650    }
651
652    /// Tests that when `RocksDB` prune deletes nothing (limit exhausted), checkpoint doesn't
653    /// advance.
654    ///
655    /// This test simulates a scenario where:
656    /// 1. Some transactions have already been pruned (checkpoint at tx 5)
657    /// 2. The deleted entries limit is exhausted before any new deletions
658    /// 3. The checkpoint should NOT advance to the next start position
659    #[test]
660    fn prune_rocksdb_zero_deleted_checkpoint() {
661        use reth_db_api::models::StorageSettings;
662        use reth_provider::RocksDBProviderFactory;
663        use reth_storage_api::StorageSettingsCache;
664
665        let db = TestStageDB::default();
666        let mut rng = generators::rng();
667
668        let blocks = random_block_range(
669            &mut rng,
670            1..=10,
671            BlockRangeParams { parent: Some(B256::ZERO), tx_count: 2..3, ..Default::default() },
672        );
673        db.insert_blocks(blocks.iter(), StorageKind::Static).expect("insert blocks");
674
675        // Collect transaction hashes and their tx numbers
676        let mut tx_hash_numbers = Vec::new();
677        for block in &blocks {
678            tx_hash_numbers.reserve_exact(block.transaction_count());
679            for transaction in &block.body().transactions {
680                tx_hash_numbers.push((*transaction.tx_hash(), tx_hash_numbers.len() as u64));
681            }
682        }
683
684        // Insert into RocksDB
685        {
686            let rocksdb = db.factory.rocksdb_provider();
687            let mut batch = rocksdb.batch();
688            for (hash, tx_num) in &tx_hash_numbers {
689                batch.put::<tables::TransactionHashNumbers>(*hash, tx_num).unwrap();
690            }
691            batch.commit().expect("commit rocksdb batch");
692        }
693
694        // Enable RocksDB storage for transaction hash numbers
695        db.factory.set_storage_settings_cache(StorageSettings::v2());
696
697        let to_block: BlockNumber = 6;
698        let prune_mode = PruneMode::Before(to_block);
699
700        // Simulate that we've already pruned up to tx 5, so start will be tx 6
701        let previous_checkpoint =
702            Some(PruneCheckpoint { block_number: Some(2), tx_number: Some(5), prune_mode });
703
704        // Create a limiter with limit of 1, but exhaust it before pruning
705        // This means deleted_entries_limit_left() = Some(0)
706        let mut limiter = PruneLimiter::default().set_deleted_entries_limit(1);
707        limiter.increment_deleted_entries_count(); // Exhaust the limit
708
709        let input = PruneInput { previous_checkpoint, to_block, limiter };
710        let segment = TransactionLookup::new(prune_mode);
711
712        let provider = db.factory.database_provider_rw().unwrap();
713        let result = segment.prune(&provider, input).unwrap();
714        provider.commit().expect("commit");
715
716        // With an exhausted limit, nothing should be deleted
717        assert_eq!(result.pruned, 0, "Nothing should be pruned with exhausted limit");
718
719        // The checkpoint tx_number should NOT advance to 6 (start)
720        // With the bug: checkpoint.tx_number = start = 6 (WRONG - claims tx 6 was pruned)
721        // With the fix: checkpoint.tx_number = tx_range_end = 5 (correct - no advancement)
722        if let Some(checkpoint) = &result.checkpoint {
723            assert_eq!(
724                checkpoint.tx_number,
725                Some(5),
726                "Checkpoint should stay at 5 (previous), not advance to 6 (start)"
727            );
728        }
729
730        // All RocksDB entries should still exist (nothing was actually deleted)
731        {
732            let rocksdb = db.factory.rocksdb_provider();
733            let remaining: Vec<_> =
734                rocksdb.iter::<tables::TransactionHashNumbers>().unwrap().collect();
735            assert_eq!(
736                remaining.len(),
737                tx_hash_numbers.len(),
738                "All RocksDB entries should still exist"
739            );
740        }
741    }
742}