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 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 if provider.cached_storage_settings().storage_v2 {
101 return self.prune_rocksdb(provider, input, start, end);
102 }
103
104 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 .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 hashes.sort_unstable();
152
153 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 .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 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 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, 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 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 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 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 {
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 {
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 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 let txs_up_to_block_6: usize = blocks.iter().take(6).map(|b| b.transaction_count()).sum();
514
515 {
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 {
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 #[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 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 {
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 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 let previous_checkpoint =
591 Some(PruneCheckpoint { block_number: Some(2), tx_number: Some(5), prune_mode });
592
593 let mut limiter = PruneLimiter::default().set_deleted_entries_limit(1);
596 limiter.increment_deleted_entries_count(); 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 assert_eq!(result.pruned, 0, "Nothing should be pruned with exhausted limit");
607
608 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 {
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}