1use alloy_consensus::transaction::TxHashRef;
2use alloy_primitives::{TxHash, TxNumber};
3use num_traits::Zero;
4use reth_config::config::{EtlConfig, TransactionLookupConfig};
5use reth_db_api::{
6 table::{Decode, Decompress, Value},
7 tables,
8 transaction::DbTxMut,
9 Tables,
10};
11use reth_etl::Collector;
12use reth_primitives_traits::{NodePrimitives, SignedTransaction};
13use reth_provider::{
14 DBProvider, EitherWriter, PruneCheckpointReader, PruneCheckpointWriter, RocksDBProviderFactory,
15 StaticFileProviderFactory, StatsReader, StorageSettingsCache, TransactionsProvider,
16 TransactionsProviderExt,
17};
18use reth_prune_types::{PruneCheckpoint, PruneMode, PrunePurpose, PruneSegment};
19use reth_stages_api::{
20 EntitiesCheckpoint, ExecInput, ExecOutput, Stage, StageCheckpoint, StageError, StageId,
21 UnwindInput, UnwindOutput,
22};
23use reth_storage_errors::provider::ProviderError;
24use tracing::*;
25
26#[derive(Debug, Clone)]
35pub struct TransactionLookupStage {
36 chunk_size: u64,
39 etl_config: EtlConfig,
40 prune_mode: Option<PruneMode>,
41}
42
43impl Default for TransactionLookupStage {
44 fn default() -> Self {
45 Self { chunk_size: 5_000_000, etl_config: EtlConfig::default(), prune_mode: None }
46 }
47}
48
49impl TransactionLookupStage {
50 pub const fn new(
52 config: TransactionLookupConfig,
53 etl_config: EtlConfig,
54 prune_mode: Option<PruneMode>,
55 ) -> Self {
56 Self { chunk_size: config.chunk_size, etl_config, prune_mode }
57 }
58}
59
60impl<Provider> Stage<Provider> for TransactionLookupStage
61where
62 Provider: DBProvider<Tx: DbTxMut>
63 + PruneCheckpointWriter
64 + PruneCheckpointReader
65 + StatsReader
66 + StaticFileProviderFactory<Primitives: NodePrimitives<SignedTx: Value + SignedTransaction>>
67 + TransactionsProviderExt
68 + StorageSettingsCache
69 + RocksDBProviderFactory,
70{
71 fn id(&self) -> StageId {
73 StageId::TransactionLookup
74 }
75
76 fn execute(
78 &mut self,
79 provider: &Provider,
80 mut input: ExecInput,
81 ) -> Result<ExecOutput, StageError> {
82 if let Some((target_prunable_block, prune_mode)) = self
83 .prune_mode
84 .map(|mode| {
85 mode.prune_target_block(
86 input.target(),
87 PruneSegment::TransactionLookup,
88 PrunePurpose::User,
89 )
90 })
91 .transpose()?
92 .flatten() &&
93 target_prunable_block > input.checkpoint().block_number
94 {
95 input.checkpoint = Some(StageCheckpoint::new(target_prunable_block));
96
97 if provider.get_prune_checkpoint(PruneSegment::TransactionLookup)?.is_none() {
100 let target_prunable_tx_number = provider
101 .block_body_indices(target_prunable_block)?
102 .ok_or(ProviderError::BlockBodyIndicesNotFound(target_prunable_block))?
103 .last_tx_num();
104
105 provider.save_prune_checkpoint(
106 PruneSegment::TransactionLookup,
107 PruneCheckpoint {
108 block_number: Some(target_prunable_block),
109 tx_number: Some(target_prunable_tx_number),
110 prune_mode,
111 },
112 )?;
113 }
114 }
115 if input.target_reached() {
116 return Ok(ExecOutput::done(input.checkpoint()));
117 }
118
119 let mut hash_collector: Collector<TxHash, TxNumber> =
121 Collector::new(self.etl_config.file_size, self.etl_config.dir.clone());
122
123 info!(
124 target: "sync::stages::transaction_lookup",
125 tx_range = ?input.checkpoint().block_number..=input.target(),
126 "Updating transaction lookup"
127 );
128
129 loop {
130 let Some(range_output) =
131 input.next_block_range_with_transaction_threshold(provider, self.chunk_size)?
132 else {
133 input.checkpoint = Some(
134 StageCheckpoint::new(input.target())
135 .with_entities_stage_checkpoint(stage_checkpoint(provider)?),
136 );
137 break;
138 };
139
140 let end_block = *range_output.block_range.end();
141
142 info!(target: "sync::stages::transaction_lookup", tx_range = ?range_output.tx_range, "Calculating transaction hashes");
143
144 for (key, value) in provider.transaction_hashes_by_range(range_output.tx_range)? {
145 hash_collector.insert(key, value)?;
146 }
147
148 input.checkpoint = Some(
149 StageCheckpoint::new(end_block)
150 .with_entities_stage_checkpoint(stage_checkpoint(provider)?),
151 );
152
153 if range_output.is_final_range {
154 let total_hashes = hash_collector.len();
155 let interval = (total_hashes / 10).max(1);
156
157 let append_only =
159 provider.count_entries::<tables::TransactionHashNumbers>()?.is_zero();
160
161 provider.with_rocksdb_batch_auto_commit(|rocksdb_batch| {
163 let mut writer =
164 EitherWriter::new_transaction_hash_numbers(provider, rocksdb_batch)?;
165
166 let iter = hash_collector
167 .iter()
168 .map_err(|e| ProviderError::other(Box::new(e)))?;
169
170 for (index, hash_to_number) in iter.enumerate() {
171 let (hash_bytes, number_bytes) =
172 hash_to_number.map_err(|e| ProviderError::other(Box::new(e)))?;
173 if index > 0 && index.is_multiple_of(interval) {
174 info!(
175 target: "sync::stages::transaction_lookup",
176 ?append_only,
177 progress = %format_args!("{:.2}%", (index as f64 / total_hashes as f64) * 100.0),
178 "Inserting hashes"
179 );
180 }
181
182 let hash = TxHash::decode(&hash_bytes)?;
184 let tx_num = TxNumber::decompress(&number_bytes)?;
185 writer.put_transaction_hash_number(hash, tx_num, append_only)?;
186 }
187
188 Ok(((), writer.into_raw_rocksdb_batch()))
189 })?;
190
191 trace!(target: "sync::stages::transaction_lookup",
192 total_hashes,
193 "Transaction hashes inserted"
194 );
195
196 break;
197 }
198 }
199
200 if provider.cached_storage_settings().storage_v2 {
201 provider.commit_pending_rocksdb_batches()?;
202 provider.rocksdb_provider().flush(&[Tables::TransactionHashNumbers.name()])?;
203 }
204
205 Ok(ExecOutput::done(
206 StageCheckpoint::new(input.target())
207 .with_entities_stage_checkpoint(stage_checkpoint(provider)?),
208 ))
209 }
210
211 fn unwind(
213 &mut self,
214 provider: &Provider,
215 input: UnwindInput,
216 ) -> Result<UnwindOutput, StageError> {
217 let (range, unwind_to, _) = input.unwind_block_range_with_threshold(self.chunk_size);
218
219 provider.with_rocksdb_batch(|rocksdb_batch| {
220 let mut writer = EitherWriter::new_transaction_hash_numbers(provider, rocksdb_batch)?;
221
222 let static_file_provider = provider.static_file_provider();
223 let rev_walker = provider
224 .block_body_indices_range(range.clone())?
225 .into_iter()
226 .rev()
227 .zip(range.rev());
228
229 for (body, number) in rev_walker {
230 if number <= unwind_to {
231 break;
232 }
233
234 for transaction in
236 static_file_provider.transactions_by_tx_range(body.tx_num_range())?
237 {
238 writer.delete_transaction_hash_number(*transaction.tx_hash())?;
239 }
240 }
241
242 Ok(((), writer.into_raw_rocksdb_batch()))
243 })?;
244
245 Ok(UnwindOutput {
246 checkpoint: StageCheckpoint::new(unwind_to)
247 .with_entities_stage_checkpoint(stage_checkpoint(provider)?),
248 })
249 }
250}
251
252fn stage_checkpoint<Provider>(provider: &Provider) -> Result<EntitiesCheckpoint, StageError>
253where
254 Provider: PruneCheckpointReader + StaticFileProviderFactory + StatsReader,
255{
256 let pruned_entries = provider
257 .get_prune_checkpoint(PruneSegment::TransactionLookup)?
258 .and_then(|checkpoint| checkpoint.tx_number)
259 .map(|tx_number| tx_number + 1)
261 .unwrap_or_default();
262 Ok(EntitiesCheckpoint {
263 processed: provider.count_entries::<tables::TransactionHashNumbers>()? as u64 +
267 pruned_entries,
268 total: provider.static_file_provider().count_entries::<tables::Transactions>()? as u64,
272 })
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use crate::test_utils::{
279 stage_test_suite_ext, ExecuteStageTestRunner, StageTestRunner, StorageKind,
280 TestRunnerError, TestStageDB, UnwindStageTestRunner,
281 };
282 use alloy_primitives::{BlockNumber, B256};
283 use assert_matches::assert_matches;
284 use reth_db_api::{cursor::DbCursorRO, transaction::DbTx};
285 use reth_ethereum_primitives::Block;
286 use reth_primitives_traits::SealedBlock;
287 use reth_provider::{
288 providers::StaticFileWriter, BlockBodyIndicesProvider, DatabaseProviderFactory,
289 };
290 use reth_stages_api::StageUnitCheckpoint;
291 use reth_testing_utils::generators::{
292 self, random_block, random_block_range, BlockParams, BlockRangeParams,
293 };
294 use std::ops::Sub;
295
296 stage_test_suite_ext!(TransactionLookupTestRunner, transaction_lookup);
298
299 #[tokio::test]
300 async fn execute_single_transaction_lookup() {
301 let (previous_stage, stage_progress) = (500, 100);
302 let mut rng = generators::rng();
303
304 let runner = TransactionLookupTestRunner::default();
306 let input = ExecInput {
307 target: Some(previous_stage),
308 checkpoint: Some(StageCheckpoint::new(stage_progress)),
309 };
310
311 let non_empty_block_number = stage_progress + 10;
313 let blocks = (stage_progress..=input.target())
314 .map(|number| {
315 random_block(
316 &mut rng,
317 number,
318 BlockParams {
319 tx_count: Some((number == non_empty_block_number) as u8),
320 ..Default::default()
321 },
322 )
323 })
324 .collect::<Vec<_>>();
325 runner
326 .db
327 .insert_blocks(blocks.iter(), StorageKind::Static)
328 .expect("failed to insert blocks");
329
330 let rx = runner.execute(input);
331
332 let result = rx.await.unwrap();
334 assert_matches!(
335 result,
336 Ok(ExecOutput {
337 checkpoint: StageCheckpoint {
338 block_number,
339 stage_checkpoint: Some(StageUnitCheckpoint::Entities(EntitiesCheckpoint {
340 processed,
341 total
342 }))
343 }, done: true }) if block_number == previous_stage && processed == total &&
344 total == runner.db.count_entries::<tables::Transactions>().unwrap() as u64
345 );
346
347 assert!(runner.validate_execution(input, result.ok()).is_ok(), "execution validation");
349 }
350
351 #[tokio::test]
352 async fn execute_pruned_transaction_lookup() {
353 let (previous_stage, prune_target, stage_progress) = (500, 400, 100);
354 let mut rng = generators::rng();
355
356 let mut runner = TransactionLookupTestRunner::default();
358 let input = ExecInput {
359 target: Some(previous_stage),
360 checkpoint: Some(StageCheckpoint::new(stage_progress)),
361 };
362
363 let seed = random_block_range(
365 &mut rng,
366 stage_progress + 1..=previous_stage,
367 BlockRangeParams { parent: Some(B256::ZERO), tx_count: 0..2, ..Default::default() },
368 );
369 runner
370 .db
371 .insert_blocks(seed.iter(), StorageKind::Static)
372 .expect("failed to seed execution");
373
374 runner.set_prune_mode(PruneMode::Before(prune_target));
375
376 let rx = runner.execute(input);
377
378 let result = rx.await.unwrap();
380 assert_matches!(
381 result,
382 Ok(ExecOutput {
383 checkpoint: StageCheckpoint {
384 block_number,
385 stage_checkpoint: Some(StageUnitCheckpoint::Entities(EntitiesCheckpoint {
386 processed,
387 total
388 }))
389 }, done: true }) if block_number == previous_stage && processed == total &&
390 total == runner.db.count_entries::<tables::Transactions>().unwrap() as u64
391 );
392
393 assert!(runner.validate_execution(input, result.ok()).is_ok(), "execution validation");
395 }
396
397 #[test]
398 fn stage_checkpoint_pruned() {
399 let db = TestStageDB::default();
400 let mut rng = generators::rng();
401
402 let blocks = random_block_range(
403 &mut rng,
404 0..=100,
405 BlockRangeParams { parent: Some(B256::ZERO), tx_count: 0..10, ..Default::default() },
406 );
407 db.insert_blocks(blocks.iter(), StorageKind::Static).expect("insert blocks");
408
409 let max_pruned_block = 30;
410 let max_processed_block = 70;
411
412 let mut tx_hash_numbers = Vec::new();
413 let mut tx_hash_number = 0;
414 for block in &blocks[..=max_processed_block] {
415 for transaction in &block.body().transactions {
416 if block.number > max_pruned_block {
417 tx_hash_numbers.push((*transaction.tx_hash(), tx_hash_number));
418 }
419 tx_hash_number += 1;
420 }
421 }
422 db.insert_tx_hash_numbers(tx_hash_numbers).expect("insert tx hash numbers");
423
424 let provider = db.factory.provider_rw().unwrap();
425 provider
426 .save_prune_checkpoint(
427 PruneSegment::TransactionLookup,
428 PruneCheckpoint {
429 block_number: Some(max_pruned_block),
430 tx_number: Some(
431 blocks[..=max_pruned_block as usize]
432 .iter()
433 .map(|block| block.transaction_count() as u64)
434 .sum::<u64>()
435 .sub(1), ),
437 prune_mode: PruneMode::Full,
438 },
439 )
440 .expect("save stage checkpoint");
441 provider.commit().expect("commit");
442
443 let provider = db.factory.database_provider_rw().unwrap();
444 assert_eq!(
445 stage_checkpoint(&provider).expect("stage checkpoint"),
446 EntitiesCheckpoint {
447 processed: blocks[..=max_processed_block]
448 .iter()
449 .map(|block| block.transaction_count() as u64)
450 .sum(),
451 total: blocks.iter().map(|block| block.transaction_count() as u64).sum()
452 }
453 );
454 }
455
456 struct TransactionLookupTestRunner {
457 db: TestStageDB,
458 chunk_size: u64,
459 etl_config: EtlConfig,
460 prune_mode: Option<PruneMode>,
461 }
462
463 impl Default for TransactionLookupTestRunner {
464 fn default() -> Self {
465 Self {
466 db: TestStageDB::default(),
467 chunk_size: 1000,
468 etl_config: EtlConfig::default(),
469 prune_mode: None,
470 }
471 }
472 }
473
474 impl TransactionLookupTestRunner {
475 fn set_prune_mode(&mut self, prune_mode: PruneMode) {
476 self.prune_mode = Some(prune_mode);
477 }
478
479 fn ensure_no_hash_by_block(&self, number: BlockNumber) -> Result<(), TestRunnerError> {
486 let body_result = self
487 .db
488 .factory
489 .provider_rw()?
490 .block_body_indices(number)?
491 .ok_or(ProviderError::BlockBodyIndicesNotFound(number));
492 match body_result {
493 Ok(body) => {
494 self.db.ensure_no_entry_above_by_value::<tables::TransactionHashNumbers, _>(
495 body.last_tx_num(),
496 |key| key,
497 )?
498 }
499 Err(_) => {
500 assert!(self.db.table_is_empty::<tables::TransactionHashNumbers>()?);
501 }
502 };
503
504 Ok(())
505 }
506 }
507
508 impl StageTestRunner for TransactionLookupTestRunner {
509 type S = TransactionLookupStage;
510
511 fn db(&self) -> &TestStageDB {
512 &self.db
513 }
514
515 fn stage(&self) -> Self::S {
516 TransactionLookupStage {
517 chunk_size: self.chunk_size,
518 etl_config: self.etl_config.clone(),
519 prune_mode: self.prune_mode,
520 }
521 }
522 }
523
524 impl ExecuteStageTestRunner for TransactionLookupTestRunner {
525 type Seed = Vec<SealedBlock<Block>>;
526
527 fn seed_execution(&mut self, input: ExecInput) -> Result<Self::Seed, TestRunnerError> {
528 let stage_progress = input.checkpoint().block_number;
529 let end = input.target();
530 let mut rng = generators::rng();
531
532 let blocks = random_block_range(
533 &mut rng,
534 stage_progress + 1..=end,
535 BlockRangeParams { parent: Some(B256::ZERO), tx_count: 0..2, ..Default::default() },
536 );
537 self.db.insert_blocks(blocks.iter(), StorageKind::Static)?;
538 Ok(blocks)
539 }
540
541 fn validate_execution(
542 &self,
543 mut input: ExecInput,
544 output: Option<ExecOutput>,
545 ) -> Result<(), TestRunnerError> {
546 match output {
547 Some(output) => {
548 let provider = self.db.factory.provider()?;
549
550 if let Some((target_prunable_block, _)) = self
551 .prune_mode
552 .map(|mode| {
553 mode.prune_target_block(
554 input.target(),
555 PruneSegment::TransactionLookup,
556 PrunePurpose::User,
557 )
558 })
559 .transpose()
560 .expect("prune target block for transaction lookup")
561 .flatten() &&
562 target_prunable_block > input.checkpoint().block_number
563 {
564 input.checkpoint = Some(StageCheckpoint::new(target_prunable_block));
565 }
566 let start_block = input.next_block();
567 let end_block = output.checkpoint.block_number;
568
569 if start_block > end_block {
570 return Ok(())
571 }
572
573 let mut body_cursor =
574 provider.tx_ref().cursor_read::<tables::BlockBodyIndices>()?;
575 body_cursor.seek_exact(start_block)?;
576
577 while let Some((_, body)) = body_cursor.next()? {
578 for tx_id in body.tx_num_range() {
579 let transaction =
580 provider.transaction_by_id(tx_id)?.expect("no transaction entry");
581 assert_eq!(
582 Some(tx_id),
583 provider.transaction_id(*transaction.tx_hash())?
584 );
585 }
586 }
587 }
588 None => self.ensure_no_hash_by_block(input.checkpoint().block_number)?,
589 };
590 Ok(())
591 }
592 }
593
594 impl UnwindStageTestRunner for TransactionLookupTestRunner {
595 fn validate_unwind(&self, input: UnwindInput) -> Result<(), TestRunnerError> {
596 self.ensure_no_hash_by_block(input.unwind_to)
597 }
598 }
599
600 mod rocksdb_tests {
601 use super::*;
602 use reth_provider::RocksDBProviderFactory;
603 use reth_storage_api::StorageSettings;
604
605 #[tokio::test]
608 async fn execute_writes_to_rocksdb_when_enabled() {
609 let (previous_stage, stage_progress) = (110, 100);
610 let mut rng = generators::rng();
611
612 let runner = TransactionLookupTestRunner::default();
614
615 runner.db.factory.set_storage_settings_cache(StorageSettings::v2());
617
618 let input = ExecInput {
619 target: Some(previous_stage),
620 checkpoint: Some(StageCheckpoint::new(stage_progress)),
621 };
622
623 let blocks = random_block_range(
625 &mut rng,
626 stage_progress + 1..=previous_stage,
627 BlockRangeParams {
628 parent: Some(B256::ZERO),
629 tx_count: 1..3, ..Default::default()
631 },
632 );
633 runner
634 .db
635 .insert_blocks(blocks.iter(), StorageKind::Static)
636 .expect("failed to insert blocks");
637
638 let expected_tx_count: usize = blocks.iter().map(|b| b.body().transactions.len()).sum();
640 assert!(expected_tx_count > 0, "test requires at least one transaction");
641
642 let rx = runner.execute(input);
644 let result = rx.await.unwrap();
645 assert!(result.is_ok(), "stage execution failed: {:?}", result);
646
647 let mdbx_count = runner.db.count_entries::<tables::TransactionHashNumbers>().unwrap();
649 assert_eq!(
650 mdbx_count, 0,
651 "MDBX TransactionHashNumbers should be empty when RocksDB is enabled"
652 );
653
654 let rocksdb = runner.db.factory.rocksdb_provider();
656 let mut rocksdb_count = 0;
657 for block in &blocks {
658 for tx in &block.body().transactions {
659 let hash = *tx.tx_hash();
660 let result = rocksdb.get::<tables::TransactionHashNumbers>(hash).unwrap();
661 assert!(result.is_some(), "Transaction hash {:?} not found in RocksDB", hash);
662 rocksdb_count += 1;
663 }
664 }
665 assert_eq!(
666 rocksdb_count, expected_tx_count,
667 "RocksDB should contain all transaction hashes"
668 );
669 }
670
671 #[tokio::test]
674 async fn unwind_deletes_from_rocksdb_when_enabled() {
675 let (previous_stage, stage_progress) = (110, 100);
676 let mut rng = generators::rng();
677
678 let runner = TransactionLookupTestRunner::default();
680
681 runner.db.factory.set_storage_settings_cache(StorageSettings::v2());
683
684 let blocks = random_block_range(
686 &mut rng,
687 stage_progress + 1..=previous_stage,
688 BlockRangeParams {
689 parent: Some(B256::ZERO),
690 tx_count: 1..3, ..Default::default()
692 },
693 );
694 runner
695 .db
696 .insert_blocks(blocks.iter(), StorageKind::Static)
697 .expect("failed to insert blocks");
698
699 let expected_tx_count: usize = blocks.iter().map(|b| b.body().transactions.len()).sum();
701 assert!(expected_tx_count > 0, "test requires at least one transaction");
702
703 let exec_input = ExecInput {
705 target: Some(previous_stage),
706 checkpoint: Some(StageCheckpoint::new(stage_progress)),
707 };
708 let rx = runner.execute(exec_input);
709 let result = rx.await.unwrap();
710 assert!(result.is_ok(), "stage execution failed: {:?}", result);
711
712 let rocksdb = runner.db.factory.rocksdb_provider();
714 for block in &blocks {
715 for tx in &block.body().transactions {
716 let hash = *tx.tx_hash();
717 let result = rocksdb.get::<tables::TransactionHashNumbers>(hash).unwrap();
718 assert!(
719 result.is_some(),
720 "Transaction hash {:?} should exist before unwind",
721 hash
722 );
723 }
724 }
725
726 let unwind_input = UnwindInput {
728 checkpoint: StageCheckpoint::new(previous_stage),
729 unwind_to: stage_progress,
730 bad_block: None,
731 };
732 let unwind_result = runner.unwind(unwind_input).await;
733 assert!(unwind_result.is_ok(), "stage unwind failed: {:?}", unwind_result);
734
735 let rocksdb = runner.db.factory.rocksdb_provider();
737 for block in &blocks {
738 for tx in &block.body().transactions {
739 let hash = *tx.tx_hash();
740 let result = rocksdb.get::<tables::TransactionHashNumbers>(hash).unwrap();
741 assert!(
742 result.is_none(),
743 "Transaction hash {:?} should be deleted from RocksDB after unwind",
744 hash
745 );
746 }
747 }
748 }
749 }
750}