From 9883b7140e3c2773587cdd870f2f52a2e54f3a7b Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Thu, 16 Apr 2026 12:38:41 +0000 Subject: [PATCH 01/83] feat(trie): add disjoint_by_keys for sorted overlays Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d9639-935f-73a4-ba50-7682a7b9aca0 Co-authored-by: Amp --- crates/trie/common/src/hashed_state.rs | 150 ++++++++++++++++++++++++- crates/trie/common/src/updates.rs | 139 ++++++++++++++++++++++- crates/trie/common/src/utils.rs | 61 ++++++++++ 3 files changed, 348 insertions(+), 2 deletions(-) diff --git a/crates/trie/common/src/hashed_state.rs b/crates/trie/common/src/hashed_state.rs index 9addbfeafd0..f12aa1814cd 100644 --- a/crates/trie/common/src/hashed_state.rs +++ b/crates/trie/common/src/hashed_state.rs @@ -3,7 +3,7 @@ use core::ops::Not; use crate::{ added_removed_keys::MultiAddedRemovedKeys, prefix_set::{PrefixSetMut, TriePrefixSetsMut}, - utils::{extend_sorted_vec, kway_merge_sorted}, + utils::{extend_sorted_vec, kway_merge_disjoint_sorted, kway_merge_sorted}, KeyHasher, MultiProofTargets, Nibbles, }; use alloc::{borrow::Cow, vec::Vec}; @@ -691,6 +691,40 @@ impl HashedPostStateSorted { Self { accounts, storages } } + /// Merges the left-hand states and removes any top-level keys present on the right. + /// + /// For duplicate keys on the left, later items take precedence over earlier ones. The order of + /// the right-hand side does not matter. + pub fn disjoint_by_keys<'a>(left: Vec<&'a Self>, right: Vec<&'a Self>) -> Self { + let accounts = kway_merge_disjoint_sorted( + left.iter().map(|item| item.accounts.len()).sum(), + left.iter().rev().map(|item| item.accounts.as_slice()), + right.iter().map(|item| item.accounts.as_slice()), + ); + + let mut storages = B256Map::with_capacity_and_hasher( + left.iter().map(|item| item.storages.len()).sum(), + Default::default(), + ); + + for item in left { + for (hashed_address, storage) in &item.storages { + storages + .entry(*hashed_address) + .and_modify(|existing: &mut HashedStorageSorted| existing.extend_ref(storage)) + .or_insert_with(|| storage.clone()); + } + } + + for item in right { + for hashed_address in item.storages.keys() { + storages.remove(hashed_address); + } + } + + Self { accounts, storages } + } + /// Clears all accounts and storage data. pub fn clear(&mut self) { self.accounts.clear(); @@ -1534,6 +1568,120 @@ mod tests { assert_eq!(state.accounts.get(&addr1), Some(&None)); } + #[test] + fn test_hashed_post_state_sorted_disjoint_by_keys() { + fn account(nonce: u64) -> Account { + Account { nonce, balance: U256::ZERO, bytecode_hash: None } + } + + let kept_account = B256::with_last_byte(1); + let removed_account = B256::with_last_byte(2); + let kept_storage = B256::with_last_byte(3); + let removed_storage = B256::with_last_byte(4); + let slot1 = B256::with_last_byte(11); + let slot2 = B256::with_last_byte(12); + + let older = HashedPostStateSorted::new( + vec![(kept_account, Some(account(1))), (removed_account, Some(account(10)))], + B256Map::from_iter([ + ( + kept_storage, + HashedStorageSorted { + wiped: false, + storage_slots: vec![(slot1, U256::from(1))], + }, + ), + ( + removed_storage, + HashedStorageSorted { + wiped: false, + storage_slots: vec![(slot1, U256::from(2))], + }, + ), + ]), + ); + + let newer = HashedPostStateSorted::new( + vec![(kept_account, Some(account(2)))], + B256Map::from_iter([( + kept_storage, + HashedStorageSorted { + wiped: false, + storage_slots: vec![(slot1, U256::from(3)), (slot2, U256::from(4))], + }, + )]), + ); + + let remove_a = HashedPostStateSorted::new( + vec![(removed_account, None)], + B256Map::from_iter([( + removed_storage, + HashedStorageSorted { wiped: true, storage_slots: vec![] }, + )]), + ); + + let remove_b = HashedPostStateSorted::new( + vec![(B256::with_last_byte(255), Some(account(99)))], + B256Map::default(), + ); + + let result = HashedPostStateSorted::disjoint_by_keys( + vec![&older, &newer], + vec![&remove_b, &remove_a], + ); + + assert_eq!(result.accounts, vec![(kept_account, Some(account(2)))]); + assert_eq!(result.storages.len(), 1); + assert_eq!( + result.storages.get(&kept_storage), + Some(&HashedStorageSorted { + wiped: false, + storage_slots: vec![(slot1, U256::from(3)), (slot2, U256::from(4))], + }) + ); + assert!(!result.storages.contains_key(&removed_storage)); + } + + #[test] + fn test_hashed_post_state_sorted_disjoint_by_keys_removes_overlapping_left_key() { + fn account(nonce: u64) -> Account { + Account { nonce, balance: U256::ZERO, bytecode_hash: None } + } + + let overlapping_account = B256::with_last_byte(21); + let overlapping_storage = B256::with_last_byte(22); + let slot = B256::with_last_byte(23); + + let older = HashedPostStateSorted::new( + vec![(overlapping_account, Some(account(1)))], + B256Map::from_iter([( + overlapping_storage, + HashedStorageSorted { wiped: false, storage_slots: vec![(slot, U256::from(1))] }, + )]), + ); + + let newer = HashedPostStateSorted::new( + vec![(overlapping_account, Some(account(2)))], + B256Map::from_iter([( + overlapping_storage, + HashedStorageSorted { wiped: false, storage_slots: vec![(slot, U256::from(2))] }, + )]), + ); + + let remove = HashedPostStateSorted::new( + vec![(overlapping_account, None)], + B256Map::from_iter([( + overlapping_storage, + HashedStorageSorted { wiped: true, storage_slots: vec![] }, + )]), + ); + + let result = HashedPostStateSorted::disjoint_by_keys(vec![&older, &newer], vec![&remove]); + + assert!(result.accounts.is_empty()); + assert!(result.storages.is_empty()); + } + /// Test non-wiped storage merges both zero and non-zero valued slots #[test] fn test_hashed_storage_extend_from_sorted_non_wiped() { diff --git a/crates/trie/common/src/updates.rs b/crates/trie/common/src/updates.rs index d73b2c4d460..2076db0f493 100644 --- a/crates/trie/common/src/updates.rs +++ b/crates/trie/common/src/updates.rs @@ -1,5 +1,5 @@ use crate::{ - utils::{extend_sorted_vec, kway_merge_sorted}, + utils::{extend_sorted_vec, kway_merge_disjoint_sorted, kway_merge_sorted}, BranchNodeCompact, HashBuilder, Nibbles, }; use alloc::{ @@ -710,6 +710,42 @@ impl TrieUpdatesSorted { Self { account_nodes, storage_tries } } + + /// Merges the left-hand updates and removes any top-level keys present on the right. + /// + /// For duplicate keys on the left, later items take precedence over earlier ones. The order of + /// the right-hand side does not matter. + pub fn disjoint_by_keys<'a>(left: Vec<&'a Self>, right: Vec<&'a Self>) -> Self { + let account_nodes = kway_merge_disjoint_sorted( + left.iter().map(|item| item.account_nodes.len()).sum(), + left.iter().rev().map(|item| item.account_nodes.as_slice()), + right.iter().map(|item| item.account_nodes.as_slice()), + ); + + let mut storage_tries = B256Map::with_capacity_and_hasher( + left.iter().map(|item| item.storage_tries.len()).sum(), + Default::default(), + ); + + for item in left { + for (hashed_address, storage_trie) in &item.storage_tries { + storage_tries + .entry(*hashed_address) + .and_modify(|existing: &mut StorageTrieUpdatesSorted| { + existing.extend_ref(storage_trie) + }) + .or_insert_with(|| storage_trie.clone()); + } + } + + for item in right { + for hashed_address in item.storage_tries.keys() { + storage_tries.remove(hashed_address); + } + } + + Self::new(account_nodes, storage_tries) + } } impl AsRef for TrieUpdatesSorted { @@ -977,6 +1013,107 @@ mod tests { assert_eq!(storage3.storage_nodes[1].0, Nibbles::from_nibbles_unchecked([0x07])); } + #[test] + fn test_trie_updates_sorted_disjoint_by_keys() { + let kept_node = Nibbles::from_nibbles_unchecked([0x01]); + let removed_node = Nibbles::from_nibbles_unchecked([0x02]); + let kept_storage = B256::from([3; 32]); + let removed_storage = B256::from([4; 32]); + let slot1 = Nibbles::from_nibbles_unchecked([0x0a]); + let slot2 = Nibbles::from_nibbles_unchecked([0x0b]); + + let older = TrieUpdatesSorted::new( + vec![(kept_node, Some(BranchNodeCompact::default())), (removed_node, None)], + B256Map::from_iter([ + ( + kept_storage, + StorageTrieUpdatesSorted { + is_deleted: false, + storage_nodes: vec![(slot1, None)], + }, + ), + ( + removed_storage, + StorageTrieUpdatesSorted { + is_deleted: false, + storage_nodes: vec![(slot1, Some(BranchNodeCompact::default()))], + }, + ), + ]), + ); + + let newer = TrieUpdatesSorted::new( + vec![(kept_node, None)], + B256Map::from_iter([( + kept_storage, + StorageTrieUpdatesSorted { + is_deleted: false, + storage_nodes: vec![(slot1, Some(BranchNodeCompact::default())), (slot2, None)], + }, + )]), + ); + + let remove_a = TrieUpdatesSorted::new( + vec![(removed_node, Some(BranchNodeCompact::default()))], + B256Map::from_iter([(removed_storage, StorageTrieUpdatesSorted::default())]), + ); + + let remove_b = TrieUpdatesSorted::new( + vec![(Nibbles::from_nibbles_unchecked([0x0f]), Some(BranchNodeCompact::default()))], + B256Map::default(), + ); + + let result = + TrieUpdatesSorted::disjoint_by_keys(vec![&older, &newer], vec![&remove_b, &remove_a]); + + assert_eq!(result.account_nodes, vec![(kept_node, None)]); + assert_eq!(result.storage_tries.len(), 1); + assert_eq!( + result.storage_tries.get(&kept_storage), + Some(&StorageTrieUpdatesSorted { + is_deleted: false, + storage_nodes: vec![(slot1, Some(BranchNodeCompact::default())), (slot2, None)], + }) + ); + assert!(!result.storage_tries.contains_key(&removed_storage)); + } + + #[test] + fn test_trie_updates_sorted_disjoint_by_keys_removes_overlapping_left_key() { + let overlapping_node = Nibbles::from_nibbles_unchecked([0x03]); + let overlapping_storage = B256::from([5; 32]); + let slot = Nibbles::from_nibbles_unchecked([0x0c]); + + let older = TrieUpdatesSorted::new( + vec![(overlapping_node, Some(BranchNodeCompact::default()))], + B256Map::from_iter([( + overlapping_storage, + StorageTrieUpdatesSorted { + is_deleted: false, + storage_nodes: vec![(slot, Some(BranchNodeCompact::default()))], + }, + )]), + ); + + let newer = TrieUpdatesSorted::new( + vec![(overlapping_node, None)], + B256Map::from_iter([( + overlapping_storage, + StorageTrieUpdatesSorted { is_deleted: false, storage_nodes: vec![(slot, None)] }, + )]), + ); + + let remove = TrieUpdatesSorted::new( + vec![(overlapping_node, Some(BranchNodeCompact::default()))], + B256Map::from_iter([(overlapping_storage, StorageTrieUpdatesSorted::default())]), + ); + + let result = TrieUpdatesSorted::disjoint_by_keys(vec![&older, &newer], vec![&remove]); + + assert!(result.account_nodes.is_empty()); + assert!(result.storage_tries.is_empty()); + } + /// Test extending with storage tries adds both nodes and removed nodes correctly #[test] fn test_trie_updates_extend_from_sorted_with_storage_tries() { diff --git a/crates/trie/common/src/utils.rs b/crates/trie/common/src/utils.rs index 6d6f134a3ac..ae6b9f3e236 100644 --- a/crates/trie/common/src/utils.rs +++ b/crates/trie/common/src/utils.rs @@ -26,6 +26,51 @@ where .collect() } +/// Merge sorted left slices into a sorted `Vec`, excluding keys present in any right slice. +/// +/// Callers pass left slices in priority order (index 0 = highest priority), so the first +/// left slice's value for a key takes precedence over later slices. Right slice order is ignored; +/// the right-hand side only contributes keys to exclude. +pub(crate) fn kway_merge_disjoint_sorted<'a, K, V>( + capacity: usize, + left_slices: impl IntoIterator, + right_slices: impl IntoIterator, +) -> Vec<(K, V)> +where + K: Ord + Clone + 'a, + V: Clone + 'a, +{ + let mut right_keys = right_slices + .into_iter() + .filter(|s| !s.is_empty()) + .map(|s| s.iter().map(|(k, _)| k)) + .kmerge() + .dedup() + .peekable(); + + let mut out = Vec::with_capacity(capacity); + for (_, key, value) in left_slices + .into_iter() + .filter(|s| !s.is_empty()) + .enumerate() + .map(|(i, s)| s.iter().map(move |(k, v)| (i, k, v))) + .kmerge_by(|(i1, k1, _), (i2, k2, _)| (k1, i1) < (k2, i2)) + .dedup_by(|(_, k1, _), (_, k2, _)| *k1 == *k2) + { + while right_keys.peek().is_some_and(|right_key| *right_key < key) { + right_keys.next(); + } + + if right_keys.peek().is_some_and(|right_key| *right_key == key) { + continue; + } + + out.push((key.clone(), value.clone())); + } + + out +} + /// Extend a sorted vector with another sorted vector using 2 pointer merge. /// Values from `other` take precedence for duplicate keys. pub(crate) fn extend_sorted_vec(target: &mut Vec<(K, V)>, other: &[(K, V)]) @@ -183,4 +228,20 @@ mod tests { let result: Vec<(i32, &str)> = kway_merge_sorted(Vec::<&[(i32, &str)]>::new()); assert!(result.is_empty()); } + + #[test] + fn test_kway_merge_disjoint_sorted() { + let left_old = vec![(1, "old"), (2, "drop"), (4, "keep")]; + let left_new = vec![(1, "new"), (3, "new_only")]; + let right_a = vec![(2, "ignored"), (5, "ignored")]; + let right_b = vec![(3, "ignored")]; + + let result = kway_merge_disjoint_sorted( + left_old.len() + left_new.len(), + [left_new.as_slice(), left_old.as_slice()], + [right_a.as_slice(), right_b.as_slice()], + ); + + assert_eq!(result, vec![(1, "new"), (4, "keep")]); + } } From 037828f6aa317cb8aa186da574d0075b37fdcad4 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Thu, 16 Apr 2026 12:55:43 +0000 Subject: [PATCH 02/83] feat(stages): add partial finish checkpoint field Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d9639-935f-73a4-ba50-7682a7b9aca0 Co-authored-by: Amp --- crates/stages/stages/src/stages/bodies.rs | 18 ++-- crates/stages/stages/src/stages/era.rs | 4 +- .../stages/stages/src/stages/execution/mod.rs | 12 ++- .../stages/src/stages/hashing_account.rs | 1 + crates/stages/stages/src/stages/headers.rs | 6 +- .../src/stages/index_account_history.rs | 7 +- .../src/stages/index_storage_history.rs | 7 +- crates/stages/stages/src/stages/merkle.rs | 9 +- .../stages/src/stages/sender_recovery.rs | 3 +- crates/stages/stages/src/stages/tx_lookup.rs | 6 +- crates/stages/types/src/checkpoints.rs | 87 ++++++++++++++++++- 11 files changed, 136 insertions(+), 24 deletions(-) diff --git a/crates/stages/stages/src/stages/bodies.rs b/crates/stages/stages/src/stages/bodies.rs index 649b48b86e5..9e863f1b806 100644 --- a/crates/stages/stages/src/stages/bodies.rs +++ b/crates/stages/stages/src/stages/bodies.rs @@ -295,7 +295,8 @@ mod tests { stage_checkpoint: Some(StageUnitCheckpoint::Entities(EntitiesCheckpoint { processed, // 1 seeded block body + batch size total // seeded headers - })) + })), + .. }, done: false }) if block_number < 200 && processed == batch_size + 1 && total == previous_stage + 1 ); @@ -333,7 +334,8 @@ mod tests { stage_checkpoint: Some(StageUnitCheckpoint::Entities(EntitiesCheckpoint { processed, total - })) + })), + .. }, done: true }) if processed + 1 == total && total == previous_stage + 1 @@ -370,7 +372,8 @@ mod tests { stage_checkpoint: Some(StageUnitCheckpoint::Entities(EntitiesCheckpoint { processed, total - })) + })), + .. }, done: false }) if block_number >= 10 && processed - 1 == batch_size && total == previous_stage + 1 ); @@ -391,7 +394,8 @@ mod tests { stage_checkpoint: Some(StageUnitCheckpoint::Entities(EntitiesCheckpoint { processed, total - })) + })), + .. }, done: true }) if block_number > first_run_checkpoint.block_number && processed + 1 == total && total == previous_stage + 1 ); @@ -432,7 +436,8 @@ mod tests { stage_checkpoint: Some(StageUnitCheckpoint::Entities(EntitiesCheckpoint { processed, total - })) + })), + .. }, done: true }) if block_number == previous_stage && processed + 1 == total && total == previous_stage + 1 ); @@ -460,7 +465,8 @@ mod tests { stage_checkpoint: Some(StageUnitCheckpoint::Entities(EntitiesCheckpoint { processed: 1, total - })) + })), + .. }}) if total == previous_stage + 1 ); diff --git a/crates/stages/stages/src/stages/era.rs b/crates/stages/stages/src/stages/era.rs index 6e81054ed68..862b63a3880 100644 --- a/crates/stages/stages/src/stages/era.rs +++ b/crates/stages/stages/src/stages/era.rs @@ -298,7 +298,7 @@ mod tests { assert_matches!( output, Ok(ExecOutput { - checkpoint: StageCheckpoint { block_number, stage_checkpoint: None }, + checkpoint: StageCheckpoint { block_number, stage_checkpoint: None, .. }, done: false }) if block_number == era_cap ); @@ -318,7 +318,7 @@ mod tests { assert_matches!( output, Ok(ExecOutput { - checkpoint: StageCheckpoint { block_number, stage_checkpoint: None }, + checkpoint: StageCheckpoint { block_number, stage_checkpoint: None, .. }, done: true }) if block_number == target ); diff --git a/crates/stages/stages/src/stages/execution/mod.rs b/crates/stages/stages/src/stages/execution/mod.rs index 2a05915391d..fe0930ffd4b 100644 --- a/crates/stages/stages/src/stages/execution/mod.rs +++ b/crates/stages/stages/src/stages/execution/mod.rs @@ -799,6 +799,7 @@ mod tests { let previous_checkpoint = StageCheckpoint { block_number: 0, stage_checkpoint: Some(StageUnitCheckpoint::Execution(previous_stage_checkpoint)), + partial_state_trie: None, }; let stage_checkpoint = execution_checkpoint( @@ -839,6 +840,7 @@ mod tests { let previous_checkpoint = StageCheckpoint { block_number: 1, stage_checkpoint: Some(StageUnitCheckpoint::Execution(previous_stage_checkpoint)), + partial_state_trie: None, }; let stage_checkpoint = @@ -880,6 +882,7 @@ mod tests { let previous_checkpoint = StageCheckpoint { block_number: 1, stage_checkpoint: Some(StageUnitCheckpoint::Execution(previous_stage_checkpoint)), + partial_state_trie: None, }; let stage_checkpoint = @@ -914,7 +917,8 @@ mod tests { .unwrap(); provider.commit().unwrap(); - let previous_checkpoint = StageCheckpoint { block_number: 1, stage_checkpoint: None }; + let previous_checkpoint = + StageCheckpoint { block_number: 1, stage_checkpoint: None, partial_state_trie: None }; let stage_checkpoint = execution_checkpoint(&factory.static_file_provider(), 1, 1, previous_checkpoint); @@ -1015,7 +1019,8 @@ mod tests { processed, total } - })) + })), + .. }, done: true } if processed == total && total == block.gas_used); @@ -1165,7 +1170,8 @@ mod tests { processed: 0, total } - })) + })), + .. } } if total == block.gas_used); diff --git a/crates/stages/stages/src/stages/hashing_account.rs b/crates/stages/stages/src/stages/hashing_account.rs index 2410e8131fe..ddf26b41b1e 100644 --- a/crates/stages/stages/src/stages/hashing_account.rs +++ b/crates/stages/stages/src/stages/hashing_account.rs @@ -397,6 +397,7 @@ mod tests { }, .. })), + .. }, done: true, }) if block_number == previous_stage && diff --git a/crates/stages/stages/src/stages/headers.rs b/crates/stages/stages/src/stages/headers.rs index f9ca2a86f3a..6f719d0e542 100644 --- a/crates/stages/stages/src/stages/headers.rs +++ b/crates/stages/stages/src/stages/headers.rs @@ -594,7 +594,8 @@ mod tests { processed, total, } - })) + })), + .. }, done: true }) if block_number == tip.number && from == checkpoint && to == previous_stage && // -1 because we don't need to download the local head @@ -666,7 +667,8 @@ mod tests { processed, total, } - })) + })), + .. }, done: true }) if block_number == tip.number && from == checkpoint && to == previous_stage && // -1 because we don't need to download the local head diff --git a/crates/stages/stages/src/stages/index_account_history.rs b/crates/stages/stages/src/stages/index_account_history.rs index 617be0a5ee7..3f608ee8e54 100644 --- a/crates/stages/stages/src/stages/index_account_history.rs +++ b/crates/stages/stages/src/stages/index_account_history.rs @@ -240,8 +240,11 @@ mod tests { fn run(db: &TestStageDB, run_to: u64, input_checkpoint: Option) { let input = ExecInput { target: Some(run_to), - checkpoint: input_checkpoint - .map(|block_number| StageCheckpoint { block_number, stage_checkpoint: None }), + checkpoint: input_checkpoint.map(|block_number| StageCheckpoint { + block_number, + stage_checkpoint: None, + partial_state_trie: None, + }), }; let mut stage = IndexAccountHistoryStage::default(); let provider = db.factory.database_provider_rw().unwrap(); diff --git a/crates/stages/stages/src/stages/index_storage_history.rs b/crates/stages/stages/src/stages/index_storage_history.rs index 182bc1c96f5..be2299c187d 100644 --- a/crates/stages/stages/src/stages/index_storage_history.rs +++ b/crates/stages/stages/src/stages/index_storage_history.rs @@ -258,8 +258,11 @@ mod tests { fn run(db: &TestStageDB, run_to: u64, input_checkpoint: Option) { let input = ExecInput { target: Some(run_to), - checkpoint: input_checkpoint - .map(|block_number| StageCheckpoint { block_number, stage_checkpoint: None }), + checkpoint: input_checkpoint.map(|block_number| StageCheckpoint { + block_number, + stage_checkpoint: None, + partial_state_trie: None, + }), }; let mut stage = IndexStorageHistoryStage::default(); let provider = db.factory.database_provider_rw().unwrap(); diff --git a/crates/stages/stages/src/stages/merkle.rs b/crates/stages/stages/src/stages/merkle.rs index 3271eeaa219..cd2c130f780 100644 --- a/crates/stages/stages/src/stages/merkle.rs +++ b/crates/stages/stages/src/stages/merkle.rs @@ -502,7 +502,8 @@ mod tests { stage_checkpoint: Some(StageUnitCheckpoint::Entities(EntitiesCheckpoint { processed, total - })) + })), + .. }, done: true }) if block_number == previous_stage && processed == total && @@ -542,7 +543,8 @@ mod tests { stage_checkpoint: Some(StageUnitCheckpoint::Entities(EntitiesCheckpoint { processed, total - })) + })), + .. }, done: true }) if block_number == previous_stage && processed == total && @@ -584,7 +586,8 @@ mod tests { stage_checkpoint: Some(StageUnitCheckpoint::Entities(EntitiesCheckpoint { processed, total - })) + })), + .. }, done: true }) if block_number == previous_stage && processed == total && diff --git a/crates/stages/stages/src/stages/sender_recovery.rs b/crates/stages/stages/src/stages/sender_recovery.rs index 1d44de77271..7487099d6bb 100644 --- a/crates/stages/stages/src/stages/sender_recovery.rs +++ b/crates/stages/stages/src/stages/sender_recovery.rs @@ -527,7 +527,8 @@ mod tests { stage_checkpoint: Some(StageUnitCheckpoint::Entities(EntitiesCheckpoint { processed: 1, total: 1 - })) + })), + .. }, done: true }) if block_number == previous_stage ); diff --git a/crates/stages/stages/src/stages/tx_lookup.rs b/crates/stages/stages/src/stages/tx_lookup.rs index 6940403976d..b5d852b9b0b 100644 --- a/crates/stages/stages/src/stages/tx_lookup.rs +++ b/crates/stages/stages/src/stages/tx_lookup.rs @@ -341,7 +341,8 @@ mod tests { stage_checkpoint: Some(StageUnitCheckpoint::Entities(EntitiesCheckpoint { processed, total - })) + })), + .. }, done: true }) if block_number == previous_stage && processed == total && total == runner.db.count_entries::().unwrap() as u64 ); @@ -387,7 +388,8 @@ mod tests { stage_checkpoint: Some(StageUnitCheckpoint::Entities(EntitiesCheckpoint { processed, total - })) + })), + .. }, done: true }) if block_number == previous_stage && processed == total && total == runner.db.count_entries::().unwrap() as u64 ); diff --git a/crates/stages/types/src/checkpoints.rs b/crates/stages/types/src/checkpoints.rs index 6486dce31be..12a056b9c9e 100644 --- a/crates/stages/types/src/checkpoints.rs +++ b/crates/stages/types/src/checkpoints.rs @@ -369,7 +369,6 @@ impl From<&RangeInclusive> for CheckpointBlockRange { /// Saves the progress of a stage. #[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] #[cfg_attr(any(test, feature = "test-utils"), derive(arbitrary::Arbitrary))] -#[cfg_attr(any(test, feature = "reth-codec"), derive(reth_codecs::Compact))] #[cfg_attr(any(test, feature = "reth-codec"), reth_codecs::add_arbitrary_tests(compact))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct StageCheckpoint { @@ -377,6 +376,8 @@ pub struct StageCheckpoint { pub block_number: BlockNumber, /// Stage-specific checkpoint. None if stage uses only block-based checkpoints. pub stage_checkpoint: Option, + /// The highest block with a partially persisted state trie for the Finish stage. + pub partial_state_trie: Option, } impl StageCheckpoint { @@ -385,6 +386,12 @@ impl StageCheckpoint { Self { block_number, ..Default::default() } } + /// Used bytes by the compact-encoding bitflags. + #[cfg(any(test, feature = "reth-codec"))] + pub const fn bitflag_encoded_bytes() -> usize { + 1 + } + /// Sets the block number. pub const fn with_block_number(mut self, block_number: BlockNumber) -> Self { self.block_number = block_number; @@ -436,9 +443,67 @@ impl StageCheckpoint { } } +#[cfg(any(test, feature = "reth-codec"))] +impl reth_codecs::Compact for StageCheckpoint { + fn to_compact(&self, buf: &mut B) -> usize + where + B: bytes::BufMut + AsMut<[u8]>, + { + let mut len = StageCheckpointLegacy { + block_number: self.block_number, + stage_checkpoint: self.stage_checkpoint, + } + .to_compact(buf); + + match self.partial_state_trie { + Some(block_number) => { + buf.put_u8(1); + buf.put_u64(block_number); + len += 9; + } + None => { + buf.put_u8(0); + len += 1; + } + } + + len + } + + fn from_compact(buf: &[u8], len: usize) -> (Self, &[u8]) { + use bytes::Buf; + + let (legacy, mut rest) = StageCheckpointLegacy::from_compact(buf, len); + let partial_state_trie = if rest.is_empty() { + None + } else { + match rest.get_u8() { + 1 => Some(rest.get_u64()), + _ => None, + } + }; + + ( + Self { + block_number: legacy.block_number, + stage_checkpoint: legacy.stage_checkpoint, + partial_state_trie, + }, + rest, + ) + } +} + #[cfg(any(test, feature = "reth-codec"))] reth_codecs::impl_compression_for_compact!(StageCheckpoint); +#[cfg(any(test, feature = "reth-codec"))] +#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, reth_codecs::Compact)] +struct StageCheckpointLegacy { + block_number: BlockNumber, + stage_checkpoint: Option, +} + // TODO(alexey): add a merkle checkpoint. Currently it's hard because [`MerkleCheckpoint`] // is not a Copy type. /// Stage-specific checkpoint metrics. @@ -664,4 +729,24 @@ mod tests { let (decoded, _) = MerkleCheckpoint::from_compact(&buf, encoded); assert_eq!(decoded, checkpoint); } + + #[test] + fn stage_checkpoint_decodes_legacy_compact_without_partial_state_trie() { + let legacy = StageCheckpointLegacy { + block_number: 42, + stage_checkpoint: Some(StageUnitCheckpoint::Entities(EntitiesCheckpoint { + processed: 10, + total: 20, + })), + }; + + let mut buf = Vec::new(); + let encoded = legacy.to_compact(&mut buf); + let (decoded, rest) = StageCheckpoint::from_compact(&buf, encoded); + + assert!(rest.is_empty()); + assert_eq!(decoded.block_number, legacy.block_number); + assert_eq!(decoded.stage_checkpoint, legacy.stage_checkpoint); + assert_eq!(decoded.partial_state_trie, None); + } } From b97544a05e1b22de38193a8234c8d69b006a9ed6 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:10:51 +0000 Subject: [PATCH 03/83] feat(stages): move partial trie progress into finish checkpoint Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d968b-970d-735e-844c-b83ff245ce05 Co-authored-by: Amp --- .../stages/stages/src/stages/execution/mod.rs | 6 +- crates/stages/stages/src/stages/finish.rs | 14 ++- .../src/stages/index_account_history.rs | 7 +- .../src/stages/index_storage_history.rs | 7 +- crates/stages/types/src/checkpoints.rs | 110 +++++------------- crates/stages/types/src/lib.rs | 2 +- 6 files changed, 47 insertions(+), 99 deletions(-) diff --git a/crates/stages/stages/src/stages/execution/mod.rs b/crates/stages/stages/src/stages/execution/mod.rs index fe0930ffd4b..fc7ae189fe5 100644 --- a/crates/stages/stages/src/stages/execution/mod.rs +++ b/crates/stages/stages/src/stages/execution/mod.rs @@ -799,7 +799,6 @@ mod tests { let previous_checkpoint = StageCheckpoint { block_number: 0, stage_checkpoint: Some(StageUnitCheckpoint::Execution(previous_stage_checkpoint)), - partial_state_trie: None, }; let stage_checkpoint = execution_checkpoint( @@ -840,7 +839,6 @@ mod tests { let previous_checkpoint = StageCheckpoint { block_number: 1, stage_checkpoint: Some(StageUnitCheckpoint::Execution(previous_stage_checkpoint)), - partial_state_trie: None, }; let stage_checkpoint = @@ -882,7 +880,6 @@ mod tests { let previous_checkpoint = StageCheckpoint { block_number: 1, stage_checkpoint: Some(StageUnitCheckpoint::Execution(previous_stage_checkpoint)), - partial_state_trie: None, }; let stage_checkpoint = @@ -917,8 +914,7 @@ mod tests { .unwrap(); provider.commit().unwrap(); - let previous_checkpoint = - StageCheckpoint { block_number: 1, stage_checkpoint: None, partial_state_trie: None }; + let previous_checkpoint = StageCheckpoint { block_number: 1, stage_checkpoint: None }; let stage_checkpoint = execution_checkpoint(&factory.static_file_provider(), 1, 1, previous_checkpoint); diff --git a/crates/stages/stages/src/stages/finish.rs b/crates/stages/stages/src/stages/finish.rs index 8d676c35b99..18470650977 100644 --- a/crates/stages/stages/src/stages/finish.rs +++ b/crates/stages/stages/src/stages/finish.rs @@ -1,5 +1,6 @@ use reth_stages_api::{ - ExecInput, ExecOutput, Stage, StageCheckpoint, StageError, StageId, UnwindInput, UnwindOutput, + ExecInput, ExecOutput, FinishCheckpoint, Stage, StageCheckpoint, StageError, StageId, + UnwindInput, UnwindOutput, }; /// The finish stage. @@ -20,7 +21,11 @@ impl Stage for FinishStage { _provider: &Provider, input: ExecInput, ) -> Result { - Ok(ExecOutput { checkpoint: StageCheckpoint::new(input.target()), done: true }) + Ok(ExecOutput { + checkpoint: StageCheckpoint::new(input.target()) + .with_finish_stage_checkpoint(FinishCheckpoint { partial_state_trie: None }), + done: true, + }) } fn unwind( @@ -28,7 +33,10 @@ impl Stage for FinishStage { _provider: &Provider, input: UnwindInput, ) -> Result { - Ok(UnwindOutput { checkpoint: StageCheckpoint::new(input.unwind_to) }) + Ok(UnwindOutput { + checkpoint: StageCheckpoint::new(input.unwind_to) + .with_finish_stage_checkpoint(FinishCheckpoint { partial_state_trie: None }), + }) } } diff --git a/crates/stages/stages/src/stages/index_account_history.rs b/crates/stages/stages/src/stages/index_account_history.rs index 3f608ee8e54..617be0a5ee7 100644 --- a/crates/stages/stages/src/stages/index_account_history.rs +++ b/crates/stages/stages/src/stages/index_account_history.rs @@ -240,11 +240,8 @@ mod tests { fn run(db: &TestStageDB, run_to: u64, input_checkpoint: Option) { let input = ExecInput { target: Some(run_to), - checkpoint: input_checkpoint.map(|block_number| StageCheckpoint { - block_number, - stage_checkpoint: None, - partial_state_trie: None, - }), + checkpoint: input_checkpoint + .map(|block_number| StageCheckpoint { block_number, stage_checkpoint: None }), }; let mut stage = IndexAccountHistoryStage::default(); let provider = db.factory.database_provider_rw().unwrap(); diff --git a/crates/stages/stages/src/stages/index_storage_history.rs b/crates/stages/stages/src/stages/index_storage_history.rs index be2299c187d..182bc1c96f5 100644 --- a/crates/stages/stages/src/stages/index_storage_history.rs +++ b/crates/stages/stages/src/stages/index_storage_history.rs @@ -258,11 +258,8 @@ mod tests { fn run(db: &TestStageDB, run_to: u64, input_checkpoint: Option) { let input = ExecInput { target: Some(run_to), - checkpoint: input_checkpoint.map(|block_number| StageCheckpoint { - block_number, - stage_checkpoint: None, - partial_state_trie: None, - }), + checkpoint: input_checkpoint + .map(|block_number| StageCheckpoint { block_number, stage_checkpoint: None }), }; let mut stage = IndexStorageHistoryStage::default(); let provider = db.factory.database_provider_rw().unwrap(); diff --git a/crates/stages/types/src/checkpoints.rs b/crates/stages/types/src/checkpoints.rs index 12a056b9c9e..d6e2aa9054e 100644 --- a/crates/stages/types/src/checkpoints.rs +++ b/crates/stages/types/src/checkpoints.rs @@ -369,6 +369,7 @@ impl From<&RangeInclusive> for CheckpointBlockRange { /// Saves the progress of a stage. #[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] #[cfg_attr(any(test, feature = "test-utils"), derive(arbitrary::Arbitrary))] +#[cfg_attr(any(test, feature = "reth-codec"), derive(reth_codecs::Compact))] #[cfg_attr(any(test, feature = "reth-codec"), reth_codecs::add_arbitrary_tests(compact))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct StageCheckpoint { @@ -376,22 +377,17 @@ pub struct StageCheckpoint { pub block_number: BlockNumber, /// Stage-specific checkpoint. None if stage uses only block-based checkpoints. pub stage_checkpoint: Option, - /// The highest block with a partially persisted state trie for the Finish stage. - pub partial_state_trie: Option, } +#[cfg(any(test, feature = "reth-codec"))] +reth_codecs::impl_compression_for_compact!(StageCheckpoint); + impl StageCheckpoint { /// Creates a new [`StageCheckpoint`] with only `block_number` set. pub fn new(block_number: BlockNumber) -> Self { Self { block_number, ..Default::default() } } - /// Used bytes by the compact-encoding bitflags. - #[cfg(any(test, feature = "reth-codec"))] - pub const fn bitflag_encoded_bytes() -> usize { - 1 - } - /// Sets the block number. pub const fn with_block_number(mut self, block_number: BlockNumber) -> Self { self.block_number = block_number; @@ -438,70 +434,20 @@ impl StageCheckpoint { progress: entities, .. }) => Some(entities), - StageUnitCheckpoint::MerkleChangeSets(_) => None, + StageUnitCheckpoint::MerkleChangeSets(_) | StageUnitCheckpoint::Finish(_) => None, } } } -#[cfg(any(test, feature = "reth-codec"))] -impl reth_codecs::Compact for StageCheckpoint { - fn to_compact(&self, buf: &mut B) -> usize - where - B: bytes::BufMut + AsMut<[u8]>, - { - let mut len = StageCheckpointLegacy { - block_number: self.block_number, - stage_checkpoint: self.stage_checkpoint, - } - .to_compact(buf); - - match self.partial_state_trie { - Some(block_number) => { - buf.put_u8(1); - buf.put_u64(block_number); - len += 9; - } - None => { - buf.put_u8(0); - len += 1; - } - } - - len - } - - fn from_compact(buf: &[u8], len: usize) -> (Self, &[u8]) { - use bytes::Buf; - - let (legacy, mut rest) = StageCheckpointLegacy::from_compact(buf, len); - let partial_state_trie = if rest.is_empty() { - None - } else { - match rest.get_u8() { - 1 => Some(rest.get_u64()), - _ => None, - } - }; - - ( - Self { - block_number: legacy.block_number, - stage_checkpoint: legacy.stage_checkpoint, - partial_state_trie, - }, - rest, - ) - } -} - -#[cfg(any(test, feature = "reth-codec"))] -reth_codecs::impl_compression_for_compact!(StageCheckpoint); - -#[cfg(any(test, feature = "reth-codec"))] -#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, reth_codecs::Compact)] -struct StageCheckpointLegacy { - block_number: BlockNumber, - stage_checkpoint: Option, +/// Saves the progress of the Finish stage. +#[derive(Default, Debug, Copy, Clone, PartialEq, Eq)] +#[cfg_attr(any(test, feature = "test-utils"), derive(arbitrary::Arbitrary))] +#[cfg_attr(any(test, feature = "reth-codec"), derive(reth_codecs::Compact))] +#[cfg_attr(any(test, feature = "reth-codec"), reth_codecs::add_arbitrary_tests(compact))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct FinishCheckpoint { + /// The highest block with a partially persisted state trie. + pub partial_state_trie: Option, } // TODO(alexey): add a merkle checkpoint. Currently it's hard because [`MerkleCheckpoint`] @@ -530,6 +476,8 @@ pub enum StageUnitCheckpoint { /// Note: This variant is only kept for backward compatibility with the Compact codec. /// The `MerkleChangeSets` stage has been removed. MerkleChangeSets(MerkleChangeSetsCheckpoint), + /// Saves the progress of the Finish stage. + Finish(FinishCheckpoint), } impl StageUnitCheckpoint { @@ -638,6 +586,15 @@ stage_unit_checkpoints!( index_history_stage_checkpoint, /// Sets the stage checkpoint to index history. with_index_history_stage_checkpoint + ), + ( + 6, + Finish, + FinishCheckpoint, + /// Returns the finish stage checkpoint, if any. + finish_stage_checkpoint, + /// Sets the stage checkpoint to finish. + with_finish_stage_checkpoint ) ); @@ -731,22 +688,15 @@ mod tests { } #[test] - fn stage_checkpoint_decodes_legacy_compact_without_partial_state_trie() { - let legacy = StageCheckpointLegacy { - block_number: 42, - stage_checkpoint: Some(StageUnitCheckpoint::Entities(EntitiesCheckpoint { - processed: 10, - total: 20, - })), - }; + fn finish_checkpoint_roundtrip() { + let checkpoint = StageCheckpoint::new(42) + .with_finish_stage_checkpoint(FinishCheckpoint { partial_state_trie: Some(21) }); let mut buf = Vec::new(); - let encoded = legacy.to_compact(&mut buf); + let encoded = checkpoint.to_compact(&mut buf); let (decoded, rest) = StageCheckpoint::from_compact(&buf, encoded); assert!(rest.is_empty()); - assert_eq!(decoded.block_number, legacy.block_number); - assert_eq!(decoded.stage_checkpoint, legacy.stage_checkpoint); - assert_eq!(decoded.partial_state_trie, None); + assert_eq!(decoded, checkpoint); } } diff --git a/crates/stages/types/src/lib.rs b/crates/stages/types/src/lib.rs index 4e30ce27cd7..70c5de17fe5 100644 --- a/crates/stages/types/src/lib.rs +++ b/crates/stages/types/src/lib.rs @@ -18,7 +18,7 @@ pub use id::StageId; mod checkpoints; pub use checkpoints::{ AccountHashingCheckpoint, CheckpointBlockRange, EntitiesCheckpoint, ExecutionCheckpoint, - HeadersCheckpoint, IndexHistoryCheckpoint, MerkleCheckpoint, StageCheckpoint, + FinishCheckpoint, HeadersCheckpoint, IndexHistoryCheckpoint, MerkleCheckpoint, StageCheckpoint, StageUnitCheckpoint, StorageHashingCheckpoint, StorageRootMerkleCheckpoint, }; From 761acad8032aca047ecc16e29fab759cfc579714 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:33:52 +0000 Subject: [PATCH 04/83] fix(node): unwind startup to partial trie checkpoint Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d968b-970d-735e-844c-b83ff245ce05 Co-authored-by: Amp --- crates/node/builder/src/launch/common.rs | 57 +++++++++++++++++++----- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/crates/node/builder/src/launch/common.rs b/crates/node/builder/src/launch/common.rs index 140f970c28b..46f2a129605 100644 --- a/crates/node/builder/src/launch/common.rs +++ b/crates/node/builder/src/launch/common.rs @@ -66,8 +66,8 @@ use reth_node_metrics::{ }; use reth_provider::{ providers::{NodeTypesForProvider, ProviderNodeTypes, RocksDBProvider, StaticFileProvider}, - BlockHashReader, BlockNumReader, ProviderError, ProviderFactory, ProviderResult, - RocksDBProviderFactory, StageCheckpointReader, StaticFileProviderBuilder, + BlockHashReader, BlockNumReader, DatabaseProviderFactory, ProviderError, ProviderFactory, + ProviderResult, RocksDBProviderFactory, StageCheckpointReader, StaticFileProviderBuilder, StaticFileProviderFactory, }; use reth_prune::{PruneModes, PrunerBuilder}; @@ -75,7 +75,7 @@ use reth_rpc_builder::config::RethRpcServerConfig; use reth_rpc_layer::JwtSecret; use reth_stages::{ sets::DefaultStages, stages::EraImportSource, MetricEvent, PipelineBuilder, PipelineTarget, - StageId, + StageCheckpoint, StageId, }; use reth_static_file::StaticFileProducer; use reth_tasks::TaskExecutor; @@ -517,19 +517,26 @@ where // the unwind targets for each storage layer if inconsistencies are // found. let (rocksdb_unwind, static_file_unwind) = factory.check_consistency()?; + let partial_trie_unwind = partial_trie_unwind_target( + factory.database_provider_ro()?.get_stage_checkpoint(StageId::Finish)?, + ); // Take the minimum block number to ensure all storage layers are consistent. - let unwind_target = [rocksdb_unwind, static_file_unwind].into_iter().flatten().min(); + let unwind_target = + [rocksdb_unwind, static_file_unwind, partial_trie_unwind].into_iter().flatten().min(); if let Some(unwind_block) = unwind_target { // Highly unlikely to happen, and given its destructive nature, it's better to panic // instead. Unwinding to 0 would leave MDBX with a huge free list size. - let inconsistency_source = match (rocksdb_unwind, static_file_unwind) { - (Some(_), Some(_)) => "RocksDB and static file", - (Some(_), None) => "RocksDB", - (None, Some(_)) => "static file", - (None, None) => unreachable!(), - }; + let inconsistency_source = [ + rocksdb_unwind.map(|_| "RocksDB"), + static_file_unwind.map(|_| "static file"), + partial_trie_unwind.map(|_| "partial state trie"), + ] + .into_iter() + .flatten() + .collect::>() + .join(" and "); assert_ne!( unwind_block, 0, "A {} inconsistency was found that would trigger an unwind to block 0", @@ -1268,11 +1275,19 @@ pub fn metrics_hooks(provider_factory: &ProviderFactory) .build() } +fn partial_trie_unwind_target(finish_checkpoint: Option) -> Option { + let finish_checkpoint = finish_checkpoint?; + let partial_state_trie = finish_checkpoint.finish_stage_checkpoint()?.partial_state_trie?; + + (partial_state_trie != finish_checkpoint.block_number).then_some(partial_state_trie) +} + #[cfg(test)] mod tests { - use super::{LaunchContext, NodeConfig}; + use super::{partial_trie_unwind_target, LaunchContext, NodeConfig}; use reth_config::Config; use reth_node_core::args::PruningArgs; + use reth_stages::{FinishCheckpoint, StageCheckpoint}; const EXTENSION: &str = "toml"; @@ -1324,4 +1339,24 @@ mod tests { assert_eq!(reth_config, loaded_config); }) } + + #[test] + fn partial_trie_unwind_target_uses_partial_finish_checkpoint() { + let finish_checkpoint = StageCheckpoint::new(42) + .with_finish_stage_checkpoint(FinishCheckpoint { partial_state_trie: Some(21) }); + + assert_eq!(partial_trie_unwind_target(Some(finish_checkpoint)), Some(21)); + } + + #[test] + fn partial_trie_unwind_target_ignores_matching_or_missing_partial_checkpoint() { + let matching_finish_checkpoint = StageCheckpoint::new(42) + .with_finish_stage_checkpoint(FinishCheckpoint { partial_state_trie: Some(42) }); + let missing_partial_finish_checkpoint = StageCheckpoint::new(42) + .with_finish_stage_checkpoint(FinishCheckpoint { partial_state_trie: None }); + + assert_eq!(partial_trie_unwind_target(Some(matching_finish_checkpoint)), None); + assert_eq!(partial_trie_unwind_target(Some(missing_partial_finish_checkpoint)), None); + assert_eq!(partial_trie_unwind_target(None), None); + } } From e2bd5180971409358d625abb43c4b00ddceddb02 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:25:21 +0000 Subject: [PATCH 05/83] refactor(provider): simplify save_blocks trie masking API Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d9728-846b-7418-8a69-ddbba65ce656 Co-authored-by: Amp --- crates/engine/tree/src/persistence.rs | 8 +- .../src/providers/blockchain_provider.rs | 8 +- .../src/providers/database/provider.rs | 334 ++++++++++++++++-- 3 files changed, 326 insertions(+), 24 deletions(-) diff --git a/crates/engine/tree/src/persistence.rs b/crates/engine/tree/src/persistence.rs index da778e1a30f..33f4449454b 100644 --- a/crates/engine/tree/src/persistence.rs +++ b/crates/engine/tree/src/persistence.rs @@ -162,7 +162,7 @@ where if let Some(last) = last_block { let provider_rw = self.provider.database_provider_rw()?; - provider_rw.save_blocks(blocks, SaveBlocksMode::Full)?; + provider_rw.save_blocks(&blocks, 0..0, SaveBlocksMode::Full)?; if let Some(finalized) = pending_finalized { provider_rw.save_finalized_block_number(finalized.min(last.number))?; @@ -555,7 +555,7 @@ mod tests { { let provider_rw = provider_factory.database_provider_rw().unwrap(); - provider_rw.save_blocks(blocks_a, SaveBlocksMode::Full).unwrap(); + provider_rw.save_blocks(&blocks_a, 0..0, SaveBlocksMode::Full).unwrap(); provider_rw.commit().unwrap(); } @@ -612,7 +612,9 @@ mod tests { provider_rw.commit().unwrap(); let provider_rw = pf.database_provider_rw().unwrap(); - provider_rw.save_blocks(vec![block_b2], SaveBlocksMode::Full).unwrap(); + provider_rw + .save_blocks(std::slice::from_ref(&block_b2), 0..0, SaveBlocksMode::Full) + .unwrap(); provider_rw.commit().unwrap(); }); diff --git a/crates/storage/provider/src/providers/blockchain_provider.rs b/crates/storage/provider/src/providers/blockchain_provider.rs index 2fa0ff4653e..b1b21a9ec6c 100644 --- a/crates/storage/provider/src/providers/blockchain_provider.rs +++ b/crates/storage/provider/src/providers/blockchain_provider.rs @@ -1007,7 +1007,13 @@ mod tests { // Push to disk let provider_rw = hook_provider.database_provider_rw().unwrap(); - provider_rw.save_blocks(vec![lowest_memory_block], SaveBlocksMode::Full).unwrap(); + provider_rw + .save_blocks( + std::slice::from_ref(&lowest_memory_block), + 0..0, + SaveBlocksMode::Full, + ) + .unwrap(); provider_rw.commit().unwrap(); // Remove from memory diff --git a/crates/storage/provider/src/providers/database/provider.rs b/crates/storage/provider/src/providers/database/provider.rs index 113351531dc..10666122b65 100644 --- a/crates/storage/provider/src/providers/database/provider.rs +++ b/crates/storage/provider/src/providers/database/provider.rs @@ -559,12 +559,28 @@ impl DatabaseProvider>, + blocks: &[ExecutedBlock], + trie_masking_range: std::ops::Range, save_mode: SaveBlocksMode, ) -> ProviderResult<()> { if blocks.is_empty() { @@ -572,6 +588,8 @@ impl DatabaseProvider DatabaseProvider = { let mut nums = Vec::with_capacity(blocks.len()); let mut current = first_tx_num; - for block in &blocks { + for block in blocks { nums.push(current); current += block.recovered_block().body().transaction_count() as u64; } @@ -622,7 +640,7 @@ impl DatabaseProvider DatabaseProvider DatabaseProvider = blocks + .iter() + .enumerate() + .filter_map(|(index, block)| { + (!trie_masking_range.contains(&index)).then(|| block.trie_data()) + }) + .collect(); + let trie_masking_data: Vec<_> = + trie_masking_blocks.iter().map(|block| block.trie_data()).collect(); + let start = Instant::now(); - let merged_hashed_state = HashedPostStateSorted::merge_batch( - blocks.iter().rev().map(|b| b.trie_data().hashed_state), - ); - if !merged_hashed_state.is_empty() { - self.write_hashed_state(&merged_hashed_state)?; + if !trie_persist_data.is_empty() { + let merged_hashed_state = HashedPostStateSorted::disjoint_by_keys( + trie_persist_data.iter().map(|data| data.hashed_state.as_ref()).collect(), + trie_masking_data.iter().map(|data| data.hashed_state.as_ref()).collect(), + ); + if !merged_hashed_state.is_empty() { + self.write_hashed_state(&merged_hashed_state)?; + } } timings.write_hashed_state += start.elapsed(); let start = Instant::now(); - let merged_trie = - TrieUpdatesSorted::merge_batch(blocks.iter().rev().map(|b| b.trie_updates())); - if !merged_trie.is_empty() { - self.write_trie_updates_sorted(&merged_trie)?; + if !trie_persist_data.is_empty() { + let merged_trie = TrieUpdatesSorted::disjoint_by_keys( + trie_persist_data.iter().map(|data| data.trie_updates.as_ref()).collect(), + trie_masking_data.iter().map(|data| data.trie_updates.as_ref()).collect(), + ); + if !merged_trie.is_empty() { + self.write_trie_updates_sorted(&merged_trie)?; + } } timings.write_trie_updates += start.elapsed(); } @@ -3490,7 +3524,7 @@ impl BlockWriter ); // Delegate to save_blocks with BlocksOnly mode (skips receipts/state/trie) - self.save_blocks(vec![executed_block], SaveBlocksMode::BlocksOnly)?; + self.save_blocks(std::slice::from_ref(&executed_block), 0..0, SaveBlocksMode::BlocksOnly)?; // Return the body indices self.block_body_indices(block_number)? @@ -3912,7 +3946,7 @@ mod tests { map::{AddressMap, B256Map}, U256, }; - use reth_chain_state::ExecutedBlock; + use reth_chain_state::{test_utils::TestBlockBuilder, ComputedTrieData, ExecutedBlock}; use reth_db_api::models::StorageSettings; use reth_ethereum_primitives::Receipt; use reth_execution_types::{AccountRevertInit, BlockExecutionOutput, BlockExecutionResult}; @@ -3924,7 +3958,10 @@ mod tests { }; use revm_database::BundleState; use revm_state::AccountInfo; - use std::{sync::mpsc, time::Duration}; + use std::{ + sync::{mpsc, Arc}, + time::Duration, + }; #[test] fn test_receipts_by_block_range_empty_range() { @@ -4414,6 +4451,261 @@ mod tests { provider_rw.commit().unwrap(); } + #[test] + fn test_save_blocks_disjoints_in_memory_trie_data() { + use reth_trie::{ + updates::{StorageTrieUpdatesSorted, TrieUpdatesSorted}, + BranchNodeCompact, HashedPostStateSorted, HashedStorageSorted, + }; + + fn empty_execution_output() -> BlockExecutionOutput { + BlockExecutionOutput { + result: BlockExecutionResult { + receipts: vec![], + requests: Default::default(), + gas_used: 0, + blob_gas_used: 0, + }, + state: Default::default(), + } + } + + fn branch(mask: u16) -> BranchNodeCompact { + BranchNodeCompact::new(mask, 0, 0, vec![], None) + } + + let factory = create_test_provider_factory(); + factory.set_storage_settings_cache(StorageSettings::v1()); + + let genesis = SealedBlock::::from_sealed_parts( + SealedHeader::new( + Header { number: 0, difficulty: U256::from(1), ..Default::default() }, + B256::ZERO, + ), + Default::default(), + ); + let genesis_executed = ExecutedBlock::new( + Arc::new(genesis.try_recover().unwrap()), + Arc::new(empty_execution_output()), + ComputedTrieData::default(), + ); + + let provider_rw = factory.provider_rw().unwrap(); + provider_rw + .save_blocks(std::slice::from_ref(&genesis_executed), 0..0, SaveBlocksMode::Full) + .unwrap(); + provider_rw.commit().unwrap(); + + let kept_account = B256::with_last_byte(0x11); + let overlapping_account = B256::with_last_byte(0x12); + let kept_storage = B256::with_last_byte(0x21); + let overlapping_storage = B256::with_last_byte(0x22); + let kept_slot = B256::with_last_byte(0x31); + let overlapping_slot = B256::with_last_byte(0x32); + let in_memory_hashed_account = B256::with_last_byte(0x43); + let in_memory_hashed_storage = B256::with_last_byte(0x44); + let in_memory_hashed_slot = B256::with_last_byte(0x45); + let kept_account_node = Nibbles::from_nibbles([0x1, 0x2]); + let overlapping_account_node = Nibbles::from_nibbles([0x1, 0x3]); + let kept_storage_node = Nibbles::from_nibbles([0x2, 0x1]); + let overlapping_storage_node = Nibbles::from_nibbles([0x2, 0x2]); + let in_memory_account_node = Nibbles::from_nibbles([0x2, 0x3]); + let in_memory_storage_node = Nibbles::from_nibbles([0x2, 0x4]); + let plain_storage_address = Address::new([0xAA; 20]); + let plain_storage_slot = U256::from_limbs([1, 0, 0, 0]); + let blocks: Vec<_> = + TestBlockBuilder::eth().with_state().get_executed_blocks(1..3).collect(); + let partial_persist_base = &blocks[0]; + let in_memory_only_base = &blocks[1]; + + let partial_persist_hashed_state = HashedPostStateSorted::new( + vec![ + (kept_account, Some(Account::default())), + (overlapping_account, Some(Account { nonce: 1, ..Default::default() })), + ], + B256Map::from_iter([ + ( + kept_storage, + HashedStorageSorted { + wiped: false, + storage_slots: vec![(kept_slot, U256::from(1))], + }, + ), + ( + overlapping_storage, + HashedStorageSorted { + wiped: false, + storage_slots: vec![(overlapping_slot, U256::from(2))], + }, + ), + ]), + ); + let partial_persist_trie_updates = TrieUpdatesSorted::new( + vec![ + (kept_account_node.clone(), Some(branch(0b0000_1111_0000_1111))), + (overlapping_account_node.clone(), Some(branch(0b1111_0000_1111_0000))), + ], + B256Map::from_iter([ + ( + kept_storage, + StorageTrieUpdatesSorted { + is_deleted: false, + storage_nodes: vec![(kept_storage_node.clone(), Some(branch(0b1010)))], + }, + ), + ( + overlapping_storage, + StorageTrieUpdatesSorted { + is_deleted: false, + storage_nodes: vec![( + overlapping_storage_node.clone(), + Some(branch(0b0101)), + )], + }, + ), + ]), + ); + + let partial_persist_block = ExecutedBlock::new( + Arc::clone(&partial_persist_base.recovered_block), + Arc::clone(&partial_persist_base.execution_output), + ComputedTrieData { + hashed_state: Arc::new(partial_persist_hashed_state), + trie_updates: Arc::new(partial_persist_trie_updates), + ..Default::default() + }, + ); + + let in_memory_only_hashed_state = HashedPostStateSorted::new( + vec![ + (overlapping_account, Some(Account { nonce: 2, ..Default::default() })), + (in_memory_hashed_account, Some(Account { nonce: 3, ..Default::default() })), + ], + B256Map::from_iter([ + ( + overlapping_storage, + HashedStorageSorted { + wiped: false, + storage_slots: vec![(overlapping_slot, U256::from(3))], + }, + ), + ( + in_memory_hashed_storage, + HashedStorageSorted { + wiped: false, + storage_slots: vec![(in_memory_hashed_slot, U256::from(4))], + }, + ), + ]), + ); + let in_memory_only_trie_updates = TrieUpdatesSorted::new( + vec![ + (overlapping_account_node.clone(), Some(branch(0b0011_0011))), + (in_memory_account_node.clone(), Some(branch(0b0101_0101))), + ], + B256Map::from_iter([ + ( + overlapping_storage, + StorageTrieUpdatesSorted { + is_deleted: false, + storage_nodes: vec![( + overlapping_storage_node.clone(), + Some(branch(0b1100)), + )], + }, + ), + ( + in_memory_hashed_storage, + StorageTrieUpdatesSorted { + is_deleted: false, + storage_nodes: vec![(in_memory_storage_node.clone(), Some(branch(0b1111)))], + }, + ), + ]), + ); + let in_memory_only_block = ExecutedBlock::new( + Arc::clone(&in_memory_only_base.recovered_block), + Arc::clone(&in_memory_only_base.execution_output), + ComputedTrieData { + hashed_state: Arc::new(in_memory_only_hashed_state), + trie_updates: Arc::new(in_memory_only_trie_updates), + ..Default::default() + }, + ); + + let provider_rw = factory.provider_rw().unwrap(); + let blocks = vec![partial_persist_block, in_memory_only_block]; + provider_rw.save_blocks(&blocks, 1..2, SaveBlocksMode::Full).unwrap(); + provider_rw.commit().unwrap(); + + let provider = factory.provider().unwrap(); + let tx = provider.tx_ref(); + + let mut plain_storages = tx.cursor_dup_read::().unwrap(); + assert_eq!( + plain_storages + .seek_by_key_subkey( + plain_storage_address, + B256::from(plain_storage_slot.to_be_bytes()), + ) + .unwrap(), + Some(StorageEntry { + key: B256::from(plain_storage_slot.to_be_bytes()), + value: U256::from(3), + }) + ); + assert!(provider.block_hash(2).unwrap().is_some()); + + let mut hashed_accounts = tx.cursor_read::().unwrap(); + assert!(hashed_accounts.seek_exact(kept_account).unwrap().is_some()); + assert!(hashed_accounts.seek_exact(overlapping_account).unwrap().is_none()); + assert!(hashed_accounts.seek_exact(in_memory_hashed_account).unwrap().is_none()); + + let mut hashed_storages = tx.cursor_dup_read::().unwrap(); + assert!(hashed_storages.seek_by_key_subkey(kept_storage, kept_slot).unwrap().is_some()); + assert!(hashed_storages + .walk_dup(Some(overlapping_storage), None) + .unwrap() + .next() + .transpose() + .unwrap() + .is_none()); + + let mut account_trie = tx.cursor_read::().unwrap(); + assert!(account_trie + .seek_exact(StoredNibbles(kept_account_node.clone())) + .unwrap() + .is_some()); + assert!(account_trie + .seek_exact(StoredNibbles(overlapping_account_node)) + .unwrap() + .is_none()); + assert!(account_trie.seek_exact(StoredNibbles(in_memory_account_node)).unwrap().is_none()); + + let mut storage_trie = tx.cursor_dup_read::().unwrap(); + let kept_entries: Vec<_> = storage_trie + .walk_dup(Some(kept_storage), None) + .unwrap() + .collect::, _>>() + .unwrap(); + assert_eq!(kept_entries.len(), 1); + assert_eq!(kept_entries[0].1.nibbles.0, kept_storage_node); + + let overlapping_entries: Vec<_> = storage_trie + .walk_dup(Some(overlapping_storage), None) + .unwrap() + .collect::, _>>() + .unwrap(); + assert!(overlapping_entries.is_empty()); + + let in_memory_entries: Vec<_> = storage_trie + .walk_dup(Some(in_memory_hashed_storage), None) + .unwrap() + .collect::, _>>() + .unwrap(); + assert!(in_memory_entries.is_empty()); + } + #[test] fn test_prunable_receipts_logic() { let insert_blocks = @@ -4997,7 +5289,9 @@ mod tests { ComputedTrieData::default(), ); let provider_rw = factory.provider_rw().unwrap(); - provider_rw.save_blocks(vec![genesis_executed], SaveBlocksMode::Full).unwrap(); + provider_rw + .save_blocks(std::slice::from_ref(&genesis_executed), 0..0, SaveBlocksMode::Full) + .unwrap(); provider_rw.commit().unwrap(); let mut blocks: Vec = Vec::new(); @@ -5069,7 +5363,7 @@ mod tests { } let provider_rw = factory.provider_rw().unwrap(); - provider_rw.save_blocks(blocks, SaveBlocksMode::Full).unwrap(); + provider_rw.save_blocks(&blocks, 0..0, SaveBlocksMode::Full).unwrap(); provider_rw.commit().unwrap(); let provider = factory.provider().unwrap(); From 84b520560b7b319f94a066b9e2fac47eaee83ba3 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:26:24 +0000 Subject: [PATCH 06/83] docs(provider): explain save_blocks trie masking range Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d9728-846b-7418-8a69-ddbba65ce656 Co-authored-by: Amp --- .../provider/src/providers/database/provider.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/storage/provider/src/providers/database/provider.rs b/crates/storage/provider/src/providers/database/provider.rs index 10666122b65..cc551298175 100644 --- a/crates/storage/provider/src/providers/database/provider.rs +++ b/crates/storage/provider/src/providers/database/provider.rs @@ -559,11 +559,12 @@ impl DatabaseProvider Date: Fri, 17 Apr 2026 08:32:16 +0000 Subject: [PATCH 07/83] fix(engine): raise persistence defaults Raise the default persistence threshold to 10 and derive the default backpressure threshold from the persistence and in-memory thresholds. Enable alloy getrandom for reth-engine-primitives tests so the existing B256::random() test coverage keeps compiling. Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d9a2b-716e-774d-8db7-b0308fa96a23 Co-authored-by: Amp --- bin/reth-bench/README.md | 2 +- bin/reth-bench/src/bench/new_payload_fcu.rs | 4 +- crates/engine/primitives/Cargo.toml | 3 + crates/engine/primitives/src/config.rs | 48 ++++++- crates/node/core/src/args/engine.rs | 142 +++++++++++--------- docs/vocs/docs/pages/cli/reth/node.mdx | 4 +- 6 files changed, 130 insertions(+), 73 deletions(-) diff --git a/bin/reth-bench/README.md b/bin/reth-bench/README.md index aa63c6ec336..5e2584bec0c 100644 --- a/bin/reth-bench/README.md +++ b/bin/reth-bench/README.md @@ -39,7 +39,7 @@ Both `new-payload-fcu` and `new-payload-only` support `--rpc-block-fetch-retries to control how many times block fetches are retried after an RPC failure. The default is `10`. Use `--rpc-block-fetch-retries forever` to keep retrying indefinitely. -When using `--wait-for-persistence`, the benchmark waits after every `(threshold + 1)` blocks, where the threshold defaults to the engine's persistence threshold (2). This can be customized with `--persistence-threshold `. +When using `--wait-for-persistence`, the benchmark waits after every `(threshold + 1)` blocks, where the threshold defaults to the engine's persistence threshold. This can be customized with `--persistence-threshold `. By default, the WebSocket URL for persistence subscriptions is derived from `--engine-rpc-url` (converting to ws:// on port 8546). Use `--ws-rpc-url` to override this. diff --git a/bin/reth-bench/src/bench/new_payload_fcu.rs b/bin/reth-bench/src/bench/new_payload_fcu.rs index c0e1e4e6bed..98a8a5a6c49 100644 --- a/bin/reth-bench/src/bench/new_payload_fcu.rs +++ b/bin/reth-bench/src/bench/new_payload_fcu.rs @@ -42,8 +42,8 @@ pub struct Command { /// Engine persistence threshold used for deciding when to wait for persistence. /// /// The benchmark waits after every `(threshold + 1)` blocks. By default this - /// matches the engine's `DEFAULT_PERSISTENCE_THRESHOLD` (2), so waits occur - /// at blocks 3, 6, 9, etc. + /// matches the engine's `DEFAULT_PERSISTENCE_THRESHOLD`, so waits occur at + /// blocks 11, 22, 33, etc. #[arg( long = "persistence-threshold", value_name = "PERSISTENCE_THRESHOLD", diff --git a/crates/engine/primitives/Cargo.toml b/crates/engine/primitives/Cargo.toml index aca33f4aff6..128f777cb38 100644 --- a/crates/engine/primitives/Cargo.toml +++ b/crates/engine/primitives/Cargo.toml @@ -37,6 +37,9 @@ auto_impl.workspace = true serde.workspace = true thiserror.workspace = true +[dev-dependencies] +alloy-primitives = { workspace = true, features = ["getrandom"] } + [features] default = ["std"] trie-debug = [] diff --git a/crates/engine/primitives/src/config.rs b/crates/engine/primitives/src/config.rs index 892372bf2ef..30611e2fe5b 100644 --- a/crates/engine/primitives/src/config.rs +++ b/crates/engine/primitives/src/config.rs @@ -4,14 +4,26 @@ use alloy_eips::merge::EPOCH_SLOTS; use core::time::Duration; /// Triggers persistence when the number of canonical blocks in memory exceeds this threshold. -pub const DEFAULT_PERSISTENCE_THRESHOLD: u64 = 2; - -/// Maximum canonical-minus-persisted gap before engine API processing is stalled. -pub const DEFAULT_PERSISTENCE_BACKPRESSURE_THRESHOLD: u64 = 16; +pub const DEFAULT_PERSISTENCE_THRESHOLD: u64 = 10; /// How close to the canonical head we persist blocks. pub const DEFAULT_MEMORY_BLOCK_BUFFER_TARGET: u64 = 0; +/// Derives the default canonical-minus-persisted gap that triggers backpressure. +pub const fn default_persistence_backpressure_threshold( + persistence_threshold: u64, + memory_block_buffer_target: u64, +) -> u64 { + 2 * (persistence_threshold + memory_block_buffer_target) +} + +/// Maximum canonical-minus-persisted gap before engine API processing is stalled. +pub const DEFAULT_PERSISTENCE_BACKPRESSURE_THRESHOLD: u64 = + default_persistence_backpressure_threshold( + DEFAULT_PERSISTENCE_THRESHOLD, + DEFAULT_MEMORY_BLOCK_BUFFER_TARGET, + ); + /// The size of proof targets chunk to spawn in one multiproof calculation. pub const DEFAULT_MULTIPROOF_TASK_CHUNK_SIZE: usize = 5; @@ -179,14 +191,18 @@ pub struct TreeConfig { impl Default for TreeConfig { fn default() -> Self { + let persistence_backpressure_threshold = default_persistence_backpressure_threshold( + DEFAULT_PERSISTENCE_THRESHOLD, + DEFAULT_MEMORY_BLOCK_BUFFER_TARGET, + ); assert_backpressure_threshold_invariant( DEFAULT_PERSISTENCE_THRESHOLD, - DEFAULT_PERSISTENCE_BACKPRESSURE_THRESHOLD, + persistence_backpressure_threshold, ); Self { persistence_threshold: DEFAULT_PERSISTENCE_THRESHOLD, memory_block_buffer_target: DEFAULT_MEMORY_BLOCK_BUFFER_TARGET, - persistence_backpressure_threshold: DEFAULT_PERSISTENCE_BACKPRESSURE_THRESHOLD, + persistence_backpressure_threshold, block_buffer_limit: DEFAULT_BLOCK_BUFFER_LIMIT, max_invalid_header_cache_length: DEFAULT_MAX_INVALID_HEADER_CACHE_LENGTH, max_execute_block_batch_size: DEFAULT_MAX_EXECUTE_BLOCK_BATCH_SIZE, @@ -662,7 +678,25 @@ impl TreeConfig { #[cfg(test)] mod tests { - use super::TreeConfig; + use super::{ + default_persistence_backpressure_threshold, TreeConfig, DEFAULT_MEMORY_BLOCK_BUFFER_TARGET, + DEFAULT_PERSISTENCE_THRESHOLD, + }; + + #[test] + fn default_thresholds_use_derived_backpressure_threshold() { + let config = TreeConfig::default(); + + assert_eq!(config.persistence_threshold(), DEFAULT_PERSISTENCE_THRESHOLD); + assert_eq!(config.memory_block_buffer_target(), DEFAULT_MEMORY_BLOCK_BUFFER_TARGET); + assert_eq!( + config.persistence_backpressure_threshold(), + default_persistence_backpressure_threshold( + DEFAULT_PERSISTENCE_THRESHOLD, + DEFAULT_MEMORY_BLOCK_BUFFER_TARGET, + ) + ); + } #[test] #[should_panic( diff --git a/crates/node/core/src/args/engine.rs b/crates/node/core/src/args/engine.rs index f6614f67532..0c8aff7f950 100644 --- a/crates/node/core/src/args/engine.rs +++ b/crates/node/core/src/args/engine.rs @@ -4,7 +4,7 @@ use clap::{builder::Resettable, Args}; use eyre::ensure; use reth_cli_util::{parse_duration_from_secs_or_ms, parsers::format_duration_as_secs_or_ms}; use reth_engine_primitives::{ - TreeConfig, DEFAULT_MULTIPROOF_TASK_CHUNK_SIZE, DEFAULT_PERSISTENCE_BACKPRESSURE_THRESHOLD, + default_persistence_backpressure_threshold, TreeConfig, DEFAULT_MULTIPROOF_TASK_CHUNK_SIZE, DEFAULT_SPARSE_TRIE_MAX_HOT_ACCOUNTS, DEFAULT_SPARSE_TRIE_MAX_HOT_SLOTS, }; use std::{sync::OnceLock, time::Duration}; @@ -23,7 +23,7 @@ static ENGINE_DEFAULTS: OnceLock = OnceLock::new(); #[derive(Debug, Clone)] pub struct DefaultEngineValues { persistence_threshold: u64, - persistence_backpressure_threshold: u64, + persistence_backpressure_threshold: Option, memory_block_buffer_target: u64, legacy_state_root_task_enabled: bool, state_cache_disabled: bool, @@ -68,9 +68,20 @@ impl DefaultEngineValues { self } + /// Get the default persistence backpressure threshold. + pub const fn persistence_backpressure_threshold(&self) -> u64 { + match self.persistence_backpressure_threshold { + Some(v) => v, + None => default_persistence_backpressure_threshold( + self.persistence_threshold, + self.memory_block_buffer_target, + ), + } + } + /// Set the default persistence backpressure threshold pub const fn with_persistence_backpressure_threshold(mut self, v: u64) -> Self { - self.persistence_backpressure_threshold = v; + self.persistence_backpressure_threshold = Some(v); self } @@ -232,7 +243,7 @@ impl Default for DefaultEngineValues { fn default() -> Self { Self { persistence_threshold: DEFAULT_PERSISTENCE_THRESHOLD, - persistence_backpressure_threshold: DEFAULT_PERSISTENCE_BACKPRESSURE_THRESHOLD, + persistence_backpressure_threshold: None, memory_block_buffer_target: DEFAULT_MEMORY_BLOCK_BUFFER_TARGET, legacy_state_root_task_enabled: false, state_cache_disabled: false, @@ -278,7 +289,7 @@ pub struct EngineArgs { /// Configure the maximum canonical-minus-persisted gap before engine API processing stalls. /// /// This value must be greater than `--engine.persistence-threshold`. - #[arg(long = "engine.persistence-backpressure-threshold", default_value_t = DefaultEngineValues::get_global().persistence_backpressure_threshold)] + #[arg(long = "engine.persistence-backpressure-threshold", default_value_t = DefaultEngineValues::get_global().persistence_backpressure_threshold())] pub persistence_backpressure_threshold: u64, /// Configure the target number of blocks to keep in memory. @@ -476,69 +487,44 @@ pub struct EngineArgs { #[allow(deprecated)] impl Default for EngineArgs { fn default() -> Self { - let DefaultEngineValues { - persistence_threshold, - persistence_backpressure_threshold, - memory_block_buffer_target, - legacy_state_root_task_enabled, - state_cache_disabled, - prewarming_disabled, - state_provider_metrics, - cross_block_cache_size, - state_root_task_compare_updates, - accept_execution_requests_hash, - multiproof_chunk_size, - reserved_cpu_cores, - precompile_cache_disabled, - state_root_fallback, - always_process_payload_attributes_on_canonical_head, - allow_unwind_canonical_header, - storage_worker_count, - account_worker_count, - prewarming_threads, - cache_metrics_disabled, - sparse_trie_max_hot_slots, - sparse_trie_max_hot_accounts, - slow_block_threshold, - disable_sparse_trie_cache_pruning, - state_root_task_timeout, - share_execution_cache_with_payload_builder, - share_sparse_trie_with_payload_builder, - } = DefaultEngineValues::get_global().clone(); + let defaults = DefaultEngineValues::get_global(); Self { - persistence_threshold, - persistence_backpressure_threshold, - memory_block_buffer_target, - legacy_state_root_task_enabled, - state_root_task_compare_updates, + persistence_threshold: defaults.persistence_threshold, + persistence_backpressure_threshold: defaults.persistence_backpressure_threshold(), + memory_block_buffer_target: defaults.memory_block_buffer_target, + legacy_state_root_task_enabled: defaults.legacy_state_root_task_enabled, + state_root_task_compare_updates: defaults.state_root_task_compare_updates, caching_and_prewarming_enabled: true, - state_cache_disabled, - prewarming_disabled, + state_cache_disabled: defaults.state_cache_disabled, + prewarming_disabled: defaults.prewarming_disabled, parallel_sparse_trie_enabled: true, parallel_sparse_trie_disabled: false, - state_provider_metrics, - cross_block_cache_size, - accept_execution_requests_hash, - multiproof_chunk_size, - reserved_cpu_cores, + state_provider_metrics: defaults.state_provider_metrics, + cross_block_cache_size: defaults.cross_block_cache_size, + accept_execution_requests_hash: defaults.accept_execution_requests_hash, + multiproof_chunk_size: defaults.multiproof_chunk_size, + reserved_cpu_cores: defaults.reserved_cpu_cores, precompile_cache_enabled: true, - precompile_cache_disabled, - state_root_fallback, - always_process_payload_attributes_on_canonical_head, - allow_unwind_canonical_header, - storage_worker_count, - account_worker_count, - prewarming_threads, - cache_metrics_disabled, - sparse_trie_max_hot_slots, - sparse_trie_max_hot_accounts, - slow_block_threshold, - disable_sparse_trie_cache_pruning, - state_root_task_timeout: state_root_task_timeout + precompile_cache_disabled: defaults.precompile_cache_disabled, + state_root_fallback: defaults.state_root_fallback, + always_process_payload_attributes_on_canonical_head: defaults + .always_process_payload_attributes_on_canonical_head, + allow_unwind_canonical_header: defaults.allow_unwind_canonical_header, + storage_worker_count: defaults.storage_worker_count, + account_worker_count: defaults.account_worker_count, + prewarming_threads: defaults.prewarming_threads, + cache_metrics_disabled: defaults.cache_metrics_disabled, + sparse_trie_max_hot_slots: defaults.sparse_trie_max_hot_slots, + sparse_trie_max_hot_accounts: defaults.sparse_trie_max_hot_accounts, + slow_block_threshold: defaults.slow_block_threshold, + disable_sparse_trie_cache_pruning: defaults.disable_sparse_trie_cache_pruning, + state_root_task_timeout: defaults + .state_root_task_timeout .as_deref() .map(|s| humantime::parse_duration(s).expect("valid default duration")), - share_execution_cache_with_payload_builder, - share_sparse_trie_with_payload_builder, + share_execution_cache_with_payload_builder: defaults + .share_execution_cache_with_payload_builder, + share_sparse_trie_with_payload_builder: defaults.share_sparse_trie_with_payload_builder, #[cfg(feature = "trie-debug")] proof_jitter: None, } @@ -614,6 +600,40 @@ mod tests { assert_eq!(args, default_args); } + #[test] + fn default_engine_values_derive_backpressure_threshold() { + let defaults = DefaultEngineValues::default() + .with_persistence_threshold(10) + .with_memory_block_buffer_target(3); + + assert_eq!(defaults.persistence_backpressure_threshold(), 26); + } + + #[test] + fn explicit_backpressure_default_override_is_preserved() { + let defaults = DefaultEngineValues::default() + .with_persistence_backpressure_threshold(99) + .with_persistence_threshold(10) + .with_memory_block_buffer_target(3); + + assert_eq!(defaults.persistence_backpressure_threshold(), 99); + } + + #[test] + fn engine_args_default_thresholds_match_expected_defaults() { + let args = EngineArgs::default(); + + assert_eq!(args.persistence_threshold, DEFAULT_PERSISTENCE_THRESHOLD); + assert_eq!(args.memory_block_buffer_target, DEFAULT_MEMORY_BLOCK_BUFFER_TARGET); + assert_eq!( + args.persistence_backpressure_threshold, + default_persistence_backpressure_threshold( + args.persistence_threshold, + args.memory_block_buffer_target, + ) + ); + } + #[test] #[allow(deprecated)] fn engine_args() { diff --git a/docs/vocs/docs/pages/cli/reth/node.mdx b/docs/vocs/docs/pages/cli/reth/node.mdx index 39522debcd5..5a563ccbcf1 100644 --- a/docs/vocs/docs/pages/cli/reth/node.mdx +++ b/docs/vocs/docs/pages/cli/reth/node.mdx @@ -926,14 +926,14 @@ Engine: To persist blocks as fast as the node receives them, set this value to zero. This will cause more frequent DB writes. - [default: 2] + [default: 10] --engine.persistence-backpressure-threshold Configure the maximum canonical-minus-persisted gap before engine API processing stalls. This value must be greater than `--engine.persistence-threshold`. - [default: 16] + [default: 20] --engine.memory-block-buffer-target Configure the target number of blocks to keep in memory From 9e38dde3e09997f665ecb39a09233c96635e7b45 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Fri, 17 Apr 2026 09:05:04 +0000 Subject: [PATCH 08/83] fix(provider): persist partial trie finish checkpoint Make masked save_blocks persistence track partial trie progress in the Finish checkpoint and switch the trie-masking API to a masked-suffix start index so the final block is always covered by the mask when partial trie persistence is used. Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d9a2b-716e-774d-8db7-b0308fa96a23 Co-authored-by: Amp --- crates/engine/tree/src/persistence.rs | 6 +- .../src/providers/blockchain_provider.rs | 2 +- .../src/providers/database/provider.rs | 62 ++++++++++++++----- 3 files changed, 51 insertions(+), 19 deletions(-) diff --git a/crates/engine/tree/src/persistence.rs b/crates/engine/tree/src/persistence.rs index 33f4449454b..215e3e49642 100644 --- a/crates/engine/tree/src/persistence.rs +++ b/crates/engine/tree/src/persistence.rs @@ -162,7 +162,7 @@ where if let Some(last) = last_block { let provider_rw = self.provider.database_provider_rw()?; - provider_rw.save_blocks(&blocks, 0..0, SaveBlocksMode::Full)?; + provider_rw.save_blocks(&blocks, blocks.len(), SaveBlocksMode::Full)?; if let Some(finalized) = pending_finalized { provider_rw.save_finalized_block_number(finalized.min(last.number))?; @@ -555,7 +555,7 @@ mod tests { { let provider_rw = provider_factory.database_provider_rw().unwrap(); - provider_rw.save_blocks(&blocks_a, 0..0, SaveBlocksMode::Full).unwrap(); + provider_rw.save_blocks(&blocks_a, blocks_a.len(), SaveBlocksMode::Full).unwrap(); provider_rw.commit().unwrap(); } @@ -613,7 +613,7 @@ mod tests { let provider_rw = pf.database_provider_rw().unwrap(); provider_rw - .save_blocks(std::slice::from_ref(&block_b2), 0..0, SaveBlocksMode::Full) + .save_blocks(std::slice::from_ref(&block_b2), 1, SaveBlocksMode::Full) .unwrap(); provider_rw.commit().unwrap(); }); diff --git a/crates/storage/provider/src/providers/blockchain_provider.rs b/crates/storage/provider/src/providers/blockchain_provider.rs index b1b21a9ec6c..77c7985a5ad 100644 --- a/crates/storage/provider/src/providers/blockchain_provider.rs +++ b/crates/storage/provider/src/providers/blockchain_provider.rs @@ -1010,7 +1010,7 @@ mod tests { provider_rw .save_blocks( std::slice::from_ref(&lowest_memory_block), - 0..0, + 1, SaveBlocksMode::Full, ) .unwrap(); diff --git a/crates/storage/provider/src/providers/database/provider.rs b/crates/storage/provider/src/providers/database/provider.rs index cc551298175..2c65cfd4731 100644 --- a/crates/storage/provider/src/providers/database/provider.rs +++ b/crates/storage/provider/src/providers/database/provider.rs @@ -57,7 +57,7 @@ use reth_primitives_traits::{ use reth_prune_types::{ PruneCheckpoint, PruneMode, PruneModes, PruneSegment, MINIMUM_UNWIND_SAFE_DISTANCE, }; -use reth_stages_types::{StageCheckpoint, StageId}; +use reth_stages_types::{FinishCheckpoint, StageCheckpoint, StageId}; use reth_static_file_types::StaticFileSegment; use reth_storage_api::{ BlockBodyIndicesProvider, BlockBodyReader, MetadataProvider, MetadataWriter, @@ -561,10 +561,11 @@ impl DatabaseProvider DatabaseProvider], - trie_masking_range: std::ops::Range, + trie_masked_block_start: usize, save_mode: SaveBlocksMode, ) -> ProviderResult<()> { if blocks.is_empty() { @@ -589,7 +589,14 @@ impl DatabaseProvider blocks.len() { + return Err(ProviderError::Database(reth_db_api::DatabaseError::Other(format!( + "trie masked block start {trie_masked_block_start} exceeds block count {}", + blocks.len() + )))) + } + + let trie_masking_blocks = &blocks[trie_masked_block_start..]; let total_start = Instant::now(); let block_count = blocks.len() as u64; @@ -733,7 +740,7 @@ impl DatabaseProvider = @@ -774,6 +781,25 @@ impl DatabaseProvider 0 { + Some(blocks[trie_masked_block_start - 1].recovered_block().number()) + } else { + self.get_stage_checkpoint(StageId::Finish)?.map(|checkpoint| { + checkpoint + .finish_stage_checkpoint() + .and_then(|finish| finish.partial_state_trie) + .unwrap_or(checkpoint.block_number) + }) + }; + self.save_stage_checkpoint( + StageId::Finish, + StageCheckpoint::new(last_block_number) + .with_finish_stage_checkpoint(FinishCheckpoint { partial_state_trie }), + )?; + } timings.update_pipeline_stages = start.elapsed(); timings.mdbx = mdbx_start.elapsed(); @@ -3525,7 +3551,7 @@ impl BlockWriter ); // Delegate to save_blocks with BlocksOnly mode (skips receipts/state/trie) - self.save_blocks(std::slice::from_ref(&executed_block), 0..0, SaveBlocksMode::BlocksOnly)?; + self.save_blocks(std::slice::from_ref(&executed_block), 1, SaveBlocksMode::BlocksOnly)?; // Return the body indices self.block_body_indices(block_number)? @@ -4493,7 +4519,7 @@ mod tests { let provider_rw = factory.provider_rw().unwrap(); provider_rw - .save_blocks(std::slice::from_ref(&genesis_executed), 0..0, SaveBlocksMode::Full) + .save_blocks(std::slice::from_ref(&genesis_executed), 1, SaveBlocksMode::Full) .unwrap(); provider_rw.commit().unwrap(); @@ -4636,11 +4662,17 @@ mod tests { let provider_rw = factory.provider_rw().unwrap(); let blocks = vec![partial_persist_block, in_memory_only_block]; - provider_rw.save_blocks(&blocks, 1..2, SaveBlocksMode::Full).unwrap(); + provider_rw.save_blocks(&blocks, 1, SaveBlocksMode::Full).unwrap(); provider_rw.commit().unwrap(); let provider = factory.provider().unwrap(); let tx = provider.tx_ref(); + let finish_checkpoint = provider.get_stage_checkpoint(StageId::Finish).unwrap().unwrap(); + assert_eq!(finish_checkpoint.block_number, 2); + assert_eq!( + finish_checkpoint.finish_stage_checkpoint().unwrap().partial_state_trie, + Some(1) + ); let mut plain_storages = tx.cursor_dup_read::().unwrap(); assert_eq!( @@ -5291,7 +5323,7 @@ mod tests { ); let provider_rw = factory.provider_rw().unwrap(); provider_rw - .save_blocks(std::slice::from_ref(&genesis_executed), 0..0, SaveBlocksMode::Full) + .save_blocks(std::slice::from_ref(&genesis_executed), 1, SaveBlocksMode::Full) .unwrap(); provider_rw.commit().unwrap(); @@ -5364,7 +5396,7 @@ mod tests { } let provider_rw = factory.provider_rw().unwrap(); - provider_rw.save_blocks(&blocks, 0..0, SaveBlocksMode::Full).unwrap(); + provider_rw.save_blocks(&blocks, blocks.len(), SaveBlocksMode::Full).unwrap(); provider_rw.commit().unwrap(); let provider = factory.provider().unwrap(); From ca20cc13efd3c3a23016ddb5a8f64c11f76dc68d Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:02:17 +0000 Subject: [PATCH 09/83] fix(engine): retain partial trie suffix after persistence Return finish-stage partial trie progress from the persistence thread and keep blocks above that trie boundary resident in memory after persistence completes. This preserves the old prune-through-tip behavior when the partial trie matches the persisted tip or is absent. Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d9ab4-1eab-713f-9d5d-71903b7bc724 Co-authored-by: Amp --- crates/chain-state/src/in_memory.rs | 20 ++++- crates/engine/tree/src/persistence.rs | 39 +++++++-- crates/engine/tree/src/tree/mod.rs | 59 ++++++++++--- crates/engine/tree/src/tree/tests.rs | 120 ++++++++++++++++++++++++++ 4 files changed, 214 insertions(+), 24 deletions(-) diff --git a/crates/chain-state/src/in_memory.rs b/crates/chain-state/src/in_memory.rs index ecdece9a337..702031adfbb 100644 --- a/crates/chain-state/src/in_memory.rs +++ b/crates/chain-state/src/in_memory.rs @@ -320,6 +320,19 @@ impl CanonicalInMemoryState { /// This will update the links between blocks and remove all blocks that are [.. /// `persisted_height`]. pub fn remove_persisted_blocks(&self, persisted_num_hash: BlockNumHash) { + self.remove_persisted_blocks_until(persisted_num_hash, persisted_num_hash.number); + } + + /// Removes blocks from the in-memory state through `remove_until` while still reporting the + /// provided block as the persisted tip. + /// + /// This is used when block bodies/plain state have been persisted further than trie data, so a + /// suffix still needs to remain in memory for trie-backed operations. + pub fn remove_persisted_blocks_until( + &self, + persisted_num_hash: BlockNumHash, + remove_until: BlockNumber, + ) { self.set_persisted(persisted_num_hash); // if the persisted hash is not in the canonical in memory state, do nothing, because it // means canonical blocks were not actually persisted. @@ -337,16 +350,15 @@ impl CanonicalInMemoryState { let mut numbers = self.inner.in_memory_state.numbers.write(); let mut blocks = self.inner.in_memory_state.blocks.write(); - let BlockNumHash { number: persisted_height, hash: _ } = persisted_num_hash; + let remove_until = remove_until.min(persisted_num_hash.number); // clear all numbers numbers.clear(); - // drain all blocks and only keep the ones that are not persisted (below the persisted - // height) + // Drain all blocks and keep only the suffix that still has to stay in memory. let mut old_blocks = blocks .drain() - .filter(|(_, b)| b.block_ref().recovered_block().number() > persisted_height) + .filter(|(_, b)| b.block_ref().recovered_block().number() > remove_until) .map(|(_, b)| b.block.clone()) .collect::>(); diff --git a/crates/engine/tree/src/persistence.rs b/crates/engine/tree/src/persistence.rs index 215e3e49642..a27be3b9f8b 100644 --- a/crates/engine/tree/src/persistence.rs +++ b/crates/engine/tree/src/persistence.rs @@ -7,10 +7,10 @@ use reth_ethereum_primitives::EthPrimitives; use reth_primitives_traits::{FastInstant as Instant, NodePrimitives}; use reth_provider::{ providers::ProviderNodeTypes, BlockExecutionWriter, BlockHashReader, ChainStateBlockWriter, - DBProvider, DatabaseProviderFactory, ProviderFactory, SaveBlocksMode, + DBProvider, DatabaseProviderFactory, ProviderFactory, SaveBlocksMode, StageCheckpointReader, }; use reth_prune::{PrunerError, PrunerWithFactory}; -use reth_stages_api::{MetricEvent, MetricEventsSender}; +use reth_stages_api::{MetricEvent, MetricEventsSender, StageId}; use reth_tasks::spawn_os_thread; use std::{ sync::{ @@ -28,6 +28,11 @@ use tracing::{debug, error, instrument}; pub struct PersistenceResult { /// The last block that was persisted, if any. pub last_block: Option, + /// The highest block whose trie data is fully persisted, if known. + /// + /// When this lags behind [`Self::last_block`], callers must retain the suffix above it in + /// memory so trie-backed operations can still unwind from that point. + pub partial_state_trie: Option, /// The commit duration, only available for save-blocks operations. pub commit_duration: Option, } @@ -100,7 +105,11 @@ where // send new sync metrics based on removed blocks let _ = self.sync_metrics_tx.send(MetricEvent::SyncHeight { height: new_tip_num }); - let _ = sender.send(PersistenceResult { last_block, commit_duration: None }); + let _ = sender.send(PersistenceResult { + last_block, + partial_state_trie: None, + commit_duration: None, + }); } PersistenceAction::SaveBlocks(blocks, sender) => { let result = self.on_save_blocks(blocks)?; @@ -152,6 +161,7 @@ where let first_block = blocks.first().map(|b| b.recovered_block.num_hash()); let last_block = blocks.last().map(|b| b.recovered_block.num_hash()); let block_count = blocks.len(); + let mut partial_state_trie = None; let pending_finalized = self.pending_finalized_block.take(); let pending_safe = self.pending_safe_block.take(); @@ -163,6 +173,14 @@ where if let Some(last) = last_block { let provider_rw = self.provider.database_provider_rw()?; provider_rw.save_blocks(&blocks, blocks.len(), SaveBlocksMode::Full)?; + partial_state_trie = provider_rw + .get_stage_checkpoint(StageId::Finish)? + .and_then(|checkpoint| { + checkpoint + .finish_stage_checkpoint() + .and_then(|finish| finish.partial_state_trie) + }) + .or(Some(last.number)); if let Some(finalized) = pending_finalized { provider_rw.save_finalized_block_number(finalized.min(last.number))?; @@ -200,7 +218,7 @@ where self.metrics.save_blocks_batch_size.record(block_count as f64); self.metrics.save_blocks_duration_seconds.record(elapsed); - Ok(PersistenceResult { last_block, commit_duration: Some(elapsed) }) + Ok(PersistenceResult { last_block, partial_state_trie, commit_duration: Some(elapsed) }) } } @@ -411,6 +429,7 @@ mod tests { let result = rx.recv().unwrap(); assert!(result.last_block.is_none()); + assert!(result.partial_state_trie.is_none()); } #[test] @@ -430,7 +449,9 @@ mod tests { let result = rx.recv_timeout(std::time::Duration::from_secs(10)).expect("test timed out"); - assert_eq!(block_hash, result.last_block.unwrap().hash); + let last_block = result.last_block.unwrap(); + assert_eq!(block_hash, last_block.hash); + assert_eq!(result.partial_state_trie, Some(last_block.number)); } #[test] @@ -445,7 +466,9 @@ mod tests { handle.save_blocks(blocks, tx).unwrap(); let result = rx.recv().unwrap(); - assert_eq!(last_hash, result.last_block.unwrap().hash); + let last_block = result.last_block.unwrap(); + assert_eq!(last_hash, last_block.hash); + assert_eq!(result.partial_state_trie, Some(last_block.number)); } #[test] @@ -463,7 +486,9 @@ mod tests { handle.save_blocks(blocks, tx).unwrap(); let result = rx.recv().unwrap(); - assert_eq!(last_hash, result.last_block.unwrap().hash); + let last_block = result.last_block.unwrap(); + assert_eq!(last_hash, last_block.hash); + assert_eq!(result.partial_state_trie, Some(last_block.number)); } } diff --git a/crates/engine/tree/src/tree/mod.rs b/crates/engine/tree/src/tree/mod.rs index a254f85003d..df18fee2752 100644 --- a/crates/engine/tree/src/tree/mod.rs +++ b/crates/engine/tree/src/tree/mod.rs @@ -1456,17 +1456,22 @@ where ) -> Result<(), AdvancePersistenceError> { self.metrics.engine.persistence_duration.record(start_time.elapsed()); - let commit_duration = result.commit_duration; + let PersistenceResult { last_block, partial_state_trie, commit_duration } = result; let Some(BlockNumHash { hash: last_persisted_block_hash, number: last_persisted_block_number, - }) = result.last_block + }) = last_block else { // if this happened, then we persisted no blocks because we sent an empty vec of blocks warn!(target: "engine::tree", "Persistence task completed but did not persist any blocks"); return Ok(()) }; + let last_persisted_block = + BlockNumHash::new(last_persisted_block_number, last_persisted_block_hash); + let in_memory_persisted_block = + self.in_memory_persisted_block(last_persisted_block, partial_state_trie)?; + debug!(target: "engine::tree", ?last_persisted_block_hash, ?last_persisted_block_number, elapsed=?start_time.elapsed(), "Finished persisting, calling finish"); self.persistence_state.finish(last_persisted_block_hash, last_persisted_block_number); @@ -1495,7 +1500,7 @@ where // Invalidate cached overlay since the anchor has changed self.state.tree_state.invalidate_cached_overlay(); - self.on_new_persisted_block()?; + self.on_new_persisted_block(in_memory_persisted_block)?; // Re-prepare overlay for the current canonical head with the new anchor. // Spawn a background task to trigger computation so it's ready when the next payload @@ -1511,6 +1516,31 @@ where Ok(()) } + /// Returns the highest block that can be dropped from memory after persistence completes. + fn in_memory_persisted_block( + &self, + last_persisted_block: BlockNumHash, + partial_state_trie: Option, + ) -> ProviderResult { + let Some(partial_state_trie) = + partial_state_trie.filter(|block_number| *block_number < last_persisted_block.number) + else { + return Ok(last_persisted_block) + }; + + let hash = self + .canonical_in_memory_state + .hash_by_number(partial_state_trie) + .map(Ok) + .unwrap_or_else(|| { + self.provider + .block_hash(partial_state_trie)? + .ok_or_else(|| ProviderError::HeaderNotFound(partial_state_trie.into())) + })?; + + Ok(BlockNumHash::new(partial_state_trie, hash)) + } + /// Handles a message from the engine. /// /// Returns `ControlFlow::Break(())` if the engine should terminate. @@ -2071,14 +2101,17 @@ where Ok(blocks_to_persist) } - /// This clears the blocks from the in-memory tree state that have been persisted to the - /// database. + /// This clears the blocks from the in-memory tree state that no longer need to stay resident + /// after persistence completes. /// - /// This also updates the canonical in-memory state to reflect the newest persisted block - /// height. + /// This also updates the canonical in-memory state to reflect the newest persisted block tip, + /// even if trie persistence only advanced through an earlier block. /// /// Assumes that `finish` has been called on the `persistence_state` at least once - fn on_new_persisted_block(&mut self) -> ProviderResult<()> { + fn on_new_persisted_block( + &mut self, + in_memory_persisted_block: BlockNumHash, + ) -> ProviderResult<()> { // If we have an on-disk reorg, we need to handle it first before touching the in-memory // state. if let Some(remove_above) = self.find_disk_reorg()? { @@ -2087,11 +2120,11 @@ where } let finalized = self.state.forkchoice_state_tracker.last_valid_finalized(); - self.remove_before(self.persistence_state.last_persisted_block, finalized)?; - self.canonical_in_memory_state.remove_persisted_blocks(BlockNumHash { - number: self.persistence_state.last_persisted_block.number, - hash: self.persistence_state.last_persisted_block.hash, - }); + self.remove_before(in_memory_persisted_block, finalized)?; + self.canonical_in_memory_state.remove_persisted_blocks_until( + self.persistence_state.last_persisted_block, + in_memory_persisted_block.number, + ); Ok(()) } diff --git a/crates/engine/tree/src/tree/tests.rs b/crates/engine/tree/src/tree/tests.rs index bbb6c4c59b1..357d98ee583 100644 --- a/crates/engine/tree/src/tree/tests.rs +++ b/crates/engine/tree/src/tree/tests.rs @@ -730,6 +730,7 @@ fn test_backpressure_waits_for_persistence_before_reading_incoming() { persist_tx .send(PersistenceResult { last_block: Some(persisted), + partial_state_trie: Some(persisted.number), commit_duration: Some(Duration::ZERO), }) .unwrap(); @@ -827,6 +828,7 @@ async fn test_tree_state_on_new_head_reorg() { sender .send(PersistenceResult { last_block: Some(blocks[1].recovered_block().num_hash()), + partial_state_trie: Some(blocks[1].recovered_block().number()), commit_duration: Some(Duration::ZERO), }) .unwrap(); @@ -1012,6 +1014,121 @@ async fn test_get_canonical_blocks_to_persist() { ); } +#[test] +fn test_on_persistence_complete_retains_blocks_above_partial_state_trie() { + let chain_spec = MAINNET.clone(); + let mut test_harness = TestHarness::new(chain_spec); + let mut test_block_builder = TestBlockBuilder::eth(); + + let blocks: Vec<_> = test_block_builder.get_executed_blocks(0..7).collect(); + test_harness = test_harness.with_blocks(blocks.clone()); + test_harness.tree.persistence_state.last_persisted_block = + blocks[1].recovered_block().num_hash(); + + let persisted_tip = blocks[5].recovered_block().num_hash(); + let partial_state_trie = blocks[3].recovered_block().number(); + + test_harness + .tree + .on_persistence_complete( + PersistenceResult { + last_block: Some(persisted_tip), + partial_state_trie: Some(partial_state_trie), + commit_duration: Some(Duration::ZERO), + }, + Instant::now(), + ) + .unwrap(); + + assert_eq!(test_harness.tree.persistence_state.last_persisted_block, persisted_tip); + assert_eq!( + test_harness.tree.canonical_in_memory_state.get_persisted_num_hash(), + Some(persisted_tip) + ); + + for block in &blocks[..=partial_state_trie as usize] { + assert!(test_harness + .tree + .state + .tree_state + .executed_block_by_hash(block.recovered_block().hash()) + .is_none()); + assert!(test_harness + .tree + .canonical_in_memory_state + .state_by_number(block.recovered_block().number()) + .is_none()); + } + + for block in &blocks[partial_state_trie as usize + 1..] { + assert!(test_harness + .tree + .state + .tree_state + .executed_block_by_hash(block.recovered_block().hash()) + .is_some()); + assert!(test_harness + .tree + .canonical_in_memory_state + .state_by_number(block.recovered_block().number()) + .is_some()); + } +} + +#[test] +fn test_on_persistence_complete_without_partial_state_trie_prunes_through_tip() { + let chain_spec = MAINNET.clone(); + let mut test_harness = TestHarness::new(chain_spec); + let mut test_block_builder = TestBlockBuilder::eth(); + + let blocks: Vec<_> = test_block_builder.get_executed_blocks(0..7).collect(); + test_harness = test_harness.with_blocks(blocks.clone()); + test_harness.tree.persistence_state.last_persisted_block = + blocks[1].recovered_block().num_hash(); + + let persisted_tip = blocks[5].recovered_block().num_hash(); + + test_harness + .tree + .on_persistence_complete( + PersistenceResult { + last_block: Some(persisted_tip), + partial_state_trie: None, + commit_duration: Some(Duration::ZERO), + }, + Instant::now(), + ) + .unwrap(); + + for block in &blocks[..=persisted_tip.number as usize] { + assert!(test_harness + .tree + .state + .tree_state + .executed_block_by_hash(block.recovered_block().hash()) + .is_none()); + assert!(test_harness + .tree + .canonical_in_memory_state + .state_by_number(block.recovered_block().number()) + .is_none()); + } + + for block in &blocks[persisted_tip.number as usize + 1..] { + assert!(test_harness + .tree + .state + .tree_state + .executed_block_by_hash(block.recovered_block().hash()) + .is_some()); + assert!(test_harness + .tree + .canonical_in_memory_state + .state_by_number(block.recovered_block().number()) + .is_some()); + } +} + #[tokio::test] async fn test_engine_tree_fcu_missing_head() { let chain_spec = MAINNET.clone(); @@ -2115,6 +2232,9 @@ mod forkchoice_updated_tests { sender .send(PersistenceResult { last_block: saved_blocks.last().map(|b| b.recovered_block().num_hash()), + partial_state_trie: saved_blocks + .last() + .map(|b| b.recovered_block().number()), commit_duration: Some(Duration::ZERO), }) .unwrap(); From c757a310e1a1e99218a1efcffc29c63061959e87 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:04:12 +0000 Subject: [PATCH 10/83] feat(engine): add dual-frontier persistence planning Co-Authored-By: Brian Picciano <933154+mediocregopher@users.noreply.github.com> --- crates/engine/primitives/src/config.rs | 26 +- crates/engine/tree/src/persistence.rs | 178 ++++++++--- crates/engine/tree/src/tree/mod.rs | 178 ++++++----- .../engine/tree/src/tree/persistence_state.rs | 24 +- crates/engine/tree/src/tree/tests.rs | 124 +++++--- .../src/providers/blockchain_provider.rs | 2 + .../src/providers/database/provider.rs | 296 ++++++++++++------ 7 files changed, 564 insertions(+), 264 deletions(-) diff --git a/crates/engine/primitives/src/config.rs b/crates/engine/primitives/src/config.rs index 30611e2fe5b..821afe03135 100644 --- a/crates/engine/primitives/src/config.rs +++ b/crates/engine/primitives/src/config.rs @@ -6,6 +6,10 @@ use core::time::Duration; /// Triggers persistence when the number of canonical blocks in memory exceeds this threshold. pub const DEFAULT_PERSISTENCE_THRESHOLD: u64 = 10; +/// Maximum number of consecutive canonical blocks whose non-trie outputs may be persisted ahead +/// of trie persistence. +pub const DEFAULT_DEFERRED_TRIE_BLOCKS: u64 = 0; + /// How close to the canonical head we persist blocks. pub const DEFAULT_MEMORY_BLOCK_BUFFER_TARGET: u64 = 0; @@ -102,6 +106,9 @@ pub struct TreeConfig { /// Maximum number of blocks to be kept only in memory without triggering /// persistence. persistence_threshold: u64, + /// Maximum number of consecutive canonical blocks whose non-trie outputs may be persisted + /// ahead of trie persistence. + deferred_trie_blocks: u64, /// How close to the canonical head we persist blocks. Represents the ideal /// number of most recent blocks to keep in memory for quick access and reorgs. /// @@ -201,6 +208,7 @@ impl Default for TreeConfig { ); Self { persistence_threshold: DEFAULT_PERSISTENCE_THRESHOLD, + deferred_trie_blocks: DEFAULT_DEFERRED_TRIE_BLOCKS, memory_block_buffer_target: DEFAULT_MEMORY_BLOCK_BUFFER_TARGET, persistence_backpressure_threshold, block_buffer_limit: DEFAULT_BLOCK_BUFFER_LIMIT, @@ -239,6 +247,7 @@ impl TreeConfig { #[expect(clippy::too_many_arguments)] pub const fn new( persistence_threshold: u64, + deferred_trie_blocks: u64, memory_block_buffer_target: u64, persistence_backpressure_threshold: u64, block_buffer_limit: u32, @@ -272,6 +281,7 @@ impl TreeConfig { ); Self { persistence_threshold, + deferred_trie_blocks, memory_block_buffer_target, persistence_backpressure_threshold, block_buffer_limit, @@ -309,6 +319,11 @@ impl TreeConfig { self.persistence_threshold } + /// Return the deferred trie block target. + pub const fn deferred_trie_blocks(&self) -> u64 { + self.deferred_trie_blocks + } + /// Return the memory block buffer target. pub const fn memory_block_buffer_target(&self) -> u64 { self.memory_block_buffer_target @@ -422,6 +437,12 @@ impl TreeConfig { self } + /// Setter for deferred trie blocks. + pub const fn with_deferred_trie_blocks(mut self, deferred_trie_blocks: u64) -> Self { + self.deferred_trie_blocks = deferred_trie_blocks; + self + } + /// Setter for memory block buffer target. pub const fn with_memory_block_buffer_target( mut self, @@ -679,8 +700,8 @@ impl TreeConfig { #[cfg(test)] mod tests { use super::{ - default_persistence_backpressure_threshold, TreeConfig, DEFAULT_MEMORY_BLOCK_BUFFER_TARGET, - DEFAULT_PERSISTENCE_THRESHOLD, + default_persistence_backpressure_threshold, TreeConfig, DEFAULT_DEFERRED_TRIE_BLOCKS, + DEFAULT_MEMORY_BLOCK_BUFFER_TARGET, DEFAULT_PERSISTENCE_THRESHOLD, }; #[test] @@ -688,6 +709,7 @@ mod tests { let config = TreeConfig::default(); assert_eq!(config.persistence_threshold(), DEFAULT_PERSISTENCE_THRESHOLD); + assert_eq!(config.deferred_trie_blocks(), DEFAULT_DEFERRED_TRIE_BLOCKS); assert_eq!(config.memory_block_buffer_target(), DEFAULT_MEMORY_BLOCK_BUFFER_TARGET); assert_eq!( config.persistence_backpressure_threshold(), diff --git a/crates/engine/tree/src/persistence.rs b/crates/engine/tree/src/persistence.rs index a27be3b9f8b..9e68da7276b 100644 --- a/crates/engine/tree/src/persistence.rs +++ b/crates/engine/tree/src/persistence.rs @@ -26,17 +26,69 @@ use tracing::{debug, error, instrument}; /// Unified result of any persistence operation. #[derive(Debug)] pub struct PersistenceResult { - /// The last block that was persisted, if any. - pub last_block: Option, + /// The highest block whose non-trie outputs are persisted, if any. + pub non_trie_persisted_tip: Option, /// The highest block whose trie data is fully persisted, if known. /// - /// When this lags behind [`Self::last_block`], callers must retain the suffix above it in - /// memory so trie-backed operations can still unwind from that point. - pub partial_state_trie: Option, + /// When this lags behind [`Self::non_trie_persisted_tip`], callers must retain the suffix + /// above it in memory so trie-backed operations can still unwind from that point. + pub trie_persisted_tip: Option, /// The commit duration, only available for save-blocks operations. pub commit_duration: Option, } +/// Plan for a single persistence cycle. +#[derive(Debug)] +pub struct SaveBlocksPlan { + /// Canonical blocks starting at `trie_persisted_tip + 1`. + pub blocks: Vec>, + /// Prefix of [`Self::blocks`] that persists trie only. + pub trie_catchup_block_count: usize, + /// Region of [`Self::blocks`] that persists both non-trie outputs and trie data. + pub full_persist_block_count: usize, + /// Following region of [`Self::blocks`] that persists non-trie data only. + pub deferred_trie_block_count: usize, +} + +impl SaveBlocksPlan { + /// Creates a new save plan. + pub const fn new( + blocks: Vec>, + trie_catchup_block_count: usize, + full_persist_block_count: usize, + deferred_trie_block_count: usize, + ) -> Self { + Self { + blocks, + trie_catchup_block_count, + full_persist_block_count, + deferred_trie_block_count, + } + } + + /// Returns `true` if the plan contains no blocks. + pub const fn is_empty(&self) -> bool { + self.non_trie_persisted_block_count() == 0 + } + + /// Returns the number of blocks whose non-trie outputs are persisted by this plan. + pub const fn non_trie_persisted_block_count(&self) -> usize { + self.full_persist_block_count + self.deferred_trie_block_count + } + + /// Returns the in-memory block start index. + pub const fn in_memory_block_start(&self) -> usize { + self.trie_catchup_block_count + self.non_trie_persisted_block_count() + } + + /// Returns the highest block whose non-trie outputs are persisted by this plan. + pub fn non_trie_persisted_tip(&self) -> Option { + self.blocks + .get(self.in_memory_block_start().checked_sub(1)?) + .map(|block| block.recovered_block().num_hash()) + } +} + /// Writes parts of reth's in memory tree state to the database and static files. /// /// This is meant to be a spawned service that listens for various incoming persistence operations, @@ -106,14 +158,14 @@ where let _ = self.sync_metrics_tx.send(MetricEvent::SyncHeight { height: new_tip_num }); let _ = sender.send(PersistenceResult { - last_block, - partial_state_trie: None, + non_trie_persisted_tip: last_block, + trie_persisted_tip: None, commit_duration: None, }); } - PersistenceAction::SaveBlocks(blocks, sender) => { - let result = self.on_save_blocks(blocks)?; - let result_number = result.last_block.map(|b| b.number); + PersistenceAction::SaveBlocks(plan, sender) => { + let result = self.on_save_blocks(plan)?; + let result_number = result.non_trie_persisted_tip.map(|b| b.number); let _ = sender.send(result); @@ -153,63 +205,83 @@ where Ok(new_tip_hash.map(|hash| BlockNumHash { hash, number: new_tip_num })) } - #[instrument(level = "debug", target = "engine::persistence", skip_all, fields(block_count = blocks.len()))] + #[instrument(level = "debug", target = "engine::persistence", skip_all, fields(block_count = plan.blocks.len()))] fn on_save_blocks( &mut self, - blocks: Vec>, + plan: SaveBlocksPlan, ) -> Result { + let SaveBlocksPlan { + blocks, + trie_catchup_block_count, + full_persist_block_count, + deferred_trie_block_count, + } = plan; let first_block = blocks.first().map(|b| b.recovered_block.num_hash()); - let last_block = blocks.last().map(|b| b.recovered_block.num_hash()); + let non_trie_persisted_tip = trie_catchup_block_count + .checked_add(full_persist_block_count) + .and_then(|count| count.checked_add(deferred_trie_block_count)) + .and_then(|in_memory_block_start| in_memory_block_start.checked_sub(1)) + .and_then(|last_persisted_index| { + blocks.get(last_persisted_index).map(|block| block.recovered_block().num_hash()) + }); let block_count = blocks.len(); - let mut partial_state_trie = None; + let mut trie_persisted_tip = None; let pending_finalized = self.pending_finalized_block.take(); let pending_safe = self.pending_safe_block.take(); - debug!(target: "engine::persistence", ?block_count, first=?first_block, last=?last_block, "Saving range of blocks"); + debug!(target: "engine::persistence", ?block_count, first=?first_block, last=?non_trie_persisted_tip, "Saving range of blocks"); let start_time = Instant::now(); - if let Some(last) = last_block { + if let Some(non_trie_persisted_tip) = non_trie_persisted_tip { let provider_rw = self.provider.database_provider_rw()?; - provider_rw.save_blocks(&blocks, blocks.len(), SaveBlocksMode::Full)?; - partial_state_trie = provider_rw + provider_rw.save_blocks( + &blocks, + trie_catchup_block_count, + full_persist_block_count, + deferred_trie_block_count, + SaveBlocksMode::Full, + )?; + trie_persisted_tip = provider_rw .get_stage_checkpoint(StageId::Finish)? .and_then(|checkpoint| { checkpoint .finish_stage_checkpoint() .and_then(|finish| finish.partial_state_trie) }) - .or(Some(last.number)); + .or(Some(non_trie_persisted_tip.number)); if let Some(finalized) = pending_finalized { - provider_rw.save_finalized_block_number(finalized.min(last.number))?; - if finalized > last.number { + provider_rw + .save_finalized_block_number(finalized.min(non_trie_persisted_tip.number))?; + if finalized > non_trie_persisted_tip.number { self.pending_finalized_block = Some(finalized); } } if let Some(safe) = pending_safe { - provider_rw.save_safe_block_number(safe.min(last.number))?; - if safe > last.number { + provider_rw.save_safe_block_number(safe.min(non_trie_persisted_tip.number))?; + if safe > non_trie_persisted_tip.number { self.pending_safe_block = Some(safe); } } provider_rw.commit()?; - debug!(target: "engine::persistence", first=?first_block, last=?last_block, "Saved range of blocks"); + debug!(target: "engine::persistence", first=?first_block, last=?non_trie_persisted_tip, "Saved range of blocks"); // Run the pruner in a separate provider so it reads committed RocksDB state // that includes the history entries written by save_blocks above. // // The pruner reads the indices from rocksdb, filters it, and writes to indices, so it // must be able to read anything written by save_blocks. - if self.pruner.is_pruning_needed(last.number) { - debug!(target: "engine::persistence", block_num=?last.number, "Running pruner"); + if self.pruner.is_pruning_needed(non_trie_persisted_tip.number) { + debug!(target: "engine::persistence", block_num=?non_trie_persisted_tip.number, "Running pruner"); let prune_start = Instant::now(); let provider_rw = self.provider.database_provider_rw()?; - let _ = self.pruner.run_with_provider(&provider_rw, last.number)?; + let _ = + self.pruner.run_with_provider(&provider_rw, non_trie_persisted_tip.number)?; provider_rw.commit()?; - debug!(target: "engine::persistence", tip=?last.number, "Finished pruning after saving blocks"); + debug!(target: "engine::persistence", tip=?non_trie_persisted_tip.number, "Finished pruning after saving blocks"); self.metrics.prune_before_duration_seconds.record(prune_start.elapsed()); } } @@ -218,7 +290,11 @@ where self.metrics.save_blocks_batch_size.record(block_count as f64); self.metrics.save_blocks_duration_seconds.record(elapsed); - Ok(PersistenceResult { last_block, partial_state_trie, commit_duration: Some(elapsed) }) + Ok(PersistenceResult { + non_trie_persisted_tip, + trie_persisted_tip, + commit_duration: Some(elapsed), + }) } } @@ -240,9 +316,10 @@ pub enum PersistenceAction { /// The section of tree state that should be persisted. These blocks are expected in order of /// increasing block number. /// - /// First, header, transaction, and receipt-related data should be written to static files. - /// Then the execution history-related data will be written to the database. - SaveBlocks(Vec>, CrossbeamSender), + /// First, header, transaction, and receipt-related data should be written to static files for + /// the deferred trie region. Then the execution history-related data will be written to the + /// database, while trie catchup is persisted for the prefix. + SaveBlocks(SaveBlocksPlan, CrossbeamSender), /// Removes block data above the given block number from the database. /// @@ -326,10 +403,10 @@ impl PersistenceHandle { /// If there are no blocks to persist, then `None` is sent in the sender. pub fn save_blocks( &self, - blocks: Vec>, + plan: SaveBlocksPlan, tx: CrossbeamSender, ) -> Result<(), SendError>> { - self.send_action(PersistenceAction::SaveBlocks(blocks, tx)) + self.send_action(PersistenceAction::SaveBlocks(plan, tx)) } /// Queues the finalized block number to be persisted on disk. @@ -417,19 +494,24 @@ mod tests { PersistenceHandle::::spawn_service(provider, pruner, sync_metrics_tx) } + fn full_save_plan(blocks: Vec>) -> SaveBlocksPlan { + let full_persist_block_count = blocks.len(); + SaveBlocksPlan::new(blocks, 0, full_persist_block_count, 0) + } + #[test] fn test_save_blocks_empty() { reth_tracing::init_test_tracing(); let handle = default_persistence_handle(); - let blocks = vec![]; + let blocks = full_save_plan(vec![]); let (tx, rx) = crossbeam_channel::bounded(1); handle.save_blocks(blocks, tx).unwrap(); let result = rx.recv().unwrap(); - assert!(result.last_block.is_none()); - assert!(result.partial_state_trie.is_none()); + assert!(result.non_trie_persisted_tip.is_none()); + assert!(result.trie_persisted_tip.is_none()); } #[test] @@ -442,16 +524,16 @@ mod tests { test_block_builder.get_executed_block_with_number(block_number, B256::random()); let block_hash = executed.recovered_block().hash(); - let blocks = vec![executed]; + let blocks = full_save_plan(vec![executed]); let (tx, rx) = crossbeam_channel::bounded(1); handle.save_blocks(blocks, tx).unwrap(); let result = rx.recv_timeout(std::time::Duration::from_secs(10)).expect("test timed out"); - let last_block = result.last_block.unwrap(); + let last_block = result.non_trie_persisted_tip.unwrap(); assert_eq!(block_hash, last_block.hash); - assert_eq!(result.partial_state_trie, Some(last_block.number)); + assert_eq!(result.trie_persisted_tip, Some(last_block.number)); } #[test] @@ -464,11 +546,11 @@ mod tests { let last_hash = blocks.last().unwrap().recovered_block().hash(); let (tx, rx) = crossbeam_channel::bounded(1); - handle.save_blocks(blocks, tx).unwrap(); + handle.save_blocks(full_save_plan(blocks), tx).unwrap(); let result = rx.recv().unwrap(); - let last_block = result.last_block.unwrap(); + let last_block = result.non_trie_persisted_tip.unwrap(); assert_eq!(last_hash, last_block.hash); - assert_eq!(result.partial_state_trie, Some(last_block.number)); + assert_eq!(result.trie_persisted_tip, Some(last_block.number)); } #[test] @@ -483,12 +565,12 @@ mod tests { let last_hash = blocks.last().unwrap().recovered_block().hash(); let (tx, rx) = crossbeam_channel::bounded(1); - handle.save_blocks(blocks, tx).unwrap(); + handle.save_blocks(full_save_plan(blocks), tx).unwrap(); let result = rx.recv().unwrap(); - let last_block = result.last_block.unwrap(); + let last_block = result.non_trie_persisted_tip.unwrap(); assert_eq!(last_hash, last_block.hash); - assert_eq!(result.partial_state_trie, Some(last_block.number)); + assert_eq!(result.trie_persisted_tip, Some(last_block.number)); } } @@ -580,7 +662,7 @@ mod tests { { let provider_rw = provider_factory.database_provider_rw().unwrap(); - provider_rw.save_blocks(&blocks_a, blocks_a.len(), SaveBlocksMode::Full).unwrap(); + provider_rw.save_blocks(&blocks_a, 0, blocks_a.len(), 0, SaveBlocksMode::Full).unwrap(); provider_rw.commit().unwrap(); } @@ -638,7 +720,7 @@ mod tests { let provider_rw = pf.database_provider_rw().unwrap(); provider_rw - .save_blocks(std::slice::from_ref(&block_b2), 1, SaveBlocksMode::Full) + .save_blocks(std::slice::from_ref(&block_b2), 0, 1, 0, SaveBlocksMode::Full) .unwrap(); provider_rw.commit().unwrap(); }); diff --git a/crates/engine/tree/src/tree/mod.rs b/crates/engine/tree/src/tree/mod.rs index df18fee2752..3013605f04f 100644 --- a/crates/engine/tree/src/tree/mod.rs +++ b/crates/engine/tree/src/tree/mod.rs @@ -2,7 +2,7 @@ use crate::{ backfill::{BackfillAction, BackfillSyncState}, chain::FromOrchestrator, engine::{DownloadRequest, EngineApiEvent, EngineApiKind, EngineApiRequest, FromEngine}, - persistence::PersistenceHandle, + persistence::{PersistenceHandle, SaveBlocksPlan}, tree::{error::InsertPayloadError, payload_validator::TreeCtx}, }; use alloy_consensus::BlockHeader; @@ -424,7 +424,8 @@ where let header = provider.sealed_header(best_block_number).ok().flatten().unwrap_or_default(); let persistence_state = PersistenceState { - last_persisted_block: BlockNumHash::new(best_block_number, header.hash()), + non_trie_persisted_tip: BlockNumHash::new(best_block_number, header.hash()), + trie_persisted_tip: BlockNumHash::new(best_block_number, header.hash()), rx: None, }; @@ -479,7 +480,7 @@ where self.state .tree_state .canonical_block_number() - .saturating_sub(self.persistence_state.last_persisted_block.number) + .saturating_sub(self.persistence_state.non_trie_persisted_tip.number) } /// Returns `true` when the main loop should stop draining the tree input channel. @@ -1339,8 +1340,8 @@ where /// Helper method to remove blocks and set the persistence state. This ensures we keep track of /// the current persistence action while we're removing blocks. fn remove_blocks(&mut self, new_tip_num: u64) { - debug!(target: "engine::tree", ?new_tip_num, last_persisted_block_number=?self.persistence_state.last_persisted_block.number, "Removing blocks using persistence task"); - if new_tip_num < self.persistence_state.last_persisted_block.number { + debug!(target: "engine::tree", ?new_tip_num, non_trie_persisted_tip=?self.persistence_state.non_trie_persisted_tip.number, "Removing blocks using persistence task"); + if new_tip_num < self.persistence_state.non_trie_persisted_tip.number { debug!(target: "engine::tree", ?new_tip_num, "Starting remove blocks job"); let (tx, rx) = crossbeam_channel::bounded(1); let _ = self.persistence.remove_blocks_above(new_tip_num, tx); @@ -1350,24 +1351,28 @@ where /// Helper method to save blocks and set the persistence state. This ensures we keep track of /// the current persistence action while we're saving blocks. - fn persist_blocks(&mut self, blocks_to_persist: Vec>) { - if blocks_to_persist.is_empty() { + fn persist_blocks(&mut self, plan: SaveBlocksPlan) { + if plan.is_empty() { debug!(target: "engine::tree", "Returned empty set of blocks to persist"); return } - // NOTE: checked non-empty above - let highest_num_hash = blocks_to_persist - .iter() - .max_by_key(|block| block.recovered_block().number()) - .map(|b| b.recovered_block().num_hash()) - .expect("Checked non-empty persisting blocks"); + let non_trie_persisted_tip = + plan.non_trie_persisted_tip().expect("checked non-empty persisting blocks"); - debug!(target: "engine::tree", count=blocks_to_persist.len(), blocks = ?blocks_to_persist.iter().map(|block| block.recovered_block().num_hash()).collect::>(), "Persisting blocks"); + debug!( + target: "engine::tree", + count = plan.blocks.len(), + trie_catchup_blocks = plan.trie_catchup_block_count, + full_persist_blocks = plan.full_persist_block_count, + deferred_trie_blocks = plan.deferred_trie_block_count, + blocks = ?plan.blocks.iter().map(|block| block.recovered_block().num_hash()).collect::>(), + "Persisting blocks" + ); let (tx, rx) = crossbeam_channel::bounded(1); - let _ = self.persistence.save_blocks(blocks_to_persist, tx); + let _ = self.persistence.save_blocks(plan, tx); - self.persistence_state.start_save(highest_num_hash, rx); + self.persistence_state.start_save(non_trie_persisted_tip, rx); } /// Triggers new persistence actions if no persistence task is currently in progress. @@ -1379,9 +1384,8 @@ where if let Some(new_tip_num) = self.find_disk_reorg()? { self.remove_blocks(new_tip_num) } else if self.should_persist() { - let blocks_to_persist = - self.get_canonical_blocks_to_persist(PersistTarget::Threshold)?; - self.persist_blocks(blocks_to_persist); + let plan = self.get_save_blocks_plan(PersistTarget::Threshold)?; + self.persist_blocks(plan); } } @@ -1412,15 +1416,15 @@ where self.on_persistence_complete(result, start_time)?; } - let blocks_to_persist = self.get_canonical_blocks_to_persist(PersistTarget::Head)?; + let plan = self.get_save_blocks_plan(PersistTarget::Head)?; - if blocks_to_persist.is_empty() { + if plan.is_empty() { debug!(target: "engine::tree", "persistence complete, signaling termination"); return Ok(()) } - debug!(target: "engine::tree", count = blocks_to_persist.len(), "persisting remaining blocks before shutdown"); - self.persist_blocks(blocks_to_persist); + debug!(target: "engine::tree", count = plan.blocks.len(), "persisting remaining blocks before shutdown"); + self.persist_blocks(plan); } } @@ -1456,30 +1460,31 @@ where ) -> Result<(), AdvancePersistenceError> { self.metrics.engine.persistence_duration.record(start_time.elapsed()); - let PersistenceResult { last_block, partial_state_trie, commit_duration } = result; + let PersistenceResult { non_trie_persisted_tip, trie_persisted_tip, commit_duration } = + result; let Some(BlockNumHash { - hash: last_persisted_block_hash, - number: last_persisted_block_number, - }) = last_block + hash: non_trie_persisted_tip_hash, + number: non_trie_persisted_tip_number, + }) = non_trie_persisted_tip else { // if this happened, then we persisted no blocks because we sent an empty vec of blocks warn!(target: "engine::tree", "Persistence task completed but did not persist any blocks"); return Ok(()) }; - let last_persisted_block = - BlockNumHash::new(last_persisted_block_number, last_persisted_block_hash); - let in_memory_persisted_block = - self.in_memory_persisted_block(last_persisted_block, partial_state_trie)?; + let non_trie_persisted_tip = + BlockNumHash::new(non_trie_persisted_tip_number, non_trie_persisted_tip_hash); + let trie_persisted_tip = + self.trie_persisted_tip(non_trie_persisted_tip, trie_persisted_tip)?; - debug!(target: "engine::tree", ?last_persisted_block_hash, ?last_persisted_block_number, elapsed=?start_time.elapsed(), "Finished persisting, calling finish"); - self.persistence_state.finish(last_persisted_block_hash, last_persisted_block_number); + debug!(target: "engine::tree", ?non_trie_persisted_tip_hash, ?non_trie_persisted_tip_number, trie_persisted_tip = trie_persisted_tip.number, elapsed=?start_time.elapsed(), "Finished persisting, calling finish"); + self.persistence_state.finish(non_trie_persisted_tip, trie_persisted_tip); // Evict trie changesets for blocks below the eviction threshold. // Keep at least CHANGESET_CACHE_RETENTION_BLOCKS from the persisted tip, and also respect // the finalized block if set. let min_threshold = - last_persisted_block_number.saturating_sub(CHANGESET_CACHE_RETENTION_BLOCKS); + non_trie_persisted_tip_number.saturating_sub(CHANGESET_CACHE_RETENTION_BLOCKS); let eviction_threshold = if let Some(finalized) = self.canonical_in_memory_state.get_finalized_num_hash() { // Use the minimum of finalized block and retention threshold to be conservative @@ -1490,7 +1495,7 @@ where }; debug!( target: "engine::tree", - last_persisted = last_persisted_block_number, + non_trie_persisted = non_trie_persisted_tip_number, finalized_number = ?self.canonical_in_memory_state.get_finalized_num_hash().map(|f| f.number), eviction_threshold, "Evicting changesets below threshold" @@ -1500,7 +1505,7 @@ where // Invalidate cached overlay since the anchor has changed self.state.tree_state.invalidate_cached_overlay(); - self.on_new_persisted_block(in_memory_persisted_block)?; + self.on_new_persisted_block(trie_persisted_tip)?; // Re-prepare overlay for the current canonical head with the new anchor. // Spawn a background task to trigger computation so it's ready when the next payload @@ -1511,34 +1516,34 @@ where }); } - self.purge_timing_stats(last_persisted_block_number, commit_duration); + self.purge_timing_stats(non_trie_persisted_tip_number, commit_duration); Ok(()) } /// Returns the highest block that can be dropped from memory after persistence completes. - fn in_memory_persisted_block( + fn trie_persisted_tip( &self, - last_persisted_block: BlockNumHash, - partial_state_trie: Option, + non_trie_persisted_tip: BlockNumHash, + trie_persisted_tip: Option, ) -> ProviderResult { - let Some(partial_state_trie) = - partial_state_trie.filter(|block_number| *block_number < last_persisted_block.number) + let Some(trie_persisted_tip) = + trie_persisted_tip.filter(|block_number| *block_number < non_trie_persisted_tip.number) else { - return Ok(last_persisted_block) + return Ok(non_trie_persisted_tip) }; let hash = self .canonical_in_memory_state - .hash_by_number(partial_state_trie) + .hash_by_number(trie_persisted_tip) .map(Ok) .unwrap_or_else(|| { self.provider - .block_hash(partial_state_trie)? - .ok_or_else(|| ProviderError::HeaderNotFound(partial_state_trie.into())) + .block_hash(trie_persisted_tip)? + .ok_or_else(|| ProviderError::HeaderNotFound(trie_persisted_tip.into())) })?; - Ok(BlockNumHash::new(partial_state_trie, hash)) + Ok(BlockNumHash::new(trie_persisted_tip, hash)) } /// Handles a message from the engine. @@ -1830,7 +1835,7 @@ where } else { self.state.tree_state.remove_until( backfill_num_hash, - self.persistence_state.last_persisted_block.hash, + self.persistence_state.non_trie_persisted_tip.hash, Some(backfill_num_hash), ); } @@ -1849,7 +1854,7 @@ where // update the tracked chain height, after backfill sync both the canonical height and // persisted height are the same self.state.tree_state.set_canonical_head(new_head.num_hash()); - self.persistence_state.finish(new_head.hash(), new_head.number()); + self.persistence_state.finish(new_head.num_hash(), new_head.num_hash()); // update the tracked canonical head self.canonical_in_memory_state.set_canonical_head(new_head); @@ -2048,57 +2053,80 @@ where return false } - let min_block = self.persistence_state.last_persisted_block.number; + let min_block = self.persistence_state.non_trie_persisted_tip.number; self.state.tree_state.canonical_block_number().saturating_sub(min_block) > self.config.persistence_threshold() } - /// Returns a batch of consecutive canonical blocks to persist in the range - /// `(last_persisted_number .. target]`. The expected order is oldest -> newest. - fn get_canonical_blocks_to_persist( + /// Returns the save plan for the next persistence cycle. + fn get_save_blocks_plan( &self, target: PersistTarget, - ) -> Result>, AdvancePersistenceError> { + ) -> Result, AdvancePersistenceError> { // We will calculate the state root using the database, so we need to be sure there are no // changes debug_assert!(!self.persistence_state.in_progress()); - let mut blocks_to_persist = Vec::new(); + let mut blocks = Vec::new(); let mut current_hash = self.state.tree_state.canonical_block_hash(); - let last_persisted_number = self.persistence_state.last_persisted_block.number; + let trie_persisted_tip_number = self.persistence_state.trie_persisted_tip.number; + let non_trie_persisted_tip_number = self.persistence_state.non_trie_persisted_tip.number; let canonical_head_number = self.state.tree_state.canonical_block_number(); - - let target_number = match target { - PersistTarget::Head => canonical_head_number, - PersistTarget::Threshold => { - canonical_head_number.saturating_sub(self.config.memory_block_buffer_target()) - } - }; + let threshold_non_trie_target = + canonical_head_number.saturating_sub(self.config.memory_block_buffer_target()); debug!( target: "engine::tree", ?current_hash, - ?last_persisted_number, + ?trie_persisted_tip_number, + ?non_trie_persisted_tip_number, ?canonical_head_number, - ?target_number, - "Returning canonical blocks to persist" + target = ?target, + "Returning save plan" ); while let Some(block) = self.state.tree_state.blocks_by_hash.get(¤t_hash) { - if block.recovered_block().number() <= last_persisted_number { + if block.recovered_block().number() <= trie_persisted_tip_number { break; } - if block.recovered_block().number() <= target_number { - blocks_to_persist.push(block.clone()); + if self.config.deferred_trie_blocks() > 0 || + matches!(target, PersistTarget::Head) || + block.recovered_block().number() <= threshold_non_trie_target + { + blocks.push(block.clone()); } current_hash = block.recovered_block().parent_hash(); } // Reverse the order so that the oldest block comes first - blocks_to_persist.reverse(); + blocks.reverse(); + + let trie_catchup_block_count = non_trie_persisted_tip_number + .saturating_sub(trie_persisted_tip_number) + .min(blocks.len() as u64) as usize; + let available_deferred_trie_blocks = blocks.len().saturating_sub(trie_catchup_block_count); + let (full_persist_block_count, deferred_trie_block_count) = + if self.config.deferred_trie_blocks() == 0 { + (available_deferred_trie_blocks, 0) + } else { + ( + 0, + match target { + PersistTarget::Head => available_deferred_trie_blocks, + PersistTarget::Threshold => available_deferred_trie_blocks + .saturating_sub(self.config.memory_block_buffer_target() as usize) + .min(self.config.deferred_trie_blocks() as usize), + }, + ) + }; - Ok(blocks_to_persist) + Ok(SaveBlocksPlan::new( + blocks, + trie_catchup_block_count, + full_persist_block_count, + deferred_trie_block_count, + )) } /// This clears the blocks from the in-memory tree state that no longer need to stay resident @@ -2122,7 +2150,7 @@ where let finalized = self.state.forkchoice_state_tracker.last_valid_finalized(); self.remove_before(in_memory_persisted_block, finalized)?; self.canonical_in_memory_state.remove_persisted_blocks_until( - self.persistence_state.last_persisted_block, + self.persistence_state.non_trie_persisted_tip, in_memory_persisted_block.number, ); Ok(()) @@ -2592,7 +2620,7 @@ where /// happen if a reorg is happening while we are persisting a block. fn find_disk_reorg(&self) -> ProviderResult> { let mut canonical = self.state.tree_state.current_canonical_head; - let mut persisted = self.persistence_state.last_persisted_block; + let mut persisted = self.persistence_state.non_trie_persisted_tip; let parent_num_hash = |num_hash: NumHash| -> ProviderResult { Ok(self @@ -2923,7 +2951,7 @@ where // Only query DB if block could be persisted (number <= last persisted block). // New blocks from CL always have number > last persisted, so skip DB lookup for them. - if block_num_hash.number <= self.persistence_state.last_persisted_block.number { + if block_num_hash.number <= self.persistence_state.non_trie_persisted_tip.number { match self.provider.sealed_header_by_hash(block_num_hash.hash) { Err(err) => { let block = convert_to_block(self, input)?; @@ -3284,7 +3312,7 @@ where self.state.tree_state.remove_until( upper_bound, - self.persistence_state.last_persisted_block.hash, + self.persistence_state.non_trie_persisted_tip.hash, num, ); Ok(()) diff --git a/crates/engine/tree/src/tree/persistence_state.rs b/crates/engine/tree/src/tree/persistence_state.rs index c3ab00dbece..6eee9f8cde8 100644 --- a/crates/engine/tree/src/tree/persistence_state.rs +++ b/crates/engine/tree/src/tree/persistence_state.rs @@ -22,7 +22,6 @@ use crate::persistence::PersistenceResult; use alloy_eips::BlockNumHash; -use alloy_primitives::B256; use crossbeam_channel::Receiver as CrossbeamReceiver; use reth_primitives_traits::FastInstant as Instant; use tracing::trace; @@ -30,10 +29,12 @@ use tracing::trace; /// The state of the persistence task. #[derive(Debug)] pub struct PersistenceState { - /// Hash and number of the last block persisted. + /// Hash and number of the highest block whose non-trie outputs are persisted. /// - /// This tracks the chain height that is persisted on disk - pub(crate) last_persisted_block: BlockNumHash, + /// This tracks the highest canonical block with durable block/static-file/plain-state data. + pub(crate) non_trie_persisted_tip: BlockNumHash, + /// Hash and number of the highest block whose trie outputs are persisted. + pub(crate) trie_persisted_tip: BlockNumHash, /// Receiver end of channel where the result of the persistence task will be /// sent when done. A None value means there's no persistence task in progress. pub(crate) rx: @@ -76,13 +77,18 @@ impl PersistenceState { /// Sets state for a finished persistence task. pub(crate) fn finish( &mut self, - last_persisted_block_hash: B256, - last_persisted_block_number: u64, + non_trie_persisted_tip: BlockNumHash, + trie_persisted_tip: BlockNumHash, ) { - trace!(target: "engine::tree", block= %last_persisted_block_number, hash=%last_persisted_block_hash, "updating persistence state"); + trace!( + target: "engine::tree", + non_trie_persisted_tip = %non_trie_persisted_tip.number, + trie_persisted_tip = %trie_persisted_tip.number, + "updating persistence state" + ); self.rx = None; - self.last_persisted_block = - BlockNumHash::new(last_persisted_block_number, last_persisted_block_hash); + self.non_trie_persisted_tip = non_trie_persisted_tip; + self.trie_persisted_tip = trie_persisted_tip; } } diff --git a/crates/engine/tree/src/tree/tests.rs b/crates/engine/tree/src/tree/tests.rs index 357d98ee583..4e872dee064 100644 --- a/crates/engine/tree/src/tree/tests.rs +++ b/crates/engine/tree/src/tree/tests.rs @@ -215,7 +215,11 @@ impl TestHarness { engine_api_tree_state, canonical_in_memory_state, persistence_handle, - PersistenceState { last_persisted_block: BlockNumHash::default(), rx: None }, + PersistenceState { + non_trie_persisted_tip: BlockNumHash::default(), + trie_persisted_tip: BlockNumHash::default(), + rx: None, + }, payload_builder, // always assume enough parallelism for tests TreeConfig::default().with_legacy_state_root(false).with_has_enough_parallelism(true), @@ -548,12 +552,15 @@ async fn test_tree_persist_blocks() { let received_action = test_harness.action_rx.recv().expect("Failed to receive save blocks action"); - if let PersistenceAction::SaveBlocks(saved_blocks, _) = received_action { + if let PersistenceAction::SaveBlocks(plan, _) = received_action { // only blocks.len() - tree_config.memory_block_buffer_target() will be // persisted let expected_persist_len = blocks.len() - tree_config.memory_block_buffer_target() as usize; - assert_eq!(saved_blocks.len(), expected_persist_len); - assert_eq!(saved_blocks, blocks[..expected_persist_len]); + assert_eq!(plan.blocks.len(), expected_persist_len); + assert_eq!(plan.blocks, blocks[..expected_persist_len]); + assert_eq!(plan.trie_catchup_block_count, 0); + assert_eq!(plan.full_persist_block_count, expected_persist_len); + assert_eq!(plan.deferred_trie_block_count, 0); } else { panic!("unexpected action received {received_action:?}"); } @@ -729,8 +736,8 @@ fn test_backpressure_waits_for_persistence_before_reading_incoming() { std::thread::sleep(Duration::from_millis(10)); persist_tx .send(PersistenceResult { - last_block: Some(persisted), - partial_state_trie: Some(persisted.number), + non_trie_persisted_tip: Some(persisted), + trie_persisted_tip: Some(persisted.number), commit_duration: Some(Duration::ZERO), }) .unwrap(); @@ -819,16 +826,16 @@ async fn test_tree_state_on_new_head_reorg() { // get rid of the prev action let received_action = test_harness.action_rx.recv().unwrap(); - let PersistenceAction::SaveBlocks(saved_blocks, sender) = received_action else { + let PersistenceAction::SaveBlocks(plan, sender) = received_action else { panic!("received wrong action"); }; - assert_eq!(saved_blocks, vec![blocks[0].clone(), blocks[1].clone()]); + assert_eq!(plan.blocks, vec![blocks[0].clone(), blocks[1].clone()]); // send the response so we can advance again sender .send(PersistenceResult { - last_block: Some(blocks[1].recovered_block().num_hash()), - partial_state_trie: Some(blocks[1].recovered_block().number()), + non_trie_persisted_tip: Some(blocks[1].recovered_block().num_hash()), + trie_persisted_tip: Some(blocks[1].recovered_block().number()), commit_duration: Some(Duration::ZERO), }) .unwrap(); @@ -963,9 +970,11 @@ async fn test_get_canonical_blocks_to_persist() { test_block_builder.get_executed_blocks(0..canonical_head_number + 1).collect(); test_harness = test_harness.with_blocks(blocks.clone()); - let last_persisted_block_number = 3; - test_harness.tree.persistence_state.last_persisted_block = - blocks[last_persisted_block_number as usize].recovered_block.num_hash(); + let non_trie_persisted_tip_number = 3; + let non_trie_persisted_tip = + blocks[non_trie_persisted_tip_number as usize].recovered_block.num_hash(); + test_harness.tree.persistence_state.non_trie_persisted_tip = non_trie_persisted_tip; + test_harness.tree.persistence_state.trie_persisted_tip = non_trie_persisted_tip; let persistence_threshold = 4; let memory_block_buffer_target = 3; @@ -973,17 +982,16 @@ async fn test_get_canonical_blocks_to_persist() { .with_persistence_threshold(persistence_threshold) .with_memory_block_buffer_target(memory_block_buffer_target); - let blocks_to_persist = - test_harness.tree.get_canonical_blocks_to_persist(PersistTarget::Threshold).unwrap(); + let plan = test_harness.tree.get_save_blocks_plan(PersistTarget::Threshold).unwrap(); let expected_blocks_to_persist_length: usize = - (canonical_head_number - memory_block_buffer_target - last_persisted_block_number) + (canonical_head_number - memory_block_buffer_target - non_trie_persisted_tip_number) .try_into() .unwrap(); - assert_eq!(blocks_to_persist.len(), expected_blocks_to_persist_length); - for (i, item) in blocks_to_persist.iter().enumerate().take(expected_blocks_to_persist_length) { - assert_eq!(item.recovered_block().number, last_persisted_block_number + i as u64 + 1); + assert_eq!(plan.blocks.len(), expected_blocks_to_persist_length); + for (i, item) in plan.blocks.iter().enumerate().take(expected_blocks_to_persist_length) { + assert_eq!(item.recovered_block().number, non_trie_persisted_tip_number + i as u64 + 1); } // make sure only canonical blocks are included @@ -993,15 +1001,14 @@ async fn test_get_canonical_blocks_to_persist() { assert!(test_harness.tree.state.tree_state.sealed_header_by_hash(&fork_block_hash).is_some()); - let blocks_to_persist = - test_harness.tree.get_canonical_blocks_to_persist(PersistTarget::Threshold).unwrap(); - assert_eq!(blocks_to_persist.len(), expected_blocks_to_persist_length); + let plan = test_harness.tree.get_save_blocks_plan(PersistTarget::Threshold).unwrap(); + assert_eq!(plan.blocks.len(), expected_blocks_to_persist_length); // check that the fork block is not included in the blocks to persist - assert!(!blocks_to_persist.iter().any(|b| b.recovered_block().hash() == fork_block_hash)); + assert!(!plan.blocks.iter().any(|b| b.recovered_block().hash() == fork_block_hash)); // check that the original block 4 is still included - assert!(blocks_to_persist.iter().any(|b| b.recovered_block().number == 4 && + assert!(plan.blocks.iter().any(|b| b.recovered_block().number == 4 && b.recovered_block().hash() == blocks[4].recovered_block().hash())); // check that if we advance persistence, the persistence action is the correct value @@ -1009,11 +1016,40 @@ async fn test_get_canonical_blocks_to_persist() { assert_eq!( test_harness.tree.persistence_state.current_action().cloned(), Some(CurrentPersistenceAction::SavingBlocks { - highest: blocks_to_persist.last().unwrap().recovered_block().num_hash() + highest: plan.blocks.last().unwrap().recovered_block().num_hash() }) ); } +#[test] +fn test_get_save_blocks_plan_with_deferred_trie_blocks() { + let chain_spec = MAINNET.clone(); + let mut test_harness = TestHarness::new(chain_spec); + let mut test_block_builder = TestBlockBuilder::eth(); + + let blocks: Vec<_> = test_block_builder.get_executed_blocks(0..7).collect(); + test_harness = test_harness.with_blocks(blocks.clone()); + test_harness.tree.persistence_state.trie_persisted_tip = blocks[1].recovered_block().num_hash(); + test_harness.tree.persistence_state.non_trie_persisted_tip = + blocks[3].recovered_block().num_hash(); + test_harness.tree.config = TreeConfig::default() + .with_persistence_threshold(1) + .with_memory_block_buffer_target(1) + .with_deferred_trie_blocks(2); + + let plan = test_harness.tree.get_save_blocks_plan(PersistTarget::Threshold).unwrap(); + + assert_eq!(plan.trie_catchup_block_count, 2); + assert_eq!(plan.full_persist_block_count, 0); + assert_eq!(plan.deferred_trie_block_count, 2); + assert_eq!(plan.blocks.len(), 5); + assert_eq!( + plan.blocks.iter().map(|block| block.recovered_block().number()).collect::>(), + vec![2, 3, 4, 5, 6] + ); + assert_eq!(plan.non_trie_persisted_tip(), Some(blocks[5].recovered_block().num_hash())); +} + #[test] fn test_on_persistence_complete_retains_blocks_above_partial_state_trie() { let chain_spec = MAINNET.clone(); @@ -1022,31 +1058,36 @@ fn test_on_persistence_complete_retains_blocks_above_partial_state_trie() { let blocks: Vec<_> = test_block_builder.get_executed_blocks(0..7).collect(); test_harness = test_harness.with_blocks(blocks.clone()); - test_harness.tree.persistence_state.last_persisted_block = + test_harness.tree.persistence_state.non_trie_persisted_tip = blocks[1].recovered_block().num_hash(); + test_harness.tree.persistence_state.trie_persisted_tip = blocks[1].recovered_block().num_hash(); let persisted_tip = blocks[5].recovered_block().num_hash(); - let partial_state_trie = blocks[3].recovered_block().number(); + let trie_persisted_tip = blocks[3].recovered_block().number(); test_harness .tree .on_persistence_complete( PersistenceResult { - last_block: Some(persisted_tip), - partial_state_trie: Some(partial_state_trie), + non_trie_persisted_tip: Some(persisted_tip), + trie_persisted_tip: Some(trie_persisted_tip), commit_duration: Some(Duration::ZERO), }, Instant::now(), ) .unwrap(); - assert_eq!(test_harness.tree.persistence_state.last_persisted_block, persisted_tip); + assert_eq!(test_harness.tree.persistence_state.non_trie_persisted_tip, persisted_tip); + assert_eq!( + test_harness.tree.persistence_state.trie_persisted_tip, + blocks[3].recovered_block().num_hash() + ); assert_eq!( test_harness.tree.canonical_in_memory_state.get_persisted_num_hash(), Some(persisted_tip) ); - for block in &blocks[..=partial_state_trie as usize] { + for block in &blocks[..=trie_persisted_tip as usize] { assert!(test_harness .tree .state @@ -1060,7 +1101,7 @@ fn test_on_persistence_complete_retains_blocks_above_partial_state_trie() { .is_none()); } - for block in &blocks[partial_state_trie as usize + 1..] { + for block in &blocks[trie_persisted_tip as usize + 1..] { assert!(test_harness .tree .state @@ -1083,8 +1124,9 @@ fn test_on_persistence_complete_without_partial_state_trie_prunes_through_tip() let blocks: Vec<_> = test_block_builder.get_executed_blocks(0..7).collect(); test_harness = test_harness.with_blocks(blocks.clone()); - test_harness.tree.persistence_state.last_persisted_block = + test_harness.tree.persistence_state.non_trie_persisted_tip = blocks[1].recovered_block().num_hash(); + test_harness.tree.persistence_state.trie_persisted_tip = blocks[1].recovered_block().num_hash(); let persisted_tip = blocks[5].recovered_block().num_hash(); @@ -1092,8 +1134,8 @@ fn test_on_persistence_complete_without_partial_state_trie_prunes_through_tip() .tree .on_persistence_complete( PersistenceResult { - last_block: Some(persisted_tip), - partial_state_trie: None, + non_trie_persisted_tip: Some(persisted_tip), + trie_persisted_tip: None, commit_duration: Some(Duration::ZERO), }, Instant::now(), @@ -2223,18 +2265,18 @@ mod forkchoice_updated_tests { break; } - if let Ok(PersistenceAction::SaveBlocks(saved_blocks, sender)) = + if let Ok(PersistenceAction::SaveBlocks(plan, sender)) = action_rx.recv_timeout(std::time::Duration::from_millis(100)) { - if let Some(last) = saved_blocks.last() { + if let Some(last) = plan.non_trie_persisted_tip() { + last_persisted_number = last.number; + } else if let Some(last) = plan.blocks.last() { last_persisted_number = last.recovered_block().number; } sender .send(PersistenceResult { - last_block: saved_blocks.last().map(|b| b.recovered_block().num_hash()), - partial_state_trie: saved_blocks - .last() - .map(|b| b.recovered_block().number()), + non_trie_persisted_tip: plan.non_trie_persisted_tip(), + trie_persisted_tip: plan.non_trie_persisted_tip().map(|tip| tip.number), commit_duration: Some(Duration::ZERO), }) .unwrap(); diff --git a/crates/storage/provider/src/providers/blockchain_provider.rs b/crates/storage/provider/src/providers/blockchain_provider.rs index 77c7985a5ad..09410938c5f 100644 --- a/crates/storage/provider/src/providers/blockchain_provider.rs +++ b/crates/storage/provider/src/providers/blockchain_provider.rs @@ -1010,7 +1010,9 @@ mod tests { provider_rw .save_blocks( std::slice::from_ref(&lowest_memory_block), + 0, 1, + 0, SaveBlocksMode::Full, ) .unwrap(); diff --git a/crates/storage/provider/src/providers/database/provider.rs b/crates/storage/provider/src/providers/database/provider.rs index 2c65cfd4731..b777701d589 100644 --- a/crates/storage/provider/src/providers/database/provider.rs +++ b/crates/storage/provider/src/providers/database/provider.rs @@ -559,13 +559,15 @@ impl DatabaseProvider DatabaseProvider], - trie_masked_block_start: usize, + trie_catchup_block_count: usize, + full_persist_block_count: usize, + deferred_trie_block_count: usize, save_mode: SaveBlocksMode, ) -> ProviderResult<()> { if blocks.is_empty() { @@ -589,34 +595,46 @@ impl DatabaseProvider blocks.len() { + let full_persist_end = trie_catchup_block_count + full_persist_block_count; + let in_memory_block_start = full_persist_end + deferred_trie_block_count; + if in_memory_block_start > blocks.len() { return Err(ProviderError::Database(reth_db_api::DatabaseError::Other(format!( - "trie masked block start {trie_masked_block_start} exceeds block count {}", + "save block plan ({trie_catchup_block_count} catchup + {full_persist_block_count} full + {deferred_trie_block_count} deferred) exceeds block count {}", blocks.len() )))) } - let trie_masking_blocks = &blocks[trie_masked_block_start..]; + let trie_catchup_blocks = &blocks[..trie_catchup_block_count]; + let full_persist_blocks = &blocks[trie_catchup_block_count..full_persist_end]; + let deferred_trie_blocks = &blocks[full_persist_end..in_memory_block_start]; + let non_trie_blocks = &blocks[trie_catchup_block_count..in_memory_block_start]; + let in_memory_blocks = &blocks[in_memory_block_start..]; let total_start = Instant::now(); let block_count = blocks.len() as u64; let first_number = blocks.first().unwrap().recovered_block().number(); - let last_block_number = blocks.last().unwrap().recovered_block().number(); + let last_non_trie_block_number = non_trie_blocks + .last() + .or_else(|| trie_catchup_blocks.last()) + .expect("checked non-empty block range") + .recovered_block() + .number(); debug!(target: "providers::db", block_count, "Writing blocks and execution data to storage"); - // Compute tx_nums upfront (both threads need these) - let first_tx_num = self - .tx - .cursor_read::()? - .last()? - .map(|(n, _)| n + 1) - .unwrap_or_default(); + let tx_nums: Vec = if non_trie_blocks.is_empty() { + Vec::new() + } else { + let first_tx_num = self + .tx + .cursor_read::()? + .last()? + .map(|(n, _)| n + 1) + .unwrap_or_default(); - let tx_nums: Vec = { - let mut nums = Vec::with_capacity(blocks.len()); + let mut nums = Vec::with_capacity(non_trie_blocks.len()); let mut current = first_tx_num; - for block in blocks { + for block in non_trie_blocks { nums.push(current); current += block.recovered_block().body().transaction_count() as u64; } @@ -626,12 +644,31 @@ impl DatabaseProvider DatabaseProvider DatabaseProvider DatabaseProvider DatabaseProvider = blocks + let trie_persist_data: Vec<_> = trie_catchup_blocks .iter() - .enumerate() - .filter_map(|(index, block)| { - (index < trie_masked_block_start).then(|| block.trie_data()) - }) + .chain(full_persist_blocks.iter()) + .map(|block| block.trie_data()) + .collect(); + let trie_masking_data: Vec<_> = deferred_trie_blocks + .iter() + .chain(in_memory_blocks.iter()) + .map(|block| block.trie_data()) .collect(); - let trie_masking_data: Vec<_> = - trie_masking_blocks.iter().map(|block| block.trie_data()).collect(); let start = Instant::now(); if !trie_persist_data.is_empty() { @@ -772,31 +829,38 @@ impl DatabaseProvider 0 { - Some(blocks[trie_masked_block_start - 1].recovered_block().number()) - } else { + let current_trie_persisted_tip = self.get_stage_checkpoint(StageId::Finish)?.map(|checkpoint| { checkpoint .finish_stage_checkpoint() .and_then(|finish| finish.partial_state_trie) .unwrap_or(checkpoint.block_number) - }) - }; + }); + let partial_state_trie = + if let Some(last_full_persist_block) = full_persist_blocks.last() { + Some(last_full_persist_block.recovered_block().number()) + } else if let Some(last_trie_catchup_block) = trie_catchup_blocks.last() { + Some(last_trie_catchup_block.recovered_block().number()) + } else { + current_trie_persisted_tip.or(Some(last_non_trie_block_number)) + }; self.save_stage_checkpoint( StageId::Finish, - StageCheckpoint::new(last_block_number) + StageCheckpoint::new(last_non_trie_block_number) .with_finish_stage_checkpoint(FinishCheckpoint { partial_state_trie }), )?; } @@ -807,21 +871,27 @@ impl DatabaseProvider(()) })?; - // Collect results from spawned tasks - timings.sf = sf_result.ok_or(StaticFileWriterError::ThreadPanic("static file"))??; + // Collect results from spawned tasks. + if has_non_trie_blocks { + timings.sf = sf_result.ok_or(StaticFileWriterError::ThreadPanic("static file"))??; - if rocksdb_enabled { - timings.rocksdb = rocksdb_result.ok_or_else(|| { - ProviderError::Database(reth_db_api::DatabaseError::Other( - "RocksDB thread panicked".into(), - )) - })??; + if rocksdb_enabled { + timings.rocksdb = rocksdb_result.ok_or_else(|| { + ProviderError::Database(reth_db_api::DatabaseError::Other( + "RocksDB thread panicked".into(), + )) + })??; + } } timings.total = total_start.elapsed(); self.metrics.record_save_blocks(&timings); - debug!(target: "providers::db", range = ?first_number..=last_block_number, "Appended block data"); + debug!( + target: "providers::db", + range = ?first_number..=last_non_trie_block_number, + "Appended block data" + ); Ok(()) } @@ -3551,7 +3621,13 @@ impl BlockWriter ); // Delegate to save_blocks with BlocksOnly mode (skips receipts/state/trie) - self.save_blocks(std::slice::from_ref(&executed_block), 1, SaveBlocksMode::BlocksOnly)?; + self.save_blocks( + std::slice::from_ref(&executed_block), + 0, + 1, + 0, + SaveBlocksMode::BlocksOnly, + )?; // Return the body indices self.block_body_indices(block_number)? @@ -4519,7 +4595,7 @@ mod tests { let provider_rw = factory.provider_rw().unwrap(); provider_rw - .save_blocks(std::slice::from_ref(&genesis_executed), 1, SaveBlocksMode::Full) + .save_blocks(std::slice::from_ref(&genesis_executed), 0, 1, 0, SaveBlocksMode::Full) .unwrap(); provider_rw.commit().unwrap(); @@ -4662,7 +4738,7 @@ mod tests { let provider_rw = factory.provider_rw().unwrap(); let blocks = vec![partial_persist_block, in_memory_only_block]; - provider_rw.save_blocks(&blocks, 1, SaveBlocksMode::Full).unwrap(); + provider_rw.save_blocks(&blocks, 1, 0, 1, SaveBlocksMode::Full).unwrap(); provider_rw.commit().unwrap(); let provider = factory.provider().unwrap(); @@ -4739,6 +4815,48 @@ mod tests { assert!(in_memory_entries.is_empty()); } + #[test] + fn test_save_blocks_partial_cycles_do_not_duplicate_static_file_writes() { + let factory = create_test_provider_factory(); + let mut test_block_builder = TestBlockBuilder::eth().with_state(); + + let genesis = test_block_builder.get_executed_blocks(0..1).next().unwrap(); + let blocks: Vec<_> = test_block_builder.get_executed_blocks(1..5).collect(); + + let provider_rw = factory.provider_rw().unwrap(); + provider_rw + .save_blocks(std::slice::from_ref(&genesis), 0, 1, 0, SaveBlocksMode::Full) + .unwrap(); + provider_rw.commit().unwrap(); + + let provider_rw = factory.provider_rw().unwrap(); + provider_rw.save_blocks(&blocks[..2], 0, 2, 0, SaveBlocksMode::Full).unwrap(); + provider_rw.commit().unwrap(); + + let provider_rw = factory.provider_rw().unwrap(); + provider_rw.save_blocks(&blocks, 2, 0, 2, SaveBlocksMode::Full).unwrap(); + provider_rw.commit().unwrap(); + + let provider = factory.provider().unwrap(); + let finish_checkpoint = provider.get_stage_checkpoint(StageId::Finish).unwrap().unwrap(); + assert_eq!(finish_checkpoint.block_number, 4); + assert_eq!( + finish_checkpoint.finish_stage_checkpoint().unwrap().partial_state_trie, + Some(2) + ); + + let static_files = factory.static_file_provider(); + assert_eq!(static_files.get_highest_static_file_block(StaticFileSegment::Headers), Some(4)); + assert_eq!( + static_files.get_highest_static_file_block(StaticFileSegment::Transactions), + Some(4) + ); + assert_eq!( + static_files.get_highest_static_file_block(StaticFileSegment::Receipts), + Some(4) + ); + } + #[test] fn test_prunable_receipts_logic() { let insert_blocks = @@ -5323,7 +5441,7 @@ mod tests { ); let provider_rw = factory.provider_rw().unwrap(); provider_rw - .save_blocks(std::slice::from_ref(&genesis_executed), 1, SaveBlocksMode::Full) + .save_blocks(std::slice::from_ref(&genesis_executed), 0, 1, 0, SaveBlocksMode::Full) .unwrap(); provider_rw.commit().unwrap(); @@ -5396,7 +5514,7 @@ mod tests { } let provider_rw = factory.provider_rw().unwrap(); - provider_rw.save_blocks(&blocks, blocks.len(), SaveBlocksMode::Full).unwrap(); + provider_rw.save_blocks(&blocks, 0, blocks.len(), 0, SaveBlocksMode::Full).unwrap(); provider_rw.commit().unwrap(); let provider = factory.provider().unwrap(); From be4e8cd01754c0d5d78dea413bb87fb1531f9382 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:51:24 +0000 Subject: [PATCH 11/83] refactor(chain-state): derive lazy overlay anchor from blocks Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019dab40-7d65-709b-90d6-98965c5c6a65 Co-authored-by: Amp --- crates/chain-state/src/lazy_overlay.rs | 108 +++++++++++------- .../tree/src/tree/payload_processor/mod.rs | 7 +- .../engine/tree/src/tree/payload_validator.rs | 17 ++- crates/engine/tree/src/tree/state.rs | 27 ++--- .../provider/src/providers/state/overlay.rs | 29 +++-- crates/trie/parallel/src/proof_task.rs | 8 +- crates/trie/parallel/src/root.rs | 8 +- 7 files changed, 113 insertions(+), 91 deletions(-) diff --git a/crates/chain-state/src/lazy_overlay.rs b/crates/chain-state/src/lazy_overlay.rs index 58ccee5f90f..f38a27fa400 100644 --- a/crates/chain-state/src/lazy_overlay.rs +++ b/crates/chain-state/src/lazy_overlay.rs @@ -4,26 +4,31 @@ //! lazily on first access. This allows execution to start before the trie overlay //! is fully computed. -use crate::DeferredTrieData; +use crate::{EthPrimitives, ExecutedBlock}; use alloy_primitives::B256; +use reth_primitives_traits::{AlloyBlockHeader, NodePrimitives}; use reth_trie::{updates::TrieUpdatesSorted, HashedPostStateSorted, TrieInputSorted}; use std::sync::{Arc, OnceLock}; use tracing::{debug, trace}; /// Inputs captured for lazy overlay computation. #[derive(Clone)] -struct LazyOverlayInputs { - /// The persisted ancestor hash (anchor) this overlay should be built on. - anchor_hash: B256, - /// Deferred trie data handles for all in-memory blocks (newest to oldest). - blocks: Vec, +struct LazyOverlayInputs { + /// In-memory blocks from tip to anchor child. + /// + /// Blocks must be provided in reverse chain order (newest to oldest). The overlay anchor is + /// derived from the last block's parent hash. + blocks: Vec>, } /// Lazily computed trie overlay. /// /// Captures the inputs needed to compute a [`TrieInputSorted`] and defers the actual -/// computation until first access. This is conceptually similar to [`DeferredTrieData`] -/// but for overlay computation. +/// computation until first access. +/// +/// Blocks must be provided in reverse chain order (newest to oldest), so the first block is the +/// chain tip and the last block is the child of the persisted anchor. The anchor hash for the +/// overlay is derived from `blocks.last().parent_hash()`. /// /// # Fast Path vs Slow Path /// @@ -31,37 +36,43 @@ struct LazyOverlayInputs { /// matches our expected anchor, we can reuse it directly (O(1)). /// - **Slow path**: Otherwise, we merge all ancestor blocks' trie data into a new overlay. #[derive(Clone)] -pub struct LazyOverlay { +pub struct LazyOverlay { /// Computed result, cached after first access. inner: Arc>, /// Inputs for lazy computation. - inputs: LazyOverlayInputs, + inputs: LazyOverlayInputs, } -impl std::fmt::Debug for LazyOverlay { +impl std::fmt::Debug for LazyOverlay { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("LazyOverlay") - .field("anchor_hash", &self.inputs.anchor_hash) + .field("anchor_hash", &self.anchor_hash()) .field("num_blocks", &self.inputs.blocks.len()) .field("computed", &self.inner.get().is_some()) .finish() } } -impl LazyOverlay { - /// Create a new lazy overlay with the given anchor hash and block handles. +impl LazyOverlay { + /// Create a new lazy overlay from in-memory blocks. /// /// # Arguments /// - /// * `anchor_hash` - The persisted ancestor hash this overlay is built on top of - /// * `blocks` - Deferred trie data handles for in-memory blocks (newest to oldest) - pub fn new(anchor_hash: B256, blocks: Vec) -> Self { - Self { inner: Arc::new(OnceLock::new()), inputs: LazyOverlayInputs { anchor_hash, blocks } } + /// * `blocks` - Executed blocks in reverse chain order (newest to oldest) + pub fn new(blocks: Vec>) -> Self { + debug_assert!( + blocks.windows(2).all(|window| { + window[0].recovered_block().parent_hash() == window[1].recovered_block().hash() + }), + "LazyOverlay blocks must be ordered newest to oldest along a single chain" + ); + + Self { inner: Arc::new(OnceLock::new()), inputs: LazyOverlayInputs { blocks } } } /// Returns the anchor hash this overlay is built on. - pub const fn anchor_hash(&self) -> B256 { - self.inputs.anchor_hash + pub fn anchor_hash(&self) -> Option { + self.inputs.blocks.last().map(|block| block.recovered_block().parent_hash()) } /// Returns the number of in-memory blocks this overlay covers. @@ -90,18 +101,17 @@ impl LazyOverlay { /// Compute the trie input overlay. fn compute(&self) -> TrieInputSorted { - let anchor_hash = self.inputs.anchor_hash; let blocks = &self.inputs.blocks; - if blocks.is_empty() { + let Some(anchor_hash) = self.anchor_hash() else { debug!(target: "chain_state::lazy_overlay", "No in-memory blocks, returning empty overlay"); return TrieInputSorted::default(); - } + }; // Fast path: Check if tip block's overlay is ready and anchor matches. // The tip block (first in list) has the cumulative overlay from all ancestors. if let Some(tip) = blocks.first() { - let data = tip.wait_cloned(); + let data = tip.trie_data(); if let Some(anchored) = &data.anchored_trie_input { if anchored.anchor_hash == anchor_hash { trace!(target: "chain_state::lazy_overlay", %anchor_hash, "Reusing tip block's cached overlay (fast path)"); @@ -124,15 +134,18 @@ impl LazyOverlay { /// Merge all blocks' trie data into a single [`TrieInputSorted`]. /// /// Blocks are ordered newest to oldest. - fn merge_blocks(blocks: &[DeferredTrieData]) -> TrieInputSorted { + fn merge_blocks(blocks: &[ExecutedBlock]) -> TrieInputSorted { if blocks.is_empty() { return TrieInputSorted::default(); } - let state = - HashedPostStateSorted::merge_batch(blocks.iter().map(|b| b.wait_cloned().hashed_state)); - let nodes = - TrieUpdatesSorted::merge_batch(blocks.iter().map(|b| b.wait_cloned().trie_updates)); + let trie_data: Vec<_> = blocks.iter().map(|block| block.trie_data()).collect(); + let state = HashedPostStateSorted::merge_batch( + trie_data.iter().map(|trie_data| Arc::clone(&trie_data.hashed_state)), + ); + let nodes = TrieUpdatesSorted::merge_batch( + trie_data.iter().map(|trie_data| Arc::clone(&trie_data.trie_updates)), + ); TrieInputSorted { state, nodes, prefix_sets: Default::default() } } @@ -141,20 +154,11 @@ impl LazyOverlay { #[cfg(test)] mod tests { use super::*; - use reth_trie::{updates::TrieUpdates, HashedPostState}; - - fn empty_deferred(anchor: B256) -> DeferredTrieData { - DeferredTrieData::pending( - Arc::new(HashedPostState::default()), - Arc::new(TrieUpdates::default()), - anchor, - Vec::new(), - ) - } + use crate::{test_utils::TestBlockBuilder, EthPrimitives}; #[test] fn empty_blocks_returns_default() { - let overlay = LazyOverlay::new(B256::ZERO, vec![]); + let overlay = LazyOverlay::::new(vec![]); let result = overlay.get(); assert!(result.state.is_empty()); assert!(result.nodes.is_empty()); @@ -162,9 +166,8 @@ mod tests { #[test] fn single_block_uses_data_directly() { - let anchor = B256::random(); - let deferred = empty_deferred(anchor); - let overlay = LazyOverlay::new(anchor, vec![deferred]); + let block = TestBlockBuilder::eth().get_executed_block_with_number(1, B256::random()); + let overlay = LazyOverlay::new(vec![block]); assert!(!overlay.is_computed()); let _ = overlay.get(); @@ -173,7 +176,7 @@ mod tests { #[test] fn cached_after_first_access() { - let overlay = LazyOverlay::new(B256::ZERO, vec![]); + let overlay = LazyOverlay::::new(vec![]); // First access computes let _ = overlay.get(); @@ -183,4 +186,21 @@ mod tests { let _ = overlay.get(); assert!(overlay.is_computed()); } + + #[test] + fn anchor_hash_comes_from_oldest_parent() { + let blocks: Vec<_> = TestBlockBuilder::eth().get_executed_blocks(1..3).collect(); + let overlay = LazyOverlay::new(blocks.into_iter().rev().collect()); + + assert_eq!(overlay.anchor_hash(), Some(B256::ZERO)); + } + + #[test] + #[should_panic( + expected = "LazyOverlay blocks must be ordered newest to oldest along a single chain" + )] + fn misordered_blocks_panic() { + let blocks: Vec<_> = TestBlockBuilder::eth().get_executed_blocks(1..3).collect(); + let _ = LazyOverlay::new(blocks); + } } diff --git a/crates/engine/tree/src/tree/payload_processor/mod.rs b/crates/engine/tree/src/tree/payload_processor/mod.rs index 92ff14890b5..8386bfd3b06 100644 --- a/crates/engine/tree/src/tree/payload_processor/mod.rs +++ b/crates/engine/tree/src/tree/payload_processor/mod.rs @@ -956,7 +956,7 @@ mod tests { use rand::Rng; use reth_chainspec::ChainSpec; use reth_db_common::init::init_genesis; - use reth_ethereum_primitives::TransactionSigned; + use reth_ethereum_primitives::{EthPrimitives, TransactionSigned}; use reth_evm::OnStateHook; use reth_evm_ethereum::EthEvmConfig; use reth_primitives_traits::{Account, Recovered, StorageEntry}; @@ -1236,7 +1236,10 @@ mod tests { std::convert::identity, ), StateProviderBuilder::new(provider_factory.clone(), genesis_hash, None), - OverlayStateProviderFactory::new(provider_factory, ChangesetCache::new()), + OverlayStateProviderFactory::<_, EthPrimitives>::new( + provider_factory, + ChangesetCache::new(), + ), &TreeConfig::default(), None, // No BAL for test ); diff --git a/crates/engine/tree/src/tree/payload_validator.rs b/crates/engine/tree/src/tree/payload_validator.rs index 9ba884c8be7..2f56dde234f 100644 --- a/crates/engine/tree/src/tree/payload_validator.rs +++ b/crates/engine/tree/src/tree/payload_validator.rs @@ -1087,7 +1087,7 @@ where #[instrument(level = "debug", target = "engine::tree::payload_validator", skip_all)] fn compute_state_root_parallel( &self, - overlay_factory: OverlayStateProviderFactory

, + overlay_factory: OverlayStateProviderFactory, hashed_state: &LazyHashedPostState, ) -> Result<(B256, TrieUpdates), ParallelStateRootError> { let hashed_state = hashed_state.get(); @@ -1107,7 +1107,7 @@ where /// [`HashedPostState`] containing the changes of this block, to compute the state root and /// trie updates for this block. fn compute_state_root_serial( - overlay_factory: OverlayStateProviderFactory

, + overlay_factory: OverlayStateProviderFactory, hashed_state: &LazyHashedPostState, ) -> ProviderResult<(B256, TrieUpdates)> { let hashed_state = hashed_state.get(); @@ -1147,7 +1147,7 @@ where fn await_state_root_with_timeout( &self, handle: &mut PayloadHandle, - overlay_factory: OverlayStateProviderFactory

, + overlay_factory: OverlayStateProviderFactory, hashed_state: &LazyHashedPostState, ) -> ProviderResult> { let Some(timeout) = self.config.state_root_task_timeout() else { @@ -1239,7 +1239,7 @@ where /// updates. fn compare_trie_updates_with_serial( &self, - overlay_factory: OverlayStateProviderFactory

, + overlay_factory: OverlayStateProviderFactory, hashed_state: &LazyHashedPostState, task_trie_updates: TrieUpdates, ) -> bool { @@ -1437,7 +1437,7 @@ where env: ExecutionEnv, txs: T, provider_builder: StateProviderBuilder, - overlay_factory: OverlayStateProviderFactory

, + overlay_factory: OverlayStateProviderFactory, strategy: StateRootStrategy, block_access_list: Option>, ) -> Result< @@ -1563,7 +1563,7 @@ where fn get_parent_lazy_overlay( parent_hash: B256, state: &EngineApiTreeState, - ) -> (Option, B256) { + ) -> (Option>, B256) { // Get blocks leading to the parent to determine the anchor let (anchor_hash, blocks) = state.tree_state.blocks_by_hash(parent_hash).unwrap_or_else(|| (parent_hash, vec![])); @@ -1591,10 +1591,7 @@ where "Creating lazy overlay for in-memory blocks" ); - // Extract deferred trie data handles (non-blocking) - let handles: Vec = blocks.iter().map(|b| b.trie_data_handle()).collect(); - - (Some(LazyOverlay::new(anchor_hash, handles)), anchor_hash) + (Some(LazyOverlay::new(blocks)), anchor_hash) } /// Spawns a background task to compute and sort trie data for the executed block. diff --git a/crates/engine/tree/src/tree/state.rs b/crates/engine/tree/src/tree/state.rs index 797c1081724..6f3f2bdd6e1 100644 --- a/crates/engine/tree/src/tree/state.rs +++ b/crates/engine/tree/src/tree/state.rs @@ -6,7 +6,7 @@ use alloy_primitives::{ map::{B256Map, B256Set}, BlockNumber, B256, }; -use reth_chain_state::{DeferredTrieData, EthPrimitives, ExecutedBlock, LazyOverlay}; +use reth_chain_state::{EthPrimitives, ExecutedBlock, LazyOverlay}; use reth_primitives_traits::{AlloyBlockHeader, NodePrimitives, SealedHeader}; use std::{ collections::{btree_map, hash_map, BTreeMap, VecDeque}, @@ -43,7 +43,7 @@ pub struct TreeState { /// This is optimistically prepared after the canonical head changes, so that /// the next payload building on the canonical head can use it immediately /// without recomputing. - pub(crate) cached_canonical_overlay: Option, + pub(crate) cached_canonical_overlay: Option>, } impl TreeState { @@ -109,7 +109,7 @@ impl TreeState { /// Returns a clone of the [`LazyOverlay`] so the caller can spawn a background /// task to trigger computation via [`LazyOverlay::get`]. This ensures the overlay /// is actually computed before the next payload arrives. - pub(crate) fn prepare_canonical_overlay(&mut self) -> Option { + pub(crate) fn prepare_canonical_overlay(&mut self) -> Option> { let canonical_hash = self.current_canonical_head.hash; // Get blocks leading to the canonical head @@ -119,10 +119,7 @@ impl TreeState { return None; }; - // Extract deferred trie data handles from blocks (newest to oldest) - let handles: Vec = blocks.iter().map(|b| b.trie_data_handle()).collect(); - - let overlay = LazyOverlay::new(anchor_hash, handles); + let overlay = LazyOverlay::new(blocks.clone()); self.cached_canonical_overlay = Some(PreparedCanonicalOverlay { parent_hash: canonical_hash, overlay: overlay.clone(), @@ -148,7 +145,7 @@ impl TreeState { &self, parent_hash: B256, expected_anchor: B256, - ) -> Option<&PreparedCanonicalOverlay> { + ) -> Option<&PreparedCanonicalOverlay> { self.cached_canonical_overlay.as_ref().filter(|cached| { cached.parent_hash == parent_hash && cached.anchor_hash == expected_anchor }) @@ -429,10 +426,10 @@ impl TreeState { /// the next payload (which typically builds on the canonical head) to reuse /// the pre-computed overlay immediately without re-traversing in-memory blocks. /// -/// The overlay captures deferred trie data handles from all in-memory blocks +/// The overlay captures executed blocks from all in-memory blocks /// between the canonical head and the persisted anchor. When a new payload /// arrives building on the canonical head, this cached overlay can be used -/// directly instead of calling `blocks_by_hash` and collecting handles again. +/// directly instead of calling `blocks_by_hash` again. /// /// # Invalidation /// @@ -440,16 +437,16 @@ impl TreeState { /// - Persistence completes (anchor changes) /// - The canonical head changes to a different block #[derive(Debug, Clone)] -pub struct PreparedCanonicalOverlay { +pub struct PreparedCanonicalOverlay { /// The block hash for which this overlay is prepared as a parent. /// /// When a payload arrives with this parent hash, the overlay can be reused. pub parent_hash: B256, - /// The pre-computed lazy overlay containing deferred trie data handles. + /// The pre-computed lazy overlay containing executed blocks for the canonical segment. /// - /// This is computed optimistically after `set_canonical_head` so subsequent - /// payloads don't need to re-collect the handles. - pub overlay: LazyOverlay, + /// This is computed optimistically after `set_canonical_head` so subsequent payloads don't + /// need to walk the in-memory chain again. + pub overlay: LazyOverlay, /// The anchor hash (persisted ancestor) this overlay is based on. /// /// Used to verify the overlay is still valid (anchor hasn't changed due to persistence). diff --git a/crates/storage/provider/src/providers/state/overlay.rs b/crates/storage/provider/src/providers/state/overlay.rs index f52498c71d8..898e7a69032 100644 --- a/crates/storage/provider/src/providers/state/overlay.rs +++ b/crates/storage/provider/src/providers/state/overlay.rs @@ -1,10 +1,13 @@ use alloy_primitives::{BlockNumber, B256}; use metrics::{Counter, Histogram}; -use reth_chain_state::LazyOverlay; +use reth_chain_state::{EthPrimitives, LazyOverlay}; use reth_db_api::{tables, transaction::DbTx, DatabaseError}; use reth_errors::{ProviderError, ProviderResult}; use reth_metrics::Metrics; -use reth_primitives_traits::dashmap::{self, DashMap}; +use reth_primitives_traits::{ + dashmap::{self, DashMap}, + NodePrimitives, +}; use reth_prune_types::PruneSegment; use reth_stages_types::StageId; use reth_storage_api::{ @@ -61,7 +64,7 @@ struct Overlay { /// Either provides immediate pre-computed overlay data, or a lazy overlay that computes /// on first access. #[derive(Debug, Clone)] -pub enum OverlaySource { +pub enum OverlaySource { /// Immediate overlay with already-computed data. Immediate { /// Trie updates overlay. @@ -70,10 +73,10 @@ pub enum OverlaySource { state: Arc, }, /// Lazy overlay computed on first access. - Lazy(LazyOverlay), + Lazy(LazyOverlay), } -impl OverlaySource { +impl OverlaySource { /// Resolve the overlay source into (trie, state) tuple. /// /// For lazy overlays, this may block waiting for deferred data. @@ -90,13 +93,13 @@ impl OverlaySource { /// This factory allows building an `OverlayStateProvider` whose DB state has been reverted to a /// particular block, and/or with additional overlay information added on top. #[derive(Debug, Clone)] -pub struct OverlayStateProviderFactory { +pub struct OverlayStateProviderFactory { /// The underlying database provider factory factory: F, /// Optional block hash for collecting reverts block_hash: Option, /// Optional overlay source (lazy or immediate). - overlay_source: Option, + overlay_source: Option>, /// Changeset cache handle for retrieving trie changesets changeset_cache: ChangesetCache, /// Metrics for tracking provider operations @@ -106,7 +109,7 @@ pub struct OverlayStateProviderFactory { overlay_cache: Arc>, } -impl OverlayStateProviderFactory { +impl OverlayStateProviderFactory { /// Create a new overlay state provider factory pub fn new(factory: F, changeset_cache: ChangesetCache) -> Self { Self { @@ -129,7 +132,7 @@ impl OverlayStateProviderFactory { /// Set the overlay source (lazy or immediate). /// /// This overlay will be applied on top of any reverts applied via `with_block_hash`. - pub fn with_overlay_source(mut self, source: Option) -> Self { + pub fn with_overlay_source(mut self, source: Option>) -> Self { self.overlay_source = source; // Clear the overlay cache since we've updated the source. self.overlay_cache = Default::default(); @@ -139,7 +142,7 @@ impl OverlayStateProviderFactory { /// Set a lazy overlay that will be computed on first access. /// /// Convenience method that wraps the lazy overlay in `OverlaySource::Lazy`. - pub fn with_lazy_overlay(mut self, lazy_overlay: Option) -> Self { + pub fn with_lazy_overlay(mut self, lazy_overlay: Option>) -> Self { self.overlay_source = lazy_overlay.map(OverlaySource::Lazy); // Clear the overlay cache since we've updated the source. self.overlay_cache = Default::default(); @@ -192,8 +195,9 @@ impl OverlayStateProviderFactory { } } -impl OverlayStateProviderFactory +impl OverlayStateProviderFactory where + N: NodePrimitives, F: DatabaseProviderFactory, F::Provider: StageCheckpointReader + PruneCheckpointReader @@ -430,8 +434,9 @@ where } } -impl DatabaseProviderROFactory for OverlayStateProviderFactory +impl DatabaseProviderROFactory for OverlayStateProviderFactory where + N: NodePrimitives, F: DatabaseProviderFactory, F::Provider: StageCheckpointReader + PruneCheckpointReader diff --git a/crates/trie/parallel/src/proof_task.rs b/crates/trie/parallel/src/proof_task.rs index a7546c9cd3e..f7de9030bc8 100644 --- a/crates/trie/parallel/src/proof_task.rs +++ b/crates/trie/parallel/src/proof_task.rs @@ -1400,10 +1400,10 @@ mod tests { fn spawn_proof_workers_creates_handle() { let provider_factory = create_test_provider_factory(); let changeset_cache = reth_trie_db::ChangesetCache::new(); - let factory = reth_provider::providers::OverlayStateProviderFactory::new( - provider_factory, - changeset_cache, - ); + let factory = reth_provider::providers::OverlayStateProviderFactory::< + _, + reth_ethereum_primitives::EthPrimitives, + >::new(provider_factory, changeset_cache); let ctx = test_ctx(factory); let runtime = reth_tasks::Runtime::test(); diff --git a/crates/trie/parallel/src/root.rs b/crates/trie/parallel/src/root.rs index edd453cca1f..4d0f58e25f9 100644 --- a/crates/trie/parallel/src/root.rs +++ b/crates/trie/parallel/src/root.rs @@ -283,10 +283,10 @@ mod tests { async fn random_parallel_root() { let factory = create_test_provider_factory(); let changeset_cache = reth_trie_db::ChangesetCache::new(); - let mut overlay_factory = reth_provider::providers::OverlayStateProviderFactory::new( - factory.clone(), - changeset_cache, - ); + let mut overlay_factory = reth_provider::providers::OverlayStateProviderFactory::< + _, + reth_ethereum_primitives::EthPrimitives, + >::new(factory.clone(), changeset_cache); let mut rng = rand::rng(); let mut state = (0..100) From cacb69aca9f96e9957f58aff94c4abf8a27e4248 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:01:54 +0000 Subject: [PATCH 12/83] refactor(chain-state): address lazy overlay review feedback Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019dab40-7d65-709b-90d6-98965c5c6a65 Co-authored-by: Amp --- crates/chain-state/src/lazy_overlay.rs | 13 +++++-------- crates/engine/tree/src/tree/state.rs | 5 +++-- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/crates/chain-state/src/lazy_overlay.rs b/crates/chain-state/src/lazy_overlay.rs index f38a27fa400..f805a5e3d9a 100644 --- a/crates/chain-state/src/lazy_overlay.rs +++ b/crates/chain-state/src/lazy_overlay.rs @@ -16,8 +16,7 @@ use tracing::{debug, trace}; struct LazyOverlayInputs { /// In-memory blocks from tip to anchor child. /// - /// Blocks must be provided in reverse chain order (newest to oldest). The overlay anchor is - /// derived from the last block's parent hash. + /// Blocks must be provided in reverse chain order (newest to oldest). blocks: Vec>, } @@ -27,8 +26,7 @@ struct LazyOverlayInputs { /// computation until first access. /// /// Blocks must be provided in reverse chain order (newest to oldest), so the first block is the -/// chain tip and the last block is the child of the persisted anchor. The anchor hash for the -/// overlay is derived from `blocks.last().parent_hash()`. +/// chain tip and the last block is the oldest in-memory block in the chain segment. /// /// # Fast Path vs Slow Path /// @@ -71,7 +69,7 @@ impl LazyOverlay { } /// Returns the anchor hash this overlay is built on. - pub fn anchor_hash(&self) -> Option { + fn anchor_hash(&self) -> Option { self.inputs.blocks.last().map(|block| block.recovered_block().parent_hash()) } @@ -139,12 +137,11 @@ impl LazyOverlay { return TrieInputSorted::default(); } - let trie_data: Vec<_> = blocks.iter().map(|block| block.trie_data()).collect(); let state = HashedPostStateSorted::merge_batch( - trie_data.iter().map(|trie_data| Arc::clone(&trie_data.hashed_state)), + blocks.iter().map(|block| block.trie_data().hashed_state), ); let nodes = TrieUpdatesSorted::merge_batch( - trie_data.iter().map(|trie_data| Arc::clone(&trie_data.trie_updates)), + blocks.iter().map(|block| block.trie_data().trie_updates), ); TrieInputSorted { state, nodes, prefix_sets: Default::default() } diff --git a/crates/engine/tree/src/tree/state.rs b/crates/engine/tree/src/tree/state.rs index 6f3f2bdd6e1..6d3809c6c34 100644 --- a/crates/engine/tree/src/tree/state.rs +++ b/crates/engine/tree/src/tree/state.rs @@ -119,7 +119,8 @@ impl TreeState { return None; }; - let overlay = LazyOverlay::new(blocks.clone()); + let num_blocks = blocks.len(); + let overlay = LazyOverlay::new(blocks); self.cached_canonical_overlay = Some(PreparedCanonicalOverlay { parent_hash: canonical_hash, overlay: overlay.clone(), @@ -130,7 +131,7 @@ impl TreeState { target: "engine::tree", %canonical_hash, %anchor_hash, - num_blocks = blocks.len(), + num_blocks, "Prepared cached canonical overlay" ); From ebfaa6f4c51f5661c2c00a6c6bad4e2e3aac6aad Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:17:35 +0000 Subject: [PATCH 13/83] refactor(chain-state): cache lazy overlays by anchor Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019dab6f-7cee-755e-9f9c-309ae0b8517c Co-authored-by: Amp --- crates/chain-state/Cargo.toml | 2 +- crates/chain-state/src/lazy_overlay.rs | 207 +++++++++++++----- crates/engine/tree/src/tree/mod.rs | 4 +- .../engine/tree/src/tree/payload_validator.rs | 4 +- crates/engine/tree/src/tree/state.rs | 18 +- .../provider/src/providers/state/overlay.rs | 22 +- 6 files changed, 186 insertions(+), 71 deletions(-) diff --git a/crates/chain-state/Cargo.toml b/crates/chain-state/Cargo.toml index 25b8f36aa81..a4ce20fd434 100644 --- a/crates/chain-state/Cargo.toml +++ b/crates/chain-state/Cargo.toml @@ -18,7 +18,7 @@ reth-errors.workspace = true reth-execution-types.workspace = true reth-metrics.workspace = true reth-ethereum-primitives.workspace = true -reth-primitives-traits.workspace = true +reth-primitives-traits = { workspace = true, features = ["dashmap"] } reth-storage-api.workspace = true reth-trie.workspace = true diff --git a/crates/chain-state/src/lazy_overlay.rs b/crates/chain-state/src/lazy_overlay.rs index f805a5e3d9a..d39dc741a72 100644 --- a/crates/chain-state/src/lazy_overlay.rs +++ b/crates/chain-state/src/lazy_overlay.rs @@ -6,9 +6,12 @@ use crate::{EthPrimitives, ExecutedBlock}; use alloy_primitives::B256; -use reth_primitives_traits::{AlloyBlockHeader, NodePrimitives}; +use reth_primitives_traits::{ + dashmap::{self, DashMap}, + AlloyBlockHeader, NodePrimitives, +}; use reth_trie::{updates::TrieUpdatesSorted, HashedPostStateSorted, TrieInputSorted}; -use std::sync::{Arc, OnceLock}; +use std::sync::Arc; use tracing::{debug, trace}; /// Inputs captured for lazy overlay computation. @@ -35,8 +38,8 @@ struct LazyOverlayInputs { /// - **Slow path**: Otherwise, we merge all ancestor blocks' trie data into a new overlay. #[derive(Clone)] pub struct LazyOverlay { - /// Computed result, cached after first access. - inner: Arc>, + /// Computed results, cached by requested anchor hash. + inner: Arc>>, /// Inputs for lazy computation. inputs: LazyOverlayInputs, } @@ -44,9 +47,12 @@ pub struct LazyOverlay { impl std::fmt::Debug for LazyOverlay { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("LazyOverlay") - .field("anchor_hash", &self.anchor_hash()) + .field( + "oldest_block_parent_hash", + &self.inputs.blocks.last().map(|block| block.recovered_block().parent_hash()), + ) .field("num_blocks", &self.inputs.blocks.len()) - .field("computed", &self.inner.get().is_some()) + .field("cached_anchors", &self.inner.len()) .finish() } } @@ -65,12 +71,7 @@ impl LazyOverlay { "LazyOverlay blocks must be ordered newest to oldest along a single chain" ); - Self { inner: Arc::new(OnceLock::new()), inputs: LazyOverlayInputs { blocks } } - } - - /// Returns the anchor hash this overlay is built on. - fn anchor_hash(&self) -> Option { - self.inputs.blocks.last().map(|block| block.recovered_block().parent_hash()) + Self { inner: Default::default(), inputs: LazyOverlayInputs { blocks } } } /// Returns the number of in-memory blocks this overlay covers. @@ -78,42 +79,56 @@ impl LazyOverlay { self.inputs.blocks.len() } - /// Returns true if the overlay has already been computed. - pub fn is_computed(&self) -> bool { - self.inner.get().is_some() + /// Returns true if the overlay has already been computed for the requested anchor. + pub fn is_computed(&self, anchor_hash: B256) -> bool { + self.inner.contains_key(&anchor_hash) } - /// Returns the computed trie input, computing it if necessary. + /// Returns the computed trie input for the requested anchor, computing it if necessary. /// /// The first call triggers computation (which may block waiting for deferred data). - /// Subsequent calls return the cached result immediately. - pub fn get(&self) -> &TrieInputSorted { - self.inner.get_or_init(|| self.compute()) + /// Subsequent calls for the same anchor return the cached result immediately. + pub fn get(&self, anchor_hash: B256) -> Arc { + match self.inner.entry(anchor_hash) { + dashmap::Entry::Occupied(entry) => Arc::clone(entry.get()), + dashmap::Entry::Vacant(entry) => { + let input = self.compute(anchor_hash); + entry.insert(Arc::clone(&input)); + input + } + } } /// Returns the overlay as (nodes, state) tuple for use with `OverlayStateProviderFactory`. - pub fn as_overlay(&self) -> (Arc, Arc) { - let input = self.get(); + pub fn as_overlay( + &self, + anchor_hash: B256, + ) -> (Arc, Arc) { + let input = self.get(anchor_hash); (Arc::clone(&input.nodes), Arc::clone(&input.state)) } /// Compute the trie input overlay. - fn compute(&self) -> TrieInputSorted { + fn compute(&self, anchor_hash: B256) -> Arc { let blocks = &self.inputs.blocks; - - let Some(anchor_hash) = self.anchor_hash() else { - debug!(target: "chain_state::lazy_overlay", "No in-memory blocks, returning empty overlay"); - return TrieInputSorted::default(); + let Some(last_index) = + blocks.iter().position(|block| block.recovered_block().parent_hash() == anchor_hash) + else { + panic!( + "LazyOverlay does not contain a block whose parent hash matches requested anchor {anchor_hash}" + ); }; + let blocks = &blocks[..=last_index]; // Fast path: Check if tip block's overlay is ready and anchor matches. - // The tip block (first in list) has the cumulative overlay from all ancestors. + // The tip block (first in list) has the cumulative overlay from all ancestors up to the + // requested anchor. if let Some(tip) = blocks.first() { let data = tip.trie_data(); if let Some(anchored) = &data.anchored_trie_input { if anchored.anchor_hash == anchor_hash { trace!(target: "chain_state::lazy_overlay", %anchor_hash, "Reusing tip block's cached overlay (fast path)"); - return (*anchored.trie_input).clone(); + return Arc::clone(&anchored.trie_input); } debug!( target: "chain_state::lazy_overlay", @@ -124,9 +139,14 @@ impl LazyOverlay { } } - // Slow path: Merge all blocks' trie data into a new overlay. - debug!(target: "chain_state::lazy_overlay", num_blocks = blocks.len(), "Merging blocks (slow path)"); - Self::merge_blocks(blocks) + // Slow path: Merge the prefix of blocks from the tip back to the requested anchor. + debug!( + target: "chain_state::lazy_overlay", + %anchor_hash, + num_blocks = blocks.len(), + "Merging blocks (slow path)" + ); + Arc::new(Self::merge_blocks(blocks)) } /// Merge all blocks' trie data into a single [`TrieInputSorted`]. @@ -151,45 +171,130 @@ impl LazyOverlay { #[cfg(test)] mod tests { use super::*; - use crate::{test_utils::TestBlockBuilder, EthPrimitives}; + use crate::{test_utils::TestBlockBuilder, ComputedTrieData, EthPrimitives, ExecutedBlock}; + use alloy_primitives::U256; + use reth_primitives_traits::Account; + use reth_trie::{updates::TrieUpdatesSorted, HashedPostState, HashedStorage}; + use std::sync::Arc; + + fn with_unique_state( + block: &ExecutedBlock, + id: u8, + ) -> ExecutedBlock { + let hashed_address = B256::with_last_byte(id); + let hashed_slot = B256::with_last_byte(id.saturating_add(32)); + let hashed_state = HashedPostState::default() + .with_accounts([(hashed_address, Some(Account::default()))]) + .with_storages([( + hashed_address, + HashedStorage::from_iter(false, [(hashed_slot, U256::from(id))]), + )]) + .into_sorted(); + + ExecutedBlock::new( + Arc::clone(&block.recovered_block), + Arc::clone(&block.execution_output), + ComputedTrieData::without_trie_input( + Arc::new(hashed_state), + Arc::new(TrieUpdatesSorted::default()), + ), + ) + } + + fn test_blocks() -> Vec> { + TestBlockBuilder::eth() + .get_executed_blocks(1..4) + .collect::>() + .into_iter() + .rev() + .enumerate() + .map(|(index, block)| with_unique_state(&block, index as u8 + 1)) + .collect() + } #[test] - fn empty_blocks_returns_default() { + #[should_panic( + expected = "LazyOverlay does not contain a block whose parent hash matches requested anchor" + )] + fn empty_blocks_panic_for_any_anchor() { let overlay = LazyOverlay::::new(vec![]); - let result = overlay.get(); - assert!(result.state.is_empty()); - assert!(result.nodes.is_empty()); + let _ = overlay.get(B256::ZERO); } #[test] fn single_block_uses_data_directly() { let block = TestBlockBuilder::eth().get_executed_block_with_number(1, B256::random()); + let anchor_hash = block.recovered_block().parent_hash(); let overlay = LazyOverlay::new(vec![block]); - assert!(!overlay.is_computed()); - let _ = overlay.get(); - assert!(overlay.is_computed()); + assert!(!overlay.is_computed(anchor_hash)); + let _ = overlay.get(anchor_hash); + assert!(overlay.is_computed(anchor_hash)); } #[test] - fn cached_after_first_access() { - let overlay = LazyOverlay::::new(vec![]); + fn caches_results_per_anchor() { + let blocks = test_blocks(); + let prefix_anchor = blocks[2].recovered_block().hash(); + let full_anchor = blocks[2].recovered_block().parent_hash(); + let overlay = LazyOverlay::new(blocks); - // First access computes - let _ = overlay.get(); - assert!(overlay.is_computed()); + let prefix = overlay.get(prefix_anchor); + let full = overlay.get(full_anchor); - // Second access uses cache - let _ = overlay.get(); - assert!(overlay.is_computed()); + assert!(overlay.is_computed(prefix_anchor)); + assert!(overlay.is_computed(full_anchor)); + assert!(!Arc::ptr_eq(&prefix, &full)); + assert!(Arc::ptr_eq(&prefix, &overlay.get(prefix_anchor))); + assert!(Arc::ptr_eq(&full, &overlay.get(full_anchor))); } #[test] - fn anchor_hash_comes_from_oldest_parent() { - let blocks: Vec<_> = TestBlockBuilder::eth().get_executed_blocks(1..3).collect(); - let overlay = LazyOverlay::new(blocks.into_iter().rev().collect()); + fn requested_anchor_limits_the_merged_prefix() { + let blocks = test_blocks(); + let prefix_anchor = blocks[2].recovered_block().hash(); + let expected = LazyOverlay::merge_blocks(&blocks[..2]); + let overlay = LazyOverlay::new(blocks); + let actual = overlay.get(prefix_anchor); + + assert_eq!(actual.nodes.as_ref(), expected.nodes.as_ref()); + assert_eq!(actual.state.as_ref(), expected.state.as_ref()); + } + + #[test] + fn reuses_tip_overlay_when_anchor_matches() { + let mut blocks = test_blocks(); + let prefix_anchor = blocks[2].recovered_block().hash(); + let tip_overlay = Arc::new(LazyOverlay::merge_blocks(&blocks[..2])); + let tip_data = blocks[0].trie_data(); + + blocks[0] = ExecutedBlock::new( + Arc::clone(&blocks[0].recovered_block), + Arc::clone(&blocks[0].execution_output), + ComputedTrieData::with_trie_input( + tip_data.hashed_state, + tip_data.trie_updates, + prefix_anchor, + Arc::clone(&tip_overlay), + ), + ); + + let overlay = LazyOverlay::new(blocks); + let actual = overlay.get(prefix_anchor); + + assert!(Arc::ptr_eq(&actual, &tip_overlay)); + } + + #[test] + #[should_panic( + expected = "LazyOverlay does not contain a block whose parent hash matches requested anchor" + )] + fn missing_anchor_panics() { + let blocks = test_blocks(); + let missing_anchor = blocks[0].recovered_block().hash(); + let overlay = LazyOverlay::new(blocks); - assert_eq!(overlay.anchor_hash(), Some(B256::ZERO)); + let _ = overlay.get(missing_anchor); } #[test] diff --git a/crates/engine/tree/src/tree/mod.rs b/crates/engine/tree/src/tree/mod.rs index e5230db98b6..49999e2063c 100644 --- a/crates/engine/tree/src/tree/mod.rs +++ b/crates/engine/tree/src/tree/mod.rs @@ -1500,9 +1500,9 @@ where // Re-prepare overlay for the current canonical head with the new anchor. // Spawn a background task to trigger computation so it's ready when the next payload // arrives. - if let Some(overlay) = self.state.tree_state.prepare_canonical_overlay() { + if let Some(prepared) = self.state.tree_state.prepare_canonical_overlay() { self.runtime.spawn_blocking_named("prepare-overlay", move || { - let _ = overlay.get(); + let _ = prepared.overlay.get(prepared.anchor_hash); }); } diff --git a/crates/engine/tree/src/tree/payload_validator.rs b/crates/engine/tree/src/tree/payload_validator.rs index 2f56dde234f..c4f74316745 100644 --- a/crates/engine/tree/src/tree/payload_validator.rs +++ b/crates/engine/tree/src/tree/payload_validator.rs @@ -526,7 +526,7 @@ where let overlay_factory = OverlayStateProviderFactory::new(self.provider.clone(), self.changeset_cache.clone()) .with_block_hash(Some(anchor_hash)) - .with_lazy_overlay(lazy_overlay); + .with_lazy_overlay(lazy_overlay, anchor_hash); // Spawn the appropriate processor based on strategy let mut handle = ensure_ok!(self.spawn_payload_processor( @@ -2026,7 +2026,7 @@ where let overlay_factory = OverlayStateProviderFactory::new(self.provider.clone(), self.changeset_cache.clone()) .with_block_hash(Some(anchor_hash)) - .with_lazy_overlay(lazy_overlay); + .with_lazy_overlay(lazy_overlay, anchor_hash); Some(self.payload_processor.spawn_state_root( overlay_factory, diff --git a/crates/engine/tree/src/tree/state.rs b/crates/engine/tree/src/tree/state.rs index 6d3809c6c34..a5b3e40d0b0 100644 --- a/crates/engine/tree/src/tree/state.rs +++ b/crates/engine/tree/src/tree/state.rs @@ -106,10 +106,10 @@ impl TreeState { /// This should be called after the canonical head changes to optimistically /// prepare the overlay for the next payload that will likely build on it. /// - /// Returns a clone of the [`LazyOverlay`] so the caller can spawn a background - /// task to trigger computation via [`LazyOverlay::get`]. This ensures the overlay - /// is actually computed before the next payload arrives. - pub(crate) fn prepare_canonical_overlay(&mut self) -> Option> { + /// Returns a clone of the prepared overlay so the caller can spawn a background + /// task to trigger computation via [`LazyOverlay::get`] for the cached anchor. + /// This ensures the overlay is actually computed before the next payload arrives. + pub(crate) fn prepare_canonical_overlay(&mut self) -> Option> { let canonical_hash = self.current_canonical_head.hash; // Get blocks leading to the canonical head @@ -120,12 +120,12 @@ impl TreeState { }; let num_blocks = blocks.len(); - let overlay = LazyOverlay::new(blocks); - self.cached_canonical_overlay = Some(PreparedCanonicalOverlay { + let prepared = PreparedCanonicalOverlay { parent_hash: canonical_hash, - overlay: overlay.clone(), + overlay: LazyOverlay::new(blocks), anchor_hash, - }); + }; + self.cached_canonical_overlay = Some(prepared.clone()); debug!( target: "engine::tree", @@ -135,7 +135,7 @@ impl TreeState { "Prepared cached canonical overlay" ); - Some(overlay) + Some(prepared) } /// Returns the cached overlay if it matches the requested parent hash and anchor. diff --git a/crates/storage/provider/src/providers/state/overlay.rs b/crates/storage/provider/src/providers/state/overlay.rs index 898e7a69032..69c198444d9 100644 --- a/crates/storage/provider/src/providers/state/overlay.rs +++ b/crates/storage/provider/src/providers/state/overlay.rs @@ -73,7 +73,12 @@ pub enum OverlaySource { state: Arc, }, /// Lazy overlay computed on first access. - Lazy(LazyOverlay), + Lazy { + /// Lazy overlay that can compute trie input for the requested anchor. + overlay: LazyOverlay, + /// Persisted anchor hash the overlay should be resolved against. + anchor_hash: B256, + }, } impl OverlaySource { @@ -83,7 +88,7 @@ impl OverlaySource { fn resolve(&self) -> (Arc, Arc) { match self { Self::Immediate { trie, state } => (Arc::clone(trie), Arc::clone(state)), - Self::Lazy(lazy) => lazy.as_overlay(), + Self::Lazy { overlay, anchor_hash } => overlay.as_overlay(*anchor_hash), } } } @@ -142,8 +147,13 @@ impl OverlayStateProviderFactory { /// Set a lazy overlay that will be computed on first access. /// /// Convenience method that wraps the lazy overlay in `OverlaySource::Lazy`. - pub fn with_lazy_overlay(mut self, lazy_overlay: Option>) -> Self { - self.overlay_source = lazy_overlay.map(OverlaySource::Lazy); + pub fn with_lazy_overlay( + mut self, + lazy_overlay: Option>, + anchor_hash: B256, + ) -> Self { + self.overlay_source = + lazy_overlay.map(|overlay| OverlaySource::Lazy { overlay, anchor_hash }); // Clear the overlay cache since we've updated the source. self.overlay_cache = Default::default(); self @@ -176,9 +186,9 @@ impl OverlayStateProviderFactory { Some(OverlaySource::Immediate { state, .. }) => { Arc::make_mut(state).extend_ref_and_sort(&other); } - Some(OverlaySource::Lazy(lazy)) => { + Some(OverlaySource::Lazy { overlay, anchor_hash }) => { // Resolve lazy overlay and convert to immediate with extension - let (trie, mut state) = lazy.as_overlay(); + let (trie, mut state) = overlay.as_overlay(*anchor_hash); Arc::make_mut(&mut state).extend_ref_and_sort(&other); self.overlay_source = Some(OverlaySource::Immediate { trie, state }); } From 5041d55bc310dde697cce5737fa9d9e440ad2a72 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:46:58 +0000 Subject: [PATCH 14/83] refactor(provider): resolve lazy overlay anchors at use time Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019dab6f-7cee-755e-9f9c-309ae0b8517c Co-authored-by: Amp --- .../engine/tree/src/tree/payload_validator.rs | 4 +- .../provider/src/providers/state/overlay.rs | 43 +++++++++---------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/crates/engine/tree/src/tree/payload_validator.rs b/crates/engine/tree/src/tree/payload_validator.rs index c4f74316745..2f56dde234f 100644 --- a/crates/engine/tree/src/tree/payload_validator.rs +++ b/crates/engine/tree/src/tree/payload_validator.rs @@ -526,7 +526,7 @@ where let overlay_factory = OverlayStateProviderFactory::new(self.provider.clone(), self.changeset_cache.clone()) .with_block_hash(Some(anchor_hash)) - .with_lazy_overlay(lazy_overlay, anchor_hash); + .with_lazy_overlay(lazy_overlay); // Spawn the appropriate processor based on strategy let mut handle = ensure_ok!(self.spawn_payload_processor( @@ -2026,7 +2026,7 @@ where let overlay_factory = OverlayStateProviderFactory::new(self.provider.clone(), self.changeset_cache.clone()) .with_block_hash(Some(anchor_hash)) - .with_lazy_overlay(lazy_overlay, anchor_hash); + .with_lazy_overlay(lazy_overlay); Some(self.payload_processor.spawn_state_root( overlay_factory, diff --git a/crates/storage/provider/src/providers/state/overlay.rs b/crates/storage/provider/src/providers/state/overlay.rs index 69c198444d9..ecbe86808f1 100644 --- a/crates/storage/provider/src/providers/state/overlay.rs +++ b/crates/storage/provider/src/providers/state/overlay.rs @@ -73,22 +73,21 @@ pub enum OverlaySource { state: Arc, }, /// Lazy overlay computed on first access. - Lazy { - /// Lazy overlay that can compute trie input for the requested anchor. - overlay: LazyOverlay, - /// Persisted anchor hash the overlay should be resolved against. - anchor_hash: B256, - }, + Lazy(LazyOverlay), } impl OverlaySource { /// Resolve the overlay source into (trie, state) tuple. /// /// For lazy overlays, this may block waiting for deferred data. - fn resolve(&self) -> (Arc, Arc) { + fn resolve( + &self, + anchor_hash: Option, + ) -> (Arc, Arc) { match self { Self::Immediate { trie, state } => (Arc::clone(trie), Arc::clone(state)), - Self::Lazy { overlay, anchor_hash } => overlay.as_overlay(*anchor_hash), + Self::Lazy(overlay) => overlay + .as_overlay(anchor_hash.expect("lazy overlay resolution requires an anchor hash")), } } } @@ -147,13 +146,8 @@ impl OverlayStateProviderFactory { /// Set a lazy overlay that will be computed on first access. /// /// Convenience method that wraps the lazy overlay in `OverlaySource::Lazy`. - pub fn with_lazy_overlay( - mut self, - lazy_overlay: Option>, - anchor_hash: B256, - ) -> Self { - self.overlay_source = - lazy_overlay.map(|overlay| OverlaySource::Lazy { overlay, anchor_hash }); + pub fn with_lazy_overlay(mut self, lazy_overlay: Option>) -> Self { + self.overlay_source = lazy_overlay.map(OverlaySource::Lazy); // Clear the overlay cache since we've updated the source. self.overlay_cache = Default::default(); self @@ -186,9 +180,11 @@ impl OverlayStateProviderFactory { Some(OverlaySource::Immediate { state, .. }) => { Arc::make_mut(state).extend_ref_and_sort(&other); } - Some(OverlaySource::Lazy { overlay, anchor_hash }) => { + Some(OverlaySource::Lazy(overlay)) => { // Resolve lazy overlay and convert to immediate with extension - let (trie, mut state) = overlay.as_overlay(*anchor_hash); + let (trie, mut state) = overlay.as_overlay( + self.block_hash.expect("extending a lazy overlay requires an anchor hash"), + ); Arc::make_mut(&mut state).extend_ref_and_sort(&other); self.overlay_source = Some(OverlaySource::Immediate { trie, state }); } @@ -221,9 +217,12 @@ where /// /// If an overlay source is set, it is resolved (blocking if lazy). /// Otherwise, returns empty defaults. - fn resolve_overlays(&self) -> (Arc, Arc) { + fn resolve_overlays( + &self, + anchor_hash: Option, + ) -> (Arc, Arc) { match &self.overlay_source { - Some(source) => source.resolve(), + Some(source) => source.resolve(anchor_hash), None => { (Arc::new(TrieUpdatesSorted::default()), Arc::new(HashedPostStateSorted::default())) } @@ -360,7 +359,7 @@ where // Resolve overlays (lazy or immediate) and extend reverts with them. // If reverts are empty, use overlays directly to avoid cloning. - let (overlay_trie, overlay_state) = self.resolve_overlays(); + let (overlay_trie, overlay_state) = self.resolve_overlays(self.block_hash); let trie_updates = if trie_reverts.is_empty() { overlay_trie @@ -395,7 +394,7 @@ where (trie_updates, hashed_state_updates) } else { // If no block_hash, use overlays directly (resolving lazy if set) - let (trie_updates, hashed_state) = self.resolve_overlays(); + let (trie_updates, hashed_state) = self.resolve_overlays(self.block_hash); retrieve_trie_reverts_duration = Duration::ZERO; retrieve_hashed_state_reverts_duration = Duration::ZERO; @@ -424,7 +423,7 @@ where fn get_overlay(&self, provider: &F::Provider) -> ProviderResult { // No anchor block — just resolve the in-memory overlay directly. if self.block_hash.is_none() { - let (trie_updates, hashed_post_state) = self.resolve_overlays(); + let (trie_updates, hashed_post_state) = self.resolve_overlays(None); return Ok(Overlay { trie_updates, hashed_post_state }) } From 812e479b69471dad38e3bdc2d2c009957b668ce8 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:10:42 +0000 Subject: [PATCH 15/83] refactor(provider): separate overlay anchors from revert state Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019daba4-3598-758d-8771-cb5db784db81 Co-authored-by: Amp --- crates/chain-state/src/lazy_overlay.rs | 16 +++++ .../engine/tree/src/tree/payload_validator.rs | 15 ++++- .../provider/src/providers/state/overlay.rs | 61 ++++++++++++------- 3 files changed, 67 insertions(+), 25 deletions(-) diff --git a/crates/chain-state/src/lazy_overlay.rs b/crates/chain-state/src/lazy_overlay.rs index d39dc741a72..3abe73bee7e 100644 --- a/crates/chain-state/src/lazy_overlay.rs +++ b/crates/chain-state/src/lazy_overlay.rs @@ -79,6 +79,13 @@ impl LazyOverlay { self.inputs.blocks.len() } + /// Returns the oldest anchor hash this overlay can serve. + /// + /// This is the parent hash of the oldest block in the stored newest-to-oldest chain segment. + pub fn anchor_hash(&self) -> Option { + self.inputs.blocks.last().map(|block| block.recovered_block().parent_hash()) + } + /// Returns true if the overlay has already been computed for the requested anchor. pub fn is_computed(&self, anchor_hash: B256) -> bool { self.inner.contains_key(&anchor_hash) @@ -261,6 +268,15 @@ mod tests { assert_eq!(actual.state.as_ref(), expected.state.as_ref()); } + #[test] + fn anchor_hash_returns_oldest_served_anchor() { + let blocks = test_blocks(); + let expected_anchor = blocks.last().unwrap().recovered_block().parent_hash(); + let overlay = LazyOverlay::new(blocks); + + assert_eq!(overlay.anchor_hash(), Some(expected_anchor)); + } + #[test] fn reuses_tip_overlay_when_anchor_matches() { let mut blocks = test_blocks(); diff --git a/crates/engine/tree/src/tree/payload_validator.rs b/crates/engine/tree/src/tree/payload_validator.rs index 2f56dde234f..3f8866f27ce 100644 --- a/crates/engine/tree/src/tree/payload_validator.rs +++ b/crates/engine/tree/src/tree/payload_validator.rs @@ -83,7 +83,10 @@ use reth_provider::{ StateProviderFactory, StateReader, StorageChangeSetReader, StorageSettingsCache, }; use reth_revm::db::{states::bundle_state::BundleRetention, BundleAccount, State}; -use reth_trie::{trie_cursor::TrieCursorFactory, updates::TrieUpdates, HashedPostState, StateRoot}; +use reth_trie::{ + trie_cursor::TrieCursorFactory, updates::TrieUpdates, HashedPostState, HashedPostStateSorted, + StateRoot, +}; use reth_trie_db::ChangesetCache; use reth_trie_parallel::root::{ParallelStateRoot, ParallelStateRootError}; use revm_primitives::{Address, KECCAK_EMPTY}; @@ -523,10 +526,13 @@ where // Create overlay factory for payload processor (StateRootTask path needs it for // multiproofs) + let hashed_state_overlay = + lazy_overlay.is_none().then(|| Arc::new(HashedPostStateSorted::default())); let overlay_factory = OverlayStateProviderFactory::new(self.provider.clone(), self.changeset_cache.clone()) .with_block_hash(Some(anchor_hash)) - .with_lazy_overlay(lazy_overlay); + .with_lazy_overlay(lazy_overlay) + .with_hashed_state_overlay(anchor_hash, hashed_state_overlay); // Spawn the appropriate processor based on strategy let mut handle = ensure_ok!(self.spawn_payload_processor( @@ -2023,10 +2029,13 @@ where state: &EngineApiTreeState, ) -> Option { let (lazy_overlay, anchor_hash) = Self::get_parent_lazy_overlay(parent_hash, state); + let hashed_state_overlay = + lazy_overlay.is_none().then(|| Arc::new(HashedPostStateSorted::default())); let overlay_factory = OverlayStateProviderFactory::new(self.provider.clone(), self.changeset_cache.clone()) .with_block_hash(Some(anchor_hash)) - .with_lazy_overlay(lazy_overlay); + .with_lazy_overlay(lazy_overlay) + .with_hashed_state_overlay(anchor_hash, hashed_state_overlay); Some(self.payload_processor.spawn_state_root( overlay_factory, diff --git a/crates/storage/provider/src/providers/state/overlay.rs b/crates/storage/provider/src/providers/state/overlay.rs index ecbe86808f1..ce6610213b4 100644 --- a/crates/storage/provider/src/providers/state/overlay.rs +++ b/crates/storage/provider/src/providers/state/overlay.rs @@ -67,6 +67,8 @@ struct Overlay { pub enum OverlaySource { /// Immediate overlay with already-computed data. Immediate { + /// Anchor hash the overlay was computed against. + anchor_hash: B256, /// Trie updates overlay. trie: Arc, /// Hashed state overlay. @@ -85,7 +87,15 @@ impl OverlaySource { anchor_hash: Option, ) -> (Arc, Arc) { match self { - Self::Immediate { trie, state } => (Arc::clone(trie), Arc::clone(state)), + Self::Immediate { anchor_hash: overlay_anchor_hash, trie, state } => { + if let Some(anchor_hash) = anchor_hash { + assert_eq!( + *overlay_anchor_hash, anchor_hash, + "immediate overlay anchor hash does not match requested anchor" + ); + } + (Arc::clone(trie), Arc::clone(state)) + } Self::Lazy(overlay) => overlay .as_overlay(anchor_hash.expect("lazy overlay resolution requires an anchor hash")), } @@ -100,8 +110,8 @@ impl OverlaySource { pub struct OverlayStateProviderFactory { /// The underlying database provider factory factory: F, - /// Optional block hash for collecting reverts - block_hash: Option, + /// Optional block hash to revert the DB state to before applying overlays. + revert_block_hash: Option, /// Optional overlay source (lazy or immediate). overlay_source: Option>, /// Changeset cache handle for retrieving trie changesets @@ -118,7 +128,7 @@ impl OverlayStateProviderFactory { pub fn new(factory: F, changeset_cache: ChangesetCache) -> Self { Self { factory, - block_hash: None, + revert_block_hash: None, overlay_source: None, changeset_cache, metrics: OverlayStateProviderMetrics::default(), @@ -126,10 +136,10 @@ impl OverlayStateProviderFactory { } } - /// Set the block hash for collecting reverts. All state will be reverted to the point + /// Set the block hash to revert the DB state to. All state will be reverted to the point /// _after_ this block has been processed. pub const fn with_block_hash(mut self, block_hash: Option) -> Self { - self.block_hash = block_hash; + self.revert_block_hash = block_hash; self } @@ -158,10 +168,12 @@ impl OverlayStateProviderFactory { /// This overlay will be applied on top of any reverts applied via `with_block_hash`. pub fn with_hashed_state_overlay( mut self, + anchor_hash: B256, hashed_state_overlay: Option>, ) -> Self { if let Some(state) = hashed_state_overlay { self.overlay_source = Some(OverlaySource::Immediate { + anchor_hash, trie: Arc::new(TrieUpdatesSorted::default()), state, }); @@ -173,8 +185,9 @@ impl OverlayStateProviderFactory { /// Extends the existing hashed state overlay with the given [`HashedPostStateSorted`]. /// - /// If no overlay exists, creates a new immediate overlay with the given state. /// If a lazy overlay exists, it is resolved first then extended. + /// + /// Panics if no overlay source has been configured. pub fn with_extended_hashed_state_overlay(mut self, other: HashedPostStateSorted) -> Self { match &mut self.overlay_source { Some(OverlaySource::Immediate { state, .. }) => { @@ -183,17 +196,21 @@ impl OverlayStateProviderFactory { Some(OverlaySource::Lazy(overlay)) => { // Resolve lazy overlay and convert to immediate with extension let (trie, mut state) = overlay.as_overlay( - self.block_hash.expect("extending a lazy overlay requires an anchor hash"), + self.revert_block_hash + .expect("extending a lazy overlay requires an anchor hash"), ); Arc::make_mut(&mut state).extend_ref_and_sort(&other); - self.overlay_source = Some(OverlaySource::Immediate { trie, state }); - } - None => { self.overlay_source = Some(OverlaySource::Immediate { - trie: Arc::new(TrieUpdatesSorted::default()), - state: Arc::new(other), + anchor_hash: self + .revert_block_hash + .expect("extending a lazy overlay requires an anchor hash"), + trie, + state, }); } + None => { + panic!("extending a hashed state overlay requires an existing overlay source") + } } // Clear the overlay cache since we've updated the source. self.overlay_cache = Default::default(); @@ -229,12 +246,12 @@ where } } - /// Returns the block number for [`Self`]'s `block_hash` field, if any. + /// Returns the block number for [`Self`]'s `revert_block_hash` field, if any. fn get_requested_block_number( &self, provider: &F::Provider, ) -> ProviderResult> { - if let Some(block_hash) = self.block_hash { + if let Some(block_hash) = self.revert_block_hash { Ok(Some( provider .convert_hash_or_number(block_hash.into())? @@ -314,14 +331,14 @@ where let trie_updates_total_len; let hashed_state_updates_total_len; - // If block_hash is provided, collect reverts + // If a revert block hash is provided, collect reverts. let (trie_updates, hashed_post_state) = if let Some(from_block) = self.get_requested_block_number(provider)? && self.reverts_required(provider, db_tip_block, from_block)? { debug!( target: "providers::state::overlay", - block_hash = ?self.block_hash, + revert_block_hash = ?self.revert_block_hash, from_block, db_tip_block, range_start = from_block + 1, @@ -359,7 +376,7 @@ where // Resolve overlays (lazy or immediate) and extend reverts with them. // If reverts are empty, use overlays directly to avoid cloning. - let (overlay_trie, overlay_state) = self.resolve_overlays(self.block_hash); + let (overlay_trie, overlay_state) = self.resolve_overlays(self.revert_block_hash); let trie_updates = if trie_reverts.is_empty() { overlay_trie @@ -384,7 +401,7 @@ where debug!( target: "providers::state::overlay", - block_hash = ?self.block_hash, + revert_block_hash = ?self.revert_block_hash, ?from_block, num_trie_updates = ?trie_updates_total_len, num_state_updates = ?hashed_state_updates_total_len, @@ -393,8 +410,8 @@ where (trie_updates, hashed_state_updates) } else { - // If no block_hash, use overlays directly (resolving lazy if set) - let (trie_updates, hashed_state) = self.resolve_overlays(self.block_hash); + // If no reverts are needed, use overlays directly (resolving lazy if set). + let (trie_updates, hashed_state) = self.resolve_overlays(self.revert_block_hash); retrieve_trie_reverts_duration = Duration::ZERO; retrieve_hashed_state_reverts_duration = Duration::ZERO; @@ -422,7 +439,7 @@ where #[instrument(level = "debug", target = "providers::state::overlay", skip_all)] fn get_overlay(&self, provider: &F::Provider) -> ProviderResult { // No anchor block — just resolve the in-memory overlay directly. - if self.block_hash.is_none() { + if self.revert_block_hash.is_none() { let (trie_updates, hashed_post_state) = self.resolve_overlays(None); return Ok(Overlay { trie_updates, hashed_post_state }) } From 5036eb59fb3965ad8efb914cd0858afc7b0fdc0c Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:23:57 +0000 Subject: [PATCH 16/83] refactor(provider): infer overlay anchors from sources Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019daba4-3598-758d-8771-cb5db784db81 Co-authored-by: Amp --- .../engine/tree/src/tree/payload_validator.rs | 9 +- .../provider/src/providers/state/overlay.rs | 90 ++++++++----------- 2 files changed, 41 insertions(+), 58 deletions(-) diff --git a/crates/engine/tree/src/tree/payload_validator.rs b/crates/engine/tree/src/tree/payload_validator.rs index 3f8866f27ce..97ebde3b56d 100644 --- a/crates/engine/tree/src/tree/payload_validator.rs +++ b/crates/engine/tree/src/tree/payload_validator.rs @@ -530,7 +530,6 @@ where lazy_overlay.is_none().then(|| Arc::new(HashedPostStateSorted::default())); let overlay_factory = OverlayStateProviderFactory::new(self.provider.clone(), self.changeset_cache.clone()) - .with_block_hash(Some(anchor_hash)) .with_lazy_overlay(lazy_overlay) .with_hashed_state_overlay(anchor_hash, hashed_state_overlay); @@ -2028,14 +2027,10 @@ where parent_state_root: B256, state: &EngineApiTreeState, ) -> Option { - let (lazy_overlay, anchor_hash) = Self::get_parent_lazy_overlay(parent_hash, state); - let hashed_state_overlay = - lazy_overlay.is_none().then(|| Arc::new(HashedPostStateSorted::default())); + let (lazy_overlay, _) = Self::get_parent_lazy_overlay(parent_hash, state); let overlay_factory = OverlayStateProviderFactory::new(self.provider.clone(), self.changeset_cache.clone()) - .with_block_hash(Some(anchor_hash)) - .with_lazy_overlay(lazy_overlay) - .with_hashed_state_overlay(anchor_hash, hashed_state_overlay); + .with_lazy_overlay(lazy_overlay); Some(self.payload_processor.spawn_state_root( overlay_factory, diff --git a/crates/storage/provider/src/providers/state/overlay.rs b/crates/storage/provider/src/providers/state/overlay.rs index ce6610213b4..e1ccfffe7a9 100644 --- a/crates/storage/provider/src/providers/state/overlay.rs +++ b/crates/storage/provider/src/providers/state/overlay.rs @@ -79,25 +79,30 @@ pub enum OverlaySource { } impl OverlaySource { + /// Returns the anchor hash this overlay source should be resolved against. + fn anchor_hash(&self) -> B256 { + match self { + Self::Immediate { anchor_hash, .. } => *anchor_hash, + Self::Lazy(overlay) => { + overlay.anchor_hash().expect("lazy overlay resolution requires at least one block") + } + } + } + /// Resolve the overlay source into (trie, state) tuple. /// /// For lazy overlays, this may block waiting for deferred data. - fn resolve( - &self, - anchor_hash: Option, - ) -> (Arc, Arc) { + fn resolve(&self) -> (Arc, Arc) { match self { Self::Immediate { anchor_hash: overlay_anchor_hash, trie, state } => { - if let Some(anchor_hash) = anchor_hash { - assert_eq!( - *overlay_anchor_hash, anchor_hash, - "immediate overlay anchor hash does not match requested anchor" - ); - } + let anchor_hash = self.anchor_hash(); + assert_eq!( + *overlay_anchor_hash, anchor_hash, + "immediate overlay anchor hash does not match requested anchor" + ); (Arc::clone(trie), Arc::clone(state)) } - Self::Lazy(overlay) => overlay - .as_overlay(anchor_hash.expect("lazy overlay resolution requires an anchor hash")), + Self::Lazy(overlay) => overlay.as_overlay(self.anchor_hash()), } } } @@ -110,8 +115,6 @@ impl OverlaySource { pub struct OverlayStateProviderFactory { /// The underlying database provider factory factory: F, - /// Optional block hash to revert the DB state to before applying overlays. - revert_block_hash: Option, /// Optional overlay source (lazy or immediate). overlay_source: Option>, /// Changeset cache handle for retrieving trie changesets @@ -128,7 +131,6 @@ impl OverlayStateProviderFactory { pub fn new(factory: F, changeset_cache: ChangesetCache) -> Self { Self { factory, - revert_block_hash: None, overlay_source: None, changeset_cache, metrics: OverlayStateProviderMetrics::default(), @@ -136,16 +138,7 @@ impl OverlayStateProviderFactory { } } - /// Set the block hash to revert the DB state to. All state will be reverted to the point - /// _after_ this block has been processed. - pub const fn with_block_hash(mut self, block_hash: Option) -> Self { - self.revert_block_hash = block_hash; - self - } - /// Set the overlay source (lazy or immediate). - /// - /// This overlay will be applied on top of any reverts applied via `with_block_hash`. pub fn with_overlay_source(mut self, source: Option>) -> Self { self.overlay_source = source; // Clear the overlay cache since we've updated the source. @@ -164,8 +157,6 @@ impl OverlayStateProviderFactory { } /// Set the hashed state overlay. - /// - /// This overlay will be applied on top of any reverts applied via `with_block_hash`. pub fn with_hashed_state_overlay( mut self, anchor_hash: B256, @@ -195,18 +186,12 @@ impl OverlayStateProviderFactory { } Some(OverlaySource::Lazy(overlay)) => { // Resolve lazy overlay and convert to immediate with extension - let (trie, mut state) = overlay.as_overlay( - self.revert_block_hash - .expect("extending a lazy overlay requires an anchor hash"), - ); + let anchor_hash = overlay + .anchor_hash() + .expect("extending a lazy overlay requires an anchor hash"); + let (trie, mut state) = overlay.as_overlay(anchor_hash); Arc::make_mut(&mut state).extend_ref_and_sort(&other); - self.overlay_source = Some(OverlaySource::Immediate { - anchor_hash: self - .revert_block_hash - .expect("extending a lazy overlay requires an anchor hash"), - trie, - state, - }); + self.overlay_source = Some(OverlaySource::Immediate { anchor_hash, trie, state }); } None => { panic!("extending a hashed state overlay requires an existing overlay source") @@ -230,28 +215,30 @@ where + BlockNumReader + StorageSettingsCache, { + /// Returns the anchor hash implied by the current overlay source, if any. + fn requested_anchor_hash(&self) -> Option { + self.overlay_source.as_ref().map(OverlaySource::anchor_hash) + } + /// Resolves the effective overlay (trie updates, hashed state). /// /// If an overlay source is set, it is resolved (blocking if lazy). /// Otherwise, returns empty defaults. - fn resolve_overlays( - &self, - anchor_hash: Option, - ) -> (Arc, Arc) { + fn resolve_overlays(&self) -> (Arc, Arc) { match &self.overlay_source { - Some(source) => source.resolve(anchor_hash), + Some(source) => source.resolve(), None => { (Arc::new(TrieUpdatesSorted::default()), Arc::new(HashedPostStateSorted::default())) } } } - /// Returns the block number for [`Self`]'s `revert_block_hash` field, if any. + /// Returns the block number for [`Self`]'s overlay anchor, if any. fn get_requested_block_number( &self, provider: &F::Provider, ) -> ProviderResult> { - if let Some(block_hash) = self.revert_block_hash { + if let Some(block_hash) = self.requested_anchor_hash() { Ok(Some( provider .convert_hash_or_number(block_hash.into())? @@ -330,15 +317,16 @@ where let retrieve_hashed_state_reverts_duration; let trie_updates_total_len; let hashed_state_updates_total_len; + let requested_anchor_hash = self.requested_anchor_hash(); - // If a revert block hash is provided, collect reverts. + // If an anchor hash is provided, collect reverts. let (trie_updates, hashed_post_state) = if let Some(from_block) = self.get_requested_block_number(provider)? && self.reverts_required(provider, db_tip_block, from_block)? { debug!( target: "providers::state::overlay", - revert_block_hash = ?self.revert_block_hash, + anchor_hash = ?requested_anchor_hash, from_block, db_tip_block, range_start = from_block + 1, @@ -376,7 +364,7 @@ where // Resolve overlays (lazy or immediate) and extend reverts with them. // If reverts are empty, use overlays directly to avoid cloning. - let (overlay_trie, overlay_state) = self.resolve_overlays(self.revert_block_hash); + let (overlay_trie, overlay_state) = self.resolve_overlays(); let trie_updates = if trie_reverts.is_empty() { overlay_trie @@ -401,7 +389,7 @@ where debug!( target: "providers::state::overlay", - revert_block_hash = ?self.revert_block_hash, + anchor_hash = ?requested_anchor_hash, ?from_block, num_trie_updates = ?trie_updates_total_len, num_state_updates = ?hashed_state_updates_total_len, @@ -411,7 +399,7 @@ where (trie_updates, hashed_state_updates) } else { // If no reverts are needed, use overlays directly (resolving lazy if set). - let (trie_updates, hashed_state) = self.resolve_overlays(self.revert_block_hash); + let (trie_updates, hashed_state) = self.resolve_overlays(); retrieve_trie_reverts_duration = Duration::ZERO; retrieve_hashed_state_reverts_duration = Duration::ZERO; @@ -439,8 +427,8 @@ where #[instrument(level = "debug", target = "providers::state::overlay", skip_all)] fn get_overlay(&self, provider: &F::Provider) -> ProviderResult { // No anchor block — just resolve the in-memory overlay directly. - if self.revert_block_hash.is_none() { - let (trie_updates, hashed_post_state) = self.resolve_overlays(None); + if self.requested_anchor_hash().is_none() { + let (trie_updates, hashed_post_state) = self.resolve_overlays(); return Ok(Overlay { trie_updates, hashed_post_state }) } From b5ad0018a301172e88bd6e272d0e9dfb7233b7e4 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:36:09 +0000 Subject: [PATCH 17/83] refactor(provider): thread explicit requested anchors Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019daba4-3598-758d-8771-cb5db784db81 Co-authored-by: Amp --- crates/chain-state/src/lazy_overlay.rs | 3 +- .../engine/tree/src/tree/payload_validator.rs | 60 +++++++++++++------ .../provider/src/providers/state/overlay.rs | 58 ++++++++++-------- crates/trie/parallel/src/root.rs | 4 +- 4 files changed, 80 insertions(+), 45 deletions(-) diff --git a/crates/chain-state/src/lazy_overlay.rs b/crates/chain-state/src/lazy_overlay.rs index 3abe73bee7e..79f30727536 100644 --- a/crates/chain-state/src/lazy_overlay.rs +++ b/crates/chain-state/src/lazy_overlay.rs @@ -79,10 +79,11 @@ impl LazyOverlay { self.inputs.blocks.len() } + #[cfg(test)] /// Returns the oldest anchor hash this overlay can serve. /// /// This is the parent hash of the oldest block in the stored newest-to-oldest chain segment. - pub fn anchor_hash(&self) -> Option { + fn anchor_hash(&self) -> Option { self.inputs.blocks.last().map(|block| block.recovered_block().parent_hash()) } diff --git a/crates/engine/tree/src/tree/payload_validator.rs b/crates/engine/tree/src/tree/payload_validator.rs index 97ebde3b56d..6d1f873b243 100644 --- a/crates/engine/tree/src/tree/payload_validator.rs +++ b/crates/engine/tree/src/tree/payload_validator.rs @@ -83,10 +83,7 @@ use reth_provider::{ StateProviderFactory, StateReader, StorageChangeSetReader, StorageSettingsCache, }; use reth_revm::db::{states::bundle_state::BundleRetention, BundleAccount, State}; -use reth_trie::{ - trie_cursor::TrieCursorFactory, updates::TrieUpdates, HashedPostState, HashedPostStateSorted, - StateRoot, -}; +use reth_trie::{trie_cursor::TrieCursorFactory, updates::TrieUpdates, HashedPostState, StateRoot}; use reth_trie_db::ChangesetCache; use reth_trie_parallel::root::{ParallelStateRoot, ParallelStateRootError}; use revm_primitives::{Address, KECCAK_EMPTY}; @@ -526,12 +523,10 @@ where // Create overlay factory for payload processor (StateRootTask path needs it for // multiproofs) - let hashed_state_overlay = - lazy_overlay.is_none().then(|| Arc::new(HashedPostStateSorted::default())); let overlay_factory = OverlayStateProviderFactory::new(self.provider.clone(), self.changeset_cache.clone()) - .with_lazy_overlay(lazy_overlay) - .with_hashed_state_overlay(anchor_hash, hashed_state_overlay); + .with_anchor_hash(anchor_hash) + .with_lazy_overlay(lazy_overlay); // Spawn the appropriate processor based on strategy let mut handle = ensure_ok!(self.spawn_payload_processor( @@ -670,6 +665,7 @@ where self.await_state_root_with_timeout( &mut handle, overlay_factory.clone(), + anchor_hash, &hashed_state, ), block @@ -694,6 +690,7 @@ where if self.config.always_compare_trie_updates() { let _has_diff = self.compare_trie_updates_with_serial( overlay_factory.clone(), + anchor_hash, &hashed_state, trie_updates.as_ref().clone(), ); @@ -732,7 +729,11 @@ where } StateRootStrategy::Parallel => { debug!(target: "engine::tree::payload_validator", "Using parallel state root algorithm"); - match self.compute_state_root_parallel(overlay_factory.clone(), &hashed_state) { + match self.compute_state_root_parallel( + overlay_factory.clone(), + anchor_hash, + &hashed_state, + ) { Ok(result) => { let elapsed = root_time.elapsed(); info!( @@ -768,7 +769,11 @@ where } let (root, updates) = ensure_ok_post_block!( - Self::compute_state_root_serial(overlay_factory.clone(), &hashed_state), + Self::compute_state_root_serial( + overlay_factory.clone(), + anchor_hash, + &hashed_state + ), block ); @@ -1093,6 +1098,7 @@ where fn compute_state_root_parallel( &self, overlay_factory: OverlayStateProviderFactory, + anchor_hash: B256, hashed_state: &LazyHashedPostState, ) -> Result<(B256, TrieUpdates), ParallelStateRootError> { let hashed_state = hashed_state.get(); @@ -1100,8 +1106,14 @@ where // need to use the prefix sets which were generated from it to indicate to the // ParallelStateRoot which parts of the trie need to be recomputed. let prefix_sets = hashed_state.construct_prefix_sets().freeze(); - let overlay_factory = - overlay_factory.with_extended_hashed_state_overlay(hashed_state.clone_into_sorted()); + let overlay_factory = if overlay_factory.has_overlay_source() { + overlay_factory.with_extended_hashed_state_overlay(hashed_state.clone_into_sorted()) + } else { + overlay_factory.with_hashed_state_overlay( + anchor_hash, + Some(Arc::new(hashed_state.clone_into_sorted())), + ) + }; ParallelStateRoot::new(overlay_factory, prefix_sets, self.runtime.clone()) .incremental_root_with_updates() } @@ -1113,6 +1125,7 @@ where /// trie updates for this block. fn compute_state_root_serial( overlay_factory: OverlayStateProviderFactory, + anchor_hash: B256, hashed_state: &LazyHashedPostState, ) -> ProviderResult<(B256, TrieUpdates)> { let hashed_state = hashed_state.get(); @@ -1120,8 +1133,14 @@ where // need to use the prefix sets which were generated from it to indicate to the // StateRoot which parts of the trie need to be recomputed. let prefix_sets = hashed_state.construct_prefix_sets().freeze(); - let overlay_factory = - overlay_factory.with_extended_hashed_state_overlay(hashed_state.clone_into_sorted()); + let overlay_factory = if overlay_factory.has_overlay_source() { + overlay_factory.with_extended_hashed_state_overlay(hashed_state.clone_into_sorted()) + } else { + overlay_factory.with_hashed_state_overlay( + anchor_hash, + Some(Arc::new(hashed_state.clone_into_sorted())), + ) + }; let provider = overlay_factory.database_provider_ro()?; @@ -1153,6 +1172,7 @@ where &self, handle: &mut PayloadHandle, overlay_factory: OverlayStateProviderFactory, + anchor_hash: B256, hashed_state: &LazyHashedPostState, ) -> ProviderResult> { let Some(timeout) = self.config.state_root_task_timeout() else { @@ -1180,7 +1200,11 @@ where let seq_overlay = overlay_factory; let seq_hashed_state = hashed_state.clone(); self.payload_processor.executor().spawn_blocking_named("serial-root", move || { - let result = Self::compute_state_root_serial(seq_overlay, &seq_hashed_state); + let result = Self::compute_state_root_serial( + seq_overlay, + anchor_hash, + &seq_hashed_state, + ); let _ = seq_tx.send(result); }); @@ -1245,12 +1269,13 @@ where fn compare_trie_updates_with_serial( &self, overlay_factory: OverlayStateProviderFactory, + anchor_hash: B256, hashed_state: &LazyHashedPostState, task_trie_updates: TrieUpdates, ) -> bool { debug!(target: "engine::tree::payload_validator", "Comparing trie updates with serial computation"); - match Self::compute_state_root_serial(overlay_factory.clone(), hashed_state) { + match Self::compute_state_root_serial(overlay_factory.clone(), anchor_hash, hashed_state) { Ok((serial_root, serial_trie_updates)) => { debug!( target: "engine::tree::payload_validator", @@ -2027,9 +2052,10 @@ where parent_state_root: B256, state: &EngineApiTreeState, ) -> Option { - let (lazy_overlay, _) = Self::get_parent_lazy_overlay(parent_hash, state); + let (lazy_overlay, anchor_hash) = Self::get_parent_lazy_overlay(parent_hash, state); let overlay_factory = OverlayStateProviderFactory::new(self.provider.clone(), self.changeset_cache.clone()) + .with_anchor_hash(anchor_hash) .with_lazy_overlay(lazy_overlay); Some(self.payload_processor.spawn_state_root( diff --git a/crates/storage/provider/src/providers/state/overlay.rs b/crates/storage/provider/src/providers/state/overlay.rs index e1ccfffe7a9..5b157abdf93 100644 --- a/crates/storage/provider/src/providers/state/overlay.rs +++ b/crates/storage/provider/src/providers/state/overlay.rs @@ -79,30 +79,19 @@ pub enum OverlaySource { } impl OverlaySource { - /// Returns the anchor hash this overlay source should be resolved against. - fn anchor_hash(&self) -> B256 { - match self { - Self::Immediate { anchor_hash, .. } => *anchor_hash, - Self::Lazy(overlay) => { - overlay.anchor_hash().expect("lazy overlay resolution requires at least one block") - } - } - } - /// Resolve the overlay source into (trie, state) tuple. /// /// For lazy overlays, this may block waiting for deferred data. - fn resolve(&self) -> (Arc, Arc) { + fn resolve(&self, anchor_hash: B256) -> (Arc, Arc) { match self { Self::Immediate { anchor_hash: overlay_anchor_hash, trie, state } => { - let anchor_hash = self.anchor_hash(); assert_eq!( *overlay_anchor_hash, anchor_hash, "immediate overlay anchor hash does not match requested anchor" ); (Arc::clone(trie), Arc::clone(state)) } - Self::Lazy(overlay) => overlay.as_overlay(self.anchor_hash()), + Self::Lazy(overlay) => overlay.as_overlay(anchor_hash), } } } @@ -115,6 +104,8 @@ impl OverlaySource { pub struct OverlayStateProviderFactory { /// The underlying database provider factory factory: F, + /// Anchor hash to revert the DB state to before applying overlays. + requested_anchor_hash: Option, /// Optional overlay source (lazy or immediate). overlay_source: Option>, /// Changeset cache handle for retrieving trie changesets @@ -131,6 +122,7 @@ impl OverlayStateProviderFactory { pub fn new(factory: F, changeset_cache: ChangesetCache) -> Self { Self { factory, + requested_anchor_hash: None, overlay_source: None, changeset_cache, metrics: OverlayStateProviderMetrics::default(), @@ -138,6 +130,17 @@ impl OverlayStateProviderFactory { } } + /// Set the anchor hash to revert the DB state to before applying overlays. + pub const fn with_anchor_hash(mut self, anchor_hash: B256) -> Self { + self.requested_anchor_hash = Some(anchor_hash); + self + } + + /// Returns true if an overlay source has been configured. + pub fn has_overlay_source(&self) -> bool { + self.overlay_source.is_some() + } + /// Set the overlay source (lazy or immediate). pub fn with_overlay_source(mut self, source: Option>) -> Self { self.overlay_source = source; @@ -186,8 +189,8 @@ impl OverlayStateProviderFactory { } Some(OverlaySource::Lazy(overlay)) => { // Resolve lazy overlay and convert to immediate with extension - let anchor_hash = overlay - .anchor_hash() + let anchor_hash = self + .requested_anchor_hash .expect("extending a lazy overlay requires an anchor hash"); let (trie, mut state) = overlay.as_overlay(anchor_hash); Arc::make_mut(&mut state).extend_ref_and_sort(&other); @@ -215,30 +218,35 @@ where + BlockNumReader + StorageSettingsCache, { - /// Returns the anchor hash implied by the current overlay source, if any. - fn requested_anchor_hash(&self) -> Option { - self.overlay_source.as_ref().map(OverlaySource::anchor_hash) - } - /// Resolves the effective overlay (trie updates, hashed state). /// /// If an overlay source is set, it is resolved (blocking if lazy). /// Otherwise, returns empty defaults. fn resolve_overlays(&self) -> (Arc, Arc) { match &self.overlay_source { - Some(source) => source.resolve(), + Some(source) => match self.requested_anchor_hash { + Some(anchor_hash) => source.resolve(anchor_hash), + None => match source { + OverlaySource::Immediate { trie, state, .. } => { + (Arc::clone(trie), Arc::clone(state)) + } + OverlaySource::Lazy(_) => { + panic!("lazy overlay resolution requires an anchor hash") + } + }, + }, None => { (Arc::new(TrieUpdatesSorted::default()), Arc::new(HashedPostStateSorted::default())) } } } - /// Returns the block number for [`Self`]'s overlay anchor, if any. + /// Returns the block number for [`Self`]'s requested anchor, if any. fn get_requested_block_number( &self, provider: &F::Provider, ) -> ProviderResult> { - if let Some(block_hash) = self.requested_anchor_hash() { + if let Some(block_hash) = self.requested_anchor_hash { Ok(Some( provider .convert_hash_or_number(block_hash.into())? @@ -317,7 +325,7 @@ where let retrieve_hashed_state_reverts_duration; let trie_updates_total_len; let hashed_state_updates_total_len; - let requested_anchor_hash = self.requested_anchor_hash(); + let requested_anchor_hash = self.requested_anchor_hash; // If an anchor hash is provided, collect reverts. let (trie_updates, hashed_post_state) = if let Some(from_block) = @@ -427,7 +435,7 @@ where #[instrument(level = "debug", target = "providers::state::overlay", skip_all)] fn get_overlay(&self, provider: &F::Provider) -> ProviderResult { // No anchor block — just resolve the in-memory overlay directly. - if self.requested_anchor_hash().is_none() { + if self.requested_anchor_hash.is_none() { let (trie_updates, hashed_post_state) = self.resolve_overlays(); return Ok(Overlay { trie_updates, hashed_post_state }) } diff --git a/crates/trie/parallel/src/root.rs b/crates/trie/parallel/src/root.rs index 4d0f58e25f9..e9685f74e4c 100644 --- a/crates/trie/parallel/src/root.rs +++ b/crates/trie/parallel/src/root.rs @@ -362,8 +362,8 @@ mod tests { } let prefix_sets = hashed_state.construct_prefix_sets(); - overlay_factory = - overlay_factory.with_hashed_state_overlay(Some(Arc::new(hashed_state.into_sorted()))); + overlay_factory = overlay_factory + .with_hashed_state_overlay(B256::ZERO, Some(Arc::new(hashed_state.into_sorted()))); assert_eq!( ParallelStateRoot::new(overlay_factory, prefix_sets.freeze(), runtime) From d92ad5aa3483be6f478f5ee964b9fd651883eb87 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:53:23 +0000 Subject: [PATCH 18/83] fix(provider): anchor overlay state providers by hash Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019dafa5-05ee-739e-a0b6-037cc30e4a0e Co-authored-by: Amp --- crates/chain-state/src/lazy_overlay.rs | 24 +- .../provider/src/providers/state/overlay.rs | 211 ++++++++---------- 2 files changed, 104 insertions(+), 131 deletions(-) diff --git a/crates/chain-state/src/lazy_overlay.rs b/crates/chain-state/src/lazy_overlay.rs index 79f30727536..ecf9a9c92ac 100644 --- a/crates/chain-state/src/lazy_overlay.rs +++ b/crates/chain-state/src/lazy_overlay.rs @@ -79,14 +79,21 @@ impl LazyOverlay { self.inputs.blocks.len() } - #[cfg(test)] /// Returns the oldest anchor hash this overlay can serve. /// /// This is the parent hash of the oldest block in the stored newest-to-oldest chain segment. - fn anchor_hash(&self) -> Option { + pub fn anchor_hash(&self) -> Option { self.inputs.blocks.last().map(|block| block.recovered_block().parent_hash()) } + /// Returns true if there are no blocks in the overlay, or if one of the blocks has the given + /// hash as a parent hash. + pub fn has_anchor_hash(&self, hash: B256) -> bool { + self.inputs.blocks.is_empty() || + self.inputs.blocks.iter().any(|b| b.recovered_block().parent_hash() == hash) + } + + #[cfg(test)] /// Returns true if the overlay has already been computed for the requested anchor. pub fn is_computed(&self, anchor_hash: B256) -> bool { self.inner.contains_key(&anchor_hash) @@ -119,6 +126,10 @@ impl LazyOverlay { /// Compute the trie input overlay. fn compute(&self, anchor_hash: B256) -> Arc { let blocks = &self.inputs.blocks; + if blocks.is_empty() { + return Default::default() + } + let Some(last_index) = blocks.iter().position(|block| block.recovered_block().parent_hash() == anchor_hash) else { @@ -220,15 +231,6 @@ mod tests { .collect() } - #[test] - #[should_panic( - expected = "LazyOverlay does not contain a block whose parent hash matches requested anchor" - )] - fn empty_blocks_panic_for_any_anchor() { - let overlay = LazyOverlay::::new(vec![]); - let _ = overlay.get(B256::ZERO); - } - #[test] fn single_block_uses_data_directly() { let block = TestBlockBuilder::eth().get_executed_block_with_number(1, B256::random()); diff --git a/crates/storage/provider/src/providers/state/overlay.rs b/crates/storage/provider/src/providers/state/overlay.rs index 5b157abdf93..0cd4ca88d9b 100644 --- a/crates/storage/provider/src/providers/state/overlay.rs +++ b/crates/storage/provider/src/providers/state/overlay.rs @@ -1,4 +1,5 @@ -use alloy_primitives::{BlockNumber, B256}; +use alloy_eips::BlockNumHash; +use alloy_primitives::{BlockHash, BlockNumber, B256}; use metrics::{Counter, Histogram}; use reth_chain_state::{EthPrimitives, LazyOverlay}; use reth_db_api::{tables, transaction::DbTx, DatabaseError}; @@ -27,6 +28,7 @@ use reth_trie_db::{ PackedStoragesTrie, }; use std::{ + ops::RangeInclusive, sync::Arc, time::{Duration, Instant}, }; @@ -64,11 +66,9 @@ struct Overlay { /// Either provides immediate pre-computed overlay data, or a lazy overlay that computes /// on first access. #[derive(Debug, Clone)] -pub enum OverlaySource { +enum OverlaySource { /// Immediate overlay with already-computed data. Immediate { - /// Anchor hash the overlay was computed against. - anchor_hash: B256, /// Trie updates overlay. trie: Arc, /// Hashed state overlay. @@ -78,24 +78,6 @@ pub enum OverlaySource { Lazy(LazyOverlay), } -impl OverlaySource { - /// Resolve the overlay source into (trie, state) tuple. - /// - /// For lazy overlays, this may block waiting for deferred data. - fn resolve(&self, anchor_hash: B256) -> (Arc, Arc) { - match self { - Self::Immediate { anchor_hash: overlay_anchor_hash, trie, state } => { - assert_eq!( - *overlay_anchor_hash, anchor_hash, - "immediate overlay anchor hash does not match requested anchor" - ); - (Arc::clone(trie), Arc::clone(state)) - } - Self::Lazy(overlay) => overlay.as_overlay(anchor_hash), - } - } -} - /// Factory for creating overlay state providers with optional reverts and overlays. /// /// This factory allows building an `OverlayStateProvider` whose DB state has been reverted to a @@ -105,7 +87,7 @@ pub struct OverlayStateProviderFactory { /// The underlying database provider factory factory: F, /// Anchor hash to revert the DB state to before applying overlays. - requested_anchor_hash: Option, + anchor_hash: B256, /// Optional overlay source (lazy or immediate). overlay_source: Option>, /// Changeset cache handle for retrieving trie changesets @@ -114,15 +96,19 @@ pub struct OverlayStateProviderFactory { metrics: OverlayStateProviderMetrics, /// A cache which maps `db_tip -> Overlay`. If the db tip changes during usage of the factory /// then a new entry will get added to this, but in most cases only one entry is present. - overlay_cache: Arc>, + overlay_cache: Arc>, } impl OverlayStateProviderFactory { - /// Create a new overlay state provider factory - pub fn new(factory: F, changeset_cache: ChangesetCache) -> Self { + /// Create a new overlay state provider factory. + /// + /// `anchor_hash` is the block hash on top of which all overlays are expected to be applied on. + /// The factory will revert providers to this hash if the db has moved forward since factory + /// creation. + pub fn new(factory: F, anchor_hash: B256, changeset_cache: ChangesetCache) -> Self { Self { factory, - requested_anchor_hash: None, + anchor_hash, overlay_source: None, changeset_cache, metrics: OverlayStateProviderMetrics::default(), @@ -130,29 +116,23 @@ impl OverlayStateProviderFactory { } } - /// Set the anchor hash to revert the DB state to before applying overlays. - pub const fn with_anchor_hash(mut self, anchor_hash: B256) -> Self { - self.requested_anchor_hash = Some(anchor_hash); - self - } - /// Returns true if an overlay source has been configured. pub fn has_overlay_source(&self) -> bool { self.overlay_source.is_some() } - /// Set the overlay source (lazy or immediate). - pub fn with_overlay_source(mut self, source: Option>) -> Self { - self.overlay_source = source; - // Clear the overlay cache since we've updated the source. - self.overlay_cache = Default::default(); - self - } - /// Set a lazy overlay that will be computed on first access. /// - /// Convenience method that wraps the lazy overlay in `OverlaySource::Lazy`. + /// Panics if the [`LazyOverlay`]'s anchor hash does not match [`Self`]'s. pub fn with_lazy_overlay(mut self, lazy_overlay: Option>) -> Self { + let lazy_overlay_anchor = lazy_overlay.as_ref().and_then(LazyOverlay::anchor_hash); + assert!( + lazy_overlay_anchor.is_none_or(|h| h == self.anchor_hash), + "LazyOverlay's anchor ({}) != OverlayStateProviderFactory's anchor ({})", + lazy_overlay_anchor.expect("not None"), + self.anchor_hash, + ); + self.overlay_source = lazy_overlay.map(OverlaySource::Lazy); // Clear the overlay cache since we've updated the source. self.overlay_cache = Default::default(); @@ -162,12 +142,10 @@ impl OverlayStateProviderFactory { /// Set the hashed state overlay. pub fn with_hashed_state_overlay( mut self, - anchor_hash: B256, hashed_state_overlay: Option>, ) -> Self { if let Some(state) = hashed_state_overlay { self.overlay_source = Some(OverlaySource::Immediate { - anchor_hash, trie: Arc::new(TrieUpdatesSorted::default()), state, }); @@ -189,12 +167,9 @@ impl OverlayStateProviderFactory { } Some(OverlaySource::Lazy(overlay)) => { // Resolve lazy overlay and convert to immediate with extension - let anchor_hash = self - .requested_anchor_hash - .expect("extending a lazy overlay requires an anchor hash"); - let (trie, mut state) = overlay.as_overlay(anchor_hash); + let (trie, mut state) = overlay.as_overlay(self.anchor_hash); Arc::make_mut(&mut state).extend_ref_and_sort(&other); - self.overlay_source = Some(OverlaySource::Immediate { anchor_hash, trie, state }); + self.overlay_source = Some(OverlaySource::Immediate { trie, state }); } None => { panic!("extending a hashed state overlay requires an existing overlay source") @@ -222,49 +197,50 @@ where /// /// If an overlay source is set, it is resolved (blocking if lazy). /// Otherwise, returns empty defaults. - fn resolve_overlays(&self) -> (Arc, Arc) { + fn resolve_overlays( + &self, + anchor_hash: BlockHash, + ) -> ProviderResult<(Arc, Arc)> { match &self.overlay_source { - Some(source) => match self.requested_anchor_hash { - Some(anchor_hash) => source.resolve(anchor_hash), - None => match source { - OverlaySource::Immediate { trie, state, .. } => { - (Arc::clone(trie), Arc::clone(state)) - } - OverlaySource::Lazy(_) => { - panic!("lazy overlay resolution requires an anchor hash") - } - }, - }, - None => { - (Arc::new(TrieUpdatesSorted::default()), Arc::new(HashedPostStateSorted::default())) + Some(OverlaySource::Lazy(lazy_overlay)) => Ok(lazy_overlay.as_overlay(anchor_hash)), + Some(OverlaySource::Immediate { trie, state }) => { + if anchor_hash != self.anchor_hash { + return Err(ProviderError::other(std::io::Error::other(format!( + "anchor_hash {anchor_hash} doesn't match that of OverlayStateProviderFactory's ({})", + self.anchor_hash + )))) + } + Ok((Arc::clone(&trie), Arc::clone(&state))) } + None => Ok(( + Arc::new(TrieUpdatesSorted::default()), + Arc::new(HashedPostStateSorted::default()), + )), } } - /// Returns the block number for [`Self`]'s requested anchor, if any. - fn get_requested_block_number( - &self, - provider: &F::Provider, - ) -> ProviderResult> { - if let Some(block_hash) = self.requested_anchor_hash { - Ok(Some( - provider - .convert_hash_or_number(block_hash.into())? - .ok_or_else(|| ProviderError::BlockHashNotFound(block_hash))?, - )) - } else { - Ok(None) - } + /// Returns the block number for [`Self`]'s anchor. + fn get_anchor_block_number(&self, provider: &F::Provider) -> ProviderResult { + provider + .convert_hash_or_number(self.anchor_hash.into())? + .ok_or_else(|| ProviderError::BlockHashNotFound(self.anchor_hash)) } /// Returns the block which is at the tip of the DB, i.e. the block which the state tables of /// the DB are currently synced to. - fn get_db_tip_block_number(&self, provider: &F::Provider) -> ProviderResult { - provider + fn get_db_tip_block(&self, provider: &F::Provider) -> ProviderResult { + let block_number = provider .get_stage_checkpoint(StageId::Finish)? .as_ref() .map(|chk| chk.block_number) - .ok_or_else(|| ProviderError::InsufficientChangesets { requested: 0, available: 0..=0 }) + .ok_or_else(|| ProviderError::InsufficientChangesets { + requested: 0, + available: 0..=0, + })?; + let hash = provider + .convert_number(block_number.into())? + .ok_or_else(|| ProviderError::HeaderNotFound(block_number.into()))?; + Ok(BlockNumHash::new(block_number, hash)) } /// Returns whether or not it is required to collect reverts, and validates that there are @@ -275,15 +251,24 @@ where fn reverts_required( &self, provider: &F::Provider, - db_tip_block: BlockNumber, - requested_block: BlockNumber, - ) -> ProviderResult { - // If the requested block is the DB tip then there won't be any reverts necessary, and we - // can simply return Ok. - if db_tip_block == requested_block { - return Ok(false) + db_tip_block: BlockNumHash, + ) -> ProviderResult>> { + // If the anchor is the DB tip then there won't be any reverts necessary + if db_tip_block.hash == self.anchor_hash { + return Ok(None) + } + + // If the DB tip has moved forward into the `LazyOverlay` then we still don't need to + // revert, the `LazyOverlay` will generate a new in-memory overlay using only the relevant + // blocks data. + if let Some(OverlaySource::Lazy(lazy_overlay)) = &self.overlay_source && + lazy_overlay.has_anchor_hash(db_tip_block.hash) + { + return Ok(None) } + let anchor_number = self.get_anchor_block_number(provider)?; + // Check account history prune checkpoint to determine the lower bound of available data. // The prune checkpoint's block_number is the highest pruned block, so data is available // starting from the next block. @@ -293,17 +278,17 @@ where .map(|block_number| block_number + 1) .unwrap_or_default(); - let available_range = lower_bound..=db_tip_block; + let available_range = lower_bound..=db_tip_block.number; // Check if the requested block is within the available range - if !available_range.contains(&requested_block) { + if !available_range.contains(&anchor_number) { return Err(ProviderError::InsufficientChangesets { - requested: requested_block, + requested: anchor_number, available: available_range, }); } - Ok(true) + Ok(Some(anchor_number + 1..=db_tip_block.number)) } /// Calculates a new [`Overlay`] given a transaction and the current db tip. @@ -311,12 +296,12 @@ where level = "debug", target = "providers::state::overlay", skip_all, - fields(%db_tip_block) + fields(?db_tip_block, anchor_hash = ?self.anchor_hash) )] fn calculate_overlay( &self, provider: &F::Provider, - db_tip_block: BlockNumber, + db_tip_block: BlockNumHash, ) -> ProviderResult { // // Set up variables we'll use for recording metrics. There's two different code-paths here, @@ -325,20 +310,14 @@ where let retrieve_hashed_state_reverts_duration; let trie_updates_total_len; let hashed_state_updates_total_len; - let requested_anchor_hash = self.requested_anchor_hash; - // If an anchor hash is provided, collect reverts. - let (trie_updates, hashed_post_state) = if let Some(from_block) = - self.get_requested_block_number(provider)? && - self.reverts_required(provider, db_tip_block, from_block)? + // Collect any reverts which are required to bring the DB view back to the anchor hash. + let (trie_updates, hashed_post_state) = if let Some(revert_blocks) = + self.reverts_required(provider, db_tip_block)? { debug!( target: "providers::state::overlay", - anchor_hash = ?requested_anchor_hash, - from_block, - db_tip_block, - range_start = from_block + 1, - range_end = db_tip_block, + ?revert_blocks, "Collecting trie reverts for overlay state provider" ); @@ -352,9 +331,8 @@ where // Use changeset cache to retrieve and accumulate reverts to restore state after // from_block - let accumulated_reverts = self - .changeset_cache - .get_or_compute_range(provider, (from_block + 1)..=db_tip_block)?; + let accumulated_reverts = + self.changeset_cache.get_or_compute_range(provider, revert_blocks.clone())?; retrieve_trie_reverts_duration = start.elapsed(); accumulated_reverts @@ -365,14 +343,14 @@ where let _guard = debug_span!(target: "providers::state::overlay", "retrieving_hashed_state_reverts").entered(); let start = Instant::now(); - let res = reth_trie_db::from_reverts_auto(provider, from_block + 1..)?; + let res = reth_trie_db::from_reverts_auto(provider, revert_blocks)?; retrieve_hashed_state_reverts_duration = start.elapsed(); res }; // Resolve overlays (lazy or immediate) and extend reverts with them. // If reverts are empty, use overlays directly to avoid cloning. - let (overlay_trie, overlay_state) = self.resolve_overlays(); + let (overlay_trie, overlay_state) = self.resolve_overlays(self.anchor_hash)?; let trie_updates = if trie_reverts.is_empty() { overlay_trie @@ -397,8 +375,6 @@ where debug!( target: "providers::state::overlay", - anchor_hash = ?requested_anchor_hash, - ?from_block, num_trie_updates = ?trie_updates_total_len, num_state_updates = ?hashed_state_updates_total_len, "Reverted to target block", @@ -406,8 +382,9 @@ where (trie_updates, hashed_state_updates) } else { - // If no reverts are needed, use overlays directly (resolving lazy if set). - let (trie_updates, hashed_state) = self.resolve_overlays(); + // If no reverts are needed then we can assume that the db tip is the anchor hash or + // overlaps with the `LazyOverlay`. Use overlays directly. + let (trie_updates, hashed_state) = self.resolve_overlays(db_tip_block.hash)?; retrieve_trie_reverts_duration = Duration::ZERO; retrieve_hashed_state_reverts_duration = Duration::ZERO; @@ -434,15 +411,9 @@ where /// cached value then this calculates the [`Overlay`] and populates the cache. #[instrument(level = "debug", target = "providers::state::overlay", skip_all)] fn get_overlay(&self, provider: &F::Provider) -> ProviderResult { - // No anchor block — just resolve the in-memory overlay directly. - if self.requested_anchor_hash.is_none() { - let (trie_updates, hashed_post_state) = self.resolve_overlays(); - return Ok(Overlay { trie_updates, hashed_post_state }) - } - - let db_tip_block = self.get_db_tip_block_number(provider)?; + let db_tip_block = self.get_db_tip_block(provider)?; - let overlay = match self.overlay_cache.entry(db_tip_block) { + let overlay = match self.overlay_cache.entry(db_tip_block.hash) { dashmap::Entry::Occupied(entry) => entry.get().clone(), dashmap::Entry::Vacant(entry) => { self.metrics.overlay_cache_misses.increment(1); From 134a7f364bec7fbba2ec43bfffe7c23c8db114ad Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:08:27 +0000 Subject: [PATCH 19/83] fix(provider): pass overlay anchors via constructor Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019daba4-3598-758d-8771-cb5db784db81 Co-authored-by: Amp --- .../engine/tree/src/tree/payload_validator.rs | 58 +++++++------------ crates/trie/parallel/src/root.rs | 4 +- 2 files changed, 22 insertions(+), 40 deletions(-) diff --git a/crates/engine/tree/src/tree/payload_validator.rs b/crates/engine/tree/src/tree/payload_validator.rs index 6d1f873b243..014d99091d1 100644 --- a/crates/engine/tree/src/tree/payload_validator.rs +++ b/crates/engine/tree/src/tree/payload_validator.rs @@ -523,10 +523,12 @@ where // Create overlay factory for payload processor (StateRootTask path needs it for // multiproofs) - let overlay_factory = - OverlayStateProviderFactory::new(self.provider.clone(), self.changeset_cache.clone()) - .with_anchor_hash(anchor_hash) - .with_lazy_overlay(lazy_overlay); + let overlay_factory = OverlayStateProviderFactory::new( + self.provider.clone(), + anchor_hash, + self.changeset_cache.clone(), + ) + .with_lazy_overlay(lazy_overlay); // Spawn the appropriate processor based on strategy let mut handle = ensure_ok!(self.spawn_payload_processor( @@ -665,7 +667,6 @@ where self.await_state_root_with_timeout( &mut handle, overlay_factory.clone(), - anchor_hash, &hashed_state, ), block @@ -690,7 +691,6 @@ where if self.config.always_compare_trie_updates() { let _has_diff = self.compare_trie_updates_with_serial( overlay_factory.clone(), - anchor_hash, &hashed_state, trie_updates.as_ref().clone(), ); @@ -729,11 +729,7 @@ where } StateRootStrategy::Parallel => { debug!(target: "engine::tree::payload_validator", "Using parallel state root algorithm"); - match self.compute_state_root_parallel( - overlay_factory.clone(), - anchor_hash, - &hashed_state, - ) { + match self.compute_state_root_parallel(overlay_factory.clone(), &hashed_state) { Ok(result) => { let elapsed = root_time.elapsed(); info!( @@ -769,11 +765,7 @@ where } let (root, updates) = ensure_ok_post_block!( - Self::compute_state_root_serial( - overlay_factory.clone(), - anchor_hash, - &hashed_state - ), + Self::compute_state_root_serial(overlay_factory.clone(), &hashed_state), block ); @@ -1098,7 +1090,6 @@ where fn compute_state_root_parallel( &self, overlay_factory: OverlayStateProviderFactory, - anchor_hash: B256, hashed_state: &LazyHashedPostState, ) -> Result<(B256, TrieUpdates), ParallelStateRootError> { let hashed_state = hashed_state.get(); @@ -1109,10 +1100,8 @@ where let overlay_factory = if overlay_factory.has_overlay_source() { overlay_factory.with_extended_hashed_state_overlay(hashed_state.clone_into_sorted()) } else { - overlay_factory.with_hashed_state_overlay( - anchor_hash, - Some(Arc::new(hashed_state.clone_into_sorted())), - ) + overlay_factory + .with_hashed_state_overlay(Some(Arc::new(hashed_state.clone_into_sorted()))) }; ParallelStateRoot::new(overlay_factory, prefix_sets, self.runtime.clone()) .incremental_root_with_updates() @@ -1125,7 +1114,6 @@ where /// trie updates for this block. fn compute_state_root_serial( overlay_factory: OverlayStateProviderFactory, - anchor_hash: B256, hashed_state: &LazyHashedPostState, ) -> ProviderResult<(B256, TrieUpdates)> { let hashed_state = hashed_state.get(); @@ -1136,10 +1124,8 @@ where let overlay_factory = if overlay_factory.has_overlay_source() { overlay_factory.with_extended_hashed_state_overlay(hashed_state.clone_into_sorted()) } else { - overlay_factory.with_hashed_state_overlay( - anchor_hash, - Some(Arc::new(hashed_state.clone_into_sorted())), - ) + overlay_factory + .with_hashed_state_overlay(Some(Arc::new(hashed_state.clone_into_sorted()))) }; let provider = overlay_factory.database_provider_ro()?; @@ -1172,7 +1158,6 @@ where &self, handle: &mut PayloadHandle, overlay_factory: OverlayStateProviderFactory, - anchor_hash: B256, hashed_state: &LazyHashedPostState, ) -> ProviderResult> { let Some(timeout) = self.config.state_root_task_timeout() else { @@ -1200,11 +1185,7 @@ where let seq_overlay = overlay_factory; let seq_hashed_state = hashed_state.clone(); self.payload_processor.executor().spawn_blocking_named("serial-root", move || { - let result = Self::compute_state_root_serial( - seq_overlay, - anchor_hash, - &seq_hashed_state, - ); + let result = Self::compute_state_root_serial(seq_overlay, &seq_hashed_state); let _ = seq_tx.send(result); }); @@ -1269,13 +1250,12 @@ where fn compare_trie_updates_with_serial( &self, overlay_factory: OverlayStateProviderFactory, - anchor_hash: B256, hashed_state: &LazyHashedPostState, task_trie_updates: TrieUpdates, ) -> bool { debug!(target: "engine::tree::payload_validator", "Comparing trie updates with serial computation"); - match Self::compute_state_root_serial(overlay_factory.clone(), anchor_hash, hashed_state) { + match Self::compute_state_root_serial(overlay_factory.clone(), hashed_state) { Ok((serial_root, serial_trie_updates)) => { debug!( target: "engine::tree::payload_validator", @@ -2053,10 +2033,12 @@ where state: &EngineApiTreeState, ) -> Option { let (lazy_overlay, anchor_hash) = Self::get_parent_lazy_overlay(parent_hash, state); - let overlay_factory = - OverlayStateProviderFactory::new(self.provider.clone(), self.changeset_cache.clone()) - .with_anchor_hash(anchor_hash) - .with_lazy_overlay(lazy_overlay); + let overlay_factory = OverlayStateProviderFactory::new( + self.provider.clone(), + anchor_hash, + self.changeset_cache.clone(), + ) + .with_lazy_overlay(lazy_overlay); Some(self.payload_processor.spawn_state_root( overlay_factory, diff --git a/crates/trie/parallel/src/root.rs b/crates/trie/parallel/src/root.rs index e9685f74e4c..4d0f58e25f9 100644 --- a/crates/trie/parallel/src/root.rs +++ b/crates/trie/parallel/src/root.rs @@ -362,8 +362,8 @@ mod tests { } let prefix_sets = hashed_state.construct_prefix_sets(); - overlay_factory = overlay_factory - .with_hashed_state_overlay(B256::ZERO, Some(Arc::new(hashed_state.into_sorted()))); + overlay_factory = + overlay_factory.with_hashed_state_overlay(Some(Arc::new(hashed_state.into_sorted()))); assert_eq!( ParallelStateRoot::new(overlay_factory, prefix_sets.freeze(), runtime) From 7db14d095d5eb25ef7b3551d3dbbae29a9a602ef Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:22:09 +0000 Subject: [PATCH 20/83] fix(engine): anchor state-root test overlay factory Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019dafbb-3571-73ef-ae67-43c242e2bf23 Co-authored-by: Amp --- crates/engine/tree/src/tree/payload_processor/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/tree/src/tree/payload_processor/mod.rs b/crates/engine/tree/src/tree/payload_processor/mod.rs index 8386bfd3b06..af312d768ff 100644 --- a/crates/engine/tree/src/tree/payload_processor/mod.rs +++ b/crates/engine/tree/src/tree/payload_processor/mod.rs @@ -1238,6 +1238,7 @@ mod tests { StateProviderBuilder::new(provider_factory.clone(), genesis_hash, None), OverlayStateProviderFactory::<_, EthPrimitives>::new( provider_factory, + genesis_hash, ChangesetCache::new(), ), &TreeConfig::default(), From 45db5e0b5db95ab054f87ef9d89d80871efc8933 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:36:34 +0000 Subject: [PATCH 21/83] fix(trie): initialize test overlay anchors Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019dafbb-3571-73ef-ae67-43c242e2bf23 Co-authored-by: Amp --- Cargo.lock | 2 ++ crates/trie/parallel/Cargo.toml | 3 +++ crates/trie/parallel/src/proof_task.rs | 18 ++++++++++++------ crates/trie/parallel/src/root.rs | 16 ++++++++++------ 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 47d0a328b00..5960624e24a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10439,6 +10439,8 @@ dependencies = [ "proptest-arbitrary-interop", "rand 0.9.4", "rayon", + "reth-chainspec", + "reth-db-common", "reth-execution-errors", "reth-metrics", "reth-primitives-traits", diff --git a/crates/trie/parallel/Cargo.toml b/crates/trie/parallel/Cargo.toml index 637e98d3c09..3b9f9bd219e 100644 --- a/crates/trie/parallel/Cargo.toml +++ b/crates/trie/parallel/Cargo.toml @@ -50,6 +50,8 @@ rand = { workspace = true, optional = true } [dev-dependencies] # reth +reth-chainspec.workspace = true +reth-db-common.workspace = true reth-primitives-traits.workspace = true reth-provider = { workspace = true, features = ["test-utils"] } reth-trie-db.workspace = true @@ -72,4 +74,5 @@ test-utils = [ "reth-trie-sparse/test-utils", "reth-trie/test-utils", "reth-tasks/test-utils", + "reth-chainspec/test-utils" ] diff --git a/crates/trie/parallel/src/proof_task.rs b/crates/trie/parallel/src/proof_task.rs index f7de9030bc8..4601c1b7606 100644 --- a/crates/trie/parallel/src/proof_task.rs +++ b/crates/trie/parallel/src/proof_task.rs @@ -1389,7 +1389,10 @@ enum AccountWorkerJob { #[cfg(test)] mod tests { use super::*; - use reth_provider::test_utils::create_test_provider_factory; + use reth_chainspec::ChainSpec; + use reth_db_common::init::init_genesis; + use reth_provider::test_utils::create_test_provider_factory_with_chain_spec; + use std::sync::Arc; fn test_ctx(factory: Factory) -> ProofTaskCtx { ProofTaskCtx::new(factory) @@ -1398,12 +1401,15 @@ mod tests { /// Ensures `ProofWorkerHandle::new` spawns workers correctly. #[test] fn spawn_proof_workers_creates_handle() { - let provider_factory = create_test_provider_factory(); + let provider_factory = + create_test_provider_factory_with_chain_spec(Arc::new(ChainSpec::default())); + let genesis_hash = init_genesis(&provider_factory).unwrap(); let changeset_cache = reth_trie_db::ChangesetCache::new(); - let factory = reth_provider::providers::OverlayStateProviderFactory::< - _, - reth_ethereum_primitives::EthPrimitives, - >::new(provider_factory, changeset_cache); + let factory = reth_provider::providers::OverlayStateProviderFactory::<_>::new( + provider_factory, + genesis_hash, + changeset_cache, + ); let ctx = test_ctx(factory); let runtime = reth_tasks::Runtime::test(); diff --git a/crates/trie/parallel/src/root.rs b/crates/trie/parallel/src/root.rs index 4d0f58e25f9..27a8985a8cd 100644 --- a/crates/trie/parallel/src/root.rs +++ b/crates/trie/parallel/src/root.rs @@ -274,19 +274,23 @@ mod tests { use super::*; use alloy_primitives::{keccak256, Address, U256}; use rand::Rng; + use reth_chainspec::ChainSpec; + use reth_db_common::init::init_genesis; use reth_primitives_traits::{Account, StorageEntry}; - use reth_provider::{test_utils::create_test_provider_factory, HashingWriter}; + use reth_provider::{test_utils::create_test_provider_factory_with_chain_spec, HashingWriter}; use reth_trie::{test_utils, HashedPostState, HashedStorage}; use std::sync::Arc; #[tokio::test] async fn random_parallel_root() { - let factory = create_test_provider_factory(); + let factory = create_test_provider_factory_with_chain_spec(Arc::new(ChainSpec::default())); + let genesis_hash = init_genesis(&factory).unwrap(); let changeset_cache = reth_trie_db::ChangesetCache::new(); - let mut overlay_factory = reth_provider::providers::OverlayStateProviderFactory::< - _, - reth_ethereum_primitives::EthPrimitives, - >::new(factory.clone(), changeset_cache); + let mut overlay_factory = reth_provider::providers::OverlayStateProviderFactory::<_>::new( + factory.clone(), + genesis_hash, + changeset_cache, + ); let mut rng = rand::rng(); let mut state = (0..100) From ffb0587b1957eedef3c24f10a163ab1176e63f97 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:53:34 +0000 Subject: [PATCH 22/83] fix(provider): satisfy overlay lint checks Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019dafbb-3571-73ef-ae67-43c242e2bf23 Co-authored-by: Amp --- crates/storage/provider/src/providers/state/overlay.rs | 4 ++-- crates/trie/parallel/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/storage/provider/src/providers/state/overlay.rs b/crates/storage/provider/src/providers/state/overlay.rs index 0cd4ca88d9b..96342b2362f 100644 --- a/crates/storage/provider/src/providers/state/overlay.rs +++ b/crates/storage/provider/src/providers/state/overlay.rs @@ -117,7 +117,7 @@ impl OverlayStateProviderFactory { } /// Returns true if an overlay source has been configured. - pub fn has_overlay_source(&self) -> bool { + pub const fn has_overlay_source(&self) -> bool { self.overlay_source.is_some() } @@ -210,7 +210,7 @@ where self.anchor_hash )))) } - Ok((Arc::clone(&trie), Arc::clone(&state))) + Ok((Arc::clone(trie), Arc::clone(state))) } None => Ok(( Arc::new(TrieUpdatesSorted::default()), diff --git a/crates/trie/parallel/Cargo.toml b/crates/trie/parallel/Cargo.toml index 3b9f9bd219e..62e22addc58 100644 --- a/crates/trie/parallel/Cargo.toml +++ b/crates/trie/parallel/Cargo.toml @@ -74,5 +74,5 @@ test-utils = [ "reth-trie-sparse/test-utils", "reth-trie/test-utils", "reth-tasks/test-utils", - "reth-chainspec/test-utils" + "reth-chainspec/test-utils", ] From d5169eda88d127aa9056613a42e3a6bf608fba5c Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:11:03 +0000 Subject: [PATCH 23/83] fix(engine): update sparse trie overlay factory test Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019db57a-7f08-7761-9a50-27a2a5c8f917 Co-authored-by: Amp --- .../engine/tree/src/tree/payload_processor/sparse_trie.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/engine/tree/src/tree/payload_processor/sparse_trie.rs b/crates/engine/tree/src/tree/payload_processor/sparse_trie.rs index 668002a0824..83cb61ff1ac 100644 --- a/crates/engine/tree/src/tree/payload_processor/sparse_trie.rs +++ b/crates/engine/tree/src/tree/payload_processor/sparse_trie.rs @@ -983,8 +983,11 @@ mod tests { fn run_returns_parent_root_without_revealing_blind_trie_when_no_state_updates() { let runtime = reth_tasks::Runtime::test(); let provider_factory = create_test_provider_factory(); - let overlay_factory = - OverlayStateProviderFactory::new(provider_factory, ChangesetCache::new()); + let overlay_factory = OverlayStateProviderFactory::<_, reth_chain_state::EthPrimitives>::new( + provider_factory, + B256::ZERO, + ChangesetCache::new(), + ); let proof_worker_handle = ProofWorkerHandle::new(&runtime, ProofTaskCtx::new(overlay_factory), false); From c4d0949a23f359ed0cd6eadd81c2d1eaa1fcf2cc Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:14:11 +0000 Subject: [PATCH 24/83] style(engine): format sparse trie overlay test Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019db57a-7f08-7761-9a50-27a2a5c8f917 Co-authored-by: Amp --- .../tree/src/tree/payload_processor/sparse_trie.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/engine/tree/src/tree/payload_processor/sparse_trie.rs b/crates/engine/tree/src/tree/payload_processor/sparse_trie.rs index 83cb61ff1ac..f133ac54c37 100644 --- a/crates/engine/tree/src/tree/payload_processor/sparse_trie.rs +++ b/crates/engine/tree/src/tree/payload_processor/sparse_trie.rs @@ -983,11 +983,12 @@ mod tests { fn run_returns_parent_root_without_revealing_blind_trie_when_no_state_updates() { let runtime = reth_tasks::Runtime::test(); let provider_factory = create_test_provider_factory(); - let overlay_factory = OverlayStateProviderFactory::<_, reth_chain_state::EthPrimitives>::new( - provider_factory, - B256::ZERO, - ChangesetCache::new(), - ); + let overlay_factory = + OverlayStateProviderFactory::<_, reth_chain_state::EthPrimitives>::new( + provider_factory, + B256::ZERO, + ChangesetCache::new(), + ); let proof_worker_handle = ProofWorkerHandle::new(&runtime, ProofTaskCtx::new(overlay_factory), false); From b60758ef732ad51e640ae753f4db90fc7898ebf3 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:32:47 +0000 Subject: [PATCH 25/83] fix(trie): remove unused parallel test dependency Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019dbc30-68d0-76b8-a32c-e1173122ca48 Co-authored-by: Amp --- Cargo.lock | 1 - crates/trie/parallel/Cargo.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a20a6d75608..cc7c33df560 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10443,7 +10443,6 @@ dependencies = [ "rand 0.9.4", "rayon", "reth-chainspec", - "reth-db-common", "reth-ethereum-primitives", "reth-execution-errors", "reth-metrics", diff --git a/crates/trie/parallel/Cargo.toml b/crates/trie/parallel/Cargo.toml index b804da40093..46541fcf6a2 100644 --- a/crates/trie/parallel/Cargo.toml +++ b/crates/trie/parallel/Cargo.toml @@ -51,7 +51,6 @@ rand = { workspace = true, optional = true } [dev-dependencies] # reth reth-chainspec.workspace = true -reth-db-common.workspace = true reth-ethereum-primitives.workspace = true reth-primitives-traits.workspace = true reth-provider = { workspace = true, features = ["test-utils"] } From 31d0c7852da82ddb7ad6c5fc07cdbdde84bd099d Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:57:08 +0000 Subject: [PATCH 26/83] fix(ci): clean bench checkouts and lock cargo builds Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019dbee7-f576-769d-923c-dfdfe0a40858 Co-authored-by: Amp --- .github/scripts/bench-reth-build.sh | 2 +- .github/workflows/bench-scheduled.yml | 29 ++++++++++++++++----------- .github/workflows/bench.yml | 29 ++++++++++++++++----------- 3 files changed, 35 insertions(+), 25 deletions(-) diff --git a/.github/scripts/bench-reth-build.sh b/.github/scripts/bench-reth-build.sh index 5fa9a76522b..6127f23b765 100755 --- a/.github/scripts/bench-reth-build.sh +++ b/.github/scripts/bench-reth-build.sh @@ -53,7 +53,7 @@ build_node_binary() { # shellcheck disable=SC2086 RUSTFLAGS="-C target-cpu=native${EXTRA_RUSTFLAGS}" \ - cargo build --profile profiling $NODE_PKG $workspace_arg $features_arg + cargo build --locked --profile profiling $NODE_PKG $workspace_arg $features_arg } case "$MODE" in diff --git a/.github/workflows/bench-scheduled.yml b/.github/workflows/bench-scheduled.yml index 8ade5e9fea2..8113184a23f 100644 --- a/.github/workflows/bench-scheduled.yml +++ b/.github/workflows/bench-scheduled.yml @@ -366,19 +366,24 @@ jobs: - name: Prepare source dirs run: | - if [ -d ../reth-baseline ]; then - git -C ../reth-baseline fetch origin "$BASELINE_REF" - else - git clone . ../reth-baseline - fi - git -C ../reth-baseline checkout "$BASELINE_REF" + prepare_source_dir() { + local dir="$1" + local ref="$2" + + if [ -d "$dir" ]; then + git -C "$dir" reset --hard HEAD + git -C "$dir" clean -fdx + git -C "$dir" fetch origin "$ref" + else + git clone . "$dir" + fi - if [ -d ../reth-feature ]; then - git -C ../reth-feature fetch origin "$FEATURE_REF" - else - git clone . ../reth-feature - fi - git -C ../reth-feature checkout "$FEATURE_REF" + git -C "$dir" checkout --force "$ref" + } + + prepare_source_dir ../reth-baseline "$BASELINE_REF" + + prepare_source_dir ../reth-feature "$FEATURE_REF" - name: Build binaries id: build diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 2428dc68c29..28f004de1f0 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -802,21 +802,26 @@ jobs: - name: Prepare source dirs run: | + prepare_source_dir() { + local dir="$1" + local ref="$2" + + if [ -d "$dir" ]; then + git -C "$dir" reset --hard HEAD + git -C "$dir" clean -fdx + git -C "$dir" fetch origin "$ref" + else + git clone . "$dir" + fi + + git -C "$dir" checkout --force "$ref" + } + BASELINE_REF="${{ steps.refs.outputs.baseline-ref }}" - if [ -d ../reth-baseline ]; then - git -C ../reth-baseline fetch origin "$BASELINE_REF" - else - git clone . ../reth-baseline - fi - git -C ../reth-baseline checkout "$BASELINE_REF" + prepare_source_dir ../reth-baseline "$BASELINE_REF" FEATURE_REF="${{ steps.refs.outputs.feature-ref }}" - if [ -d ../reth-feature ]; then - git -C ../reth-feature fetch origin "$FEATURE_REF" - else - git clone . ../reth-feature - fi - git -C ../reth-feature checkout "$FEATURE_REF" + prepare_source_dir ../reth-feature "$FEATURE_REF" - name: Build binaries id: build From b6eec2e684ec6a593e26891709f86326fa06a33f Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:28:57 +0000 Subject: [PATCH 27/83] refactor(provider): require overlay builder anchor hash Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019dbf08-3c9d-736b-9b47-3070f2bf2a54 Co-authored-by: Amp --- .../tree/src/tree/payload_processor/mod.rs | 2 +- .../src/tree/payload_processor/sparse_trie.rs | 7 +- .../engine/tree/src/tree/payload_validator.rs | 6 +- .../src/providers/state/historical.rs | 11 ++- .../provider/src/providers/state/overlay.rs | 99 ++++--------------- crates/trie/parallel/src/proof_task.rs | 6 +- crates/trie/parallel/src/root.rs | 6 +- 7 files changed, 43 insertions(+), 94 deletions(-) diff --git a/crates/engine/tree/src/tree/payload_processor/mod.rs b/crates/engine/tree/src/tree/payload_processor/mod.rs index 4156a88ad90..c21b541deb8 100644 --- a/crates/engine/tree/src/tree/payload_processor/mod.rs +++ b/crates/engine/tree/src/tree/payload_processor/mod.rs @@ -1252,7 +1252,7 @@ mod tests { StateProviderBuilder::new(provider_factory.clone(), genesis_hash, None), OverlayStateProviderFactory::new( provider_factory, - OverlayBuilder::::new(ChangesetCache::new()), + OverlayBuilder::::new(genesis_hash, ChangesetCache::new()), ), &TreeConfig::default(), ); diff --git a/crates/engine/tree/src/tree/payload_processor/sparse_trie.rs b/crates/engine/tree/src/tree/payload_processor/sparse_trie.rs index 9597da27470..16a05d8745c 100644 --- a/crates/engine/tree/src/tree/payload_processor/sparse_trie.rs +++ b/crates/engine/tree/src/tree/payload_processor/sparse_trie.rs @@ -896,6 +896,7 @@ mod tests { use reth_provider::{ providers::{OverlayBuilder, OverlayStateProviderFactory}, test_utils::create_test_provider_factory, + ChainSpecProvider, }; use reth_trie_db::ChangesetCache; use reth_trie_parallel::proof_task::ProofTaskCtx; @@ -984,9 +985,13 @@ mod tests { fn run_returns_parent_root_without_revealing_blind_trie_when_no_state_updates() { let runtime = reth_tasks::Runtime::test(); let provider_factory = create_test_provider_factory(); + let anchor_hash = provider_factory.chain_spec().genesis_hash(); let overlay_factory = OverlayStateProviderFactory::new( provider_factory, - OverlayBuilder::::new(ChangesetCache::new()), + OverlayBuilder::::new( + anchor_hash, + ChangesetCache::new(), + ), ); let proof_worker_handle = ProofWorkerHandle::new(&runtime, ProofTaskCtx::new(overlay_factory), false); diff --git a/crates/engine/tree/src/tree/payload_validator.rs b/crates/engine/tree/src/tree/payload_validator.rs index 361acb23fdf..d661bf02784 100644 --- a/crates/engine/tree/src/tree/payload_validator.rs +++ b/crates/engine/tree/src/tree/payload_validator.rs @@ -527,8 +527,7 @@ where // Create overlay factory for payload processor (StateRootTask path needs it for // multiproofs) let provider_factory = self.provider.clone(); - let overlay_builder = OverlayBuilder::::new(self.changeset_cache.clone()) - .with_block_hash(Some(anchor_hash)) + let overlay_builder = OverlayBuilder::::new(anchor_hash, self.changeset_cache.clone()) .with_lazy_overlay(lazy_overlay); let overlay_factory = OverlayStateProviderFactory::new(provider_factory.clone(), overlay_builder.clone()); @@ -2030,8 +2029,7 @@ where let (lazy_overlay, anchor_hash) = Self::get_parent_lazy_overlay(parent_hash, state); let overlay_factory = OverlayStateProviderFactory::new( self.provider.clone(), - OverlayBuilder::::new(self.changeset_cache.clone()) - .with_block_hash(Some(anchor_hash)) + OverlayBuilder::::new(anchor_hash, self.changeset_cache.clone()) .with_lazy_overlay(lazy_overlay), ); diff --git a/crates/storage/provider/src/providers/state/historical.rs b/crates/storage/provider/src/providers/state/historical.rs index 7952928a4f5..df6b64ed663 100644 --- a/crates/storage/provider/src/providers/state/historical.rs +++ b/crates/storage/provider/src/providers/state/historical.rs @@ -282,16 +282,17 @@ impl<'b, Provider: DBProvider + ChangeSetReader + StorageChangeSetReader + Block // Historical providers expose state at the start of `self.block_number`, so the overlay // builder needs the previous canonical block hash to preserve those semantics. let target_block = self.block_number.saturating_sub(1); - let block_hash = self + let anchor_hash = self .provider .block_hash(target_block)? .ok_or_else(|| ProviderError::HeaderNotFound(target_block.into()))?; let TrieInputSorted { nodes, state, prefix_sets } = input; - let overlay_builder = - OverlayBuilder::::new(self.changeset_cache.clone()) - .with_block_hash(Some(block_hash)) - .with_overlay_source(Some(OverlaySource::Immediate { trie: nodes, state })); + let overlay_builder = OverlayBuilder::::new( + anchor_hash, + self.changeset_cache.clone(), + ) + .with_overlay_source(Some(OverlaySource::Immediate { trie: nodes, state })); let Overlay { trie_updates, hashed_post_state } = overlay_builder.build_overlay(self.provider)?; diff --git a/crates/storage/provider/src/providers/state/overlay.rs b/crates/storage/provider/src/providers/state/overlay.rs index 4d87f4b21ee..35b860fb110 100644 --- a/crates/storage/provider/src/providers/state/overlay.rs +++ b/crates/storage/provider/src/providers/state/overlay.rs @@ -84,8 +84,8 @@ pub(super) enum OverlaySource { /// collecting reverts. It is intentionally independent from any provider factory or overlay cache. #[derive(Debug, Clone)] pub struct OverlayBuilder { - /// Optional block hash for collecting reverts - block_hash: Option, + /// Anchor hash to revert the DB state to before applying overlays. + anchor_hash: B256, /// Optional overlay source (lazy or immediate). overlay_source: Option>, /// Changeset cache handle for retrieving trie changesets @@ -96,30 +96,18 @@ pub struct OverlayBuilder { impl OverlayBuilder { /// Create a new overlay builder. - pub fn new(changeset_cache: ChangesetCache) -> Self { + pub fn new(anchor_hash: B256, changeset_cache: ChangesetCache) -> Self { Self { - block_hash: None, + anchor_hash, overlay_source: None, changeset_cache, metrics: OverlayStateProviderMetrics::default(), } } - /// Returns true if an overlay source has been configured. - pub const fn has_overlay_source(&self) -> bool { - self.overlay_source.is_some() - } - - /// Set the block hash for collecting reverts. All state will be reverted to the point - /// _after_ this block has been processed. - pub const fn with_block_hash(mut self, block_hash: Option) -> Self { - self.block_hash = block_hash; - self - } - /// Set the overlay source (lazy or immediate). /// - /// This overlay will be applied on top of any reverts applied via `with_block_hash`. + /// This overlay will be applied on top of any reverts applied via `anchor_hash`. pub(super) fn with_overlay_source(mut self, source: Option>) -> Self { if let Some(OverlaySource::Lazy(lazy_overlay)) = source.as_ref() { self.assert_lazy_overlay_anchor(lazy_overlay); @@ -130,21 +118,17 @@ impl OverlayBuilder { fn assert_lazy_overlay_anchor(&self, lazy_overlay: &LazyOverlay) { let Some(lazy_overlay_anchor) = lazy_overlay.anchor_hash() else { return }; - let anchor_hash = self - .block_hash - .expect("OverlayBuilder must set block_hash before attaching a LazyOverlay"); - assert!( - lazy_overlay_anchor == anchor_hash, + lazy_overlay_anchor == self.anchor_hash, "LazyOverlay's anchor ({}) != OverlayBuilder's anchor ({})", lazy_overlay_anchor, - anchor_hash, + self.anchor_hash, ); } /// Set a lazy overlay that will be computed on first access. /// - /// Panics if the [`LazyOverlay`]'s anchor hash does not match [`Self`]'s `block_hash`. + /// Panics if the [`LazyOverlay`]'s anchor hash does not match [`Self`]'s `anchor_hash`. pub fn with_lazy_overlay(mut self, lazy_overlay: Option>) -> Self { if let Some(lazy_overlay) = lazy_overlay.as_ref() { self.assert_lazy_overlay_anchor(lazy_overlay); @@ -178,10 +162,7 @@ impl OverlayBuilder { } Some(OverlaySource::Lazy(overlay)) => { // Resolve lazy overlay and convert to immediate with extension - let anchor_hash = self - .block_hash - .expect("extending a lazy overlay requires OverlayBuilder::block_hash"); - let (trie, mut state) = overlay.as_overlay(anchor_hash); + let (trie, mut state) = overlay.as_overlay(self.anchor_hash); Arc::make_mut(&mut state).extend_ref_and_sort(&other); self.overlay_source = Some(OverlaySource::Immediate { trie, state }); } @@ -206,10 +187,10 @@ impl OverlayBuilder { match &self.overlay_source { Some(OverlaySource::Lazy(lazy_overlay)) => Ok(lazy_overlay.as_overlay(anchor_hash)), Some(OverlaySource::Immediate { trie, state }) => { - if self.block_hash.is_some_and(|expected_anchor| anchor_hash != expected_anchor) { + if anchor_hash != self.anchor_hash { return Err(ProviderError::other(std::io::Error::other(format!( "anchor_hash {anchor_hash} doesn't match OverlayBuilder's configured anchor ({})", - self.block_hash.expect("checked above") + self.anchor_hash )))) } Ok((Arc::clone(trie), Arc::clone(state))) @@ -221,23 +202,14 @@ impl OverlayBuilder { } } - /// Returns the block number for [`Self`]'s `block_hash` field, if any. - fn get_requested_block_number( - &self, - provider: &Provider, - ) -> ProviderResult> + /// Returns the block number for [`Self`]'s `anchor_hash` field. + fn get_block_number(&self, provider: &Provider) -> ProviderResult where Provider: BlockNumReader, { - if let Some(block_hash) = self.block_hash { - Ok(Some( - provider - .convert_hash_or_number(block_hash.into())? - .ok_or_else(|| ProviderError::BlockHashNotFound(block_hash))?, - )) - } else { - Ok(None) - } + provider + .convert_hash_or_number(self.anchor_hash.into())? + .ok_or(ProviderError::BlockHashNotFound(self.anchor_hash)) } /// Returns the block which is at the tip of the DB, i.e. the block which the state tables of @@ -273,10 +245,8 @@ impl OverlayBuilder { where Provider: BlockNumReader + PruneCheckpointReader, { - let Some(anchor_hash) = self.block_hash else { return Ok(None) }; - // If the anchor is the DB tip then there won't be any reverts necessary. - if db_tip_block.hash == anchor_hash { + if db_tip_block.hash == self.anchor_hash { return Ok(None) } @@ -289,9 +259,7 @@ impl OverlayBuilder { return Ok(None) } - let anchor_number = self - .get_requested_block_number(provider)? - .expect("OverlayBuilder::block_hash was checked above"); + let anchor_number = self.get_block_number(provider)?; // Check account history prune checkpoint to determine the lower bound of available data. // The prune checkpoint's block_number is the highest pruned block, so data is available @@ -320,7 +288,7 @@ impl OverlayBuilder { level = "debug", target = "providers::state::overlay", skip_all, - fields(?db_tip_block, anchor_hash = ?self.block_hash) + fields(?db_tip_block, anchor_hash = ?self.anchor_hash) )] fn calculate_overlay( &self, @@ -383,8 +351,7 @@ impl OverlayBuilder { // Resolve overlays (lazy or immediate) and extend reverts with them. // If reverts are empty, use overlays directly to avoid cloning. - let anchor_hash = self.block_hash.expect("reverts require OverlayBuilder::block_hash"); - let (overlay_trie, overlay_state) = self.resolve_overlays(anchor_hash)?; + let (overlay_trie, overlay_state) = self.resolve_overlays(self.anchor_hash)?; let trie_updates = if trie_reverts.is_empty() { overlay_trie @@ -453,22 +420,6 @@ impl OverlayBuilder { + BlockNumReader + StorageSettingsCache, { - if self.block_hash.is_none() { - let (trie_updates, hashed_post_state) = match &self.overlay_source { - Some(OverlaySource::Immediate { trie, state }) => { - (Arc::clone(trie), Arc::clone(state)) - } - Some(OverlaySource::Lazy(_)) => { - panic!("Lazy overlays require OverlayBuilder::block_hash") - } - None => ( - Arc::new(TrieUpdatesSorted::default()), - Arc::new(HashedPostStateSorted::default()), - ), - }; - return Ok(Overlay { trie_updates, hashed_post_state }) - } - let db_tip_block = self.get_db_tip_block(provider)?; self.calculate_overlay(provider, db_tip_block) } @@ -495,11 +446,6 @@ impl OverlayStateProviderFactory { Self { factory, overlay_builder, overlay_cache: Default::default() } } - /// Returns true if an overlay source has been configured. - pub const fn has_overlay_source(&self) -> bool { - self.overlay_builder.has_overlay_source() - } - /// Set a lazy overlay that will be computed on first access. pub fn with_lazy_overlay(mut self, lazy_overlay: Option>) -> Self { self.overlay_builder = self.overlay_builder.with_lazy_overlay(lazy_overlay); @@ -537,11 +483,6 @@ impl OverlayStateProviderFactory { + BlockNumReader + StorageSettingsCache, { - // No anchor block — just resolve the in-memory overlay directly. - if self.overlay_builder.block_hash.is_none() { - return self.overlay_builder.build_overlay(provider) - } - let db_tip_block = self.overlay_builder.get_db_tip_block(provider)?; let overlay = match self.overlay_cache.entry(db_tip_block.hash) { diff --git a/crates/trie/parallel/src/proof_task.rs b/crates/trie/parallel/src/proof_task.rs index 65bfd412b78..2fc01af6ec8 100644 --- a/crates/trie/parallel/src/proof_task.rs +++ b/crates/trie/parallel/src/proof_task.rs @@ -1162,12 +1162,14 @@ mod tests { /// Ensures `ProofWorkerHandle::new` spawns workers correctly. #[test] fn spawn_proof_workers_creates_handle() { - let provider_factory = - create_test_provider_factory_with_chain_spec(Arc::new(ChainSpec::default())); + let chain_spec = Arc::new(ChainSpec::default()); + let anchor_hash = chain_spec.genesis_hash(); + let provider_factory = create_test_provider_factory_with_chain_spec(chain_spec); let changeset_cache = reth_trie_db::ChangesetCache::new(); let factory = reth_provider::providers::OverlayStateProviderFactory::new( provider_factory, reth_provider::providers::OverlayBuilder::::new( + anchor_hash, changeset_cache, ), ); diff --git a/crates/trie/parallel/src/root.rs b/crates/trie/parallel/src/root.rs index 6d264ce1f60..b2ce83c47ad 100644 --- a/crates/trie/parallel/src/root.rs +++ b/crates/trie/parallel/src/root.rs @@ -282,11 +282,13 @@ mod tests { #[tokio::test] async fn random_parallel_root() { - let factory = create_test_provider_factory_with_chain_spec(Arc::new(ChainSpec::default())); + let chain_spec = Arc::new(ChainSpec::default()); + let anchor_hash = chain_spec.genesis_hash(); + let factory = create_test_provider_factory_with_chain_spec(chain_spec); let changeset_cache = reth_trie_db::ChangesetCache::new(); let overlay_builder = reth_provider::providers::OverlayBuilder::< reth_ethereum_primitives::EthPrimitives, - >::new(changeset_cache); + >::new(anchor_hash, changeset_cache); let mut overlay_factory = reth_provider::providers::OverlayStateProviderFactory::new( factory.clone(), overlay_builder.clone(), From 743d42ff6d2e02180424d7fde288d146c24899c8 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:04:06 +0000 Subject: [PATCH 28/83] fix(provider): anchor overlay state providers to trie frontier Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019dcdca-2c60-711c-b1b8-7a6001a950fd Co-authored-by: Amp --- .../provider/src/providers/state/overlay.rs | 146 ++++++++++++++---- 1 file changed, 112 insertions(+), 34 deletions(-) diff --git a/crates/storage/provider/src/providers/state/overlay.rs b/crates/storage/provider/src/providers/state/overlay.rs index 35b860fb110..02c0e6d10d4 100644 --- a/crates/storage/provider/src/providers/state/overlay.rs +++ b/crates/storage/provider/src/providers/state/overlay.rs @@ -212,20 +212,18 @@ impl OverlayBuilder { .ok_or(ProviderError::BlockHashNotFound(self.anchor_hash)) } - /// Returns the block which is at the tip of the DB, i.e. the block which the state tables of - /// the DB are currently synced to. - fn get_db_tip_block(&self, provider: &Provider) -> ProviderResult + /// Returns the highest block whose trie state is durably available in the database. + fn get_db_trie_tip_block(&self, provider: &Provider) -> ProviderResult where Provider: StageCheckpointReader + BlockNumReader, { - let block_number = provider - .get_stage_checkpoint(StageId::Finish)? - .as_ref() - .map(|chk| chk.block_number) - .ok_or_else(|| ProviderError::InsufficientChangesets { - requested: 0, - available: 0..=0, - })?; + let checkpoint = provider.get_stage_checkpoint(StageId::Finish)?.ok_or_else(|| { + ProviderError::InsufficientChangesets { requested: 0, available: 0..=0 } + })?; + let block_number = checkpoint + .finish_stage_checkpoint() + .and_then(|finish| finish.partial_state_trie) + .unwrap_or(checkpoint.block_number); let hash = provider .convert_number(block_number.into())? .ok_or_else(|| ProviderError::HeaderNotFound(block_number.into()))?; @@ -240,21 +238,22 @@ impl OverlayBuilder { fn reverts_required( &self, provider: &Provider, - db_tip_block: BlockNumHash, + trie_tip_block: BlockNumHash, ) -> ProviderResult>> where Provider: BlockNumReader + PruneCheckpointReader, { - // If the anchor is the DB tip then there won't be any reverts necessary. - if db_tip_block.hash == self.anchor_hash { + // If the anchor is the current durable trie frontier then there won't be any reverts + // necessary. + if trie_tip_block.hash == self.anchor_hash { return Ok(None) } - // If the DB tip has moved forward into the `LazyOverlay` then we still don't need to - // revert, the `LazyOverlay` will generate a new in-memory overlay using only the relevant - // blocks data. + // If the durable trie frontier has moved forward into the `LazyOverlay` then we still + // don't need to revert, because the overlay can trim off the persisted prefix and keep the + // remaining in-memory suffix above that trie frontier. if let Some(OverlaySource::Lazy(lazy_overlay)) = &self.overlay_source && - lazy_overlay.has_anchor_hash(db_tip_block.hash) + lazy_overlay.has_anchor_hash(trie_tip_block.hash) { return Ok(None) } @@ -270,7 +269,7 @@ impl OverlayBuilder { .map(|block_number| block_number + 1) .unwrap_or_default(); - let available_range = lower_bound..=db_tip_block.number; + let available_range = lower_bound..=trie_tip_block.number; // Check if the requested block is within the available range if !available_range.contains(&anchor_number) { @@ -280,20 +279,20 @@ impl OverlayBuilder { }); } - Ok(Some(anchor_number + 1..=db_tip_block.number)) + Ok(Some(anchor_number + 1..=trie_tip_block.number)) } - /// Calculates a new [`Overlay`] given a transaction and the current db tip. + /// Calculates a new [`Overlay`] given a transaction and the current trie frontier. #[instrument( level = "debug", target = "providers::state::overlay", skip_all, - fields(?db_tip_block, anchor_hash = ?self.anchor_hash) + fields(?trie_tip_block, anchor_hash = ?self.anchor_hash) )] fn calculate_overlay( &self, provider: &Provider, - db_tip_block: BlockNumHash, + trie_tip_block: BlockNumHash, ) -> ProviderResult where Provider: ChangeSetReader @@ -314,7 +313,7 @@ impl OverlayBuilder { // Collect any reverts which are required to bring the DB view back to the anchor hash. let (trie_updates, hashed_post_state) = if let Some(revert_blocks) = - self.reverts_required(provider, db_tip_block)? + self.reverts_required(provider, trie_tip_block)? { debug!( target: "providers::state::overlay", @@ -383,9 +382,9 @@ impl OverlayBuilder { (trie_updates, hashed_state_updates) } else { - // If no reverts are needed then we can assume that the db tip is the anchor hash or - // overlaps with the `LazyOverlay`. Use overlays directly. - let (trie_updates, hashed_state) = self.resolve_overlays(db_tip_block.hash)?; + // If no reverts are needed then we can assume that the current durable trie frontier is + // the anchor hash or overlaps with the `LazyOverlay`. Use overlays directly. + let (trie_updates, hashed_state) = self.resolve_overlays(trie_tip_block.hash)?; retrieve_trie_reverts_duration = Duration::ZERO; retrieve_hashed_state_reverts_duration = Duration::ZERO; @@ -420,8 +419,8 @@ impl OverlayBuilder { + BlockNumReader + StorageSettingsCache, { - let db_tip_block = self.get_db_tip_block(provider)?; - self.calculate_overlay(provider, db_tip_block) + let trie_tip_block = self.get_db_trie_tip_block(provider)?; + self.calculate_overlay(provider, trie_tip_block) } } @@ -435,8 +434,9 @@ pub struct OverlayStateProviderFactory { factory: F, /// Overlay builder containing the configuration and overlay calculation logic. overlay_builder: OverlayBuilder, - /// A cache which maps `db_tip -> Overlay`. If the db tip changes during usage of the factory - /// then a new entry will get added to this, but in most cases only one entry is present. + /// A cache which maps `trie_tip_hash -> Overlay`. If the durable trie frontier changes during + /// usage of the factory then a new entry will get added to this, but in most cases only one + /// entry is present. overlay_cache: Arc>, } @@ -470,7 +470,7 @@ impl OverlayStateProviderFactory { self } - /// Fetches an [`Overlay`] from the cache based on the current db tip block. If there is no + /// Fetches an [`Overlay`] from the cache based on the current trie frontier. If there is no /// cached value then this calculates the [`Overlay`] and populates the cache. #[instrument(level = "debug", target = "providers::state::overlay", skip_all)] fn get_overlay(&self, provider: &Provider) -> ProviderResult @@ -483,9 +483,9 @@ impl OverlayStateProviderFactory { + BlockNumReader + StorageSettingsCache, { - let db_tip_block = self.overlay_builder.get_db_tip_block(provider)?; + let trie_tip_block = self.overlay_builder.get_db_trie_tip_block(provider)?; - let overlay = match self.overlay_cache.entry(db_tip_block.hash) { + let overlay = match self.overlay_cache.entry(trie_tip_block.hash) { dashmap::Entry::Occupied(entry) => entry.get().clone(), dashmap::Entry::Vacant(entry) => { self.overlay_builder.metrics.overlay_cache_misses.increment(1); @@ -651,3 +651,81 @@ where hashed_cursor_factory.hashed_storage_cursor(hashed_address) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{test_utils::create_test_provider_factory, BlockWriter}; + use alloy_primitives::{B256, U256}; + use reth_chain_state::{test_utils::TestBlockBuilder, ComputedTrieData, ExecutedBlock}; + use reth_primitives_traits::Account; + use reth_stages_types::{FinishCheckpoint, StageCheckpoint}; + use reth_storage_api::StageCheckpointWriter; + use reth_trie::{updates::TrieUpdatesSorted, HashedPostState, HashedStorage}; + use std::sync::Arc; + + fn with_unique_state( + block: &ExecutedBlock, + id: u8, + ) -> ExecutedBlock { + let hashed_address = B256::with_last_byte(id); + let hashed_slot = B256::with_last_byte(id.saturating_add(32)); + let hashed_state = HashedPostState::default() + .with_accounts([(hashed_address, Some(Account::default()))]) + .with_storages([( + hashed_address, + HashedStorage::from_iter(false, [(hashed_slot, U256::from(id))]), + )]) + .into_sorted(); + + ExecutedBlock::new( + Arc::clone(&block.recovered_block), + Arc::clone(&block.execution_output), + ComputedTrieData::without_trie_input( + Arc::new(hashed_state), + Arc::new(TrieUpdatesSorted::default()), + ), + ) + } + + #[test] + fn build_overlay_uses_partial_trie_frontier_as_lazy_overlay_base() { + let factory = create_test_provider_factory(); + let mut block_builder = TestBlockBuilder::eth(); + let blocks = block_builder + .get_executed_blocks(0..5) + .enumerate() + .map(|(index, block)| with_unique_state(&block, index as u8 + 1)) + .collect::>(); + + let trie_tip = &blocks[1]; + let finish_tip = &blocks[3]; + let lazy_overlay_blocks = vec![blocks[4].clone(), blocks[3].clone(), blocks[2].clone()]; + + let provider_rw = factory.provider_rw().unwrap(); + provider_rw.insert_block(blocks[0].recovered_block()).unwrap(); + provider_rw.insert_block(trie_tip.recovered_block()).unwrap(); + provider_rw.insert_block(blocks[2].recovered_block()).unwrap(); + provider_rw.insert_block(finish_tip.recovered_block()).unwrap(); + provider_rw + .save_stage_checkpoint( + StageId::Finish, + StageCheckpoint::new(finish_tip.block_number()).with_finish_stage_checkpoint( + FinishCheckpoint { partial_state_trie: Some(trie_tip.block_number()) }, + ), + ) + .unwrap(); + provider_rw.commit().unwrap(); + + let provider = factory.provider().unwrap(); + let overlay = OverlayBuilder::::new( + trie_tip.recovered_block().hash(), + ChangesetCache::new(), + ) + .with_lazy_overlay(Some(LazyOverlay::new(lazy_overlay_blocks))) + .build_overlay(&provider) + .unwrap(); + + assert_eq!(overlay.hashed_post_state.accounts.len(), 3); + } +} From 5d4019049acdea2ae5d060bc2c0d32fe5a797aab Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:52:28 +0000 Subject: [PATCH 29/83] fix(provider): stop masking trie writes with in-memory suffix Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019dce00-c0f1-7665-a244-00f02292fc3c Co-authored-by: Amp --- .../src/providers/database/provider.rs | 223 ++++++++++++------ 1 file changed, 148 insertions(+), 75 deletions(-) diff --git a/crates/storage/provider/src/providers/database/provider.rs b/crates/storage/provider/src/providers/database/provider.rs index 14b8e8becaf..64c65938282 100644 --- a/crates/storage/provider/src/providers/database/provider.rs +++ b/crates/storage/provider/src/providers/database/provider.rs @@ -565,9 +565,10 @@ impl DatabaseProvider DatabaseProvider DatabaseProvider = deferred_trie_blocks - .iter() - .chain(in_memory_blocks.iter()) - .map(|block| block.trie_data()) - .collect(); + // Only blocks whose non-trie outputs are durably written in this call can mask + // trie writes. A fully in-memory suffix must not suppress the durable trie + // frontier because its changesets are not on disk yet. + let trie_masking_data: Vec<_> = + deferred_trie_blocks.iter().map(|block| block.trie_data()).collect(); let start = Instant::now(); if !trie_persist_data.is_empty() { @@ -4574,7 +4574,7 @@ mod tests { } #[test] - fn test_save_blocks_disjoints_in_memory_trie_data() { + fn test_save_blocks_only_masks_trie_with_deferred_blocks() { use reth_trie::{ updates::{StorageTrieUpdatesSorted, TrieUpdatesSorted}, BranchNodeCompact, HashedPostStateSorted, HashedStorageSorted, @@ -4619,31 +4619,36 @@ mod tests { provider_rw.commit().unwrap(); let kept_account = B256::with_last_byte(0x11); - let overlapping_account = B256::with_last_byte(0x12); + let deferred_masked_account = B256::with_last_byte(0x12); + let in_memory_overlap_account = B256::with_last_byte(0x13); + let in_memory_only_account = B256::with_last_byte(0x14); let kept_storage = B256::with_last_byte(0x21); - let overlapping_storage = B256::with_last_byte(0x22); + let deferred_masked_storage = B256::with_last_byte(0x22); + let in_memory_overlap_storage = B256::with_last_byte(0x23); + let in_memory_only_storage = B256::with_last_byte(0x24); let kept_slot = B256::with_last_byte(0x31); - let overlapping_slot = B256::with_last_byte(0x32); - let in_memory_hashed_account = B256::with_last_byte(0x43); - let in_memory_hashed_storage = B256::with_last_byte(0x44); - let in_memory_hashed_slot = B256::with_last_byte(0x45); + let deferred_masked_slot = B256::with_last_byte(0x32); + let in_memory_overlap_slot = B256::with_last_byte(0x33); + let in_memory_only_slot = B256::with_last_byte(0x34); let kept_account_node = Nibbles::from_nibbles([0x1, 0x2]); - let overlapping_account_node = Nibbles::from_nibbles([0x1, 0x3]); + let deferred_masked_account_node = Nibbles::from_nibbles([0x1, 0x3]); + let in_memory_overlap_account_node = Nibbles::from_nibbles([0x1, 0x4]); + let in_memory_only_account_node = Nibbles::from_nibbles([0x1, 0x5]); let kept_storage_node = Nibbles::from_nibbles([0x2, 0x1]); - let overlapping_storage_node = Nibbles::from_nibbles([0x2, 0x2]); - let in_memory_account_node = Nibbles::from_nibbles([0x2, 0x3]); - let in_memory_storage_node = Nibbles::from_nibbles([0x2, 0x4]); - let plain_storage_address = Address::new([0xAA; 20]); - let plain_storage_slot = U256::from_limbs([1, 0, 0, 0]); + let deferred_masked_storage_node = Nibbles::from_nibbles([0x2, 0x2]); + let in_memory_overlap_storage_node = Nibbles::from_nibbles([0x2, 0x3]); + let in_memory_only_storage_node = Nibbles::from_nibbles([0x2, 0x4]); let blocks: Vec<_> = - TestBlockBuilder::eth().with_state().get_executed_blocks(1..3).collect(); - let partial_persist_base = &blocks[0]; - let in_memory_only_base = &blocks[1]; + TestBlockBuilder::eth().with_state().get_executed_blocks(1..4).collect(); + let full_persist_base = &blocks[0]; + let deferred_trie_base = &blocks[1]; + let in_memory_only_base = &blocks[2]; - let partial_persist_hashed_state = HashedPostStateSorted::new( + let full_persist_hashed_state = HashedPostStateSorted::new( vec![ (kept_account, Some(Account::default())), - (overlapping_account, Some(Account { nonce: 1, ..Default::default() })), + (deferred_masked_account, Some(Account { nonce: 1, ..Default::default() })), + (in_memory_overlap_account, Some(Account { nonce: 2, ..Default::default() })), ], B256Map::from_iter([ ( @@ -4654,18 +4659,26 @@ mod tests { }, ), ( - overlapping_storage, + deferred_masked_storage, HashedStorageSorted { wiped: false, - storage_slots: vec![(overlapping_slot, U256::from(2))], + storage_slots: vec![(deferred_masked_slot, U256::from(2))], + }, + ), + ( + in_memory_overlap_storage, + HashedStorageSorted { + wiped: false, + storage_slots: vec![(in_memory_overlap_slot, U256::from(3))], }, ), ]), ); - let partial_persist_trie_updates = TrieUpdatesSorted::new( + let full_persist_trie_updates = TrieUpdatesSorted::new( vec![ (kept_account_node.clone(), Some(branch(0b0000_1111_0000_1111))), - (overlapping_account_node.clone(), Some(branch(0b1111_0000_1111_0000))), + (deferred_masked_account_node.clone(), Some(branch(0b1111_0000_1111_0000))), + (in_memory_overlap_account_node.clone(), Some(branch(0b1010_1010_1010_1010))), ], B256Map::from_iter([ ( @@ -4676,71 +4689,117 @@ mod tests { }, ), ( - overlapping_storage, + deferred_masked_storage, StorageTrieUpdatesSorted { is_deleted: false, storage_nodes: vec![( - overlapping_storage_node.clone(), + deferred_masked_storage_node.clone(), Some(branch(0b0101)), )], }, ), + ( + in_memory_overlap_storage, + StorageTrieUpdatesSorted { + is_deleted: false, + storage_nodes: vec![( + in_memory_overlap_storage_node.clone(), + Some(branch(0b0110)), + )], + }, + ), ]), ); - let partial_persist_block = ExecutedBlock::new( - Arc::clone(&partial_persist_base.recovered_block), - Arc::clone(&partial_persist_base.execution_output), + let full_persist_block = ExecutedBlock::new( + Arc::clone(&full_persist_base.recovered_block), + Arc::clone(&full_persist_base.execution_output), + ComputedTrieData { + hashed_state: Arc::new(full_persist_hashed_state), + trie_updates: Arc::new(full_persist_trie_updates), + ..Default::default() + }, + ); + + let deferred_trie_hashed_state = HashedPostStateSorted::new( + vec![(deferred_masked_account, Some(Account { nonce: 3, ..Default::default() }))], + B256Map::from_iter([( + deferred_masked_storage, + HashedStorageSorted { + wiped: false, + storage_slots: vec![(deferred_masked_slot, U256::from(4))], + }, + )]), + ); + let deferred_trie_updates = TrieUpdatesSorted::new( + vec![(deferred_masked_account_node.clone(), Some(branch(0b0011_0011)))], + B256Map::from_iter([( + deferred_masked_storage, + StorageTrieUpdatesSorted { + is_deleted: false, + storage_nodes: vec![( + deferred_masked_storage_node.clone(), + Some(branch(0b1100)), + )], + }, + )]), + ); + let deferred_trie_block = ExecutedBlock::new( + Arc::clone(&deferred_trie_base.recovered_block), + Arc::clone(&deferred_trie_base.execution_output), ComputedTrieData { - hashed_state: Arc::new(partial_persist_hashed_state), - trie_updates: Arc::new(partial_persist_trie_updates), + hashed_state: Arc::new(deferred_trie_hashed_state), + trie_updates: Arc::new(deferred_trie_updates), ..Default::default() }, ); let in_memory_only_hashed_state = HashedPostStateSorted::new( vec![ - (overlapping_account, Some(Account { nonce: 2, ..Default::default() })), - (in_memory_hashed_account, Some(Account { nonce: 3, ..Default::default() })), + (in_memory_overlap_account, Some(Account { nonce: 4, ..Default::default() })), + (in_memory_only_account, Some(Account { nonce: 5, ..Default::default() })), ], B256Map::from_iter([ ( - overlapping_storage, + in_memory_overlap_storage, HashedStorageSorted { wiped: false, - storage_slots: vec![(overlapping_slot, U256::from(3))], + storage_slots: vec![(in_memory_overlap_slot, U256::from(5))], }, ), ( - in_memory_hashed_storage, + in_memory_only_storage, HashedStorageSorted { wiped: false, - storage_slots: vec![(in_memory_hashed_slot, U256::from(4))], + storage_slots: vec![(in_memory_only_slot, U256::from(6))], }, ), ]), ); let in_memory_only_trie_updates = TrieUpdatesSorted::new( vec![ - (overlapping_account_node.clone(), Some(branch(0b0011_0011))), - (in_memory_account_node.clone(), Some(branch(0b0101_0101))), + (in_memory_overlap_account_node.clone(), Some(branch(0b0101_0101))), + (in_memory_only_account_node.clone(), Some(branch(0b1111_0000))), ], B256Map::from_iter([ ( - overlapping_storage, + in_memory_overlap_storage, StorageTrieUpdatesSorted { is_deleted: false, storage_nodes: vec![( - overlapping_storage_node.clone(), - Some(branch(0b1100)), + in_memory_overlap_storage_node.clone(), + Some(branch(0b1001)), )], }, ), ( - in_memory_hashed_storage, + in_memory_only_storage, StorageTrieUpdatesSorted { is_deleted: false, - storage_nodes: vec![(in_memory_storage_node.clone(), Some(branch(0b1111)))], + storage_nodes: vec![( + in_memory_only_storage_node.clone(), + Some(branch(0b1111)), + )], }, ), ]), @@ -4756,8 +4815,8 @@ mod tests { ); let provider_rw = factory.provider_rw().unwrap(); - let blocks = vec![partial_persist_block, in_memory_only_block]; - provider_rw.save_blocks(&blocks, 1, 0, 1, SaveBlocksMode::Full).unwrap(); + let blocks = vec![full_persist_block, deferred_trie_block, in_memory_only_block]; + provider_rw.save_blocks(&blocks, 0, 1, 1, SaveBlocksMode::Full).unwrap(); provider_rw.commit().unwrap(); let provider = factory.provider().unwrap(); @@ -4768,31 +4827,30 @@ mod tests { finish_checkpoint.finish_stage_checkpoint().unwrap().partial_state_trie, Some(1) ); - - let mut plain_storages = tx.cursor_dup_read::().unwrap(); - assert_eq!( - plain_storages - .seek_by_key_subkey( - plain_storage_address, - B256::from(plain_storage_slot.to_be_bytes()), - ) - .unwrap(), - Some(StorageEntry { - key: B256::from(plain_storage_slot.to_be_bytes()), - value: U256::from(3), - }) - ); assert!(provider.block_hash(2).unwrap().is_some()); + assert!(provider.block_hash(3).unwrap().is_none()); let mut hashed_accounts = tx.cursor_read::().unwrap(); assert!(hashed_accounts.seek_exact(kept_account).unwrap().is_some()); - assert!(hashed_accounts.seek_exact(overlapping_account).unwrap().is_none()); - assert!(hashed_accounts.seek_exact(in_memory_hashed_account).unwrap().is_none()); + assert!(hashed_accounts.seek_exact(deferred_masked_account).unwrap().is_none()); + assert!(hashed_accounts.seek_exact(in_memory_overlap_account).unwrap().is_some()); + assert!(hashed_accounts.seek_exact(in_memory_only_account).unwrap().is_none()); let mut hashed_storages = tx.cursor_dup_read::().unwrap(); assert!(hashed_storages.seek_by_key_subkey(kept_storage, kept_slot).unwrap().is_some()); assert!(hashed_storages - .walk_dup(Some(overlapping_storage), None) + .walk_dup(Some(deferred_masked_storage), None) + .unwrap() + .next() + .transpose() + .unwrap() + .is_none()); + assert!(hashed_storages + .seek_by_key_subkey(in_memory_overlap_storage, in_memory_overlap_slot) + .unwrap() + .is_some()); + assert!(hashed_storages + .walk_dup(Some(in_memory_only_storage), None) .unwrap() .next() .transpose() @@ -4805,10 +4863,17 @@ mod tests { .unwrap() .is_some()); assert!(account_trie - .seek_exact(StoredNibbles(overlapping_account_node)) + .seek_exact(StoredNibbles(deferred_masked_account_node)) + .unwrap() + .is_none()); + assert!(account_trie + .seek_exact(StoredNibbles(in_memory_overlap_account_node)) + .unwrap() + .is_some()); + assert!(account_trie + .seek_exact(StoredNibbles(in_memory_only_account_node)) .unwrap() .is_none()); - assert!(account_trie.seek_exact(StoredNibbles(in_memory_account_node)).unwrap().is_none()); let mut storage_trie = tx.cursor_dup_read::().unwrap(); let kept_entries: Vec<_> = storage_trie @@ -4819,15 +4884,23 @@ mod tests { assert_eq!(kept_entries.len(), 1); assert_eq!(kept_entries[0].1.nibbles.0, kept_storage_node); - let overlapping_entries: Vec<_> = storage_trie - .walk_dup(Some(overlapping_storage), None) + let deferred_masked_entries: Vec<_> = storage_trie + .walk_dup(Some(deferred_masked_storage), None) + .unwrap() + .collect::, _>>() + .unwrap(); + assert!(deferred_masked_entries.is_empty()); + + let in_memory_overlap_entries: Vec<_> = storage_trie + .walk_dup(Some(in_memory_overlap_storage), None) .unwrap() .collect::, _>>() .unwrap(); - assert!(overlapping_entries.is_empty()); + assert_eq!(in_memory_overlap_entries.len(), 1); + assert_eq!(in_memory_overlap_entries[0].1.nibbles.0, in_memory_overlap_storage_node); let in_memory_entries: Vec<_> = storage_trie - .walk_dup(Some(in_memory_hashed_storage), None) + .walk_dup(Some(in_memory_only_storage), None) .unwrap() .collect::, _>>() .unwrap(); From 0d19b17bf3754009e5f77ee750a65c053ec02113 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:03:31 +0000 Subject: [PATCH 30/83] fix(provider): preserve partial trie frontier across overlay and unwind Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019dcefd-7813-7693-9eca-9c8ad3da5c5b Co-authored-by: Amp --- crates/engine/tree/src/persistence.rs | 62 +++++++-- .../src/providers/database/provider.rs | 34 ++++- .../provider/src/providers/state/overlay.rs | 119 ++++++++++++++++-- 3 files changed, 195 insertions(+), 20 deletions(-) diff --git a/crates/engine/tree/src/persistence.rs b/crates/engine/tree/src/persistence.rs index 9e68da7276b..3bc1e1bda76 100644 --- a/crates/engine/tree/src/persistence.rs +++ b/crates/engine/tree/src/persistence.rs @@ -153,15 +153,11 @@ where while let Ok(action) = self.incoming.recv() { match action { PersistenceAction::RemoveBlocksAbove(new_tip_num, sender) => { - let last_block = self.on_remove_blocks_above(new_tip_num)?; + let result = self.on_remove_blocks_above(new_tip_num)?; // send new sync metrics based on removed blocks let _ = self.sync_metrics_tx.send(MetricEvent::SyncHeight { height: new_tip_num }); - let _ = sender.send(PersistenceResult { - non_trie_persisted_tip: last_block, - trie_persisted_tip: None, - commit_duration: None, - }); + let _ = sender.send(result); } PersistenceAction::SaveBlocks(plan, sender) => { let result = self.on_save_blocks(plan)?; @@ -191,18 +187,30 @@ where fn on_remove_blocks_above( &self, new_tip_num: u64, - ) -> Result, PersistenceError> { + ) -> Result { debug!(target: "engine::persistence", ?new_tip_num, "Removing blocks"); let start_time = Instant::now(); let provider_rw = self.provider.database_provider_rw()?; let new_tip_hash = provider_rw.block_hash(new_tip_num)?; provider_rw.remove_block_and_execution_above(new_tip_num)?; + let trie_persisted_tip = + provider_rw.get_stage_checkpoint(StageId::Finish)?.map(|checkpoint| { + checkpoint + .finish_stage_checkpoint() + .and_then(|finish| finish.partial_state_trie) + .unwrap_or(checkpoint.block_number) + }); provider_rw.commit()?; debug!(target: "engine::persistence", ?new_tip_num, ?new_tip_hash, "Removed blocks from disk"); self.metrics.remove_blocks_above_duration_seconds.record(start_time.elapsed()); - Ok(new_tip_hash.map(|hash| BlockNumHash { hash, number: new_tip_num })) + Ok(PersistenceResult { + non_trie_persisted_tip: new_tip_hash + .map(|hash| BlockNumHash { hash, number: new_tip_num }), + trie_persisted_tip, + commit_duration: None, + }) } #[instrument(level = "debug", target = "engine::persistence", skip_all, fields(block_count = plan.blocks.len()))] @@ -484,6 +492,13 @@ mod tests { fn default_persistence_handle() -> PersistenceHandle { let provider = create_test_provider_factory(); + persistence_handle(provider) + } + + fn persistence_handle(provider: ProviderFactory) -> PersistenceHandle + where + N: ProviderNodeTypes, + { let (_finished_exex_height_tx, finished_exex_height_rx) = tokio::sync::watch::channel(FinishedExExHeight::NoExExs); @@ -574,6 +589,37 @@ mod tests { } } + #[test] + fn test_remove_blocks_above_preserves_partial_state_trie() { + reth_tracing::init_test_tracing(); + + let provider = create_test_provider_factory(); + let mut test_block_builder = TestBlockBuilder::eth().with_state(); + let blocks = test_block_builder.get_executed_blocks(0..4).collect::>(); + + let provider_rw = provider.database_provider_rw().unwrap(); + provider_rw.save_blocks(&blocks, 0, 2, 2, SaveBlocksMode::Full).unwrap(); + provider_rw.commit().unwrap(); + + let handle = persistence_handle(provider.clone()); + let (tx, rx) = crossbeam_channel::bounded(1); + + handle.remove_blocks_above(2, tx).unwrap(); + + let result = rx.recv_timeout(std::time::Duration::from_secs(10)).expect("test timed out"); + let last_block = result.non_trie_persisted_tip.unwrap(); + assert_eq!(last_block.number, 2); + assert_eq!(result.trie_persisted_tip, Some(1)); + + let finish_checkpoint = + provider.provider().unwrap().get_stage_checkpoint(StageId::Finish).unwrap().unwrap(); + assert_eq!(finish_checkpoint.block_number, 2); + assert_eq!( + finish_checkpoint.finish_stage_checkpoint().unwrap().partial_state_trie, + Some(1) + ); + } + /// Verifies that committing `save_blocks` history before running the pruner /// prevents the pruner from overwriting new entries. /// diff --git a/crates/storage/provider/src/providers/database/provider.rs b/crates/storage/provider/src/providers/database/provider.rs index 64c65938282..2804d9c779b 100644 --- a/crates/storage/provider/src/providers/database/provider.rs +++ b/crates/storage/provider/src/providers/database/provider.rs @@ -3586,8 +3586,9 @@ impl BlockExecutionWriter // that is why it is deleted afterwards. self.remove_blocks_above(block)?; - // Update pipeline progress - self.update_pipeline_stages(block, true)?; + // Keep the finish checkpoint's trie frontier aligned with the highest trie data that is + // still durably materialized after truncation. + self.update_finish_checkpoint_after_remove(block)?; Ok(Chain::new(blocks, execution_state, BTreeMap::new())) } @@ -3602,8 +3603,35 @@ impl BlockExecutionWriter // that is why it is deleted afterwards. self.remove_blocks_above(block)?; - // Update pipeline progress + // Keep the finish checkpoint's trie frontier aligned with the highest trie data that is + // still durably materialized after truncation. + self.update_finish_checkpoint_after_remove(block)?; + + Ok(()) + } +} + +impl DatabaseProvider { + fn trie_persisted_tip_block_number(&self) -> ProviderResult> { + Ok(self.get_stage_checkpoint(StageId::Finish)?.map(|checkpoint| { + checkpoint + .finish_stage_checkpoint() + .and_then(|finish| finish.partial_state_trie) + .unwrap_or(checkpoint.block_number) + })) + } + + fn update_finish_checkpoint_after_remove(&self, block: BlockNumber) -> ProviderResult<()> { + let partial_state_trie = self + .trie_persisted_tip_block_number()? + .map(|trie_persisted_tip| trie_persisted_tip.min(block)); + self.update_pipeline_stages(block, true)?; + self.save_stage_checkpoint( + StageId::Finish, + StageCheckpoint::new(block) + .with_finish_stage_checkpoint(FinishCheckpoint { partial_state_trie }), + )?; Ok(()) } diff --git a/crates/storage/provider/src/providers/state/overlay.rs b/crates/storage/provider/src/providers/state/overlay.rs index 02c0e6d10d4..4d74cb0efd3 100644 --- a/crates/storage/provider/src/providers/state/overlay.rs +++ b/crates/storage/provider/src/providers/state/overlay.rs @@ -230,6 +230,21 @@ impl OverlayBuilder { Ok(BlockNumHash::new(block_number, hash)) } + /// Returns the highest block whose non-trie state is durably available in the database. + fn get_db_finish_tip_block(&self, provider: &Provider) -> ProviderResult + where + Provider: StageCheckpointReader + BlockNumReader, + { + let checkpoint = provider.get_stage_checkpoint(StageId::Finish)?.ok_or_else(|| { + ProviderError::InsufficientChangesets { requested: 0, available: 0..=0 } + })?; + let block_number = checkpoint.block_number; + let hash = provider + .convert_number(block_number.into())? + .ok_or_else(|| ProviderError::HeaderNotFound(block_number.into()))?; + Ok(BlockNumHash::new(block_number, hash)) + } + /// Returns whether or not it is required to collect reverts, and validates that there are /// sufficient changesets to revert to the requested block number if so. /// @@ -239,6 +254,7 @@ impl OverlayBuilder { &self, provider: &Provider, trie_tip_block: BlockNumHash, + finish_tip_block: BlockNumHash, ) -> ProviderResult>> where Provider: BlockNumReader + PruneCheckpointReader, @@ -269,7 +285,7 @@ impl OverlayBuilder { .map(|block_number| block_number + 1) .unwrap_or_default(); - let available_range = lower_bound..=trie_tip_block.number; + let available_range = lower_bound..=finish_tip_block.number; // Check if the requested block is within the available range if !available_range.contains(&anchor_number) { @@ -279,6 +295,19 @@ impl OverlayBuilder { }); } + // The durable trie frontier is still required to serve the requested anchor directly. + // When the anchor lies in the deferred-trie window we need to undo the fully durable + // suffix from Finish back to the anchor before re-applying any in-memory suffix above it. + if anchor_number > trie_tip_block.number { + if anchor_number == finish_tip_block.number { + return Err(ProviderError::InsufficientChangesets { + requested: anchor_number, + available: lower_bound..=finish_tip_block.number.saturating_sub(1), + }) + } + return Ok(Some(anchor_number + 1..=finish_tip_block.number)) + } + Ok(Some(anchor_number + 1..=trie_tip_block.number)) } @@ -293,6 +322,7 @@ impl OverlayBuilder { &self, provider: &Provider, trie_tip_block: BlockNumHash, + finish_tip_block: BlockNumHash, ) -> ProviderResult where Provider: ChangeSetReader @@ -313,7 +343,7 @@ impl OverlayBuilder { // Collect any reverts which are required to bring the DB view back to the anchor hash. let (trie_updates, hashed_post_state) = if let Some(revert_blocks) = - self.reverts_required(provider, trie_tip_block)? + self.reverts_required(provider, trie_tip_block, finish_tip_block)? { debug!( target: "providers::state::overlay", @@ -420,7 +450,8 @@ impl OverlayBuilder { + StorageSettingsCache, { let trie_tip_block = self.get_db_trie_tip_block(provider)?; - self.calculate_overlay(provider, trie_tip_block) + let finish_tip_block = self.get_db_finish_tip_block(provider)?; + self.calculate_overlay(provider, trie_tip_block, finish_tip_block) } } @@ -434,10 +465,11 @@ pub struct OverlayStateProviderFactory { factory: F, /// Overlay builder containing the configuration and overlay calculation logic. overlay_builder: OverlayBuilder, - /// A cache which maps `trie_tip_hash -> Overlay`. If the durable trie frontier changes during - /// usage of the factory then a new entry will get added to this, but in most cases only one - /// entry is present. - overlay_cache: Arc>, + /// A cache which maps `(trie_tip_hash, finish_tip_hash) -> Overlay`. + /// + /// Under partial persistence the overlay depends on both the durable trie frontier and the + /// fully durable Finish frontier, so both hashes are part of the cache key. + overlay_cache: Arc>, } impl OverlayStateProviderFactory { @@ -484,8 +516,9 @@ impl OverlayStateProviderFactory { + StorageSettingsCache, { let trie_tip_block = self.overlay_builder.get_db_trie_tip_block(provider)?; + let finish_tip_block = self.overlay_builder.get_db_finish_tip_block(provider)?; - let overlay = match self.overlay_cache.entry(trie_tip_block.hash) { + let overlay = match self.overlay_cache.entry((trie_tip_block.hash, finish_tip_block.hash)) { dashmap::Entry::Occupied(entry) => entry.get().clone(), dashmap::Entry::Vacant(entry) => { self.overlay_builder.metrics.overlay_cache_misses.increment(1); @@ -655,7 +688,7 @@ where #[cfg(test)] mod tests { use super::*; - use crate::{test_utils::create_test_provider_factory, BlockWriter}; + use crate::{test_utils::create_test_provider_factory, BlockWriter, SaveBlocksMode}; use alloy_primitives::{B256, U256}; use reth_chain_state::{test_utils::TestBlockBuilder, ComputedTrieData, ExecutedBlock}; use reth_primitives_traits::Account; @@ -728,4 +761,72 @@ mod tests { assert_eq!(overlay.hashed_post_state.accounts.len(), 3); } + + #[test] + fn build_overlay_allows_anchor_between_trie_frontier_and_finish() { + let factory = create_test_provider_factory(); + let mut block_builder = TestBlockBuilder::eth().with_state(); + + let genesis = block_builder.get_executed_blocks(0..1).next().unwrap(); + let blocks = block_builder.get_executed_blocks(1..4).collect::>(); + + let provider_rw = factory.provider_rw().unwrap(); + provider_rw + .save_blocks(std::slice::from_ref(&genesis), 0, 1, 0, SaveBlocksMode::Full) + .unwrap(); + provider_rw.commit().unwrap(); + + let provider_rw = factory.provider_rw().unwrap(); + provider_rw.save_blocks(&blocks, 0, 1, 2, SaveBlocksMode::Full).unwrap(); + provider_rw.commit().unwrap(); + + let provider = factory.provider().unwrap(); + let trie_tip = blocks[0].recovered_block().hash(); + let anchor = blocks[1].recovered_block().hash(); + + let full_overlay = OverlayBuilder::::new(trie_tip, ChangesetCache::new()) + .with_lazy_overlay(Some(LazyOverlay::new(vec![blocks[2].clone(), blocks[1].clone()]))) + .build_overlay(&provider) + .unwrap(); + + let deferred_overlay = OverlayBuilder::::new(anchor, ChangesetCache::new()) + .with_lazy_overlay(Some(LazyOverlay::new(vec![blocks[2].clone()]))) + .build_overlay(&provider) + .unwrap(); + + assert_eq!( + deferred_overlay.hashed_post_state.as_ref(), + full_overlay.hashed_post_state.as_ref() + ); + assert_eq!(deferred_overlay.trie_updates.as_ref(), full_overlay.trie_updates.as_ref()); + } + + #[test] + fn build_overlay_rejects_finish_anchor_without_trie_bridge() { + let factory = create_test_provider_factory(); + let mut block_builder = TestBlockBuilder::eth().with_state(); + + let genesis = block_builder.get_executed_blocks(0..1).next().unwrap(); + let blocks = block_builder.get_executed_blocks(1..4).collect::>(); + + let provider_rw = factory.provider_rw().unwrap(); + provider_rw + .save_blocks(std::slice::from_ref(&genesis), 0, 1, 0, SaveBlocksMode::Full) + .unwrap(); + provider_rw.commit().unwrap(); + + let provider_rw = factory.provider_rw().unwrap(); + provider_rw.save_blocks(&blocks, 0, 1, 2, SaveBlocksMode::Full).unwrap(); + provider_rw.commit().unwrap(); + + let provider = factory.provider().unwrap(); + let finish_anchor = blocks[2].recovered_block().hash(); + + let err = OverlayBuilder::::new(finish_anchor, ChangesetCache::new()) + .with_lazy_overlay(None) + .build_overlay(&provider) + .unwrap_err(); + + assert!(matches!(err, ProviderError::InsufficientChangesets { .. })); + } } From d9d3f69557dee8e388e8abdba3007d0ab31c96fd Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:29:10 +0000 Subject: [PATCH 31/83] fix(engine): wire deferred trie persistence config Expose --engine.deferred-trie-blocks and make threshold-driven persistence advance the full persisted region before leaving the deferred trie tail. This keeps the trie and non-trie frontiers aligned with the configured in-memory buffer and deferred-trie window. Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019dcf65-4035-724f-b1ce-ebd1250af9f1 Co-authored-by: Amp --- crates/engine/tree/src/tree/mod.rs | 32 +++++++++--------------- crates/engine/tree/src/tree/tests.rs | 34 ++++++++++++++++++++++++-- crates/node/core/src/args/engine.rs | 31 ++++++++++++++++++++++- docs/vocs/docs/pages/cli/reth/node.mdx | 5 ++++ 4 files changed, 78 insertions(+), 24 deletions(-) diff --git a/crates/engine/tree/src/tree/mod.rs b/crates/engine/tree/src/tree/mod.rs index 19e98855775..03afee2d5db 100644 --- a/crates/engine/tree/src/tree/mod.rs +++ b/crates/engine/tree/src/tree/mod.rs @@ -2082,8 +2082,12 @@ where let trie_persisted_tip_number = self.persistence_state.trie_persisted_tip.number; let non_trie_persisted_tip_number = self.persistence_state.non_trie_persisted_tip.number; let canonical_head_number = self.state.tree_state.canonical_block_number(); - let threshold_non_trie_target = - canonical_head_number.saturating_sub(self.config.memory_block_buffer_target()); + let non_trie_target_number = match target { + PersistTarget::Threshold => { + canonical_head_number.saturating_sub(self.config.memory_block_buffer_target()) + } + PersistTarget::Head => canonical_head_number, + }; debug!( target: "engine::tree", @@ -2099,10 +2103,7 @@ where break; } - if self.config.deferred_trie_blocks() > 0 || - matches!(target, PersistTarget::Head) || - block.recovered_block().number() <= threshold_non_trie_target - { + if block.recovered_block().number() <= non_trie_target_number { blocks.push(block.clone()); } @@ -2115,21 +2116,10 @@ where let trie_catchup_block_count = non_trie_persisted_tip_number .saturating_sub(trie_persisted_tip_number) .min(blocks.len() as u64) as usize; - let available_deferred_trie_blocks = blocks.len().saturating_sub(trie_catchup_block_count); - let (full_persist_block_count, deferred_trie_block_count) = - if self.config.deferred_trie_blocks() == 0 { - (available_deferred_trie_blocks, 0) - } else { - ( - 0, - match target { - PersistTarget::Head => available_deferred_trie_blocks, - PersistTarget::Threshold => available_deferred_trie_blocks - .saturating_sub(self.config.memory_block_buffer_target() as usize) - .min(self.config.deferred_trie_blocks() as usize), - }, - ) - }; + let non_trie_block_count = blocks.len().saturating_sub(trie_catchup_block_count); + let deferred_trie_block_count = + non_trie_block_count.min(self.config.deferred_trie_blocks() as usize); + let full_persist_block_count = non_trie_block_count - deferred_trie_block_count; Ok(SaveBlocksPlan::new( blocks, diff --git a/crates/engine/tree/src/tree/tests.rs b/crates/engine/tree/src/tree/tests.rs index 051e9d42608..4b8feb674ba 100644 --- a/crates/engine/tree/src/tree/tests.rs +++ b/crates/engine/tree/src/tree/tests.rs @@ -1048,14 +1048,44 @@ fn test_get_save_blocks_plan_with_deferred_trie_blocks() { assert_eq!(plan.trie_catchup_block_count, 2); assert_eq!(plan.full_persist_block_count, 0); assert_eq!(plan.deferred_trie_block_count, 2); - assert_eq!(plan.blocks.len(), 5); + assert_eq!(plan.blocks.len(), 4); assert_eq!( plan.blocks.iter().map(|block| block.recovered_block().number()).collect::>(), - vec![2, 3, 4, 5, 6] + vec![2, 3, 4, 5] ); assert_eq!(plan.non_trie_persisted_tip(), Some(blocks[5].recovered_block().num_hash())); } +#[test] +fn test_get_save_blocks_plan_persists_full_region_before_deferred_tail() { + let chain_spec = MAINNET.clone(); + let mut test_harness = TestHarness::new(chain_spec); + let mut test_block_builder = TestBlockBuilder::eth(); + + let blocks: Vec<_> = test_block_builder.get_executed_blocks(0..31).collect(); + test_harness = test_harness.with_blocks(blocks.clone()); + test_harness.tree.persistence_state.trie_persisted_tip = + blocks[12].recovered_block().num_hash(); + test_harness.tree.persistence_state.non_trie_persisted_tip = + blocks[15].recovered_block().num_hash(); + test_harness.tree.config = TreeConfig::default() + .with_persistence_threshold(3) + .with_memory_block_buffer_target(2) + .with_deferred_trie_blocks(2); + + let plan = test_harness.tree.get_save_blocks_plan(PersistTarget::Threshold).unwrap(); + + assert_eq!(plan.trie_catchup_block_count, 3); + assert_eq!(plan.full_persist_block_count, 11); + assert_eq!(plan.deferred_trie_block_count, 2); + assert_eq!(plan.blocks.len(), 16); + assert_eq!( + plan.blocks.iter().map(|block| block.recovered_block().number()).collect::>(), + (13..=28).collect::>() + ); + assert_eq!(plan.non_trie_persisted_tip(), Some(blocks[28].recovered_block().num_hash())); +} + #[test] fn test_on_persistence_complete_retains_blocks_above_partial_state_trie() { let chain_spec = MAINNET.clone(); diff --git a/crates/node/core/src/args/engine.rs b/crates/node/core/src/args/engine.rs index afa35462451..49097c3b551 100644 --- a/crates/node/core/src/args/engine.rs +++ b/crates/node/core/src/args/engine.rs @@ -4,7 +4,7 @@ use clap::{builder::Resettable, Args}; use eyre::ensure; use reth_cli_util::{parse_duration_from_secs_or_ms, parsers::format_duration_as_secs_or_ms}; use reth_engine_primitives::{ - default_persistence_backpressure_threshold, TreeConfig, + default_persistence_backpressure_threshold, TreeConfig, DEFAULT_DEFERRED_TRIE_BLOCKS, DEFAULT_INVALID_HEADER_HIT_EVICTION_THRESHOLD, DEFAULT_MULTIPROOF_TASK_CHUNK_SIZE, DEFAULT_SPARSE_TRIE_MAX_HOT_ACCOUNTS, DEFAULT_SPARSE_TRIE_MAX_HOT_SLOTS, }; @@ -25,6 +25,7 @@ static ENGINE_DEFAULTS: OnceLock = OnceLock::new(); pub struct DefaultEngineValues { persistence_threshold: u64, persistence_backpressure_threshold: Option, + deferred_trie_blocks: u64, memory_block_buffer_target: u64, invalid_header_hit_eviction_threshold: u8, legacy_state_root_task_enabled: bool, @@ -90,6 +91,12 @@ impl DefaultEngineValues { self } + /// Set the default deferred trie block target + pub const fn with_deferred_trie_blocks(mut self, v: u64) -> Self { + self.deferred_trie_blocks = v; + self + } + /// Set the default memory block buffer target pub const fn with_memory_block_buffer_target(mut self, v: u64) -> Self { self.memory_block_buffer_target = v; @@ -273,6 +280,7 @@ impl Default for DefaultEngineValues { Self { persistence_threshold: DEFAULT_PERSISTENCE_THRESHOLD, persistence_backpressure_threshold: None, + deferred_trie_blocks: DEFAULT_DEFERRED_TRIE_BLOCKS, memory_block_buffer_target: DEFAULT_MEMORY_BLOCK_BUFFER_TARGET, invalid_header_hit_eviction_threshold: DEFAULT_INVALID_HEADER_HIT_EVICTION_THRESHOLD, legacy_state_root_task_enabled: false, @@ -325,6 +333,11 @@ pub struct EngineArgs { #[arg(long = "engine.persistence-backpressure-threshold", default_value_t = DefaultEngineValues::get_global().persistence_backpressure_threshold())] pub persistence_backpressure_threshold: u64, + /// Configure how many canonical blocks may persist non-trie outputs while deferring durable + /// trie updates. + #[arg(long = "engine.deferred-trie-blocks", default_value_t = DefaultEngineValues::get_global().deferred_trie_blocks)] + pub deferred_trie_blocks: u64, + /// Configure the target number of blocks to keep in memory. #[arg(long = "engine.memory-block-buffer-target", default_value_t = DefaultEngineValues::get_global().memory_block_buffer_target)] pub memory_block_buffer_target: u64, @@ -558,6 +571,7 @@ impl Default for EngineArgs { Self { persistence_threshold: defaults.persistence_threshold, persistence_backpressure_threshold: defaults.persistence_backpressure_threshold(), + deferred_trie_blocks: defaults.deferred_trie_blocks, memory_block_buffer_target: defaults.memory_block_buffer_target, invalid_header_hit_eviction_threshold: defaults.invalid_header_hit_eviction_threshold, legacy_state_root_task_enabled: defaults.legacy_state_root_task_enabled, @@ -620,6 +634,7 @@ impl EngineArgs { let config = TreeConfig::default() .with_persistence_threshold(self.persistence_threshold) .with_persistence_backpressure_threshold(self.persistence_backpressure_threshold) + .with_deferred_trie_blocks(self.deferred_trie_blocks) .with_memory_block_buffer_target(self.memory_block_buffer_target) .with_invalid_header_hit_eviction_threshold(self.invalid_header_hit_eviction_threshold) .with_legacy_state_root(self.legacy_state_root_task_enabled) @@ -701,6 +716,7 @@ mod tests { let args = EngineArgs::default(); assert_eq!(args.persistence_threshold, DEFAULT_PERSISTENCE_THRESHOLD); + assert_eq!(args.deferred_trie_blocks, DEFAULT_DEFERRED_TRIE_BLOCKS); assert_eq!(args.memory_block_buffer_target, DEFAULT_MEMORY_BLOCK_BUFFER_TARGET); assert_eq!( args.persistence_backpressure_threshold, @@ -717,6 +733,7 @@ mod tests { let args = EngineArgs { persistence_threshold: 100, persistence_backpressure_threshold: 101, + deferred_trie_blocks: 25, memory_block_buffer_target: 50, invalid_header_hit_eviction_threshold: 7, legacy_state_root_task_enabled: true, @@ -761,6 +778,8 @@ mod tests { "100", "--engine.persistence-backpressure-threshold", "101", + "--engine.deferred-trie-blocks", + "25", "--engine.memory-block-buffer-target", "50", "--engine.invalid-header-cache-hit-eviction-threshold", @@ -804,6 +823,16 @@ mod tests { assert_eq!(parsed_args, args); } + #[test] + fn test_parse_deferred_trie_blocks() { + let args = + CommandParser::::parse_from(["reth", "--engine.deferred-trie-blocks", "7"]) + .args; + + assert_eq!(args.deferred_trie_blocks, 7); + assert_eq!(args.tree_config().deferred_trie_blocks(), 7); + } + #[test] fn validate_rejects_invalid_backpressure_threshold() { let args = EngineArgs { diff --git a/docs/vocs/docs/pages/cli/reth/node.mdx b/docs/vocs/docs/pages/cli/reth/node.mdx index e651eb01905..dfe65420f3b 100644 --- a/docs/vocs/docs/pages/cli/reth/node.mdx +++ b/docs/vocs/docs/pages/cli/reth/node.mdx @@ -950,6 +950,11 @@ Engine: [default: 20] + --engine.deferred-trie-blocks + Configure how many canonical blocks may persist non-trie outputs while deferring durable trie updates + + [default: 0] + --engine.memory-block-buffer-target Configure the target number of blocks to keep in memory From adf2930e840ea735cdefe869c4f325d1e31ef912 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:25:06 +0000 Subject: [PATCH 32/83] refactor(engine): split partial persistence frontiers Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019dd442-503a-7710-90ee-60264449117e Co-authored-by: Amp --- bin/reth-bench/src/bench/new_payload_fcu.rs | 5 +- .../tests/e2e-testsuite/main.rs | 2 +- crates/e2e-test-utils/tests/rocksdb/main.rs | 87 +++-- crates/engine/primitives/src/config.rs | 82 ++++- crates/engine/tree/src/persistence.rs | 192 ++++------- crates/engine/tree/src/tree/mod.rs | 152 +++++---- .../engine/tree/src/tree/persistence_state.rs | 20 +- crates/engine/tree/src/tree/tests.rs | 118 ++++--- crates/node/builder/src/launch/common.rs | 4 +- crates/node/core/src/args/engine.rs | 150 ++++++--- crates/stages/stages/src/stages/finish.rs | 14 +- crates/stages/stages/src/stages/tx_lookup.rs | 26 +- crates/stages/types/src/checkpoints.rs | 6 +- crates/storage/provider/src/lib.rs | 4 +- .../src/providers/blockchain_provider.rs | 11 +- .../provider/src/providers/database/mod.rs | 3 + .../src/providers/database/provider.rs | 301 ++++++++++-------- .../src/providers/database/save_blocks.rs | 90 ++++++ .../provider/src/providers/state/overlay.rs | 209 ++++++------ crates/trie/common/src/hashed_state.rs | 62 ++-- crates/trie/common/src/updates.rs | 70 ++-- docs/vocs/docs/pages/cli/reth/node.mdx | 6 +- 22 files changed, 959 insertions(+), 655 deletions(-) create mode 100644 crates/storage/provider/src/providers/database/save_blocks.rs diff --git a/bin/reth-bench/src/bench/new_payload_fcu.rs b/bin/reth-bench/src/bench/new_payload_fcu.rs index c4c54f53c8e..ee7ab42f037 100644 --- a/bin/reth-bench/src/bench/new_payload_fcu.rs +++ b/bin/reth-bench/src/bench/new_payload_fcu.rs @@ -67,9 +67,8 @@ pub struct Command { /// Engine persistence threshold used for deciding when to wait for persistence. /// - /// The benchmark waits after every `(threshold + 1)` blocks. By default this - /// matches the engine's `DEFAULT_PERSISTENCE_THRESHOLD`, so waits occur at - /// blocks 11, 22, 33, etc. + /// The benchmark waits after every `(threshold + 1)` blocks. + /// By default this matches the engine's `DEFAULT_PERSISTENCE_THRESHOLD`. #[arg( long = "persistence-threshold", value_name = "PERSISTENCE_THRESHOLD", diff --git a/crates/e2e-test-utils/tests/e2e-testsuite/main.rs b/crates/e2e-test-utils/tests/e2e-testsuite/main.rs index eebddef2ff8..84c21baea79 100644 --- a/crates/e2e-test-utils/tests/e2e-testsuite/main.rs +++ b/crates/e2e-test-utils/tests/e2e-testsuite/main.rs @@ -374,7 +374,7 @@ async fn test_setup_builder_with_custom_tree_config() -> Result<()> { PayloadAttributes::default() }) .with_tree_config_modifier(|config| { - config.with_persistence_threshold(0).with_memory_block_buffer_target(5) + config.with_persistence_threshold(6).with_memory_block_buffer_target(5) }) .build() .await?; diff --git a/crates/e2e-test-utils/tests/rocksdb/main.rs b/crates/e2e-test-utils/tests/rocksdb/main.rs index 3a6bce7fe7e..d4659b70e2b 100644 --- a/crates/e2e-test-utils/tests/rocksdb/main.rs +++ b/crates/e2e-test-utils/tests/rocksdb/main.rs @@ -189,7 +189,7 @@ async fn test_rocksdb_transaction_queries() -> Result<()> { test_attributes_generator, ) .with_storage_v2() - .with_tree_config_modifier(|config| config.with_persistence_threshold(0)) + .with_tree_config_modifier(|config| config.with_persistence_threshold(1)) .build() .await?; @@ -200,7 +200,7 @@ async fn test_rocksdb_transaction_queries() -> Result<()> { let signer = wallets[0].clone(); let client = nodes[0].rpc_client().expect("RPC client should be available"); - let raw_tx = TransactionTestContext::transfer_tx_bytes(chain_id, signer).await; + let raw_tx = TransactionTestContext::transfer_tx_bytes(chain_id, signer.clone()).await; let tx_hash = nodes[0].rpc.inject_tx(raw_tx).await?; // Wait for tx to enter pending pool before mining @@ -209,6 +209,14 @@ async fn test_rocksdb_transaction_queries() -> Result<()> { let payload = nodes[0].advance_block().await?; assert_eq!(payload.block().number(), 1); + let flush_tx = + TransactionTestContext::transfer_tx_bytes_with_nonce(chain_id, signer.clone(), 1).await; + let flush_tx_hash = nodes[0].rpc.inject_tx(flush_tx).await?; + wait_for_pending_tx(&client, flush_tx_hash).await; + + let flush_payload = nodes[0].advance_block().await?; + assert_eq!(flush_payload.block().number(), 2); + // Query each transaction by hash let tx: Option = client.request("eth_getTransactionByHash", [tx_hash]).await?; let tx = tx.expect("Transaction should be found"); @@ -256,7 +264,7 @@ async fn test_rocksdb_multi_tx_same_block() -> Result<()> { test_attributes_generator, ) .with_storage_v2() - .with_tree_config_modifier(|config| config.with_persistence_threshold(0)) + .with_tree_config_modifier(|config| config.with_persistence_threshold(1)) .build() .await?; @@ -283,6 +291,14 @@ async fn test_rocksdb_multi_tx_same_block() -> Result<()> { let payload = nodes[0].advance_block().await?; assert_eq!(payload.block().number(), 1); + let flush_tx = + TransactionTestContext::transfer_tx_bytes_with_nonce(chain_id, signer.clone(), 3).await; + let flush_tx_hash = nodes[0].rpc.inject_tx(flush_tx).await?; + wait_for_pending_tx(&client, flush_tx_hash).await; + + let flush_payload = nodes[0].advance_block().await?; + assert_eq!(flush_payload.block().number(), 2); + // Verify block contains all 3 txs let block: Option = client.request("eth_getBlockByNumber", ("0x1", true)).await?; @@ -324,7 +340,7 @@ async fn test_rocksdb_txs_across_blocks() -> Result<()> { test_attributes_generator, ) .with_storage_v2() - .with_tree_config_modifier(|config| config.with_persistence_threshold(0)) + .with_tree_config_modifier(|config| config.with_persistence_threshold(1)) .build() .await?; @@ -409,7 +425,7 @@ async fn test_rocksdb_pending_tx_not_in_storage() -> Result<()> { test_attributes_generator, ) .with_storage_v2() - .with_tree_config_modifier(|config| config.with_persistence_threshold(0)) + .with_tree_config_modifier(|config| config.with_persistence_threshold(1)) .build() .await?; @@ -417,7 +433,7 @@ async fn test_rocksdb_pending_tx_not_in_storage() -> Result<()> { let signer = wallets[0].clone(); // Inject tx but do NOT mine - let raw_tx = TransactionTestContext::transfer_tx_bytes(chain_id, signer).await; + let raw_tx = TransactionTestContext::transfer_tx_bytes(chain_id, signer.clone()).await; let tx_hash = nodes[0].rpc.inject_tx(raw_tx).await?; // Verify tx is in pending pool via RPC @@ -442,6 +458,14 @@ async fn test_rocksdb_pending_tx_not_in_storage() -> Result<()> { let payload = nodes[0].advance_block().await?; assert_eq!(payload.block().number(), 1); + let flush_tx = + TransactionTestContext::transfer_tx_bytes_with_nonce(chain_id, signer.clone(), 1).await; + let flush_tx_hash = nodes[0].rpc.inject_tx(flush_tx).await?; + wait_for_pending_tx(&client, flush_tx_hash).await; + + let flush_payload = nodes[0].advance_block().await?; + assert_eq!(flush_payload.block().number(), 2); + // Poll until tx appears in RocksDB let tx_number = poll_tx_in_rocksdb(&nodes[0].inner.provider, tx_hash).await; assert_eq!(tx_number, 0, "First tx should have tx_number 0"); @@ -473,7 +497,7 @@ async fn test_rocksdb_reorg_unwind() -> Result<()> { test_attributes_generator, ) .with_storage_v2() - .with_tree_config_modifier(|config| config.with_persistence_threshold(0)) + .with_tree_config_modifier(|config| config.with_persistence_threshold(1)) .build() .await?; @@ -495,10 +519,6 @@ async fn test_rocksdb_reorg_unwind() -> Result<()> { let block1_hash = payload1.block().hash(); assert_eq!(payload1.block().number(), 1); - // Poll until tx1 appears in RocksDB (ensures persistence happened) - let tx_number1 = poll_tx_in_rocksdb(&nodes[0].inner.provider, tx_hash1).await; - assert_eq!(tx_number1, 0, "First tx should have tx_number 0"); - // Mine block 2 with transaction from signer1 (nonce 1) let raw_tx2 = TransactionTestContext::transfer_tx_bytes_with_nonce(chain_id, signer1.clone(), 1).await; @@ -508,6 +528,10 @@ async fn test_rocksdb_reorg_unwind() -> Result<()> { let payload2 = nodes[0].advance_block().await?; assert_eq!(payload2.block().number(), 2); + // The second block triggers the first persistence cycle, which flushes both block 1 and 2. + let tx_number1 = poll_tx_in_rocksdb(&nodes[0].inner.provider, tx_hash1).await; + assert_eq!(tx_number1, 0, "First tx should have tx_number 0"); + // Poll until tx2 appears in RocksDB let tx_number2 = poll_tx_in_rocksdb(&nodes[0].inner.provider, tx_hash2).await; assert_eq!(tx_number2, 1, "Second tx should have tx_number 1"); @@ -521,6 +545,14 @@ async fn test_rocksdb_reorg_unwind() -> Result<()> { let payload3 = nodes[0].advance_block().await?; assert_eq!(payload3.block().number(), 3); + let flush_tx = + TransactionTestContext::transfer_tx_bytes_with_nonce(chain_id, signer1.clone(), 3).await; + let flush_tx_hash = nodes[0].rpc.inject_tx(flush_tx).await?; + wait_for_pending_tx(&client, flush_tx_hash).await; + + let flush_payload = nodes[0].advance_block().await?; + assert_eq!(flush_payload.block().number(), 4); + // Poll until tx3 appears in RocksDB let tx_number3 = poll_tx_in_rocksdb(&nodes[0].inner.provider, tx_hash3).await; assert_eq!(tx_number3, 2, "Third tx should have tx_number 2"); @@ -532,7 +564,7 @@ async fn test_rocksdb_reorg_unwind() -> Result<()> { let alt_tx_hash = nodes[0].rpc.inject_tx(raw_alt_tx).await?; wait_for_pending_tx(&client, alt_tx_hash).await; - // Build an alternate payload (this builds on top of the current head, i.e., block 3) + // Build an alternate payload on top of the current flushed head. // But we want to reorg back to block 1, so we'll use the payload and then FCU to it let alt_payload = nodes[0].new_payload().await?; let alt_block_hash = nodes[0].submit_payload(alt_payload.clone()).await?; @@ -550,8 +582,8 @@ async fn test_rocksdb_reorg_unwind() -> Result<()> { let latest: Option = client.request("eth_getBlockByNumber", ("latest", false)).await?; let latest = latest.expect("Latest block should exist"); - // The alt block is at height 4 (on top of block 3) - assert!(latest.header.number >= 3, "Should be at height >= 3 after operation"); + // The alt block is built on top of the flushed canonical head. + assert!(latest.header.number >= 4, "Should be at height >= 4 after operation"); // tx1 from block 1 should still be there let tx1: Option = client.request("eth_getTransactionByHash", [tx_hash1]).await?; @@ -596,7 +628,7 @@ async fn test_rocksdb_historical_account_queries() -> Result<()> { test_attributes_generator, ) .with_storage_v2() - .with_tree_config_modifier(|config| config.with_persistence_threshold(0)) + .with_tree_config_modifier(|config| config.with_persistence_threshold(1)) .build() .await?; @@ -621,8 +653,6 @@ async fn test_rocksdb_historical_account_queries() -> Result<()> { let payload1 = nodes[0].advance_block().await?; assert_eq!(payload1.block().number(), 1); - poll_tx_in_rocksdb(&nodes[0].inner.provider, tx_hash1).await; - // Record state after block 1 let balance_at_1: U256 = client.request("eth_getBalance", (sender, "0x1")).await?; let nonce_at_1: U256 = client.request("eth_getTransactionCount", (sender, "0x1")).await?; @@ -637,8 +667,6 @@ async fn test_rocksdb_historical_account_queries() -> Result<()> { let payload2 = nodes[0].advance_block().await?; assert_eq!(payload2.block().number(), 2); - poll_tx_in_rocksdb(&nodes[0].inner.provider, tx_hash2).await; - let balance_at_2: U256 = client.request("eth_getBalance", (sender, "0x2")).await?; let nonce_at_2: U256 = client.request("eth_getTransactionCount", (sender, "0x2")).await?; assert!(balance_at_2 < balance_at_1, "Balance should decrease further after second tx"); @@ -652,18 +680,14 @@ async fn test_rocksdb_historical_account_queries() -> Result<()> { let payload3 = nodes[0].advance_block().await?; assert_eq!(payload3.block().number(), 3); - poll_tx_in_rocksdb(&nodes[0].inner.provider, tx_hash3).await; - let balance_at_3: U256 = client.request("eth_getBalance", (sender, "0x3")).await?; let nonce_at_3: U256 = client.request("eth_getTransactionCount", (sender, "0x3")).await?; assert!(balance_at_3 < balance_at_2, "Balance should decrease further after third tx"); assert_eq!(nonce_at_3, U256::from(3), "Nonce should be 3 after third tx"); // Mine additional blocks to push blocks 1-3 out of the in-memory overlay. - // With persistence_threshold=0 and memory_block_buffer_target=0, each new block - // triggers persistence up to `head` followed by in-memory eviction. Mining several - // more blocks ensures the engine loop has completed at least one full - // persist-then-evict cycle covering blocks 1-3. + // With a persistence threshold of 1, every second block triggers a flush, so a few extra + // blocks are enough to durably persist and evict the earlier history we want to query. // Each block needs a transaction because the payload builder requires non-empty payloads. for nonce in 3..8u64 { let raw_tx = @@ -673,6 +697,7 @@ async fn test_rocksdb_historical_account_queries() -> Result<()> { wait_for_pending_tx(&client, tx_hash).await; nodes[0].advance_block().await?; } + poll_tx_in_rocksdb(&nodes[0].inner.provider, tx_hash3).await; // Allow the engine loop to process the persistence completions tokio::time::sleep(Duration::from_millis(500)).await; @@ -743,7 +768,7 @@ async fn test_rocksdb_account_history_pruning() -> Result<()> { test_attributes_generator, ) .with_storage_v2() - .with_tree_config_modifier(|config| config.with_persistence_threshold(0)) + .with_tree_config_modifier(|config| config.with_persistence_threshold(1)) .with_node_config_modifier(|mut config| { config.pruning.account_history_distance = Some(PRUNE_DISTANCE); config.pruning.minimum_distance = Some(PRUNE_DISTANCE); @@ -840,7 +865,7 @@ async fn test_rocksdb_storage_history_pruning() -> Result<()> { test_attributes_generator, ) .with_storage_v2() - .with_tree_config_modifier(|config| config.with_persistence_threshold(0)) + .with_tree_config_modifier(|config| config.with_persistence_threshold(1)) .with_node_config_modifier(|mut config| { config.pruning.storage_history_distance = Some(PRUNE_DISTANCE); config.pruning.minimum_distance = Some(PRUNE_DISTANCE); @@ -912,10 +937,6 @@ async fn test_rocksdb_storage_history_pruning() -> Result<()> { let payload1 = nodes[0].advance_block().await?; assert_eq!(payload1.block().number(), 1); - poll_tx_in_rocksdb(&nodes[0].inner.provider, deploy_hash).await; - - // Let the persistence cycle complete before the next block (same cadence as the loop below) - tokio::time::sleep(Duration::from_millis(300)).await; // Get the deployed contract address from the receipt let receipt: Option = @@ -965,6 +986,10 @@ async fn test_rocksdb_storage_history_pruning() -> Result<()> { assert_eq!(payload.block().number(), block_num); last_tx_hash = tx_hash; + if nonce == 1 { + poll_tx_in_rocksdb(&nodes[0].inner.provider, deploy_hash).await; + } + // Let the persistence cycle complete before the next block tokio::time::sleep(Duration::from_millis(300)).await; } diff --git a/crates/engine/primitives/src/config.rs b/crates/engine/primitives/src/config.rs index 5c2c2a5f272..9d3b56e44ba 100644 --- a/crates/engine/primitives/src/config.rs +++ b/crates/engine/primitives/src/config.rs @@ -4,7 +4,7 @@ use alloy_eips::merge::EPOCH_SLOTS; use core::time::Duration; /// Triggers persistence when the number of canonical blocks in memory exceeds this threshold. -pub const DEFAULT_PERSISTENCE_THRESHOLD: u64 = 10; +pub const DEFAULT_PERSISTENCE_THRESHOLD: u64 = 2; /// Maximum number of consecutive canonical blocks whose non-trie outputs may be persisted ahead /// of trie persistence. @@ -18,7 +18,12 @@ pub const fn default_persistence_backpressure_threshold( persistence_threshold: u64, memory_block_buffer_target: u64, ) -> u64 { - 2 * (persistence_threshold + memory_block_buffer_target) + let threshold = 2 * (persistence_threshold + memory_block_buffer_target); + if threshold < 16 { + 16 + } else { + threshold + } } /// Maximum canonical-minus-persisted gap before engine API processing is stalled. @@ -76,6 +81,17 @@ const fn assert_backpressure_threshold_invariant( ); } +const fn assert_state_masking_invariant( + persistence_threshold: u64, + num_state_masking_blocks: u64, + memory_block_buffer_target: u64, +) { + debug_assert!( + num_state_masking_blocks + memory_block_buffer_target < persistence_threshold, + "num_state_masking_blocks + memory_block_buffer_target must be less than persistence_threshold", + ); +} + const fn default_cross_block_cache_size() -> usize { if cfg!(test) { 1024 * 1024 // 1 MB in tests @@ -109,9 +125,9 @@ pub struct TreeConfig { /// Maximum number of blocks to be kept only in memory without triggering /// persistence. persistence_threshold: u64, - /// Maximum number of consecutive canonical blocks whose non-trie outputs may be persisted - /// ahead of trie persistence. - deferred_trie_blocks: u64, + /// Number of persisted blocks whose state/trie writes are masked instead of being durably + /// written in the current cycle. + num_state_masking_blocks: u64, /// How close to the canonical head we persist blocks. Represents the ideal /// number of most recent blocks to keep in memory for quick access and reorgs. /// @@ -231,9 +247,14 @@ impl Default for TreeConfig { DEFAULT_PERSISTENCE_THRESHOLD, persistence_backpressure_threshold, ); + assert_state_masking_invariant( + DEFAULT_PERSISTENCE_THRESHOLD, + DEFAULT_DEFERRED_TRIE_BLOCKS, + DEFAULT_MEMORY_BLOCK_BUFFER_TARGET, + ); Self { persistence_threshold: DEFAULT_PERSISTENCE_THRESHOLD, - deferred_trie_blocks: DEFAULT_DEFERRED_TRIE_BLOCKS, + num_state_masking_blocks: DEFAULT_DEFERRED_TRIE_BLOCKS, memory_block_buffer_target: DEFAULT_MEMORY_BLOCK_BUFFER_TARGET, persistence_backpressure_threshold, block_buffer_limit: DEFAULT_BLOCK_BUFFER_LIMIT, @@ -277,7 +298,7 @@ impl TreeConfig { #[expect(clippy::too_many_arguments)] pub const fn new( persistence_threshold: u64, - deferred_trie_blocks: u64, + num_state_masking_blocks: u64, memory_block_buffer_target: u64, persistence_backpressure_threshold: u64, block_buffer_limit: u32, @@ -310,9 +331,14 @@ impl TreeConfig { persistence_threshold, persistence_backpressure_threshold, ); + assert_state_masking_invariant( + persistence_threshold, + num_state_masking_blocks, + memory_block_buffer_target, + ); Self { persistence_threshold, - deferred_trie_blocks, + num_state_masking_blocks, memory_block_buffer_target, persistence_backpressure_threshold, block_buffer_limit, @@ -355,9 +381,9 @@ impl TreeConfig { self.persistence_threshold } - /// Return the deferred trie block target. - pub const fn deferred_trie_blocks(&self) -> u64 { - self.deferred_trie_blocks + /// Return the number of persisted blocks whose state/trie writes are masked. + pub const fn num_state_masking_blocks(&self) -> u64 { + self.num_state_masking_blocks } /// Return the memory block buffer target. @@ -478,12 +504,22 @@ impl TreeConfig { self.persistence_threshold, self.persistence_backpressure_threshold, ); + assert_state_masking_invariant( + self.persistence_threshold, + self.num_state_masking_blocks, + self.memory_block_buffer_target, + ); self } - /// Setter for deferred trie blocks. - pub const fn with_deferred_trie_blocks(mut self, deferred_trie_blocks: u64) -> Self { - self.deferred_trie_blocks = deferred_trie_blocks; + /// Setter for the number of persisted blocks whose state/trie writes are masked. + pub const fn with_num_state_masking_blocks(mut self, num_state_masking_blocks: u64) -> Self { + self.num_state_masking_blocks = num_state_masking_blocks; + assert_state_masking_invariant( + self.persistence_threshold, + self.num_state_masking_blocks, + self.memory_block_buffer_target, + ); self } @@ -493,6 +529,11 @@ impl TreeConfig { memory_block_buffer_target: u64, ) -> Self { self.memory_block_buffer_target = memory_block_buffer_target; + assert_state_masking_invariant( + self.persistence_threshold, + self.num_state_masking_blocks, + self.memory_block_buffer_target, + ); self } @@ -812,7 +853,7 @@ mod tests { let config = TreeConfig::default(); assert_eq!(config.persistence_threshold(), DEFAULT_PERSISTENCE_THRESHOLD); - assert_eq!(config.deferred_trie_blocks(), DEFAULT_DEFERRED_TRIE_BLOCKS); + assert_eq!(config.num_state_masking_blocks(), DEFAULT_DEFERRED_TRIE_BLOCKS); assert_eq!(config.memory_block_buffer_target(), DEFAULT_MEMORY_BLOCK_BUFFER_TARGET); assert_eq!( config.persistence_backpressure_threshold(), @@ -832,4 +873,15 @@ mod tests { .with_persistence_threshold(4) .with_persistence_backpressure_threshold(4); } + + #[test] + #[should_panic( + expected = "num_state_masking_blocks + memory_block_buffer_target must be less than persistence_threshold" + )] + fn rejects_state_masking_window_at_or_above_persistence_threshold() { + let _ = TreeConfig::default() + .with_persistence_threshold(4) + .with_num_state_masking_blocks(2) + .with_memory_block_buffer_target(2); + } } diff --git a/crates/engine/tree/src/persistence.rs b/crates/engine/tree/src/persistence.rs index 3bc1e1bda76..b980c7fe173 100644 --- a/crates/engine/tree/src/persistence.rs +++ b/crates/engine/tree/src/persistence.rs @@ -1,13 +1,13 @@ use crate::metrics::PersistenceMetrics; use alloy_eips::BlockNumHash; use crossbeam_channel::Sender as CrossbeamSender; -use reth_chain_state::ExecutedBlock; use reth_errors::ProviderError; use reth_ethereum_primitives::EthPrimitives; use reth_primitives_traits::{FastInstant as Instant, NodePrimitives}; use reth_provider::{ providers::ProviderNodeTypes, BlockExecutionWriter, BlockHashReader, ChainStateBlockWriter, - DBProvider, DatabaseProviderFactory, ProviderFactory, SaveBlocksMode, StageCheckpointReader, + DBProvider, DatabaseProviderFactory, ProviderFactory, SaveBlocksMode, SaveBlocksPlan, + StageCheckpointReader, }; use reth_prune::{PrunerError, PrunerWithFactory}; use reth_stages_api::{MetricEvent, MetricEventsSender, StageId}; @@ -26,69 +26,17 @@ use tracing::{debug, error, instrument}; /// Unified result of any persistence operation. #[derive(Debug)] pub struct PersistenceResult { - /// The highest block whose non-trie outputs are persisted, if any. - pub non_trie_persisted_tip: Option, - /// The highest block whose trie data is fully persisted, if known. + /// The highest block whose non-state/trie outputs are persisted, if any. + pub last_block: Option, + /// The highest block whose state/trie data is fully persisted, if known. /// - /// When this lags behind [`Self::non_trie_persisted_tip`], callers must retain the suffix + /// When this lags behind [`Self::last_block`], callers must retain the suffix /// above it in memory so trie-backed operations can still unwind from that point. - pub trie_persisted_tip: Option, + pub last_state_trie_block: Option, /// The commit duration, only available for save-blocks operations. pub commit_duration: Option, } -/// Plan for a single persistence cycle. -#[derive(Debug)] -pub struct SaveBlocksPlan { - /// Canonical blocks starting at `trie_persisted_tip + 1`. - pub blocks: Vec>, - /// Prefix of [`Self::blocks`] that persists trie only. - pub trie_catchup_block_count: usize, - /// Region of [`Self::blocks`] that persists both non-trie outputs and trie data. - pub full_persist_block_count: usize, - /// Following region of [`Self::blocks`] that persists non-trie data only. - pub deferred_trie_block_count: usize, -} - -impl SaveBlocksPlan { - /// Creates a new save plan. - pub const fn new( - blocks: Vec>, - trie_catchup_block_count: usize, - full_persist_block_count: usize, - deferred_trie_block_count: usize, - ) -> Self { - Self { - blocks, - trie_catchup_block_count, - full_persist_block_count, - deferred_trie_block_count, - } - } - - /// Returns `true` if the plan contains no blocks. - pub const fn is_empty(&self) -> bool { - self.non_trie_persisted_block_count() == 0 - } - - /// Returns the number of blocks whose non-trie outputs are persisted by this plan. - pub const fn non_trie_persisted_block_count(&self) -> usize { - self.full_persist_block_count + self.deferred_trie_block_count - } - - /// Returns the in-memory block start index. - pub const fn in_memory_block_start(&self) -> usize { - self.trie_catchup_block_count + self.non_trie_persisted_block_count() - } - - /// Returns the highest block whose non-trie outputs are persisted by this plan. - pub fn non_trie_persisted_tip(&self) -> Option { - self.blocks - .get(self.in_memory_block_start().checked_sub(1)?) - .map(|block| block.recovered_block().num_hash()) - } -} - /// Writes parts of reth's in memory tree state to the database and static files. /// /// This is meant to be a spawned service that listens for various incoming persistence operations, @@ -161,7 +109,7 @@ where } PersistenceAction::SaveBlocks(plan, sender) => { let result = self.on_save_blocks(plan)?; - let result_number = result.non_trie_persisted_tip.map(|b| b.number); + let result_number = result.last_block.map(|b| b.number); let _ = sender.send(result); @@ -194,7 +142,7 @@ where let new_tip_hash = provider_rw.block_hash(new_tip_num)?; provider_rw.remove_block_and_execution_above(new_tip_num)?; - let trie_persisted_tip = + let last_state_trie_block = provider_rw.get_stage_checkpoint(StageId::Finish)?.map(|checkpoint| { checkpoint .finish_stage_checkpoint() @@ -206,9 +154,8 @@ where debug!(target: "engine::persistence", ?new_tip_num, ?new_tip_hash, "Removed blocks from disk"); self.metrics.remove_blocks_above_duration_seconds.record(start_time.elapsed()); Ok(PersistenceResult { - non_trie_persisted_tip: new_tip_hash - .map(|hash| BlockNumHash { hash, number: new_tip_num }), - trie_persisted_tip, + last_block: new_tip_hash.map(|hash| BlockNumHash { hash, number: new_tip_num }), + last_state_trie_block, commit_duration: None, }) } @@ -218,78 +165,58 @@ where &mut self, plan: SaveBlocksPlan, ) -> Result { - let SaveBlocksPlan { - blocks, - trie_catchup_block_count, - full_persist_block_count, - deferred_trie_block_count, - } = plan; - let first_block = blocks.first().map(|b| b.recovered_block.num_hash()); - let non_trie_persisted_tip = trie_catchup_block_count - .checked_add(full_persist_block_count) - .and_then(|count| count.checked_add(deferred_trie_block_count)) - .and_then(|in_memory_block_start| in_memory_block_start.checked_sub(1)) - .and_then(|last_persisted_index| { - blocks.get(last_persisted_index).map(|block| block.recovered_block().num_hash()) - }); - let block_count = blocks.len(); - let mut trie_persisted_tip = None; + let first_block = plan.blocks.first().map(|block| block.recovered_block().num_hash()); + let last_block = plan.last_block(); + let block_count = plan.blocks.len(); + let mut last_state_trie_block = None; let pending_finalized = self.pending_finalized_block.take(); let pending_safe = self.pending_safe_block.take(); - debug!(target: "engine::persistence", ?block_count, first=?first_block, last=?non_trie_persisted_tip, "Saving range of blocks"); + debug!(target: "engine::persistence", ?block_count, first=?first_block, last=?last_block, "Saving range of blocks"); let start_time = Instant::now(); - if let Some(non_trie_persisted_tip) = non_trie_persisted_tip { + if let Some(last_block) = last_block { let provider_rw = self.provider.database_provider_rw()?; - provider_rw.save_blocks( - &blocks, - trie_catchup_block_count, - full_persist_block_count, - deferred_trie_block_count, - SaveBlocksMode::Full, - )?; - trie_persisted_tip = provider_rw + provider_rw.save_blocks(&plan, SaveBlocksMode::Full)?; + last_state_trie_block = provider_rw .get_stage_checkpoint(StageId::Finish)? .and_then(|checkpoint| { checkpoint .finish_stage_checkpoint() .and_then(|finish| finish.partial_state_trie) }) - .or(Some(non_trie_persisted_tip.number)); + .or(Some(last_block.number)); if let Some(finalized) = pending_finalized { - provider_rw - .save_finalized_block_number(finalized.min(non_trie_persisted_tip.number))?; - if finalized > non_trie_persisted_tip.number { + provider_rw.save_finalized_block_number(finalized.min(last_block.number))?; + if finalized > last_block.number { self.pending_finalized_block = Some(finalized); } } if let Some(safe) = pending_safe { - provider_rw.save_safe_block_number(safe.min(non_trie_persisted_tip.number))?; - if safe > non_trie_persisted_tip.number { + provider_rw.save_safe_block_number(safe.min(last_block.number))?; + if safe > last_block.number { self.pending_safe_block = Some(safe); } } provider_rw.commit()?; - debug!(target: "engine::persistence", first=?first_block, last=?non_trie_persisted_tip, "Saved range of blocks"); + debug!(target: "engine::persistence", first=?first_block, last=?last_block, "Saved range of blocks"); // Run the pruner in a separate provider so it reads committed RocksDB state // that includes the history entries written by save_blocks above. // // The pruner reads the indices from rocksdb, filters it, and writes to indices, so it // must be able to read anything written by save_blocks. - if self.pruner.is_pruning_needed(non_trie_persisted_tip.number) { - debug!(target: "engine::persistence", block_num=?non_trie_persisted_tip.number, "Running pruner"); + if self.pruner.is_pruning_needed(last_block.number) { + debug!(target: "engine::persistence", block_num=?last_block.number, "Running pruner"); let prune_start = Instant::now(); let provider_rw = self.provider.database_provider_rw()?; - let _ = - self.pruner.run_with_provider(&provider_rw, non_trie_persisted_tip.number)?; + let _ = self.pruner.run_with_provider(&provider_rw, last_block.number)?; provider_rw.commit()?; - debug!(target: "engine::persistence", tip=?non_trie_persisted_tip.number, "Finished pruning after saving blocks"); + debug!(target: "engine::persistence", tip=?last_block.number, "Finished pruning after saving blocks"); self.metrics.prune_before_duration_seconds.record(prune_start.elapsed()); } } @@ -298,11 +225,7 @@ where self.metrics.save_blocks_batch_size.record(block_count as f64); self.metrics.save_blocks_duration_seconds.record(elapsed); - Ok(PersistenceResult { - non_trie_persisted_tip, - trie_persisted_tip, - commit_duration: Some(elapsed), - }) + Ok(PersistenceResult { last_block, last_state_trie_block, commit_duration: Some(elapsed) }) } } @@ -478,12 +401,12 @@ impl Drop for ServiceGuard { mod tests { use super::*; use alloy_primitives::{B256, U256}; - use reth_chain_state::test_utils::TestBlockBuilder; + use reth_chain_state::{test_utils::TestBlockBuilder, ExecutedBlock}; use reth_exex_types::FinishedExExHeight; use reth_provider::{ providers::{ProviderFactoryBuilder, ReadOnlyConfig}, test_utils::{create_test_provider_factory, MockNodeTypes}, - AccountReader, ChainSpecProvider, HeaderProvider, StorageSettingsCache, + AccountReader, ChainSpecProvider, HeaderProvider, SaveBlocksPlanStep, StorageSettingsCache, TryIntoHistoricalStateProvider, }; use reth_prune::Pruner; @@ -510,8 +433,15 @@ mod tests { } fn full_save_plan(blocks: Vec>) -> SaveBlocksPlan { - let full_persist_block_count = blocks.len(); - SaveBlocksPlan::new(blocks, 0, full_persist_block_count, 0) + let full_range = 0..blocks.len(); + SaveBlocksPlan::new( + blocks, + vec![SaveBlocksPlanStep::new( + full_range.clone(), + Some(full_range.end..full_range.end), + true, + )], + ) } #[test] @@ -525,8 +455,8 @@ mod tests { handle.save_blocks(blocks, tx).unwrap(); let result = rx.recv().unwrap(); - assert!(result.non_trie_persisted_tip.is_none()); - assert!(result.trie_persisted_tip.is_none()); + assert!(result.last_block.is_none()); + assert!(result.last_state_trie_block.is_none()); } #[test] @@ -546,9 +476,9 @@ mod tests { let result = rx.recv_timeout(std::time::Duration::from_secs(10)).expect("test timed out"); - let last_block = result.non_trie_persisted_tip.unwrap(); + let last_block = result.last_block.unwrap(); assert_eq!(block_hash, last_block.hash); - assert_eq!(result.trie_persisted_tip, Some(last_block.number)); + assert_eq!(result.last_state_trie_block, Some(last_block.number)); } #[test] @@ -563,9 +493,9 @@ mod tests { handle.save_blocks(full_save_plan(blocks), tx).unwrap(); let result = rx.recv().unwrap(); - let last_block = result.non_trie_persisted_tip.unwrap(); + let last_block = result.last_block.unwrap(); assert_eq!(last_hash, last_block.hash); - assert_eq!(result.trie_persisted_tip, Some(last_block.number)); + assert_eq!(result.last_state_trie_block, Some(last_block.number)); } #[test] @@ -583,9 +513,9 @@ mod tests { handle.save_blocks(full_save_plan(blocks), tx).unwrap(); let result = rx.recv().unwrap(); - let last_block = result.non_trie_persisted_tip.unwrap(); + let last_block = result.last_block.unwrap(); assert_eq!(last_hash, last_block.hash); - assert_eq!(result.trie_persisted_tip, Some(last_block.number)); + assert_eq!(result.last_state_trie_block, Some(last_block.number)); } } @@ -598,7 +528,18 @@ mod tests { let blocks = test_block_builder.get_executed_blocks(0..4).collect::>(); let provider_rw = provider.database_provider_rw().unwrap(); - provider_rw.save_blocks(&blocks, 0, 2, 2, SaveBlocksMode::Full).unwrap(); + provider_rw + .save_blocks( + &SaveBlocksPlan::new( + blocks, + vec![ + SaveBlocksPlanStep::new(0..2, Some(2..4), true), + SaveBlocksPlanStep::new(2..4, None, true), + ], + ), + SaveBlocksMode::Full, + ) + .unwrap(); provider_rw.commit().unwrap(); let handle = persistence_handle(provider.clone()); @@ -607,9 +548,9 @@ mod tests { handle.remove_blocks_above(2, tx).unwrap(); let result = rx.recv_timeout(std::time::Duration::from_secs(10)).expect("test timed out"); - let last_block = result.non_trie_persisted_tip.unwrap(); + let last_block = result.last_block.unwrap(); assert_eq!(last_block.number, 2); - assert_eq!(result.trie_persisted_tip, Some(1)); + assert_eq!(result.last_state_trie_block, Some(1)); let finish_checkpoint = provider.provider().unwrap().get_stage_checkpoint(StageId::Finish).unwrap().unwrap(); @@ -708,7 +649,7 @@ mod tests { { let provider_rw = provider_factory.database_provider_rw().unwrap(); - provider_rw.save_blocks(&blocks_a, 0, blocks_a.len(), 0, SaveBlocksMode::Full).unwrap(); + provider_rw.save_blocks(&full_save_plan(blocks_a), SaveBlocksMode::Full).unwrap(); provider_rw.commit().unwrap(); } @@ -766,7 +707,10 @@ mod tests { let provider_rw = pf.database_provider_rw().unwrap(); provider_rw - .save_blocks(std::slice::from_ref(&block_b2), 0, 1, 0, SaveBlocksMode::Full) + .save_blocks( + &full_save_plan(std::slice::from_ref(&block_b2).to_vec()), + SaveBlocksMode::Full, + ) .unwrap(); provider_rw.commit().unwrap(); }); diff --git a/crates/engine/tree/src/tree/mod.rs b/crates/engine/tree/src/tree/mod.rs index 03afee2d5db..273648ce603 100644 --- a/crates/engine/tree/src/tree/mod.rs +++ b/crates/engine/tree/src/tree/mod.rs @@ -2,7 +2,7 @@ use crate::{ backfill::{BackfillAction, BackfillSyncState}, chain::FromOrchestrator, engine::{DownloadRequest, EngineApiEvent, EngineApiKind, EngineApiRequest, FromEngine}, - persistence::{PersistenceHandle, SaveBlocksPlan}, + persistence::PersistenceHandle, tree::{error::InsertPayloadError, payload_validator::TreeCtx}, }; use alloy_consensus::BlockHeader; @@ -30,9 +30,9 @@ use reth_primitives_traits::{ }; use reth_provider::{ BlockExecutionOutput, BlockExecutionResult, BlockReader, ChangeSetReader, - DatabaseProviderFactory, HashedPostStateProvider, ProviderError, StageCheckpointReader, - StateProviderBox, StateProviderFactory, StateReader, StorageChangeSetReader, - StorageSettingsCache, TransactionVariant, + DatabaseProviderFactory, HashedPostStateProvider, ProviderError, SaveBlocksPlan, + SaveBlocksPlanStep, StageCheckpointReader, StateProviderBox, StateProviderFactory, StateReader, + StorageChangeSetReader, StorageSettingsCache, TransactionVariant, }; use reth_revm::database::StateProviderDatabase; use reth_stages_api::ControlFlow; @@ -432,8 +432,8 @@ where let header = provider.sealed_header(best_block_number).ok().flatten().unwrap_or_default(); let persistence_state = PersistenceState { - non_trie_persisted_tip: BlockNumHash::new(best_block_number, header.hash()), - trie_persisted_tip: BlockNumHash::new(best_block_number, header.hash()), + last_persisted_block: BlockNumHash::new(best_block_number, header.hash()), + last_state_trie_persisted_block: BlockNumHash::new(best_block_number, header.hash()), rx: None, }; @@ -489,7 +489,7 @@ where self.state .tree_state .canonical_block_number() - .saturating_sub(self.persistence_state.non_trie_persisted_tip.number) + .saturating_sub(self.persistence_state.last_persisted_block.number) } /// Returns `true` when the main loop should stop draining the tree input channel. @@ -1351,8 +1351,8 @@ where /// Helper method to remove blocks and set the persistence state. This ensures we keep track of /// the current persistence action while we're removing blocks. fn remove_blocks(&mut self, new_tip_num: u64) { - debug!(target: "engine::tree", ?new_tip_num, non_trie_persisted_tip=?self.persistence_state.non_trie_persisted_tip.number, "Removing blocks using persistence task"); - if new_tip_num < self.persistence_state.non_trie_persisted_tip.number { + debug!(target: "engine::tree", ?new_tip_num, last_persisted_block=?self.persistence_state.last_persisted_block.number, "Removing blocks using persistence task"); + if new_tip_num < self.persistence_state.last_persisted_block.number { debug!(target: "engine::tree", ?new_tip_num, "Starting remove blocks job"); let (tx, rx) = crossbeam_channel::bounded(1); let _ = self.persistence.remove_blocks_above(new_tip_num, tx); @@ -1368,22 +1368,19 @@ where return } - let non_trie_persisted_tip = - plan.non_trie_persisted_tip().expect("checked non-empty persisting blocks"); + let last_block = plan.last_block().expect("checked non-empty persisting blocks"); debug!( target: "engine::tree", count = plan.blocks.len(), - trie_catchup_blocks = plan.trie_catchup_block_count, - full_persist_blocks = plan.full_persist_block_count, - deferred_trie_blocks = plan.deferred_trie_block_count, + steps = ?plan.steps, blocks = ?plan.blocks.iter().map(|block| block.recovered_block().num_hash()).collect::>(), "Persisting blocks" ); let (tx, rx) = crossbeam_channel::bounded(1); let _ = self.persistence.save_blocks(plan, tx); - self.persistence_state.start_save(non_trie_persisted_tip, rx); + self.persistence_state.start_save(last_block, rx); } /// Triggers new persistence actions if no persistence task is currently in progress. @@ -1471,31 +1468,25 @@ where ) -> Result<(), AdvancePersistenceError> { self.metrics.engine.persistence_duration.record(start_time.elapsed()); - let PersistenceResult { non_trie_persisted_tip, trie_persisted_tip, commit_duration } = - result; - let Some(BlockNumHash { - hash: non_trie_persisted_tip_hash, - number: non_trie_persisted_tip_number, - }) = non_trie_persisted_tip + let PersistenceResult { last_block, last_state_trie_block, commit_duration } = result; + let Some(BlockNumHash { hash: last_block_hash, number: last_block_number }) = last_block else { // if this happened, then we persisted no blocks because we sent an empty vec of blocks warn!(target: "engine::tree", "Persistence task completed but did not persist any blocks"); return Ok(()) }; - let non_trie_persisted_tip = - BlockNumHash::new(non_trie_persisted_tip_number, non_trie_persisted_tip_hash); - let trie_persisted_tip = - self.trie_persisted_tip(non_trie_persisted_tip, trie_persisted_tip)?; + let last_block = BlockNumHash::new(last_block_number, last_block_hash); + let last_state_trie_persisted_block = + self.last_state_trie_persisted_block(last_block, last_state_trie_block)?; - debug!(target: "engine::tree", ?non_trie_persisted_tip_hash, ?non_trie_persisted_tip_number, trie_persisted_tip = trie_persisted_tip.number, elapsed=?start_time.elapsed(), "Finished persisting, calling finish"); - self.persistence_state.finish(non_trie_persisted_tip, trie_persisted_tip); + debug!(target: "engine::tree", ?last_block_hash, ?last_block_number, last_state_trie_persisted_block = last_state_trie_persisted_block.number, elapsed=?start_time.elapsed(), "Finished persisting, calling finish"); + self.persistence_state.finish(last_block, last_state_trie_persisted_block); // Evict trie changesets for blocks below the eviction threshold. // Keep at least CHANGESET_CACHE_RETENTION_BLOCKS from the persisted tip, and also respect // the finalized block if set. - let min_threshold = - non_trie_persisted_tip_number.saturating_sub(CHANGESET_CACHE_RETENTION_BLOCKS); + let min_threshold = last_block_number.saturating_sub(CHANGESET_CACHE_RETENTION_BLOCKS); let eviction_threshold = if let Some(finalized) = self.canonical_in_memory_state.get_finalized_num_hash() { // Use the minimum of finalized block and retention threshold to be conservative @@ -1506,7 +1497,7 @@ where }; debug!( target: "engine::tree", - non_trie_persisted = non_trie_persisted_tip_number, + last_persisted_block = last_block_number, finalized_number = ?self.canonical_in_memory_state.get_finalized_num_hash().map(|f| f.number), eviction_threshold, "Evicting changesets below threshold" @@ -1516,7 +1507,7 @@ where // Invalidate cached overlay since the anchor has changed self.state.tree_state.invalidate_cached_overlay(); - self.on_new_persisted_block(trie_persisted_tip)?; + self.on_new_persisted_block(last_state_trie_persisted_block)?; // Re-prepare overlay for the current canonical head with the new anchor. // Spawn a background task to trigger computation so it's ready when the next payload @@ -1527,34 +1518,37 @@ where }); } - self.purge_timing_stats(non_trie_persisted_tip_number, commit_duration); + self.purge_timing_stats(last_block_number, commit_duration); Ok(()) } /// Returns the highest block that can be dropped from memory after persistence completes. - fn trie_persisted_tip( + fn last_state_trie_persisted_block( &self, - non_trie_persisted_tip: BlockNumHash, - trie_persisted_tip: Option, + last_block: BlockNumHash, + last_state_trie_block: Option, ) -> ProviderResult { - let Some(trie_persisted_tip) = - trie_persisted_tip.filter(|block_number| *block_number < non_trie_persisted_tip.number) - else { - return Ok(non_trie_persisted_tip) - }; + let Some(last_state_trie_block) = last_state_trie_block else { return Ok(last_block) }; + debug_assert!( + last_state_trie_block <= last_block.number, + "state/trie frontier cannot exceed the last persisted block" + ); + if last_state_trie_block >= last_block.number { + return Ok(last_block) + } let hash = self .canonical_in_memory_state - .hash_by_number(trie_persisted_tip) + .hash_by_number(last_state_trie_block) .map(Ok) .unwrap_or_else(|| { self.provider - .block_hash(trie_persisted_tip)? - .ok_or_else(|| ProviderError::HeaderNotFound(trie_persisted_tip.into())) + .block_hash(last_state_trie_block)? + .ok_or_else(|| ProviderError::HeaderNotFound(last_state_trie_block.into())) })?; - Ok(BlockNumHash::new(trie_persisted_tip, hash)) + Ok(BlockNumHash::new(last_state_trie_block, hash)) } /// Handles a message from the engine. @@ -1841,7 +1835,7 @@ where } else { self.state.tree_state.remove_until( backfill_num_hash, - self.persistence_state.non_trie_persisted_tip.hash, + self.persistence_state.last_persisted_block.hash, Some(backfill_num_hash), ); } @@ -2063,7 +2057,7 @@ where return false } - let min_block = self.persistence_state.non_trie_persisted_tip.number; + let min_block = self.persistence_state.last_persisted_block.number; self.state.tree_state.canonical_block_number().saturating_sub(min_block) > self.config.persistence_threshold() } @@ -2079,10 +2073,11 @@ where let mut blocks = Vec::new(); let mut current_hash = self.state.tree_state.canonical_block_hash(); - let trie_persisted_tip_number = self.persistence_state.trie_persisted_tip.number; - let non_trie_persisted_tip_number = self.persistence_state.non_trie_persisted_tip.number; + let last_state_trie_persisted_block_number = + self.persistence_state.last_state_trie_persisted_block.number; + let last_persisted_block_number = self.persistence_state.last_persisted_block.number; let canonical_head_number = self.state.tree_state.canonical_block_number(); - let non_trie_target_number = match target { + let last_block_target_number = match target { PersistTarget::Threshold => { canonical_head_number.saturating_sub(self.config.memory_block_buffer_target()) } @@ -2092,18 +2087,18 @@ where debug!( target: "engine::tree", ?current_hash, - ?trie_persisted_tip_number, - ?non_trie_persisted_tip_number, + ?last_state_trie_persisted_block_number, + ?last_persisted_block_number, ?canonical_head_number, target = ?target, "Returning save plan" ); while let Some(block) = self.state.tree_state.blocks_by_hash.get(¤t_hash) { - if block.recovered_block().number() <= trie_persisted_tip_number { + if block.recovered_block().number() <= last_state_trie_persisted_block_number { break; } - if block.recovered_block().number() <= non_trie_target_number { + if block.recovered_block().number() <= last_block_target_number { blocks.push(block.clone()); } @@ -2113,20 +2108,37 @@ where // Reverse the order so that the oldest block comes first blocks.reverse(); - let trie_catchup_block_count = non_trie_persisted_tip_number - .saturating_sub(trie_persisted_tip_number) + let trie_catchup_block_count = last_persisted_block_number + .saturating_sub(last_state_trie_persisted_block_number) .min(blocks.len() as u64) as usize; - let non_trie_block_count = blocks.len().saturating_sub(trie_catchup_block_count); - let deferred_trie_block_count = - non_trie_block_count.min(self.config.deferred_trie_blocks() as usize); - let full_persist_block_count = non_trie_block_count - deferred_trie_block_count; - - Ok(SaveBlocksPlan::new( - blocks, - trie_catchup_block_count, - full_persist_block_count, - deferred_trie_block_count, - )) + let persist_rest_block_count = blocks.len().saturating_sub(trie_catchup_block_count); + let state_masking_block_count = + persist_rest_block_count.min(self.config.num_state_masking_blocks() as usize); + let full_persist_block_count = persist_rest_block_count - state_masking_block_count; + let full_persist_start = trie_catchup_block_count; + let state_masking_start = full_persist_start + full_persist_block_count; + let state_masking_range = state_masking_start..blocks.len(); + let mut steps = Vec::new(); + + if trie_catchup_block_count > 0 { + steps.push(SaveBlocksPlanStep::new( + 0..trie_catchup_block_count, + Some(state_masking_range.clone()), + false, + )); + } + if full_persist_block_count > 0 { + steps.push(SaveBlocksPlanStep::new( + full_persist_start..state_masking_start, + Some(state_masking_range.clone()), + true, + )); + } + if state_masking_block_count > 0 { + steps.push(SaveBlocksPlanStep::new(state_masking_range, None, true)); + } + + Ok(SaveBlocksPlan::new(blocks, steps)) } /// This clears the blocks from the in-memory tree state that no longer need to stay resident @@ -2150,7 +2162,7 @@ where let finalized = self.state.forkchoice_state_tracker.last_valid_finalized(); self.remove_before(in_memory_persisted_block, finalized)?; self.canonical_in_memory_state.remove_persisted_blocks_until( - self.persistence_state.non_trie_persisted_tip, + self.persistence_state.last_persisted_block, in_memory_persisted_block.number, ); Ok(()) @@ -2620,7 +2632,7 @@ where /// happen if a reorg is happening while we are persisting a block. fn find_disk_reorg(&self) -> ProviderResult> { let mut canonical = self.state.tree_state.current_canonical_head; - let mut persisted = self.persistence_state.non_trie_persisted_tip; + let mut persisted = self.persistence_state.last_persisted_block; let parent_num_hash = |num_hash: NumHash| -> ProviderResult { Ok(self @@ -2951,7 +2963,7 @@ where // Only query DB if block could be persisted (number <= last persisted block). // New blocks from CL always have number > last persisted, so skip DB lookup for them. - if block_num_hash.number <= self.persistence_state.non_trie_persisted_tip.number { + if block_num_hash.number <= self.persistence_state.last_persisted_block.number { match self.provider.sealed_header_by_hash(block_num_hash.hash) { Err(err) => { let block = convert_to_block(self, input)?; @@ -3326,7 +3338,7 @@ where self.state.tree_state.remove_until( upper_bound, - self.persistence_state.non_trie_persisted_tip.hash, + self.persistence_state.last_persisted_block.hash, num, ); Ok(()) diff --git a/crates/engine/tree/src/tree/persistence_state.rs b/crates/engine/tree/src/tree/persistence_state.rs index 6eee9f8cde8..e4e0590fc56 100644 --- a/crates/engine/tree/src/tree/persistence_state.rs +++ b/crates/engine/tree/src/tree/persistence_state.rs @@ -29,12 +29,12 @@ use tracing::trace; /// The state of the persistence task. #[derive(Debug)] pub struct PersistenceState { - /// Hash and number of the highest block whose non-trie outputs are persisted. + /// Hash and number of the highest block whose non-state/trie outputs are persisted. /// /// This tracks the highest canonical block with durable block/static-file/plain-state data. - pub(crate) non_trie_persisted_tip: BlockNumHash, - /// Hash and number of the highest block whose trie outputs are persisted. - pub(crate) trie_persisted_tip: BlockNumHash, + pub(crate) last_persisted_block: BlockNumHash, + /// Hash and number of the highest block whose state/trie outputs are persisted. + pub(crate) last_state_trie_persisted_block: BlockNumHash, /// Receiver end of channel where the result of the persistence task will be /// sent when done. A None value means there's no persistence task in progress. pub(crate) rx: @@ -77,18 +77,18 @@ impl PersistenceState { /// Sets state for a finished persistence task. pub(crate) fn finish( &mut self, - non_trie_persisted_tip: BlockNumHash, - trie_persisted_tip: BlockNumHash, + last_persisted_block: BlockNumHash, + last_state_trie_persisted_block: BlockNumHash, ) { trace!( target: "engine::tree", - non_trie_persisted_tip = %non_trie_persisted_tip.number, - trie_persisted_tip = %trie_persisted_tip.number, + last_persisted_block = %last_persisted_block.number, + last_state_trie_persisted_block = %last_state_trie_persisted_block.number, "updating persistence state" ); self.rx = None; - self.non_trie_persisted_tip = non_trie_persisted_tip; - self.trie_persisted_tip = trie_persisted_tip; + self.last_persisted_block = last_persisted_block; + self.last_state_trie_persisted_block = last_state_trie_persisted_block; } } diff --git a/crates/engine/tree/src/tree/tests.rs b/crates/engine/tree/src/tree/tests.rs index 4b8feb674ba..6e25c2459ed 100644 --- a/crates/engine/tree/src/tree/tests.rs +++ b/crates/engine/tree/src/tree/tests.rs @@ -223,8 +223,8 @@ impl TestHarness { canonical_in_memory_state, persistence_handle, PersistenceState { - non_trie_persisted_tip: BlockNumHash::default(), - trie_persisted_tip: BlockNumHash::default(), + last_persisted_block: BlockNumHash::default(), + last_state_trie_persisted_block: BlockNumHash::default(), rx: None, }, payload_builder, @@ -364,6 +364,17 @@ impl TestHarness { } } +type ExpectedPlanStep = (std::ops::Range, Option>, bool); + +fn assert_plan_steps(plan: &SaveBlocksPlan, expected: &[ExpectedPlanStep]) { + assert_eq!(plan.steps.len(), expected.len()); + for (step, (block_range, masking_range, persist_rest)) in plan.steps.iter().zip(expected) { + assert_eq!(&step.block_range, block_range); + assert_eq!(&step.state_trie_masking_range, masking_range); + assert_eq!(step.persist_rest, *persist_rest); + } +} + /// Simplified test metrics for validation calls #[derive(Debug, Default)] struct TestMetrics { @@ -564,9 +575,10 @@ async fn test_tree_persist_blocks() { let expected_persist_len = blocks.len() - tree_config.memory_block_buffer_target() as usize; assert_eq!(plan.blocks.len(), expected_persist_len); assert_eq!(plan.blocks, blocks[..expected_persist_len]); - assert_eq!(plan.trie_catchup_block_count, 0); - assert_eq!(plan.full_persist_block_count, expected_persist_len); - assert_eq!(plan.deferred_trie_block_count, 0); + assert_plan_steps( + &plan, + &[(0..expected_persist_len, Some(expected_persist_len..expected_persist_len), true)], + ); } else { panic!("unexpected action received {received_action:?}"); } @@ -711,8 +723,8 @@ fn test_backpressure_waits_for_persistence_before_reading_incoming() { test_harness.tree.config = test_harness .tree .config - .with_persistence_threshold(0) - .with_persistence_backpressure_threshold(1); + .with_persistence_threshold(1) + .with_persistence_backpressure_threshold(2); let (persist_tx, persist_rx) = crossbeam_channel::bounded(1); let persisted = blocks.last().unwrap().recovered_block().num_hash(); @@ -742,8 +754,8 @@ fn test_backpressure_waits_for_persistence_before_reading_incoming() { std::thread::sleep(Duration::from_millis(10)); persist_tx .send(PersistenceResult { - non_trie_persisted_tip: Some(persisted), - trie_persisted_tip: Some(persisted.number), + last_block: Some(persisted), + last_state_trie_block: Some(persisted.number), commit_duration: Some(Duration::ZERO), }) .unwrap(); @@ -778,10 +790,10 @@ async fn test_tree_state_on_new_head_reorg() { reth_tracing::init_test_tracing(); let chain_spec = MAINNET.clone(); - // Set persistence_threshold to 1 + // Keep a single block in memory while still leaving room for the persistence threshold. let mut test_harness = TestHarness::new(chain_spec); test_harness.tree.config = - test_harness.tree.config.with_persistence_threshold(1).with_memory_block_buffer_target(1); + test_harness.tree.config.with_persistence_threshold(2).with_memory_block_buffer_target(1); let mut test_block_builder = TestBlockBuilder::eth(); let blocks: Vec<_> = test_block_builder.get_executed_blocks(1..6).collect(); @@ -840,8 +852,8 @@ async fn test_tree_state_on_new_head_reorg() { // send the response so we can advance again sender .send(PersistenceResult { - non_trie_persisted_tip: Some(blocks[1].recovered_block().num_hash()), - trie_persisted_tip: Some(blocks[1].recovered_block().number()), + last_block: Some(blocks[1].recovered_block().num_hash()), + last_state_trie_block: Some(blocks[1].recovered_block().number()), commit_duration: Some(Duration::ZERO), }) .unwrap(); @@ -976,11 +988,11 @@ async fn test_get_canonical_blocks_to_persist() { test_block_builder.get_executed_blocks(0..canonical_head_number + 1).collect(); test_harness = test_harness.with_blocks(blocks.clone()); - let non_trie_persisted_tip_number = 3; - let non_trie_persisted_tip = - blocks[non_trie_persisted_tip_number as usize].recovered_block.num_hash(); - test_harness.tree.persistence_state.non_trie_persisted_tip = non_trie_persisted_tip; - test_harness.tree.persistence_state.trie_persisted_tip = non_trie_persisted_tip; + let last_persisted_block_number = 3; + let last_persisted_block = + blocks[last_persisted_block_number as usize].recovered_block.num_hash(); + test_harness.tree.persistence_state.last_persisted_block = last_persisted_block; + test_harness.tree.persistence_state.last_state_trie_persisted_block = last_persisted_block; let persistence_threshold = 4; let memory_block_buffer_target = 3; @@ -991,13 +1003,13 @@ async fn test_get_canonical_blocks_to_persist() { let plan = test_harness.tree.get_save_blocks_plan(PersistTarget::Threshold).unwrap(); let expected_blocks_to_persist_length: usize = - (canonical_head_number - memory_block_buffer_target - non_trie_persisted_tip_number) + (canonical_head_number - memory_block_buffer_target - last_persisted_block_number) .try_into() .unwrap(); assert_eq!(plan.blocks.len(), expected_blocks_to_persist_length); for (i, item) in plan.blocks.iter().enumerate().take(expected_blocks_to_persist_length) { - assert_eq!(item.recovered_block().number, non_trie_persisted_tip_number + i as u64 + 1); + assert_eq!(item.recovered_block().number, last_persisted_block_number + i as u64 + 1); } // make sure only canonical blocks are included @@ -1035,25 +1047,24 @@ fn test_get_save_blocks_plan_with_deferred_trie_blocks() { let blocks: Vec<_> = test_block_builder.get_executed_blocks(0..7).collect(); test_harness = test_harness.with_blocks(blocks.clone()); - test_harness.tree.persistence_state.trie_persisted_tip = blocks[1].recovered_block().num_hash(); - test_harness.tree.persistence_state.non_trie_persisted_tip = + test_harness.tree.persistence_state.last_state_trie_persisted_block = + blocks[1].recovered_block().num_hash(); + test_harness.tree.persistence_state.last_persisted_block = blocks[3].recovered_block().num_hash(); test_harness.tree.config = TreeConfig::default() - .with_persistence_threshold(1) + .with_persistence_threshold(4) .with_memory_block_buffer_target(1) - .with_deferred_trie_blocks(2); + .with_num_state_masking_blocks(2); let plan = test_harness.tree.get_save_blocks_plan(PersistTarget::Threshold).unwrap(); - assert_eq!(plan.trie_catchup_block_count, 2); - assert_eq!(plan.full_persist_block_count, 0); - assert_eq!(plan.deferred_trie_block_count, 2); + assert_plan_steps(&plan, &[(0..2, Some(2..4), false), (2..4, None, true)]); assert_eq!(plan.blocks.len(), 4); assert_eq!( plan.blocks.iter().map(|block| block.recovered_block().number()).collect::>(), vec![2, 3, 4, 5] ); - assert_eq!(plan.non_trie_persisted_tip(), Some(blocks[5].recovered_block().num_hash())); + assert_eq!(plan.last_block(), Some(blocks[5].recovered_block().num_hash())); } #[test] @@ -1064,26 +1075,27 @@ fn test_get_save_blocks_plan_persists_full_region_before_deferred_tail() { let blocks: Vec<_> = test_block_builder.get_executed_blocks(0..31).collect(); test_harness = test_harness.with_blocks(blocks.clone()); - test_harness.tree.persistence_state.trie_persisted_tip = + test_harness.tree.persistence_state.last_state_trie_persisted_block = blocks[12].recovered_block().num_hash(); - test_harness.tree.persistence_state.non_trie_persisted_tip = + test_harness.tree.persistence_state.last_persisted_block = blocks[15].recovered_block().num_hash(); test_harness.tree.config = TreeConfig::default() - .with_persistence_threshold(3) + .with_persistence_threshold(5) .with_memory_block_buffer_target(2) - .with_deferred_trie_blocks(2); + .with_num_state_masking_blocks(2); let plan = test_harness.tree.get_save_blocks_plan(PersistTarget::Threshold).unwrap(); - assert_eq!(plan.trie_catchup_block_count, 3); - assert_eq!(plan.full_persist_block_count, 11); - assert_eq!(plan.deferred_trie_block_count, 2); + assert_plan_steps( + &plan, + &[(0..3, Some(14..16), false), (3..14, Some(14..16), true), (14..16, None, true)], + ); assert_eq!(plan.blocks.len(), 16); assert_eq!( plan.blocks.iter().map(|block| block.recovered_block().number()).collect::>(), (13..=28).collect::>() ); - assert_eq!(plan.non_trie_persisted_tip(), Some(blocks[28].recovered_block().num_hash())); + assert_eq!(plan.last_block(), Some(blocks[28].recovered_block().num_hash())); } #[test] @@ -1094,28 +1106,29 @@ fn test_on_persistence_complete_retains_blocks_above_partial_state_trie() { let blocks: Vec<_> = test_block_builder.get_executed_blocks(0..7).collect(); test_harness = test_harness.with_blocks(blocks.clone()); - test_harness.tree.persistence_state.non_trie_persisted_tip = + test_harness.tree.persistence_state.last_persisted_block = + blocks[1].recovered_block().num_hash(); + test_harness.tree.persistence_state.last_state_trie_persisted_block = blocks[1].recovered_block().num_hash(); - test_harness.tree.persistence_state.trie_persisted_tip = blocks[1].recovered_block().num_hash(); let persisted_tip = blocks[5].recovered_block().num_hash(); - let trie_persisted_tip = blocks[3].recovered_block().number(); + let last_state_trie_block = blocks[3].recovered_block().number(); test_harness .tree .on_persistence_complete( PersistenceResult { - non_trie_persisted_tip: Some(persisted_tip), - trie_persisted_tip: Some(trie_persisted_tip), + last_block: Some(persisted_tip), + last_state_trie_block: Some(last_state_trie_block), commit_duration: Some(Duration::ZERO), }, Instant::now(), ) .unwrap(); - assert_eq!(test_harness.tree.persistence_state.non_trie_persisted_tip, persisted_tip); + assert_eq!(test_harness.tree.persistence_state.last_persisted_block, persisted_tip); assert_eq!( - test_harness.tree.persistence_state.trie_persisted_tip, + test_harness.tree.persistence_state.last_state_trie_persisted_block, blocks[3].recovered_block().num_hash() ); assert_eq!( @@ -1123,7 +1136,7 @@ fn test_on_persistence_complete_retains_blocks_above_partial_state_trie() { Some(persisted_tip) ); - for block in &blocks[..=trie_persisted_tip as usize] { + for block in &blocks[..=last_state_trie_block as usize] { assert!(test_harness .tree .state @@ -1137,7 +1150,7 @@ fn test_on_persistence_complete_retains_blocks_above_partial_state_trie() { .is_none()); } - for block in &blocks[trie_persisted_tip as usize + 1..] { + for block in &blocks[last_state_trie_block as usize + 1..] { assert!(test_harness .tree .state @@ -1160,9 +1173,10 @@ fn test_on_persistence_complete_without_partial_state_trie_prunes_through_tip() let blocks: Vec<_> = test_block_builder.get_executed_blocks(0..7).collect(); test_harness = test_harness.with_blocks(blocks.clone()); - test_harness.tree.persistence_state.non_trie_persisted_tip = + test_harness.tree.persistence_state.last_persisted_block = + blocks[1].recovered_block().num_hash(); + test_harness.tree.persistence_state.last_state_trie_persisted_block = blocks[1].recovered_block().num_hash(); - test_harness.tree.persistence_state.trie_persisted_tip = blocks[1].recovered_block().num_hash(); let persisted_tip = blocks[5].recovered_block().num_hash(); @@ -1170,8 +1184,8 @@ fn test_on_persistence_complete_without_partial_state_trie_prunes_through_tip() .tree .on_persistence_complete( PersistenceResult { - non_trie_persisted_tip: Some(persisted_tip), - trie_persisted_tip: None, + last_block: Some(persisted_tip), + last_state_trie_block: None, commit_duration: Some(Duration::ZERO), }, Instant::now(), @@ -2304,15 +2318,15 @@ mod forkchoice_updated_tests { if let Ok(PersistenceAction::SaveBlocks(plan, sender)) = action_rx.recv_timeout(std::time::Duration::from_millis(100)) { - if let Some(last) = plan.non_trie_persisted_tip() { + if let Some(last) = plan.last_block() { last_persisted_number = last.number; } else if let Some(last) = plan.blocks.last() { last_persisted_number = last.recovered_block().number; } sender .send(PersistenceResult { - non_trie_persisted_tip: plan.non_trie_persisted_tip(), - trie_persisted_tip: plan.non_trie_persisted_tip().map(|tip| tip.number), + last_block: plan.last_block(), + last_state_trie_block: plan.last_block().map(|tip| tip.number), commit_duration: Some(Duration::ZERO), }) .unwrap(); diff --git a/crates/node/builder/src/launch/common.rs b/crates/node/builder/src/launch/common.rs index daab4a25dfe..6f492ef2ec1 100644 --- a/crates/node/builder/src/launch/common.rs +++ b/crates/node/builder/src/launch/common.rs @@ -527,8 +527,6 @@ where [rocksdb_unwind, static_file_unwind, partial_trie_unwind].into_iter().flatten().min(); if let Some(unwind_block) = unwind_target { - // Highly unlikely to happen, and given its destructive nature, it's better to panic - // instead. Unwinding to 0 would leave MDBX with a huge free list size. let inconsistency_source = [ rocksdb_unwind.map(|_| "RocksDB"), static_file_unwind.map(|_| "static file"), @@ -538,6 +536,8 @@ where .flatten() .collect::>() .join(" and "); + // Highly unlikely to happen, and given its destructive nature, it's better to panic + // instead. Unwinding to 0 would leave MDBX with a huge free list size. assert_ne!( unwind_block, 0, "A {} inconsistency was found that would trigger an unwind to block 0", diff --git a/crates/node/core/src/args/engine.rs b/crates/node/core/src/args/engine.rs index 49097c3b551..4b9936bec0a 100644 --- a/crates/node/core/src/args/engine.rs +++ b/crates/node/core/src/args/engine.rs @@ -333,8 +333,8 @@ pub struct EngineArgs { #[arg(long = "engine.persistence-backpressure-threshold", default_value_t = DefaultEngineValues::get_global().persistence_backpressure_threshold())] pub persistence_backpressure_threshold: u64, - /// Configure how many canonical blocks may persist non-trie outputs while deferring durable - /// trie updates. + /// Configure how many of the blocks being persisted should only mask state/trie writes instead + /// of durably persisting their state/trie updates in the current cycle. #[arg(long = "engine.deferred-trie-blocks", default_value_t = DefaultEngineValues::get_global().deferred_trie_blocks)] pub deferred_trie_blocks: u64, @@ -567,49 +567,86 @@ pub struct EngineArgs { #[allow(deprecated)] impl Default for EngineArgs { fn default() -> Self { - let defaults = DefaultEngineValues::get_global().clone(); + let DefaultEngineValues { + persistence_threshold, + persistence_backpressure_threshold, + deferred_trie_blocks, + memory_block_buffer_target, + invalid_header_hit_eviction_threshold, + legacy_state_root_task_enabled, + state_cache_disabled, + prewarming_disabled, + state_provider_metrics, + cross_block_cache_size, + state_root_task_compare_updates, + accept_execution_requests_hash, + multiproof_chunk_size, + reserved_cpu_cores, + precompile_cache_disabled, + state_root_fallback, + always_process_payload_attributes_on_canonical_head, + allow_unwind_canonical_header, + storage_worker_count, + account_worker_count, + prewarming_threads, + cache_metrics_disabled, + sparse_trie_max_hot_slots, + sparse_trie_max_hot_accounts, + slow_block_threshold, + disable_sparse_trie_cache_pruning, + state_root_task_timeout, + share_execution_cache_with_payload_builder, + share_sparse_trie_with_payload_builder, + suppress_persistence_during_build, + bal_parallel_execution_disabled, + bal_parallel_state_root_disabled, + } = DefaultEngineValues::get_global().clone(); Self { - persistence_threshold: defaults.persistence_threshold, - persistence_backpressure_threshold: defaults.persistence_backpressure_threshold(), - deferred_trie_blocks: defaults.deferred_trie_blocks, - memory_block_buffer_target: defaults.memory_block_buffer_target, - invalid_header_hit_eviction_threshold: defaults.invalid_header_hit_eviction_threshold, - legacy_state_root_task_enabled: defaults.legacy_state_root_task_enabled, - state_root_task_compare_updates: defaults.state_root_task_compare_updates, + persistence_threshold, + persistence_backpressure_threshold: persistence_backpressure_threshold.unwrap_or_else( + || { + default_persistence_backpressure_threshold( + persistence_threshold, + memory_block_buffer_target, + ) + }, + ), + deferred_trie_blocks, + memory_block_buffer_target, + invalid_header_hit_eviction_threshold, + legacy_state_root_task_enabled, + state_root_task_compare_updates, caching_and_prewarming_enabled: true, - state_cache_disabled: defaults.state_cache_disabled, - prewarming_disabled: defaults.prewarming_disabled, + state_cache_disabled, + prewarming_disabled, parallel_sparse_trie_enabled: true, parallel_sparse_trie_disabled: false, - state_provider_metrics: defaults.state_provider_metrics, - cross_block_cache_size: defaults.cross_block_cache_size, - accept_execution_requests_hash: defaults.accept_execution_requests_hash, - multiproof_chunk_size: defaults.multiproof_chunk_size, - reserved_cpu_cores: defaults.reserved_cpu_cores, + state_provider_metrics, + cross_block_cache_size, + accept_execution_requests_hash, + multiproof_chunk_size, + reserved_cpu_cores, precompile_cache_enabled: true, - precompile_cache_disabled: defaults.precompile_cache_disabled, - state_root_fallback: defaults.state_root_fallback, - always_process_payload_attributes_on_canonical_head: defaults - .always_process_payload_attributes_on_canonical_head, - allow_unwind_canonical_header: defaults.allow_unwind_canonical_header, - storage_worker_count: defaults.storage_worker_count, - account_worker_count: defaults.account_worker_count, - prewarming_threads: defaults.prewarming_threads, - cache_metrics_disabled: defaults.cache_metrics_disabled, - sparse_trie_max_hot_slots: defaults.sparse_trie_max_hot_slots, - sparse_trie_max_hot_accounts: defaults.sparse_trie_max_hot_accounts, - slow_block_threshold: defaults.slow_block_threshold, - disable_sparse_trie_cache_pruning: defaults.disable_sparse_trie_cache_pruning, - state_root_task_timeout: defaults - .state_root_task_timeout + precompile_cache_disabled, + state_root_fallback, + always_process_payload_attributes_on_canonical_head, + allow_unwind_canonical_header, + storage_worker_count, + account_worker_count, + prewarming_threads, + cache_metrics_disabled, + sparse_trie_max_hot_slots, + sparse_trie_max_hot_accounts, + slow_block_threshold, + disable_sparse_trie_cache_pruning, + state_root_task_timeout: state_root_task_timeout .as_deref() .map(|s| humantime::parse_duration(s).expect("valid default duration")), - share_execution_cache_with_payload_builder: defaults - .share_execution_cache_with_payload_builder, - share_sparse_trie_with_payload_builder: defaults.share_sparse_trie_with_payload_builder, - suppress_persistence_during_build: defaults.suppress_persistence_during_build, - bal_parallel_execution_disabled: defaults.bal_parallel_execution_disabled, - bal_parallel_state_root_disabled: defaults.bal_parallel_state_root_disabled, + share_execution_cache_with_payload_builder, + share_sparse_trie_with_payload_builder, + suppress_persistence_during_build, + bal_parallel_execution_disabled, + bal_parallel_state_root_disabled, disable_bal_batch_io: false, #[cfg(feature = "trie-debug")] proof_jitter: None, @@ -626,6 +663,13 @@ impl EngineArgs { self.persistence_backpressure_threshold, self.persistence_threshold ); + ensure!( + self.deferred_trie_blocks + self.memory_block_buffer_target < self.persistence_threshold, + "--engine.deferred-trie-blocks ({}) + --engine.memory-block-buffer-target ({}) must be less than --engine.persistence-threshold ({})", + self.deferred_trie_blocks, + self.memory_block_buffer_target, + self.persistence_threshold, + ); Ok(()) } @@ -634,7 +678,7 @@ impl EngineArgs { let config = TreeConfig::default() .with_persistence_threshold(self.persistence_threshold) .with_persistence_backpressure_threshold(self.persistence_backpressure_threshold) - .with_deferred_trie_blocks(self.deferred_trie_blocks) + .with_num_state_masking_blocks(self.deferred_trie_blocks) .with_memory_block_buffer_target(self.memory_block_buffer_target) .with_invalid_header_hit_eviction_threshold(self.invalid_header_hit_eviction_threshold) .with_legacy_state_root(self.legacy_state_root_task_enabled) @@ -825,12 +869,17 @@ mod tests { #[test] fn test_parse_deferred_trie_blocks() { - let args = - CommandParser::::parse_from(["reth", "--engine.deferred-trie-blocks", "7"]) - .args; + let args = CommandParser::::parse_from([ + "reth", + "--engine.persistence-threshold", + "8", + "--engine.deferred-trie-blocks", + "7", + ]) + .args; assert_eq!(args.deferred_trie_blocks, 7); - assert_eq!(args.tree_config().deferred_trie_blocks(), 7); + assert_eq!(args.tree_config().num_state_masking_blocks(), 7); } #[test] @@ -846,6 +895,21 @@ mod tests { assert!(err.contains("engine.persistence-threshold")); } + #[test] + fn validate_rejects_state_masking_window_at_or_above_threshold() { + let args = EngineArgs { + persistence_threshold: 4, + deferred_trie_blocks: 2, + memory_block_buffer_target: 2, + ..EngineArgs::default() + }; + + let err = args.validate().unwrap_err().to_string(); + assert!(err.contains("engine.deferred-trie-blocks")); + assert!(err.contains("engine.memory-block-buffer-target")); + assert!(err.contains("engine.persistence-threshold")); + } + #[test] fn test_parse_slow_block_threshold() { // Test default value (None - disabled) diff --git a/crates/stages/stages/src/stages/finish.rs b/crates/stages/stages/src/stages/finish.rs index 18470650977..8d676c35b99 100644 --- a/crates/stages/stages/src/stages/finish.rs +++ b/crates/stages/stages/src/stages/finish.rs @@ -1,6 +1,5 @@ use reth_stages_api::{ - ExecInput, ExecOutput, FinishCheckpoint, Stage, StageCheckpoint, StageError, StageId, - UnwindInput, UnwindOutput, + ExecInput, ExecOutput, Stage, StageCheckpoint, StageError, StageId, UnwindInput, UnwindOutput, }; /// The finish stage. @@ -21,11 +20,7 @@ impl Stage for FinishStage { _provider: &Provider, input: ExecInput, ) -> Result { - Ok(ExecOutput { - checkpoint: StageCheckpoint::new(input.target()) - .with_finish_stage_checkpoint(FinishCheckpoint { partial_state_trie: None }), - done: true, - }) + Ok(ExecOutput { checkpoint: StageCheckpoint::new(input.target()), done: true }) } fn unwind( @@ -33,10 +28,7 @@ impl Stage for FinishStage { _provider: &Provider, input: UnwindInput, ) -> Result { - Ok(UnwindOutput { - checkpoint: StageCheckpoint::new(input.unwind_to) - .with_finish_stage_checkpoint(FinishCheckpoint { partial_state_trie: None }), - }) + Ok(UnwindOutput { checkpoint: StageCheckpoint::new(input.unwind_to) }) } } diff --git a/crates/stages/stages/src/stages/tx_lookup.rs b/crates/stages/stages/src/stages/tx_lookup.rs index b5d852b9b0b..9a5d509062a 100644 --- a/crates/stages/stages/src/stages/tx_lookup.rs +++ b/crates/stages/stages/src/stages/tx_lookup.rs @@ -337,13 +337,12 @@ mod tests { result, Ok(ExecOutput { checkpoint: StageCheckpoint { - block_number, - stage_checkpoint: Some(StageUnitCheckpoint::Entities(EntitiesCheckpoint { - processed, - total - })), - .. - }, done: true }) if block_number == previous_stage && processed == total && + block_number, + stage_checkpoint: Some(StageUnitCheckpoint::Entities(EntitiesCheckpoint { + processed, + total + })) + }, done: true }) if block_number == previous_stage && processed == total && total == runner.db.count_entries::().unwrap() as u64 ); @@ -384,13 +383,12 @@ mod tests { result, Ok(ExecOutput { checkpoint: StageCheckpoint { - block_number, - stage_checkpoint: Some(StageUnitCheckpoint::Entities(EntitiesCheckpoint { - processed, - total - })), - .. - }, done: true }) if block_number == previous_stage && processed == total && + block_number, + stage_checkpoint: Some(StageUnitCheckpoint::Entities(EntitiesCheckpoint { + processed, + total + })) + }, done: true }) if block_number == previous_stage && processed == total && total == runner.db.count_entries::().unwrap() as u64 ); diff --git a/crates/stages/types/src/checkpoints.rs b/crates/stages/types/src/checkpoints.rs index d6e2aa9054e..9aff8ac726a 100644 --- a/crates/stages/types/src/checkpoints.rs +++ b/crates/stages/types/src/checkpoints.rs @@ -446,7 +446,7 @@ impl StageCheckpoint { #[cfg_attr(any(test, feature = "reth-codec"), reth_codecs::add_arbitrary_tests(compact))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct FinishCheckpoint { - /// The highest block with a partially persisted state trie. + /// The highest block with a partially persisted state and trie. pub partial_state_trie: Option, } @@ -694,9 +694,7 @@ mod tests { let mut buf = Vec::new(); let encoded = checkpoint.to_compact(&mut buf); - let (decoded, rest) = StageCheckpoint::from_compact(&buf, encoded); - - assert!(rest.is_empty()); + let (decoded, _) = StageCheckpoint::from_compact(&buf, encoded); assert_eq!(decoded, checkpoint); } } diff --git a/crates/storage/provider/src/lib.rs b/crates/storage/provider/src/lib.rs index 890051b9d1b..c392cb0fd34 100644 --- a/crates/storage/provider/src/lib.rs +++ b/crates/storage/provider/src/lib.rs @@ -24,8 +24,8 @@ pub mod providers; pub use providers::{ DatabaseProvider, DatabaseProviderRO, DatabaseProviderRW, HistoricalStateProvider, HistoricalStateProviderRef, LatestStateProvider, LatestStateProviderRef, ProviderFactory, - PruneShardOutcome, PrunedIndices, SaveBlocksMode, StaticFileAccess, StaticFileProviderBuilder, - StaticFileWriteCtx, StaticFileWriter, + PruneShardOutcome, PrunedIndices, SaveBlocksMode, SaveBlocksPlan, SaveBlocksPlanStep, + StaticFileAccess, StaticFileProviderBuilder, StaticFileWriteCtx, StaticFileWriter, }; pub mod changeset_walker; diff --git a/crates/storage/provider/src/providers/blockchain_provider.rs b/crates/storage/provider/src/providers/blockchain_provider.rs index ac91ed90488..0788def9970 100644 --- a/crates/storage/provider/src/providers/blockchain_provider.rs +++ b/crates/storage/provider/src/providers/blockchain_provider.rs @@ -790,7 +790,8 @@ mod tests { create_test_provider_factory, create_test_provider_factory_with_chain_spec, MockNodeTypesWithDB, }, - BlockWriter, CanonChainTracker, ProviderFactory, SaveBlocksMode, + BlockWriter, CanonChainTracker, ProviderFactory, SaveBlocksMode, SaveBlocksPlan, + SaveBlocksPlanStep, }; use alloy_eips::{BlockHashOrNumber, BlockNumHash, BlockNumberOrTag}; use alloy_primitives::{BlockNumber, TxNumber, B256}; @@ -1009,10 +1010,10 @@ mod tests { let provider_rw = hook_provider.database_provider_rw().unwrap(); provider_rw .save_blocks( - std::slice::from_ref(&lowest_memory_block), - 0, - 1, - 0, + &SaveBlocksPlan::new( + vec![lowest_memory_block], + vec![SaveBlocksPlanStep::new(0..1, Some(1..1), true)], + ), SaveBlocksMode::Full, ) .unwrap(); diff --git a/crates/storage/provider/src/providers/database/mod.rs b/crates/storage/provider/src/providers/database/mod.rs index 0cc2ced3aa6..92e62051743 100644 --- a/crates/storage/provider/src/providers/database/mod.rs +++ b/crates/storage/provider/src/providers/database/mod.rs @@ -51,6 +51,9 @@ pub use provider::{ CommitOrder, DatabaseProvider, DatabaseProviderRO, DatabaseProviderRW, SaveBlocksMode, }; +mod save_blocks; +pub use save_blocks::{SaveBlocksPlan, SaveBlocksPlanStep}; + use super::ProviderNodeTypes; use reth_trie::KeccakKeyHasher; diff --git a/crates/storage/provider/src/providers/database/provider.rs b/crates/storage/provider/src/providers/database/provider.rs index 2a6009437e0..d5a26376011 100644 --- a/crates/storage/provider/src/providers/database/provider.rs +++ b/crates/storage/provider/src/providers/database/provider.rs @@ -1,3 +1,4 @@ +use super::SaveBlocksPlan; use crate::{ changesets_utils::StorageRevertsIter, providers::{ @@ -567,16 +568,14 @@ impl DatabaseProvider DatabaseProvider], - trie_catchup_block_count: usize, - full_persist_block_count: usize, - deferred_trie_block_count: usize, + plan: &SaveBlocksPlan, save_mode: SaveBlocksMode, ) -> ProviderResult<()> { + let blocks = &plan.blocks; if blocks.is_empty() { debug!(target: "providers::db", "Attempted to write empty block range"); return Ok(()) } - let full_persist_end = trie_catchup_block_count + full_persist_block_count; - let in_memory_block_start = full_persist_end + deferred_trie_block_count; - if in_memory_block_start > blocks.len() { - return Err(ProviderError::Database(reth_db_api::DatabaseError::Other(format!( - "save block plan ({trie_catchup_block_count} catchup + {full_persist_block_count} full + {deferred_trie_block_count} deferred) exceeds block count {}", - blocks.len() - )))) - } + let persist_rest_range = plan.persist_rest_range(); + let persist_rest_blocks = + persist_rest_range.as_ref().map(|range| &blocks[range.clone()]).unwrap_or(&[]); + let state_trie_blocks = plan + .steps + .iter() + .filter(|step| step.persists_state_trie()) + .flat_map(|step| blocks[step.block_range.clone()].iter()) + .map(|block| block.trie_data()) + .collect::>(); - let trie_catchup_blocks = &blocks[..trie_catchup_block_count]; - let full_persist_blocks = &blocks[trie_catchup_block_count..full_persist_end]; - let deferred_trie_blocks = &blocks[full_persist_end..in_memory_block_start]; - let non_trie_blocks = &blocks[trie_catchup_block_count..in_memory_block_start]; + let mut state_trie_masking_ranges = plan + .steps + .iter() + .filter_map(|step| step.state_trie_masking_range.clone()) + .filter(|range| !range.is_empty()) + .collect::>(); + state_trie_masking_ranges.sort_unstable_by_key(|range| (range.start, range.end)); + state_trie_masking_ranges.dedup_by(|left, right| left == right); + let state_trie_masking_blocks = state_trie_masking_ranges + .iter() + .flat_map(|range| blocks[range.clone()].iter()) + .map(|block| block.trie_data()) + .collect::>(); let total_start = Instant::now(); let block_count = blocks.len() as u64; let first_number = blocks.first().unwrap().recovered_block().number(); - let last_non_trie_block_number = non_trie_blocks - .last() - .or_else(|| trie_catchup_blocks.last()) - .expect("checked non-empty block range") - .recovered_block() - .number(); + let last_block_number = plan.last_block().expect("checked non-empty block range").number; debug!(target: "providers::db", block_count, "Writing blocks and execution data to storage"); - let tx_nums: Vec = if non_trie_blocks.is_empty() { + let tx_nums: Vec = if persist_rest_blocks.is_empty() { Vec::new() } else { let first_tx_num = self @@ -640,9 +641,9 @@ impl DatabaseProvider DatabaseProvider DatabaseProvider DatabaseProvider DatabaseProvider DatabaseProvider DatabaseProvider DatabaseProvider DatabaseProvider = trie_catchup_blocks - .iter() - .chain(full_persist_blocks.iter()) - .map(|block| block.trie_data()) - .collect(); // Only blocks whose non-trie outputs are durably written in this call can mask // trie writes. A fully in-memory suffix must not suppress the durable trie // frontier because its changesets are not on disk yet. - let trie_masking_data: Vec<_> = - deferred_trie_blocks.iter().map(|block| block.trie_data()).collect(); - let start = Instant::now(); - if !trie_persist_data.is_empty() { - let merged_hashed_state = HashedPostStateSorted::disjoint_by_keys( - trie_persist_data.iter().map(|data| data.hashed_state.as_ref()).collect(), - trie_masking_data.iter().map(|data| data.hashed_state.as_ref()).collect(), + if !state_trie_blocks.is_empty() { + let merged_hashed_state = HashedPostStateSorted::disjointed_merge_batch( + state_trie_blocks.iter().map(|data| data.hashed_state.as_ref()).collect(), + state_trie_masking_blocks + .iter() + .map(|data| data.hashed_state.as_ref()) + .collect(), ); if !merged_hashed_state.is_empty() { self.write_hashed_state(&merged_hashed_state)?; @@ -824,10 +821,13 @@ impl DatabaseProvider DatabaseProvider DatabaseProvider DatabaseProvider DatabaseProvider BlockWriter // Delegate to save_blocks with BlocksOnly mode (skips receipts/state/trie) self.save_blocks( - std::slice::from_ref(&executed_block), - 0, - 1, - 0, + &SaveBlocksPlan::new( + vec![executed_block], + vec![super::SaveBlocksPlanStep::new(0..1, None, true)], + ), SaveBlocksMode::BlocksOnly, )?; @@ -4097,6 +4092,7 @@ impl StoragePath for DatabaseProvider { mod tests { use super::*; use crate::{ + providers::database::SaveBlocksPlanStep, test_utils::{blocks::BlockchainTestData, create_test_provider_factory}, BlockWriter, }; @@ -4107,7 +4103,7 @@ mod tests { }; use reth_chain_state::{test_utils::TestBlockBuilder, ComputedTrieData, ExecutedBlock}; use reth_db_api::models::StorageSettings; - use reth_ethereum_primitives::Receipt; + use reth_ethereum_primitives::{EthPrimitives, Receipt}; use reth_execution_types::{AccountRevertInit, BlockExecutionOutput, BlockExecutionResult}; use reth_primitives_traits::SealedBlock; use reth_storage_api::MetadataWriter; @@ -4122,6 +4118,28 @@ mod tests { time::Duration, }; + fn full_save_plan( + blocks: impl IntoIterator>, + ) -> SaveBlocksPlan { + let blocks = blocks.into_iter().collect::>(); + let full_range = 0..blocks.len(); + SaveBlocksPlan::new( + blocks, + vec![SaveBlocksPlanStep::new( + full_range.clone(), + Some(full_range.end..full_range.end), + true, + )], + ) + } + + fn partial_save_plan( + blocks: impl IntoIterator>, + steps: Vec, + ) -> SaveBlocksPlan { + SaveBlocksPlan::new(blocks.into_iter().collect(), steps) + } + #[test] fn test_receipts_by_block_range_empty_range() { let factory = create_test_provider_factory(); @@ -4650,7 +4668,10 @@ mod tests { let provider_rw = factory.provider_rw().unwrap(); provider_rw - .save_blocks(std::slice::from_ref(&genesis_executed), 0, 1, 0, SaveBlocksMode::Full) + .save_blocks( + &full_save_plan(std::slice::from_ref(&genesis_executed).to_vec()), + SaveBlocksMode::Full, + ) .unwrap(); provider_rw.commit().unwrap(); @@ -4712,36 +4733,30 @@ mod tests { ); let full_persist_trie_updates = TrieUpdatesSorted::new( vec![ - (kept_account_node.clone(), Some(branch(0b0000_1111_0000_1111))), - (deferred_masked_account_node.clone(), Some(branch(0b1111_0000_1111_0000))), - (in_memory_overlap_account_node.clone(), Some(branch(0b1010_1010_1010_1010))), + (kept_account_node, Some(branch(0b0000_1111_0000_1111))), + (deferred_masked_account_node, Some(branch(0b1111_0000_1111_0000))), + (in_memory_overlap_account_node, Some(branch(0b1010_1010_1010_1010))), ], B256Map::from_iter([ ( kept_storage, StorageTrieUpdatesSorted { is_deleted: false, - storage_nodes: vec![(kept_storage_node.clone(), Some(branch(0b1010)))], + storage_nodes: vec![(kept_storage_node, Some(branch(0b1010)))], }, ), ( deferred_masked_storage, StorageTrieUpdatesSorted { is_deleted: false, - storage_nodes: vec![( - deferred_masked_storage_node.clone(), - Some(branch(0b0101)), - )], + storage_nodes: vec![(deferred_masked_storage_node, Some(branch(0b0101)))], }, ), ( in_memory_overlap_storage, StorageTrieUpdatesSorted { is_deleted: false, - storage_nodes: vec![( - in_memory_overlap_storage_node.clone(), - Some(branch(0b0110)), - )], + storage_nodes: vec![(in_memory_overlap_storage_node, Some(branch(0b0110)))], }, ), ]), @@ -4768,15 +4783,12 @@ mod tests { )]), ); let deferred_trie_updates = TrieUpdatesSorted::new( - vec![(deferred_masked_account_node.clone(), Some(branch(0b0011_0011)))], + vec![(deferred_masked_account_node, Some(branch(0b0011_0011)))], B256Map::from_iter([( deferred_masked_storage, StorageTrieUpdatesSorted { is_deleted: false, - storage_nodes: vec![( - deferred_masked_storage_node.clone(), - Some(branch(0b1100)), - )], + storage_nodes: vec![(deferred_masked_storage_node, Some(branch(0b1100)))], }, )]), ); @@ -4814,28 +4826,22 @@ mod tests { ); let in_memory_only_trie_updates = TrieUpdatesSorted::new( vec![ - (in_memory_overlap_account_node.clone(), Some(branch(0b0101_0101))), - (in_memory_only_account_node.clone(), Some(branch(0b1111_0000))), + (in_memory_overlap_account_node, Some(branch(0b0101_0101))), + (in_memory_only_account_node, Some(branch(0b1111_0000))), ], B256Map::from_iter([ ( in_memory_overlap_storage, StorageTrieUpdatesSorted { is_deleted: false, - storage_nodes: vec![( - in_memory_overlap_storage_node.clone(), - Some(branch(0b1001)), - )], + storage_nodes: vec![(in_memory_overlap_storage_node, Some(branch(0b1001)))], }, ), ( in_memory_only_storage, StorageTrieUpdatesSorted { is_deleted: false, - storage_nodes: vec![( - in_memory_only_storage_node.clone(), - Some(branch(0b1111)), - )], + storage_nodes: vec![(in_memory_only_storage_node, Some(branch(0b1111)))], }, ), ]), @@ -4852,7 +4858,18 @@ mod tests { let provider_rw = factory.provider_rw().unwrap(); let blocks = vec![full_persist_block, deferred_trie_block, in_memory_only_block]; - provider_rw.save_blocks(&blocks, 0, 1, 1, SaveBlocksMode::Full).unwrap(); + provider_rw + .save_blocks( + &partial_save_plan( + blocks, + vec![ + SaveBlocksPlanStep::new(0..1, Some(1..2), true), + SaveBlocksPlanStep::new(1..2, None, true), + ], + ), + SaveBlocksMode::Full, + ) + .unwrap(); provider_rw.commit().unwrap(); let provider = factory.provider().unwrap(); @@ -4894,10 +4911,7 @@ mod tests { .is_none()); let mut account_trie = tx.cursor_read::().unwrap(); - assert!(account_trie - .seek_exact(StoredNibbles(kept_account_node.clone())) - .unwrap() - .is_some()); + assert!(account_trie.seek_exact(StoredNibbles(kept_account_node)).unwrap().is_some()); assert!(account_trie .seek_exact(StoredNibbles(deferred_masked_account_node)) .unwrap() @@ -4953,16 +4967,32 @@ mod tests { let provider_rw = factory.provider_rw().unwrap(); provider_rw - .save_blocks(std::slice::from_ref(&genesis), 0, 1, 0, SaveBlocksMode::Full) + .save_blocks( + &full_save_plan(std::slice::from_ref(&genesis).to_vec()), + SaveBlocksMode::Full, + ) .unwrap(); provider_rw.commit().unwrap(); let provider_rw = factory.provider_rw().unwrap(); - provider_rw.save_blocks(&blocks[..2], 0, 2, 0, SaveBlocksMode::Full).unwrap(); + provider_rw + .save_blocks(&full_save_plan(blocks[..2].to_vec()), SaveBlocksMode::Full) + .unwrap(); provider_rw.commit().unwrap(); let provider_rw = factory.provider_rw().unwrap(); - provider_rw.save_blocks(&blocks, 2, 0, 2, SaveBlocksMode::Full).unwrap(); + provider_rw + .save_blocks( + &partial_save_plan( + blocks, + vec![ + SaveBlocksPlanStep::new(0..2, Some(2..4), false), + SaveBlocksPlanStep::new(2..4, None, true), + ], + ), + SaveBlocksMode::Full, + ) + .unwrap(); provider_rw.commit().unwrap(); let provider = factory.provider().unwrap(); @@ -5571,7 +5601,10 @@ mod tests { ); let provider_rw = factory.provider_rw().unwrap(); provider_rw - .save_blocks(std::slice::from_ref(&genesis_executed), 0, 1, 0, SaveBlocksMode::Full) + .save_blocks( + &full_save_plan(std::slice::from_ref(&genesis_executed).to_vec()), + SaveBlocksMode::Full, + ) .unwrap(); provider_rw.commit().unwrap(); @@ -5644,7 +5677,7 @@ mod tests { } let provider_rw = factory.provider_rw().unwrap(); - provider_rw.save_blocks(&blocks, 0, blocks.len(), 0, SaveBlocksMode::Full).unwrap(); + provider_rw.save_blocks(&full_save_plan(blocks), SaveBlocksMode::Full).unwrap(); provider_rw.commit().unwrap(); let provider = factory.provider().unwrap(); diff --git a/crates/storage/provider/src/providers/database/save_blocks.rs b/crates/storage/provider/src/providers/database/save_blocks.rs new file mode 100644 index 00000000000..185136cedff --- /dev/null +++ b/crates/storage/provider/src/providers/database/save_blocks.rs @@ -0,0 +1,90 @@ +use alloy_eips::BlockNumHash; +use reth_chain_state::ExecutedBlock; +use reth_ethereum_primitives::EthPrimitives; +use reth_primitives_traits::NodePrimitives; +use std::ops::Range; + +/// A single persistence step over a contiguous region of [`SaveBlocksPlan::blocks`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SaveBlocksPlanStep { + /// Range of [`SaveBlocksPlan::blocks`] covered by this step. + pub block_range: Range, + /// Optional range of blocks whose state/trie updates should be used to mask this step's + /// durable state/trie writes. + /// + /// `Some(empty_range)` means persist state/trie without any masking. `None` means skip + /// durable state/trie persistence for this step. + pub state_trie_masking_range: Option>, + /// Whether to persist non-state/trie data for this step. + pub persist_rest: bool, +} + +impl SaveBlocksPlanStep { + /// Creates a new persistence step. + pub const fn new( + block_range: Range, + state_trie_masking_range: Option>, + persist_rest: bool, + ) -> Self { + Self { block_range, state_trie_masking_range, persist_rest } + } + + /// Returns `true` if this step persists state/trie data. + pub const fn persists_state_trie(&self) -> bool { + self.state_trie_masking_range.is_some() + } +} + +/// Plan for a single `save_blocks` persistence cycle. +#[derive(Debug, Clone)] +pub struct SaveBlocksPlan { + /// Canonical blocks covered by this plan. + pub blocks: Vec>, + /// Ordered persistence steps over [`Self::blocks`]. + pub steps: Vec, +} + +impl SaveBlocksPlan { + /// Creates a new save plan. + pub const fn new(blocks: Vec>, steps: Vec) -> Self { + Self { blocks, steps } + } + + /// Returns `true` if the plan contains no blocks to persist. + pub fn is_empty(&self) -> bool { + self.last_block().is_none() + } + + /// Returns the highest block covered by this plan. + pub fn last_block(&self) -> Option { + let last_index = + self.steps.iter().rev().find_map(|step| step.block_range.end.checked_sub(1))?; + self.blocks.get(last_index).map(|block| block.recovered_block().num_hash()) + } + + /// Returns the highest block whose state/trie data is durably persisted by this plan. + pub fn last_state_trie_block(&self) -> Option { + let last_index = self + .steps + .iter() + .rev() + .find(|step| step.persists_state_trie())? + .block_range + .end + .checked_sub(1)?; + self.blocks.get(last_index).map(|block| block.recovered_block().num_hash()) + } + + /// Returns the contiguous range of blocks whose non-state/trie outputs are persisted. + pub fn persist_rest_range(&self) -> Option> { + let mut ranges = + self.steps.iter().filter(|step| step.persist_rest).map(|step| &step.block_range); + let first = ranges.next()?.clone(); + let merged = ranges.fold(first, |mut merged, range| { + debug_assert_eq!(merged.end, range.start, "persist_rest steps must be contiguous"); + merged.end = range.end; + merged + }); + Some(merged) + } +} diff --git a/crates/storage/provider/src/providers/state/overlay.rs b/crates/storage/provider/src/providers/state/overlay.rs index 4d74cb0efd3..19ddcd6fc86 100644 --- a/crates/storage/provider/src/providers/state/overlay.rs +++ b/crates/storage/provider/src/providers/state/overlay.rs @@ -212,8 +212,12 @@ impl OverlayBuilder { .ok_or(ProviderError::BlockHashNotFound(self.anchor_hash)) } - /// Returns the highest block whose trie state is durably available in the database. - fn get_db_trie_tip_block(&self, provider: &Provider) -> ProviderResult + /// Returns the highest blocks whose state/trie data and non-state/trie data are durably + /// available in the database. + fn get_db_tip_blocks( + &self, + provider: &Provider, + ) -> ProviderResult<(BlockNumHash, BlockNumHash)> where Provider: StageCheckpointReader + BlockNumReader, { @@ -224,25 +228,17 @@ impl OverlayBuilder { .finish_stage_checkpoint() .and_then(|finish| finish.partial_state_trie) .unwrap_or(checkpoint.block_number); - let hash = provider - .convert_number(block_number.into())? - .ok_or_else(|| ProviderError::HeaderNotFound(block_number.into()))?; - Ok(BlockNumHash::new(block_number, hash)) - } - - /// Returns the highest block whose non-trie state is durably available in the database. - fn get_db_finish_tip_block(&self, provider: &Provider) -> ProviderResult - where - Provider: StageCheckpointReader + BlockNumReader, - { - let checkpoint = provider.get_stage_checkpoint(StageId::Finish)?.ok_or_else(|| { - ProviderError::InsufficientChangesets { requested: 0, available: 0..=0 } - })?; - let block_number = checkpoint.block_number; - let hash = provider + let state_trie_tip_hash = provider .convert_number(block_number.into())? .ok_or_else(|| ProviderError::HeaderNotFound(block_number.into()))?; - Ok(BlockNumHash::new(block_number, hash)) + let finish_tip_number = checkpoint.block_number; + let finish_tip_hash = provider + .convert_number(finish_tip_number.into())? + .ok_or_else(|| ProviderError::HeaderNotFound(finish_tip_number.into()))?; + Ok(( + BlockNumHash::new(block_number, state_trie_tip_hash), + BlockNumHash::new(finish_tip_number, finish_tip_hash), + )) } /// Returns whether or not it is required to collect reverts, and validates that there are @@ -253,24 +249,16 @@ impl OverlayBuilder { fn reverts_required( &self, provider: &Provider, - trie_tip_block: BlockNumHash, + state_trie_tip_block: BlockNumHash, finish_tip_block: BlockNumHash, ) -> ProviderResult>> where Provider: BlockNumReader + PruneCheckpointReader, { - // If the anchor is the current durable trie frontier then there won't be any reverts + // If the anchor is the current durable state/trie frontier then there won't be any + // reverts // necessary. - if trie_tip_block.hash == self.anchor_hash { - return Ok(None) - } - - // If the durable trie frontier has moved forward into the `LazyOverlay` then we still - // don't need to revert, because the overlay can trim off the persisted prefix and keep the - // remaining in-memory suffix above that trie frontier. - if let Some(OverlaySource::Lazy(lazy_overlay)) = &self.overlay_source && - lazy_overlay.has_anchor_hash(trie_tip_block.hash) - { + if state_trie_tip_block.hash == self.anchor_hash { return Ok(None) } @@ -295,33 +283,28 @@ impl OverlayBuilder { }); } - // The durable trie frontier is still required to serve the requested anchor directly. - // When the anchor lies in the deferred-trie window we need to undo the fully durable - // suffix from Finish back to the anchor before re-applying any in-memory suffix above it. - if anchor_number > trie_tip_block.number { - if anchor_number == finish_tip_block.number { - return Err(ProviderError::InsufficientChangesets { - requested: anchor_number, - available: lower_bound..=finish_tip_block.number.saturating_sub(1), - }) - } - return Ok(Some(anchor_number + 1..=finish_tip_block.number)) + if anchor_number > state_trie_tip_block.number { + return Err(ProviderError::InsufficientChangesets { + requested: anchor_number, + available: lower_bound..=state_trie_tip_block.number, + }) } - Ok(Some(anchor_number + 1..=trie_tip_block.number)) + Ok(Some(anchor_number + 1..=finish_tip_block.number)) } - /// Calculates a new [`Overlay`] given a transaction and the current trie frontier. + /// Calculates a new [`Overlay`] given a transaction and the current durable state/trie + /// frontier. #[instrument( level = "debug", target = "providers::state::overlay", skip_all, - fields(?trie_tip_block, anchor_hash = ?self.anchor_hash) + fields(?state_trie_tip_block, ?finish_tip_block, anchor_hash = ?self.anchor_hash) )] fn calculate_overlay( &self, provider: &Provider, - trie_tip_block: BlockNumHash, + state_trie_tip_block: BlockNumHash, finish_tip_block: BlockNumHash, ) -> ProviderResult where @@ -343,7 +326,7 @@ impl OverlayBuilder { // Collect any reverts which are required to bring the DB view back to the anchor hash. let (trie_updates, hashed_post_state) = if let Some(revert_blocks) = - self.reverts_required(provider, trie_tip_block, finish_tip_block)? + self.reverts_required(provider, state_trie_tip_block, finish_tip_block)? { debug!( target: "providers::state::overlay", @@ -412,9 +395,9 @@ impl OverlayBuilder { (trie_updates, hashed_state_updates) } else { - // If no reverts are needed then we can assume that the current durable trie frontier is - // the anchor hash or overlaps with the `LazyOverlay`. Use overlays directly. - let (trie_updates, hashed_state) = self.resolve_overlays(trie_tip_block.hash)?; + // If no reverts are needed then the requested anchor is exactly the durable + // state/trie frontier. Use overlays directly from that frontier. + let (trie_updates, hashed_state) = self.resolve_overlays(state_trie_tip_block.hash)?; retrieve_trie_reverts_duration = Duration::ZERO; retrieve_hashed_state_reverts_duration = Duration::ZERO; @@ -449,9 +432,8 @@ impl OverlayBuilder { + BlockNumReader + StorageSettingsCache, { - let trie_tip_block = self.get_db_trie_tip_block(provider)?; - let finish_tip_block = self.get_db_finish_tip_block(provider)?; - self.calculate_overlay(provider, trie_tip_block, finish_tip_block) + let (state_trie_tip_block, finish_tip_block) = self.get_db_tip_blocks(provider)?; + self.calculate_overlay(provider, state_trie_tip_block, finish_tip_block) } } @@ -465,7 +447,7 @@ pub struct OverlayStateProviderFactory { factory: F, /// Overlay builder containing the configuration and overlay calculation logic. overlay_builder: OverlayBuilder, - /// A cache which maps `(trie_tip_hash, finish_tip_hash) -> Overlay`. + /// A cache which maps `(state_trie_tip_hash, finish_tip_hash) -> Overlay`. /// /// Under partial persistence the overlay depends on both the durable trie frontier and the /// fully durable Finish frontier, so both hashes are part of the cache key. @@ -502,8 +484,8 @@ impl OverlayStateProviderFactory { self } - /// Fetches an [`Overlay`] from the cache based on the current trie frontier. If there is no - /// cached value then this calculates the [`Overlay`] and populates the cache. + /// Fetches an [`Overlay`] from the cache based on the current durable frontiers. If there is + /// no cached value then this calculates the [`Overlay`] and populates the cache. #[instrument(level = "debug", target = "providers::state::overlay", skip_all)] fn get_overlay(&self, provider: &Provider) -> ProviderResult where @@ -515,18 +497,19 @@ impl OverlayStateProviderFactory { + BlockNumReader + StorageSettingsCache, { - let trie_tip_block = self.overlay_builder.get_db_trie_tip_block(provider)?; - let finish_tip_block = self.overlay_builder.get_db_finish_tip_block(provider)?; - - let overlay = match self.overlay_cache.entry((trie_tip_block.hash, finish_tip_block.hash)) { - dashmap::Entry::Occupied(entry) => entry.get().clone(), - dashmap::Entry::Vacant(entry) => { - self.overlay_builder.metrics.overlay_cache_misses.increment(1); - let overlay = self.overlay_builder.build_overlay(provider)?; - entry.insert(overlay.clone()); - overlay - } - }; + let (state_trie_tip_block, finish_tip_block) = + self.overlay_builder.get_db_tip_blocks(provider)?; + + let overlay = + match self.overlay_cache.entry((state_trie_tip_block.hash, finish_tip_block.hash)) { + dashmap::Entry::Occupied(entry) => entry.get().clone(), + dashmap::Entry::Vacant(entry) => { + self.overlay_builder.metrics.overlay_cache_misses.increment(1); + let overlay = self.overlay_builder.build_overlay(provider)?; + entry.insert(overlay.clone()); + overlay + } + }; Ok(overlay) } @@ -688,7 +671,10 @@ where #[cfg(test)] mod tests { use super::*; - use crate::{test_utils::create_test_provider_factory, BlockWriter, SaveBlocksMode}; + use crate::{ + test_utils::create_test_provider_factory, BlockWriter, SaveBlocksMode, SaveBlocksPlan, + SaveBlocksPlanStep, + }; use alloy_primitives::{B256, U256}; use reth_chain_state::{test_utils::TestBlockBuilder, ComputedTrieData, ExecutedBlock}; use reth_primitives_traits::Account; @@ -697,6 +683,28 @@ mod tests { use reth_trie::{updates::TrieUpdatesSorted, HashedPostState, HashedStorage}; use std::sync::Arc; + fn full_save_plan( + blocks: impl IntoIterator>, + ) -> SaveBlocksPlan { + let blocks = blocks.into_iter().collect::>(); + let full_range = 0..blocks.len(); + SaveBlocksPlan::new( + blocks, + vec![SaveBlocksPlanStep::new( + full_range.clone(), + Some(full_range.end..full_range.end), + true, + )], + ) + } + + fn partial_save_plan( + blocks: impl IntoIterator>, + steps: Vec, + ) -> SaveBlocksPlan { + SaveBlocksPlan::new(blocks.into_iter().collect(), steps) + } + fn with_unique_state( block: &ExecutedBlock, id: u8, @@ -731,20 +739,20 @@ mod tests { .map(|(index, block)| with_unique_state(&block, index as u8 + 1)) .collect::>(); - let trie_tip = &blocks[1]; + let state_trie_tip = &blocks[1]; let finish_tip = &blocks[3]; let lazy_overlay_blocks = vec![blocks[4].clone(), blocks[3].clone(), blocks[2].clone()]; let provider_rw = factory.provider_rw().unwrap(); provider_rw.insert_block(blocks[0].recovered_block()).unwrap(); - provider_rw.insert_block(trie_tip.recovered_block()).unwrap(); + provider_rw.insert_block(state_trie_tip.recovered_block()).unwrap(); provider_rw.insert_block(blocks[2].recovered_block()).unwrap(); provider_rw.insert_block(finish_tip.recovered_block()).unwrap(); provider_rw .save_stage_checkpoint( StageId::Finish, StageCheckpoint::new(finish_tip.block_number()).with_finish_stage_checkpoint( - FinishCheckpoint { partial_state_trie: Some(trie_tip.block_number()) }, + FinishCheckpoint { partial_state_trie: Some(state_trie_tip.block_number()) }, ), ) .unwrap(); @@ -752,7 +760,7 @@ mod tests { let provider = factory.provider().unwrap(); let overlay = OverlayBuilder::::new( - trie_tip.recovered_block().hash(), + state_trie_tip.recovered_block().hash(), ChangesetCache::new(), ) .with_lazy_overlay(Some(LazyOverlay::new(lazy_overlay_blocks))) @@ -763,7 +771,7 @@ mod tests { } #[test] - fn build_overlay_allows_anchor_between_trie_frontier_and_finish() { + fn build_overlay_rejects_anchor_between_state_trie_frontier_and_finish() { let factory = create_test_provider_factory(); let mut block_builder = TestBlockBuilder::eth().with_state(); @@ -772,33 +780,36 @@ mod tests { let provider_rw = factory.provider_rw().unwrap(); provider_rw - .save_blocks(std::slice::from_ref(&genesis), 0, 1, 0, SaveBlocksMode::Full) + .save_blocks( + &full_save_plan(std::slice::from_ref(&genesis).to_vec()), + SaveBlocksMode::Full, + ) .unwrap(); provider_rw.commit().unwrap(); let provider_rw = factory.provider_rw().unwrap(); - provider_rw.save_blocks(&blocks, 0, 1, 2, SaveBlocksMode::Full).unwrap(); + provider_rw + .save_blocks( + &partial_save_plan( + blocks.clone(), + vec![ + SaveBlocksPlanStep::new(0..1, Some(1..3), true), + SaveBlocksPlanStep::new(1..3, None, true), + ], + ), + SaveBlocksMode::Full, + ) + .unwrap(); provider_rw.commit().unwrap(); let provider = factory.provider().unwrap(); - let trie_tip = blocks[0].recovered_block().hash(); let anchor = blocks[1].recovered_block().hash(); - - let full_overlay = OverlayBuilder::::new(trie_tip, ChangesetCache::new()) - .with_lazy_overlay(Some(LazyOverlay::new(vec![blocks[2].clone(), blocks[1].clone()]))) - .build_overlay(&provider) - .unwrap(); - - let deferred_overlay = OverlayBuilder::::new(anchor, ChangesetCache::new()) + let err = OverlayBuilder::::new(anchor, ChangesetCache::new()) .with_lazy_overlay(Some(LazyOverlay::new(vec![blocks[2].clone()]))) .build_overlay(&provider) - .unwrap(); + .unwrap_err(); - assert_eq!( - deferred_overlay.hashed_post_state.as_ref(), - full_overlay.hashed_post_state.as_ref() - ); - assert_eq!(deferred_overlay.trie_updates.as_ref(), full_overlay.trie_updates.as_ref()); + assert!(matches!(err, ProviderError::InsufficientChangesets { .. })); } #[test] @@ -811,12 +822,26 @@ mod tests { let provider_rw = factory.provider_rw().unwrap(); provider_rw - .save_blocks(std::slice::from_ref(&genesis), 0, 1, 0, SaveBlocksMode::Full) + .save_blocks( + &full_save_plan(std::slice::from_ref(&genesis).to_vec()), + SaveBlocksMode::Full, + ) .unwrap(); provider_rw.commit().unwrap(); let provider_rw = factory.provider_rw().unwrap(); - provider_rw.save_blocks(&blocks, 0, 1, 2, SaveBlocksMode::Full).unwrap(); + provider_rw + .save_blocks( + &partial_save_plan( + blocks.clone(), + vec![ + SaveBlocksPlanStep::new(0..1, Some(1..3), true), + SaveBlocksPlanStep::new(1..3, None, true), + ], + ), + SaveBlocksMode::Full, + ) + .unwrap(); provider_rw.commit().unwrap(); let provider = factory.provider().unwrap(); diff --git a/crates/trie/common/src/hashed_state.rs b/crates/trie/common/src/hashed_state.rs index f12aa1814cd..b5bc21b385e 100644 --- a/crates/trie/common/src/hashed_state.rs +++ b/crates/trie/common/src/hashed_state.rs @@ -691,37 +691,62 @@ impl HashedPostStateSorted { Self { accounts, storages } } - /// Merges the left-hand states and removes any top-level keys present on the right. + /// Merges the batch and removes any top-level keys present in the mask. /// - /// For duplicate keys on the left, later items take precedence over earlier ones. The order of - /// the right-hand side does not matter. - pub fn disjoint_by_keys<'a>(left: Vec<&'a Self>, right: Vec<&'a Self>) -> Self { + /// For duplicate keys in the batch, later items take precedence over earlier ones. The order + /// of the mask does not matter. + pub fn disjointed_merge_batch<'a>(batch: Vec<&'a Self>, mask: Vec<&'a Self>) -> Self { let accounts = kway_merge_disjoint_sorted( - left.iter().map(|item| item.accounts.len()).sum(), - left.iter().rev().map(|item| item.accounts.as_slice()), - right.iter().map(|item| item.accounts.as_slice()), + batch.iter().map(|item| item.accounts.len()).sum(), + batch.iter().rev().map(|item| item.accounts.as_slice()), + mask.iter().map(|item| item.accounts.as_slice()), ); + struct StorageAcc<'a> { + wiped: bool, + sealed: bool, + slices: Vec<&'a [(B256, U256)]>, + } + let mut storages = B256Map::with_capacity_and_hasher( - left.iter().map(|item| item.storages.len()).sum(), + batch.iter().map(|item| item.storages.len()).sum(), Default::default(), ); - for item in left { + for item in batch.iter().rev() { for (hashed_address, storage) in &item.storages { - storages - .entry(*hashed_address) - .and_modify(|existing: &mut HashedStorageSorted| existing.extend_ref(storage)) - .or_insert_with(|| storage.clone()); + let entry = storages.entry(*hashed_address).or_insert_with(|| StorageAcc { + wiped: false, + sealed: false, + slices: Vec::new(), + }); + + if entry.sealed { + continue; + } + + entry.slices.push(storage.storage_slots.as_slice()); + if storage.wiped { + entry.wiped = true; + entry.sealed = true; + } } } - for item in right { + for item in mask { for hashed_address in item.storages.keys() { storages.remove(hashed_address); } } + let storages = storages + .into_iter() + .map(|(hashed_address, entry)| { + let storage_slots = kway_merge_sorted(entry.slices); + (hashed_address, HashedStorageSorted { wiped: entry.wiped, storage_slots }) + }) + .collect(); + Self { accounts, storages } } @@ -1569,7 +1594,7 @@ mod tests { } #[test] - fn test_hashed_post_state_sorted_disjoint_by_keys() { + fn test_hashed_post_state_sorted_disjointed_merge_batch() { fn account(nonce: u64) -> Account { Account { nonce, balance: U256::ZERO, bytecode_hash: None } } @@ -1625,7 +1650,7 @@ mod tests { B256Map::default(), ); - let result = HashedPostStateSorted::disjoint_by_keys( + let result = HashedPostStateSorted::disjointed_merge_batch( vec![&older, &newer], vec![&remove_b, &remove_a], ); @@ -1643,7 +1668,7 @@ mod tests { } #[test] - fn test_hashed_post_state_sorted_disjoint_by_keys_removes_overlapping_left_key() { + fn test_hashed_post_state_sorted_disjointed_merge_batch_removes_overlapping_batch_key() { fn account(nonce: u64) -> Account { Account { nonce, balance: U256::ZERO, bytecode_hash: None } } @@ -1676,7 +1701,8 @@ mod tests { )]), ); - let result = HashedPostStateSorted::disjoint_by_keys(vec![&older, &newer], vec![&remove]); + let result = + HashedPostStateSorted::disjointed_merge_batch(vec![&older, &newer], vec![&remove]); assert!(result.accounts.is_empty()); assert!(result.storages.is_empty()); diff --git a/crates/trie/common/src/updates.rs b/crates/trie/common/src/updates.rs index 2076db0f493..bbaf916dfce 100644 --- a/crates/trie/common/src/updates.rs +++ b/crates/trie/common/src/updates.rs @@ -711,39 +711,65 @@ impl TrieUpdatesSorted { Self { account_nodes, storage_tries } } - /// Merges the left-hand updates and removes any top-level keys present on the right. + /// Merges the batch and removes any top-level keys present in the mask. /// - /// For duplicate keys on the left, later items take precedence over earlier ones. The order of - /// the right-hand side does not matter. - pub fn disjoint_by_keys<'a>(left: Vec<&'a Self>, right: Vec<&'a Self>) -> Self { + /// For duplicate keys in the batch, later items take precedence over earlier ones. The order + /// of the mask does not matter. + pub fn disjointed_merge_batch<'a>(batch: Vec<&'a Self>, mask: Vec<&'a Self>) -> Self { let account_nodes = kway_merge_disjoint_sorted( - left.iter().map(|item| item.account_nodes.len()).sum(), - left.iter().rev().map(|item| item.account_nodes.as_slice()), - right.iter().map(|item| item.account_nodes.as_slice()), + batch.iter().map(|item| item.account_nodes.len()).sum(), + batch.iter().rev().map(|item| item.account_nodes.as_slice()), + mask.iter().map(|item| item.account_nodes.as_slice()), ); + struct StorageAcc<'a> { + is_deleted: bool, + sealed: bool, + slices: Vec<&'a [(Nibbles, Option)]>, + } + let mut storage_tries = B256Map::with_capacity_and_hasher( - left.iter().map(|item| item.storage_tries.len()).sum(), + batch.iter().map(|item| item.storage_tries.len()).sum(), Default::default(), ); - for item in left { + for item in batch.iter().rev() { for (hashed_address, storage_trie) in &item.storage_tries { - storage_tries - .entry(*hashed_address) - .and_modify(|existing: &mut StorageTrieUpdatesSorted| { - existing.extend_ref(storage_trie) - }) - .or_insert_with(|| storage_trie.clone()); + let entry = storage_tries.entry(*hashed_address).or_insert_with(|| StorageAcc { + is_deleted: false, + sealed: false, + slices: Vec::new(), + }); + + if entry.sealed { + continue; + } + + entry.slices.push(storage_trie.storage_nodes.as_slice()); + if storage_trie.is_deleted { + entry.is_deleted = true; + entry.sealed = true; + } } } - for item in right { + for item in mask { for hashed_address in item.storage_tries.keys() { storage_tries.remove(hashed_address); } } + let storage_tries = storage_tries + .into_iter() + .map(|(hashed_address, entry)| { + let storage_nodes = kway_merge_sorted(entry.slices); + ( + hashed_address, + StorageTrieUpdatesSorted { is_deleted: entry.is_deleted, storage_nodes }, + ) + }) + .collect(); + Self::new(account_nodes, storage_tries) } } @@ -1014,7 +1040,7 @@ mod tests { } #[test] - fn test_trie_updates_sorted_disjoint_by_keys() { + fn test_trie_updates_sorted_disjointed_merge_batch() { let kept_node = Nibbles::from_nibbles_unchecked([0x01]); let removed_node = Nibbles::from_nibbles_unchecked([0x02]); let kept_storage = B256::from([3; 32]); @@ -1063,8 +1089,10 @@ mod tests { B256Map::default(), ); - let result = - TrieUpdatesSorted::disjoint_by_keys(vec![&older, &newer], vec![&remove_b, &remove_a]); + let result = TrieUpdatesSorted::disjointed_merge_batch( + vec![&older, &newer], + vec![&remove_b, &remove_a], + ); assert_eq!(result.account_nodes, vec![(kept_node, None)]); assert_eq!(result.storage_tries.len(), 1); @@ -1079,7 +1107,7 @@ mod tests { } #[test] - fn test_trie_updates_sorted_disjoint_by_keys_removes_overlapping_left_key() { + fn test_trie_updates_sorted_disjointed_merge_batch_removes_overlapping_batch_key() { let overlapping_node = Nibbles::from_nibbles_unchecked([0x03]); let overlapping_storage = B256::from([5; 32]); let slot = Nibbles::from_nibbles_unchecked([0x0c]); @@ -1108,7 +1136,7 @@ mod tests { B256Map::from_iter([(overlapping_storage, StorageTrieUpdatesSorted::default())]), ); - let result = TrieUpdatesSorted::disjoint_by_keys(vec![&older, &newer], vec![&remove]); + let result = TrieUpdatesSorted::disjointed_merge_batch(vec![&older, &newer], vec![&remove]); assert!(result.account_nodes.is_empty()); assert!(result.storage_tries.is_empty()); diff --git a/docs/vocs/docs/pages/cli/reth/node.mdx b/docs/vocs/docs/pages/cli/reth/node.mdx index dfe65420f3b..a001104d245 100644 --- a/docs/vocs/docs/pages/cli/reth/node.mdx +++ b/docs/vocs/docs/pages/cli/reth/node.mdx @@ -941,17 +941,17 @@ Engine: To persist blocks as fast as the node receives them, set this value to zero. This will cause more frequent DB writes. - [default: 10] + [default: 2] --engine.persistence-backpressure-threshold Configure the maximum canonical-minus-persisted gap before engine API processing stalls. This value must be greater than `--engine.persistence-threshold`. - [default: 20] + [default: 16] --engine.deferred-trie-blocks - Configure how many canonical blocks may persist non-trie outputs while deferring durable trie updates + Configure how many of the blocks being persisted should only mask state/trie writes instead of durably persisting their state/trie updates in the current cycle [default: 0] From e71cf3040bc7105ad0ae96649b202ae637d6a8f1 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:50:08 +0000 Subject: [PATCH 33/83] fix(provider): preserve masked persistence frontier state Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019dd4f6-c7f3-7649-884e-55217d141526 Co-authored-by: Amp --- .../src/providers/database/provider.rs | 87 +++++++++++++------ 1 file changed, 61 insertions(+), 26 deletions(-) diff --git a/crates/storage/provider/src/providers/database/provider.rs b/crates/storage/provider/src/providers/database/provider.rs index d5a26376011..4a350e9d791 100644 --- a/crates/storage/provider/src/providers/database/provider.rs +++ b/crates/storage/provider/src/providers/database/provider.rs @@ -624,6 +624,37 @@ impl DatabaseProvider>(); + let plan_block_numbers = + blocks.iter().map(|block| block.recovered_block().number()).collect::>(); + let plan_step_summaries = plan + .steps + .iter() + .map(|step| { + let block_numbers = blocks[step.block_range.clone()] + .iter() + .map(|block| block.recovered_block().number()) + .collect::>(); + let masking_block_numbers = step.state_trie_masking_range.as_ref().map(|range| { + blocks[range.clone()] + .iter() + .map(|block| block.recovered_block().number()) + .collect::>() + }); + (block_numbers, masking_block_numbers, step.persist_rest) + }) + .collect::>(); + + debug!( + target: "providers::db", + ?plan_block_numbers, + ?plan_step_summaries, + ?persist_rest_range, + ?state_trie_masking_ranges, + state_trie_block_count = state_trie_blocks.len(), + state_trie_masking_block_count = state_trie_masking_blocks.len(), + "Resolved save_blocks plan" + ); + let total_start = Instant::now(); let block_count = blocks.len() as u64; let first_number = blocks.first().unwrap().recovered_block().number(); @@ -802,18 +833,18 @@ impl DatabaseProvider>(); + let merged_hashed_state = HashedPostStateSorted::merge_slice(&hashed_states); if !merged_hashed_state.is_empty() { self.write_hashed_state(&merged_hashed_state)?; } @@ -822,13 +853,12 @@ impl DatabaseProvider>(); + let merged_trie = TrieUpdatesSorted::merge_slice(&trie_updates); if !merged_trie.is_empty() { self.write_trie_updates_sorted(&merged_trie)?; } @@ -861,6 +891,13 @@ impl DatabaseProvider().unwrap(); assert!(hashed_accounts.seek_exact(kept_account).unwrap().is_some()); - assert!(hashed_accounts.seek_exact(deferred_masked_account).unwrap().is_none()); + assert!(hashed_accounts.seek_exact(deferred_masked_account).unwrap().is_some()); assert!(hashed_accounts.seek_exact(in_memory_overlap_account).unwrap().is_some()); assert!(hashed_accounts.seek_exact(in_memory_only_account).unwrap().is_none()); let mut hashed_storages = tx.cursor_dup_read::().unwrap(); assert!(hashed_storages.seek_by_key_subkey(kept_storage, kept_slot).unwrap().is_some()); assert!(hashed_storages - .walk_dup(Some(deferred_masked_storage), None) - .unwrap() - .next() - .transpose() + .seek_by_key_subkey(deferred_masked_storage, deferred_masked_slot) .unwrap() - .is_none()); + .is_some()); assert!(hashed_storages .seek_by_key_subkey(in_memory_overlap_storage, in_memory_overlap_slot) .unwrap() @@ -4915,7 +4949,7 @@ mod tests { assert!(account_trie .seek_exact(StoredNibbles(deferred_masked_account_node)) .unwrap() - .is_none()); + .is_some()); assert!(account_trie .seek_exact(StoredNibbles(in_memory_overlap_account_node)) .unwrap() @@ -4939,7 +4973,8 @@ mod tests { .unwrap() .collect::, _>>() .unwrap(); - assert!(deferred_masked_entries.is_empty()); + assert_eq!(deferred_masked_entries.len(), 1); + assert_eq!(deferred_masked_entries[0].1.nibbles.0, deferred_masked_storage_node); let in_memory_overlap_entries: Vec<_> = storage_trie .walk_dup(Some(in_memory_overlap_storage), None) From baf6ef97782aab0ab7d6c3a37be9770e12bafee5 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:55:26 +0000 Subject: [PATCH 34/83] Revert "fix(provider): preserve masked persistence frontier state" This reverts commit e71cf3040bc7105ad0ae96649b202ae637d6a8f1. --- .../src/providers/database/provider.rs | 87 ++++++------------- 1 file changed, 26 insertions(+), 61 deletions(-) diff --git a/crates/storage/provider/src/providers/database/provider.rs b/crates/storage/provider/src/providers/database/provider.rs index 4a350e9d791..d5a26376011 100644 --- a/crates/storage/provider/src/providers/database/provider.rs +++ b/crates/storage/provider/src/providers/database/provider.rs @@ -624,37 +624,6 @@ impl DatabaseProvider>(); - let plan_block_numbers = - blocks.iter().map(|block| block.recovered_block().number()).collect::>(); - let plan_step_summaries = plan - .steps - .iter() - .map(|step| { - let block_numbers = blocks[step.block_range.clone()] - .iter() - .map(|block| block.recovered_block().number()) - .collect::>(); - let masking_block_numbers = step.state_trie_masking_range.as_ref().map(|range| { - blocks[range.clone()] - .iter() - .map(|block| block.recovered_block().number()) - .collect::>() - }); - (block_numbers, masking_block_numbers, step.persist_rest) - }) - .collect::>(); - - debug!( - target: "providers::db", - ?plan_block_numbers, - ?plan_step_summaries, - ?persist_rest_range, - ?state_trie_masking_ranges, - state_trie_block_count = state_trie_blocks.len(), - state_trie_masking_block_count = state_trie_masking_blocks.len(), - "Resolved save_blocks plan" - ); - let total_start = Instant::now(); let block_count = blocks.len() as u64; let first_number = blocks.first().unwrap().recovered_block().number(); @@ -833,18 +802,18 @@ impl DatabaseProvider>(); - let merged_hashed_state = HashedPostStateSorted::merge_slice(&hashed_states); + let merged_hashed_state = HashedPostStateSorted::disjointed_merge_batch( + state_trie_blocks.iter().map(|data| data.hashed_state.as_ref()).collect(), + state_trie_masking_blocks + .iter() + .map(|data| data.hashed_state.as_ref()) + .collect(), + ); if !merged_hashed_state.is_empty() { self.write_hashed_state(&merged_hashed_state)?; } @@ -853,12 +822,13 @@ impl DatabaseProvider>(); - let merged_trie = TrieUpdatesSorted::merge_slice(&trie_updates); + let merged_trie = TrieUpdatesSorted::disjointed_merge_batch( + state_trie_blocks.iter().map(|data| data.trie_updates.as_ref()).collect(), + state_trie_masking_blocks + .iter() + .map(|data| data.trie_updates.as_ref()) + .collect(), + ); if !merged_trie.is_empty() { self.write_trie_updates_sorted(&merged_trie)?; } @@ -891,13 +861,6 @@ impl DatabaseProvider().unwrap(); assert!(hashed_accounts.seek_exact(kept_account).unwrap().is_some()); - assert!(hashed_accounts.seek_exact(deferred_masked_account).unwrap().is_some()); + assert!(hashed_accounts.seek_exact(deferred_masked_account).unwrap().is_none()); assert!(hashed_accounts.seek_exact(in_memory_overlap_account).unwrap().is_some()); assert!(hashed_accounts.seek_exact(in_memory_only_account).unwrap().is_none()); let mut hashed_storages = tx.cursor_dup_read::().unwrap(); assert!(hashed_storages.seek_by_key_subkey(kept_storage, kept_slot).unwrap().is_some()); assert!(hashed_storages - .seek_by_key_subkey(deferred_masked_storage, deferred_masked_slot) + .walk_dup(Some(deferred_masked_storage), None) .unwrap() - .is_some()); + .next() + .transpose() + .unwrap() + .is_none()); assert!(hashed_storages .seek_by_key_subkey(in_memory_overlap_storage, in_memory_overlap_slot) .unwrap() @@ -4949,7 +4915,7 @@ mod tests { assert!(account_trie .seek_exact(StoredNibbles(deferred_masked_account_node)) .unwrap() - .is_some()); + .is_none()); assert!(account_trie .seek_exact(StoredNibbles(in_memory_overlap_account_node)) .unwrap() @@ -4973,8 +4939,7 @@ mod tests { .unwrap() .collect::, _>>() .unwrap(); - assert_eq!(deferred_masked_entries.len(), 1); - assert_eq!(deferred_masked_entries[0].1.nibbles.0, deferred_masked_storage_node); + assert!(deferred_masked_entries.is_empty()); let in_memory_overlap_entries: Vec<_> = storage_trie .walk_dup(Some(in_memory_overlap_storage), None) From 5ab335d04e519ae7d847a86c65d97fabc0afb7e2 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:57:41 +0000 Subject: [PATCH 35/83] refactor(provider): drive save_blocks from plan steps Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019dd4f6-c7f3-7649-884e-55217d141526 Co-authored-by: Amp --- .../src/providers/database/provider.rs | 129 ++++++++---------- 1 file changed, 59 insertions(+), 70 deletions(-) diff --git a/crates/storage/provider/src/providers/database/provider.rs b/crates/storage/provider/src/providers/database/provider.rs index d5a26376011..9b092ab6298 100644 --- a/crates/storage/provider/src/providers/database/provider.rs +++ b/crates/storage/provider/src/providers/database/provider.rs @@ -602,27 +602,6 @@ impl DatabaseProvider>(); - - let mut state_trie_masking_ranges = plan - .steps - .iter() - .filter_map(|step| step.state_trie_masking_range.clone()) - .filter(|range| !range.is_empty()) - .collect::>(); - state_trie_masking_ranges.sort_unstable_by_key(|range| (range.start, range.end)); - state_trie_masking_ranges.dedup_by(|left, right| left == right); - let state_trie_masking_blocks = state_trie_masking_ranges - .iter() - .flat_map(|range| blocks[range.clone()].iter()) - .map(|block| block.trie_data()) - .collect::>(); let total_start = Instant::now(); let block_count = blocks.len() as u64; @@ -773,69 +752,79 @@ impl DatabaseProvider>(); + let masking_trie_data = blocks[masking_range.clone()] + .iter() + .map(|block| block.trie_data()) + .collect::>(); + let start = Instant::now(); - if !state_trie_blocks.is_empty() { - let merged_hashed_state = HashedPostStateSorted::disjointed_merge_batch( - state_trie_blocks.iter().map(|data| data.hashed_state.as_ref()).collect(), - state_trie_masking_blocks - .iter() - .map(|data| data.hashed_state.as_ref()) - .collect(), - ); - if !merged_hashed_state.is_empty() { - self.write_hashed_state(&merged_hashed_state)?; - } + let merged_hashed_state = HashedPostStateSorted::disjointed_merge_batch( + step_trie_data.iter().map(|data| data.hashed_state.as_ref()).collect(), + masking_trie_data.iter().map(|data| data.hashed_state.as_ref()).collect(), + ); + if !merged_hashed_state.is_empty() { + self.write_hashed_state(&merged_hashed_state)?; } timings.write_hashed_state += start.elapsed(); let start = Instant::now(); - if !state_trie_blocks.is_empty() { - let merged_trie = TrieUpdatesSorted::disjointed_merge_batch( - state_trie_blocks.iter().map(|data| data.trie_updates.as_ref()).collect(), - state_trie_masking_blocks - .iter() - .map(|data| data.trie_updates.as_ref()) - .collect(), - ); - if !merged_trie.is_empty() { - self.write_trie_updates_sorted(&merged_trie)?; - } + let merged_trie = TrieUpdatesSorted::disjointed_merge_batch( + step_trie_data.iter().map(|data| data.trie_updates.as_ref()).collect(), + masking_trie_data.iter().map(|data| data.trie_updates.as_ref()).collect(), + ); + if !merged_trie.is_empty() { + self.write_trie_updates_sorted(&merged_trie)?; } timings.write_trie_updates += start.elapsed(); } + debug_assert_eq!(next_persist_rest_tx_num, tx_nums.len()); + // Full mode: update history indices if save_mode.with_state() && has_persist_rest_blocks { let start = Instant::now(); From 4b4a1b80d8de8dab50a0455545ebfbe620fd0ffb Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:41:52 +0000 Subject: [PATCH 36/83] fix(trie): mask storage entries in disjoint merges Only drop an entire storage entry when the masking batch wipes or deletes it. Otherwise, filter overlapping storage slots and storage trie nodes individually to preserve the rest of the account state. Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019dd592-8e45-756e-9e7c-7c9c98f8687c Co-authored-by: Amp --- crates/trie/common/src/hashed_state.rs | 92 ++++++++++++++++++--- crates/trie/common/src/updates.rs | 106 ++++++++++++++++++++++--- 2 files changed, 173 insertions(+), 25 deletions(-) diff --git a/crates/trie/common/src/hashed_state.rs b/crates/trie/common/src/hashed_state.rs index b5bc21b385e..854f486e2b7 100644 --- a/crates/trie/common/src/hashed_state.rs +++ b/crates/trie/common/src/hashed_state.rs @@ -691,10 +691,11 @@ impl HashedPostStateSorted { Self { accounts, storages } } - /// Merges the batch and removes any top-level keys present in the mask. + /// Merges the batch and removes any overlapping keys present in the mask. /// - /// For duplicate keys in the batch, later items take precedence over earlier ones. The order - /// of the mask does not matter. + /// Account keys are masked at the top level, while storage entries are only masked at the slot + /// level unless the mask wipes the entire storage. For duplicate keys in the batch, later + /// items take precedence over earlier ones. The order of the mask does not matter. pub fn disjointed_merge_batch<'a>(batch: Vec<&'a Self>, mask: Vec<&'a Self>) -> Self { let accounts = kway_merge_disjoint_sorted( batch.iter().map(|item| item.accounts.len()).sum(), @@ -705,6 +706,13 @@ impl HashedPostStateSorted { struct StorageAcc<'a> { wiped: bool, sealed: bool, + slot_count: usize, + slices: Vec<&'a [(B256, U256)]>, + } + + #[derive(Default)] + struct StorageMaskAcc<'a> { + wiped: bool, slices: Vec<&'a [(B256, U256)]>, } @@ -718,6 +726,7 @@ impl HashedPostStateSorted { let entry = storages.entry(*hashed_address).or_insert_with(|| StorageAcc { wiped: false, sealed: false, + slot_count: 0, slices: Vec::new(), }); @@ -726,6 +735,7 @@ impl HashedPostStateSorted { } entry.slices.push(storage.storage_slots.as_slice()); + entry.slot_count += storage.storage_slots.len(); if storage.wiped { entry.wiped = true; entry.sealed = true; @@ -733,17 +743,42 @@ impl HashedPostStateSorted { } } + let mut storage_masks: B256Map> = B256Map::with_capacity_and_hasher( + mask.iter().map(|item| item.storages.len()).sum(), + Default::default(), + ); for item in mask { - for hashed_address in item.storages.keys() { - storages.remove(hashed_address); + for (hashed_address, storage) in &item.storages { + let entry = storage_masks.entry(*hashed_address).or_default(); + if entry.wiped { + continue; + } + if storage.wiped { + entry.wiped = true; + entry.slices.clear(); + } else { + entry.slices.push(storage.storage_slots.as_slice()); + } } } let storages = storages .into_iter() - .map(|(hashed_address, entry)| { - let storage_slots = kway_merge_sorted(entry.slices); - (hashed_address, HashedStorageSorted { wiped: entry.wiped, storage_slots }) + .filter_map(|(hashed_address, entry)| { + let storage_slots = match storage_masks.get(&hashed_address) { + Some(mask_entry) if mask_entry.wiped => return None, + Some(mask_entry) => kway_merge_disjoint_sorted( + entry.slot_count, + entry.slices, + mask_entry.slices.iter().copied(), + ), + None => kway_merge_sorted(entry.slices), + }; + + (!storage_slots.is_empty() || entry.wiped).then_some(( + hashed_address, + HashedStorageSorted { wiped: entry.wiped, storage_slots }, + )) }) .collect(); @@ -1639,10 +1674,13 @@ mod tests { let remove_a = HashedPostStateSorted::new( vec![(removed_account, None)], - B256Map::from_iter([( - removed_storage, - HashedStorageSorted { wiped: true, storage_slots: vec![] }, - )]), + B256Map::from_iter([ + ( + kept_storage, + HashedStorageSorted { wiped: false, storage_slots: vec![(slot2, U256::ZERO)] }, + ), + (removed_storage, HashedStorageSorted { wiped: true, storage_slots: vec![] }), + ]), ); let remove_b = HashedPostStateSorted::new( @@ -1661,7 +1699,7 @@ mod tests { result.storages.get(&kept_storage), Some(&HashedStorageSorted { wiped: false, - storage_slots: vec![(slot1, U256::from(3)), (slot2, U256::from(4))], + storage_slots: vec![(slot1, U256::from(3))], }) ); assert!(!result.storages.contains_key(&removed_storage)); @@ -1708,6 +1746,34 @@ mod tests { assert!(result.storages.is_empty()); } + #[test] + fn test_hashed_post_state_sorted_disjointed_merge_batch_ignores_empty_storage_mask() { + let storage = B256::with_last_byte(31); + let slot = B256::with_last_byte(32); + + let batch = HashedPostStateSorted::new( + vec![], + B256Map::from_iter([( + storage, + HashedStorageSorted { wiped: false, storage_slots: vec![(slot, U256::from(1))] }, + )]), + ); + let mask = HashedPostStateSorted::new( + vec![], + B256Map::from_iter([( + storage, + HashedStorageSorted { wiped: false, storage_slots: vec![] }, + )]), + ); + + let result = HashedPostStateSorted::disjointed_merge_batch(vec![&batch], vec![&mask]); + + assert_eq!( + result.storages.get(&storage), + Some(&HashedStorageSorted { wiped: false, storage_slots: vec![(slot, U256::from(1))] }) + ); + } + /// Test non-wiped storage merges both zero and non-zero valued slots #[test] fn test_hashed_storage_extend_from_sorted_non_wiped() { diff --git a/crates/trie/common/src/updates.rs b/crates/trie/common/src/updates.rs index bbaf916dfce..f935fc538c9 100644 --- a/crates/trie/common/src/updates.rs +++ b/crates/trie/common/src/updates.rs @@ -711,10 +711,12 @@ impl TrieUpdatesSorted { Self { account_nodes, storage_tries } } - /// Merges the batch and removes any top-level keys present in the mask. + /// Merges the batch and removes any overlapping keys present in the mask. /// - /// For duplicate keys in the batch, later items take precedence over earlier ones. The order - /// of the mask does not matter. + /// Account trie nodes are masked at the top level, while storage trie entries are only masked + /// at the node level unless the mask deletes the entire storage trie. For duplicate keys in + /// the batch, later items take precedence over earlier ones. The order of the mask does not + /// matter. pub fn disjointed_merge_batch<'a>(batch: Vec<&'a Self>, mask: Vec<&'a Self>) -> Self { let account_nodes = kway_merge_disjoint_sorted( batch.iter().map(|item| item.account_nodes.len()).sum(), @@ -725,6 +727,13 @@ impl TrieUpdatesSorted { struct StorageAcc<'a> { is_deleted: bool, sealed: bool, + node_count: usize, + slices: Vec<&'a [(Nibbles, Option)]>, + } + + #[derive(Default)] + struct StorageMaskAcc<'a> { + is_deleted: bool, slices: Vec<&'a [(Nibbles, Option)]>, } @@ -738,6 +747,7 @@ impl TrieUpdatesSorted { let entry = storage_tries.entry(*hashed_address).or_insert_with(|| StorageAcc { is_deleted: false, sealed: false, + node_count: 0, slices: Vec::new(), }); @@ -746,6 +756,7 @@ impl TrieUpdatesSorted { } entry.slices.push(storage_trie.storage_nodes.as_slice()); + entry.node_count += storage_trie.storage_nodes.len(); if storage_trie.is_deleted { entry.is_deleted = true; entry.sealed = true; @@ -753,20 +764,42 @@ impl TrieUpdatesSorted { } } + let mut storage_masks: B256Map> = B256Map::with_capacity_and_hasher( + mask.iter().map(|item| item.storage_tries.len()).sum(), + Default::default(), + ); for item in mask { - for hashed_address in item.storage_tries.keys() { - storage_tries.remove(hashed_address); + for (hashed_address, storage_trie) in &item.storage_tries { + let entry = storage_masks.entry(*hashed_address).or_default(); + if entry.is_deleted { + continue; + } + if storage_trie.is_deleted { + entry.is_deleted = true; + entry.slices.clear(); + } else { + entry.slices.push(storage_trie.storage_nodes.as_slice()); + } } } let storage_tries = storage_tries .into_iter() - .map(|(hashed_address, entry)| { - let storage_nodes = kway_merge_sorted(entry.slices); - ( + .filter_map(|(hashed_address, entry)| { + let storage_nodes = match storage_masks.get(&hashed_address) { + Some(mask_entry) if mask_entry.is_deleted => return None, + Some(mask_entry) => kway_merge_disjoint_sorted( + entry.node_count, + entry.slices, + mask_entry.slices.iter().copied(), + ), + None => kway_merge_sorted(entry.slices), + }; + + (!storage_nodes.is_empty() || entry.is_deleted).then_some(( hashed_address, StorageTrieUpdatesSorted { is_deleted: entry.is_deleted, storage_nodes }, - ) + )) }) .collect(); @@ -1081,7 +1114,19 @@ mod tests { let remove_a = TrieUpdatesSorted::new( vec![(removed_node, Some(BranchNodeCompact::default()))], - B256Map::from_iter([(removed_storage, StorageTrieUpdatesSorted::default())]), + B256Map::from_iter([ + ( + kept_storage, + StorageTrieUpdatesSorted { + is_deleted: false, + storage_nodes: vec![(slot2, Some(BranchNodeCompact::default()))], + }, + ), + ( + removed_storage, + StorageTrieUpdatesSorted { is_deleted: true, storage_nodes: vec![] }, + ), + ]), ); let remove_b = TrieUpdatesSorted::new( @@ -1100,7 +1145,7 @@ mod tests { result.storage_tries.get(&kept_storage), Some(&StorageTrieUpdatesSorted { is_deleted: false, - storage_nodes: vec![(slot1, Some(BranchNodeCompact::default())), (slot2, None)], + storage_nodes: vec![(slot1, Some(BranchNodeCompact::default()))], }) ); assert!(!result.storage_tries.contains_key(&removed_storage)); @@ -1133,7 +1178,10 @@ mod tests { let remove = TrieUpdatesSorted::new( vec![(overlapping_node, Some(BranchNodeCompact::default()))], - B256Map::from_iter([(overlapping_storage, StorageTrieUpdatesSorted::default())]), + B256Map::from_iter([( + overlapping_storage, + StorageTrieUpdatesSorted { is_deleted: true, storage_nodes: vec![] }, + )]), ); let result = TrieUpdatesSorted::disjointed_merge_batch(vec![&older, &newer], vec![&remove]); @@ -1142,6 +1190,40 @@ mod tests { assert!(result.storage_tries.is_empty()); } + #[test] + fn test_trie_updates_sorted_disjointed_merge_batch_ignores_empty_storage_mask() { + let storage = B256::from([6; 32]); + let slot = Nibbles::from_nibbles_unchecked([0x0d]); + + let batch = TrieUpdatesSorted::new( + vec![], + B256Map::from_iter([( + storage, + StorageTrieUpdatesSorted { + is_deleted: false, + storage_nodes: vec![(slot, Some(BranchNodeCompact::default()))], + }, + )]), + ); + let mask = TrieUpdatesSorted::new( + vec![], + B256Map::from_iter([( + storage, + StorageTrieUpdatesSorted { is_deleted: false, storage_nodes: vec![] }, + )]), + ); + + let result = TrieUpdatesSorted::disjointed_merge_batch(vec![&batch], vec![&mask]); + + assert_eq!( + result.storage_tries.get(&storage), + Some(&StorageTrieUpdatesSorted { + is_deleted: false, + storage_nodes: vec![(slot, Some(BranchNodeCompact::default()))], + }) + ); + } + /// Test extending with storage tries adds both nodes and removed nodes correctly #[test] fn test_trie_updates_extend_from_sorted_with_storage_tries() { From c07e228412a3dc4ad4bb8d2c2cd969fe3a65522d Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Wed, 29 Apr 2026 07:14:10 +0000 Subject: [PATCH 37/83] fix(provider): clamp partial trie unwind during reorgs Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019dd7d9-2cea-745d-a1ea-e8965237cf20 Co-authored-by: Amp --- .../src/providers/database/provider.rs | 231 ++++++++++++++---- 1 file changed, 183 insertions(+), 48 deletions(-) diff --git a/crates/storage/provider/src/providers/database/provider.rs b/crates/storage/provider/src/providers/database/provider.rs index 9b092ab6298..147d56d36a6 100644 --- a/crates/storage/provider/src/providers/database/provider.rs +++ b/crates/storage/provider/src/providers/database/provider.rs @@ -960,32 +960,32 @@ impl DatabaseProvider ProviderResult<()> { let changed_accounts = self.account_changesets_range(from..)?; - // Unwind account hashes. - self.unwind_account_hashing(changed_accounts.iter())?; - // Unwind account history indices. self.unwind_account_history_indices(changed_accounts.iter())?; let changed_storages = self.storage_changesets_range(from..)?; - // Unwind storage hashes. - self.unwind_storage_hashing(changed_storages.iter().copied())?; - // Unwind storage history indices. self.unwind_storage_history_indices(changed_storages.iter().copied())?; - // Unwind accounts/storages trie tables using the revert. - // Get the database tip block number - let db_tip_block = self - .get_stage_checkpoint(reth_stages_types::StageId::Finish)? - .as_ref() - .map(|chk| chk.block_number) - .ok_or_else(|| ProviderError::InsufficientChangesets { - requested: from, - available: 0..=0, - })?; + let Some(trie_persisted_range) = self.durable_trie_range_from(from)? else { return Ok(()) }; - let trie_revert = self.changeset_cache.get_or_compute_range(self, from..=db_tip_block)?; + // Only hashed state and trie tables up to the durable trie frontier are materialized. + self.unwind_account_hashing( + changed_accounts + .iter() + .filter(|(block_number, _)| trie_persisted_range.contains(block_number)), + )?; + + self.unwind_storage_hashing( + changed_storages + .iter() + .copied() + .filter(|(index, _)| trie_persisted_range.contains(&index.block_number())), + )?; + + // Unwind accounts/storages trie tables using the revert. + let trie_revert = self.changeset_cache.get_or_compute_range(self, trie_persisted_range)?; self.write_trie_updates_sorted(&trie_revert)?; Ok(()) @@ -2889,41 +2889,51 @@ impl StateWriter }; if self.cached_storage_settings().use_hashed_state() { - let mut hashed_accounts_cursor = self.tx.cursor_write::()?; - let mut hashed_storage_cursor = self.tx.cursor_dup_write::()?; - - let (state, _) = self.populate_bundle_state_hashed( - account_changeset, - storage_changeset, - &mut hashed_accounts_cursor, - &mut hashed_storage_cursor, - )?; + if let Some(trie_persisted_range) = self.durable_trie_range_from(block + 1)? { + let mut hashed_accounts_cursor = + self.tx.cursor_write::()?; + let mut hashed_storage_cursor = + self.tx.cursor_dup_write::()?; + + let (state, _) = self.populate_bundle_state_hashed( + account_changeset + .into_iter() + .filter(|(block_number, _)| trie_persisted_range.contains(block_number)) + .collect::>(), + storage_changeset + .into_iter() + .filter(|(index, _)| trie_persisted_range.contains(&index.block_number())) + .collect::>(), + &mut hashed_accounts_cursor, + &mut hashed_storage_cursor, + )?; - for (address, (old_account, new_account, storage)) in &state { - if old_account != new_account { - let hashed_address = keccak256(address); - let existing_entry = hashed_accounts_cursor.seek_exact(hashed_address)?; - if let Some(account) = old_account { - hashed_accounts_cursor.upsert(hashed_address, account)?; - } else if existing_entry.is_some() { - hashed_accounts_cursor.delete_current()?; + for (address, (old_account, new_account, storage)) in &state { + if old_account != new_account { + let hashed_address = keccak256(address); + let existing_entry = hashed_accounts_cursor.seek_exact(hashed_address)?; + if let Some(account) = old_account { + hashed_accounts_cursor.upsert(hashed_address, account)?; + } else if existing_entry.is_some() { + hashed_accounts_cursor.delete_current()?; + } } - } - for (storage_key, (old_storage_value, _new_storage_value)) in storage { - let hashed_address = keccak256(address); - let hashed_storage_key = keccak256(storage_key); - let storage_entry = - StorageEntry { key: hashed_storage_key, value: *old_storage_value }; - if hashed_storage_cursor - .seek_by_key_subkey(hashed_address, hashed_storage_key)? - .is_some_and(|s| s.key == hashed_storage_key) - { - hashed_storage_cursor.delete_current()? - } + for (storage_key, (old_storage_value, _new_storage_value)) in storage { + let hashed_address = keccak256(address); + let hashed_storage_key = keccak256(storage_key); + let storage_entry = + StorageEntry { key: hashed_storage_key, value: *old_storage_value }; + if hashed_storage_cursor + .seek_by_key_subkey(hashed_address, hashed_storage_key)? + .is_some_and(|s| s.key == hashed_storage_key) + { + hashed_storage_cursor.delete_current()? + } - if !old_storage_value.is_zero() { - hashed_storage_cursor.upsert(hashed_address, &storage_entry)?; + if !old_storage_value.is_zero() { + hashed_storage_cursor.upsert(hashed_address, &storage_entry)?; + } } } } @@ -3613,6 +3623,21 @@ impl DatabaseProvider ProviderResult>> { + let Some(trie_persisted_tip) = self.trie_persisted_tip_block_number()? else { + return Ok(None) + }; + + if from > trie_persisted_tip { + return Ok(None) + } + + Ok(Some(from..=trie_persisted_tip)) + } + fn update_finish_checkpoint_after_remove(&self, block: BlockNumber) -> ProviderResult<()> { let partial_state_trie = self .trie_persisted_tip_block_number()? @@ -5004,6 +5029,116 @@ mod tests { ); } + #[test] + fn test_remove_blocks_above_keeps_masked_hashed_state_and_trie_unmaterialized() { + fn empty_execution_output() -> BlockExecutionOutput { + BlockExecutionOutput { + result: BlockExecutionResult { + receipts: vec![], + requests: Default::default(), + gas_used: 0, + blob_gas_used: 0, + }, + state: Default::default(), + } + } + + let factory = create_test_provider_factory(); + factory.set_storage_settings_cache(StorageSettings::v1()); + + let genesis = SealedBlock::::from_sealed_parts( + SealedHeader::new( + Header { number: 0, difficulty: U256::from(1), ..Default::default() }, + B256::ZERO, + ), + Default::default(), + ); + let genesis_executed = ExecutedBlock::new( + Arc::new(genesis.try_recover().unwrap()), + Arc::new(empty_execution_output()), + ComputedTrieData::default(), + ); + + let mut test_block_builder = TestBlockBuilder::eth().with_state(); + let blocks: Vec<_> = test_block_builder.get_executed_blocks(1..4).collect(); + let overlapping_trie_data = blocks[1].trie_data(); + let overlapping_hashed_account = overlapping_trie_data.hashed_state.accounts[0].0; + let (&overlapping_hashed_storage, overlapping_storage) = + overlapping_trie_data.hashed_state.storages.iter().next().unwrap(); + let overlapping_hashed_slot = overlapping_storage.storage_slots[0].0; + + let provider_rw = factory.provider_rw().unwrap(); + provider_rw + .save_blocks( + &full_save_plan(std::slice::from_ref(&genesis_executed).to_vec()), + SaveBlocksMode::Full, + ) + .unwrap(); + provider_rw.commit().unwrap(); + + let provider_rw = factory.provider_rw().unwrap(); + provider_rw + .save_blocks( + &partial_save_plan( + blocks, + vec![ + SaveBlocksPlanStep::new(0..1, Some(1..3), true), + SaveBlocksPlanStep::new(1..3, None, true), + ], + ), + SaveBlocksMode::Full, + ) + .unwrap(); + provider_rw.commit().unwrap(); + + let provider = factory.provider().unwrap(); + assert_eq!(provider.tx_ref().entries::().unwrap(), 0); + assert_eq!(provider.tx_ref().entries::().unwrap(), 0); + assert!(provider + .tx_ref() + .cursor_read::() + .unwrap() + .seek_exact(overlapping_hashed_account) + .unwrap() + .is_none()); + assert!(provider + .tx_ref() + .cursor_dup_read::() + .unwrap() + .seek_by_key_subkey(overlapping_hashed_storage, overlapping_hashed_slot) + .unwrap() + .is_none()); + drop(provider); + + let provider_rw = factory.provider_rw().unwrap(); + provider_rw.remove_block_and_execution_above(2).unwrap(); + provider_rw.commit().unwrap(); + + let provider = factory.provider().unwrap(); + let finish_checkpoint = provider.get_stage_checkpoint(StageId::Finish).unwrap().unwrap(); + assert_eq!(finish_checkpoint.block_number, 2); + assert_eq!( + finish_checkpoint.finish_stage_checkpoint().unwrap().partial_state_trie, + Some(1) + ); + assert_eq!(provider.tx_ref().entries::().unwrap(), 0); + assert_eq!(provider.tx_ref().entries::().unwrap(), 0); + assert!(provider + .tx_ref() + .cursor_read::() + .unwrap() + .seek_exact(overlapping_hashed_account) + .unwrap() + .is_none()); + assert!(provider + .tx_ref() + .cursor_dup_read::() + .unwrap() + .seek_by_key_subkey(overlapping_hashed_storage, overlapping_hashed_slot) + .unwrap() + .is_none()); + } + #[test] fn test_prunable_receipts_logic() { let insert_blocks = From f3e4ad72cfc6f5c94c53bf0b3daf23a0f9185a47 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:44:27 +0000 Subject: [PATCH 38/83] chore(provider): add save_blocks persistence debug logging Co-Authored-By: Brian Picciano <933154+mediocregopher@users.noreply.github.com> --- .../src/providers/database/provider.rs | 97 ++++++++++++++++++- 1 file changed, 95 insertions(+), 2 deletions(-) diff --git a/crates/storage/provider/src/providers/database/provider.rs b/crates/storage/provider/src/providers/database/provider.rs index 147d56d36a6..fc05f3315c6 100644 --- a/crates/storage/provider/src/providers/database/provider.rs +++ b/crates/storage/provider/src/providers/database/provider.rs @@ -68,7 +68,7 @@ use reth_storage_api::{ use reth_storage_errors::provider::{ProviderResult, StaticFileWriterError}; use reth_trie::{ updates::{StorageTrieUpdatesSorted, TrieUpdatesSorted}, - HashedPostStateSorted, + HashedPostStateSorted, Nibbles, }; use reth_trie_db::{ChangesetCache, DatabaseStorageTrieCursor, TrieTableAdapter}; use revm_database::states::{ @@ -102,6 +102,60 @@ impl CommitOrder { } } +fn format_trie_node_path(path: &Nibbles) -> String { + let mut formatted = String::from("0x"); + for nibble in path.iter() { + formatted.push(char::from_digit(nibble as u32, 16).expect("nibbles are always hex")); + } + formatted +} + +fn collect_account_trie_node_paths(trie_updates: &TrieUpdatesSorted) -> Vec { + trie_updates + .account_nodes_ref() + .iter() + .map(|(path, node)| { + format!( + "{} ({})", + format_trie_node_path(path), + if node.is_some() { "upsert" } else { "remove" } + ) + }) + .collect() +} + +fn collect_all_trie_node_paths(trie_updates: &TrieUpdatesSorted) -> Vec { + let mut paths = trie_updates + .account_nodes_ref() + .iter() + .map(|(path, node)| { + format!( + "account {} ({})", + format_trie_node_path(path), + if node.is_some() { "upsert" } else { "remove" } + ) + }) + .collect::>(); + + for (hashed_address, storage_trie) in + trie_updates.storage_tries_ref().iter().sorted_by_key(|(hashed_address, _)| *hashed_address) + { + if storage_trie.is_deleted() { + paths.push(format!("storage {hashed_address:#x} (delete trie)")); + } + + paths.extend(storage_trie.storage_nodes_ref().iter().map(|(path, node)| { + format!( + "storage {hashed_address:#x}@{} ({})", + format_trie_node_path(path), + if node.is_some() { "upsert" } else { "remove" } + ) + })); + } + + paths +} + /// A [`DatabaseProvider`] that holds a read-only database transaction. pub type DatabaseProviderRO = DatabaseProvider<::TX, N>; @@ -609,6 +663,33 @@ impl DatabaseProvider>(); + let masking_blocks = step + .state_trie_masking_range + .as_ref() + .map(|range| { + blocks[range.clone()] + .iter() + .map(|block| block.recovered_block().num_hash()) + .collect::>() + }) + .unwrap_or_default(); + + debug!( + target: "providers::db", + step = step_index, + block_range = ?step.block_range, + persist_rest = step.persist_rest, + state_trie_masking_range = ?step.state_trie_masking_range, + step_blocks = ?step_blocks, + masking_blocks = ?masking_blocks, + "save_blocks step plan" + ); + } let tx_nums: Vec = if persist_rest_blocks.is_empty() { Vec::new() @@ -753,7 +834,7 @@ impl DatabaseProvider DatabaseProvider>(); + let masking_trie_updates = masking_trie_data + .iter() + .map(|data| data.trie_updates.as_ref()) + .collect::>(); + let merged_masking_trie = TrieUpdatesSorted::merge_slice(&masking_trie_updates); let start = Instant::now(); let merged_hashed_state = HashedPostStateSorted::disjointed_merge_batch( @@ -817,6 +903,13 @@ impl DatabaseProvider Date: Thu, 30 Apr 2026 21:03:38 +0000 Subject: [PATCH 39/83] fix(trie): keep masked ancestors of unmasked nodes Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019de030-fef5-767d-b5ea-72dbb13f2a37 Co-authored-by: Amp --- crates/trie/common/src/updates.rs | 69 ++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/crates/trie/common/src/updates.rs b/crates/trie/common/src/updates.rs index f935fc538c9..7435277f1ae 100644 --- a/crates/trie/common/src/updates.rs +++ b/crates/trie/common/src/updates.rs @@ -10,6 +10,7 @@ use alloy_primitives::{ map::{B256Map, B256Set, HashMap, HashSet}, FixedBytes, B256, }; +use itertools::Itertools; /// The aggregation of trie updates. #[derive(PartialEq, Eq, Clone, Default, Debug)] @@ -718,11 +719,35 @@ impl TrieUpdatesSorted { /// the batch, later items take precedence over earlier ones. The order of the mask does not /// matter. pub fn disjointed_merge_batch<'a>(batch: Vec<&'a Self>, mask: Vec<&'a Self>) -> Self { - let account_nodes = kway_merge_disjoint_sorted( - batch.iter().map(|item| item.account_nodes.len()).sum(), - batch.iter().rev().map(|item| item.account_nodes.as_slice()), - mask.iter().map(|item| item.account_nodes.as_slice()), - ); + let merged_batch_account_nodes = + kway_merge_sorted(batch.iter().rev().map(|item| item.account_nodes.as_slice())); + let masked_account_node_keys = mask + .iter() + .map(|item| item.account_nodes.as_slice()) + .filter(|slice| !slice.is_empty()) + .map(|slice| slice.iter().map(|(key, _)| key)) + .kmerge() + .dedup() + .cloned() + .collect::>(); + let directly_unmasked_account_node_keys = merged_batch_account_nodes + .iter() + .filter(|(key, _)| masked_account_node_keys.binary_search(key).is_err()) + .map(|(key, _)| key.clone()) + .collect::>(); + + let account_nodes = merged_batch_account_nodes + .into_iter() + .filter(|(key, _)| { + masked_account_node_keys.binary_search(key).is_err() || + directly_unmasked_account_node_keys + .get( + directly_unmasked_account_node_keys + .partition_point(|candidate| candidate <= key), + ) + .is_some_and(|candidate| candidate.starts_with(key)) + }) + .collect(); struct StorageAcc<'a> { is_deleted: bool, @@ -1224,6 +1249,40 @@ mod tests { ); } + #[test] + fn test_trie_updates_sorted_disjointed_merge_batch_keeps_masked_ancestors_of_unmasked_nodes() { + let grandparent = Nibbles::from_nibbles_unchecked([0x05]); + let parent = Nibbles::from_nibbles_unchecked([0x05, 0x04]); + let child = Nibbles::from_nibbles_unchecked([0x05, 0x04, 0x03]); + + let batch = TrieUpdatesSorted::new( + vec![ + (grandparent, Some(BranchNodeCompact::default())), + (parent, Some(BranchNodeCompact::default())), + (child, Some(BranchNodeCompact::default())), + ], + B256Map::default(), + ); + let mask = TrieUpdatesSorted::new( + vec![ + (grandparent, Some(BranchNodeCompact::default())), + (parent, Some(BranchNodeCompact::default())), + ], + B256Map::default(), + ); + + let result = TrieUpdatesSorted::disjointed_merge_batch(vec![&batch], vec![&mask]); + + assert_eq!( + result.account_nodes, + vec![ + (grandparent, Some(BranchNodeCompact::default())), + (parent, Some(BranchNodeCompact::default())), + (child, Some(BranchNodeCompact::default())), + ] + ); + } + /// Test extending with storage tries adds both nodes and removed nodes correctly #[test] fn test_trie_updates_extend_from_sorted_with_storage_tries() { From 411e65c741db690309b9831e0a652e88fc4f3ae8 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:28:31 +0000 Subject: [PATCH 40/83] fix(trie): keep masked storage ancestors of unmasked nodes Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019de030-fef5-767d-b5ea-72dbb13f2a37 Co-authored-by: Amp --- crates/trie/common/src/updates.rs | 122 +++++++++++++++++++++--------- 1 file changed, 87 insertions(+), 35 deletions(-) diff --git a/crates/trie/common/src/updates.rs b/crates/trie/common/src/updates.rs index 7435277f1ae..04cd21c2de3 100644 --- a/crates/trie/common/src/updates.rs +++ b/crates/trie/common/src/updates.rs @@ -1,5 +1,5 @@ use crate::{ - utils::{extend_sorted_vec, kway_merge_disjoint_sorted, kway_merge_sorted}, + utils::{extend_sorted_vec, kway_merge_sorted}, BranchNodeCompact, HashBuilder, Nibbles, }; use alloc::{ @@ -719,40 +719,14 @@ impl TrieUpdatesSorted { /// the batch, later items take precedence over earlier ones. The order of the mask does not /// matter. pub fn disjointed_merge_batch<'a>(batch: Vec<&'a Self>, mask: Vec<&'a Self>) -> Self { - let merged_batch_account_nodes = - kway_merge_sorted(batch.iter().rev().map(|item| item.account_nodes.as_slice())); - let masked_account_node_keys = mask - .iter() - .map(|item| item.account_nodes.as_slice()) - .filter(|slice| !slice.is_empty()) - .map(|slice| slice.iter().map(|(key, _)| key)) - .kmerge() - .dedup() - .cloned() - .collect::>(); - let directly_unmasked_account_node_keys = merged_batch_account_nodes - .iter() - .filter(|(key, _)| masked_account_node_keys.binary_search(key).is_err()) - .map(|(key, _)| key.clone()) - .collect::>(); - - let account_nodes = merged_batch_account_nodes - .into_iter() - .filter(|(key, _)| { - masked_account_node_keys.binary_search(key).is_err() || - directly_unmasked_account_node_keys - .get( - directly_unmasked_account_node_keys - .partition_point(|candidate| candidate <= key), - ) - .is_some_and(|candidate| candidate.starts_with(key)) - }) - .collect(); + let account_nodes = merge_masked_trie_nodes_preserving_ancestors( + batch.iter().rev().map(|item| item.account_nodes.as_slice()), + mask.iter().map(|item| item.account_nodes.as_slice()), + ); struct StorageAcc<'a> { is_deleted: bool, sealed: bool, - node_count: usize, slices: Vec<&'a [(Nibbles, Option)]>, } @@ -772,7 +746,6 @@ impl TrieUpdatesSorted { let entry = storage_tries.entry(*hashed_address).or_insert_with(|| StorageAcc { is_deleted: false, sealed: false, - node_count: 0, slices: Vec::new(), }); @@ -781,7 +754,6 @@ impl TrieUpdatesSorted { } entry.slices.push(storage_trie.storage_nodes.as_slice()); - entry.node_count += storage_trie.storage_nodes.len(); if storage_trie.is_deleted { entry.is_deleted = true; entry.sealed = true; @@ -813,8 +785,7 @@ impl TrieUpdatesSorted { .filter_map(|(hashed_address, entry)| { let storage_nodes = match storage_masks.get(&hashed_address) { Some(mask_entry) if mask_entry.is_deleted => return None, - Some(mask_entry) => kway_merge_disjoint_sorted( - entry.node_count, + Some(mask_entry) => merge_masked_trie_nodes_preserving_ancestors( entry.slices, mask_entry.slices.iter().copied(), ), @@ -832,6 +803,36 @@ impl TrieUpdatesSorted { } } +fn merge_masked_trie_nodes_preserving_ancestors<'a>( + batch_slices: impl IntoIterator)]>, + mask_slices: impl IntoIterator)]>, +) -> Vec<(Nibbles, Option)> { + let merged_batch_nodes = kway_merge_sorted(batch_slices); + let masked_node_keys = mask_slices + .into_iter() + .filter(|slice| !slice.is_empty()) + .map(|slice| slice.iter().map(|(key, _)| key)) + .kmerge() + .dedup() + .cloned() + .collect::>(); + let directly_unmasked_node_keys = merged_batch_nodes + .iter() + .filter(|(key, _)| masked_node_keys.binary_search(key).is_err()) + .map(|(key, _)| key.clone()) + .collect::>(); + + merged_batch_nodes + .into_iter() + .filter(|(key, _)| { + masked_node_keys.binary_search(key).is_err() || + directly_unmasked_node_keys + .get(directly_unmasked_node_keys.partition_point(|candidate| candidate <= key)) + .is_some_and(|candidate| candidate.starts_with(key)) + }) + .collect() +} + impl AsRef for TrieUpdatesSorted { fn as_ref(&self) -> &Self { self @@ -1283,6 +1284,57 @@ mod tests { ); } + #[test] + fn test_trie_updates_sorted_disjointed_merge_batch_keeps_masked_storage_ancestors_of_unmasked_nodes( + ) { + let hashed_address = B256::from([7; 32]); + let grandparent = Nibbles::from_nibbles_unchecked([0x05]); + let parent = Nibbles::from_nibbles_unchecked([0x05, 0x04]); + let child = Nibbles::from_nibbles_unchecked([0x05, 0x04, 0x03]); + + let batch = TrieUpdatesSorted::new( + vec![], + B256Map::from_iter([( + hashed_address, + StorageTrieUpdatesSorted { + is_deleted: false, + storage_nodes: vec![ + (grandparent, Some(BranchNodeCompact::default())), + (parent, Some(BranchNodeCompact::default())), + (child, Some(BranchNodeCompact::default())), + ], + }, + )]), + ); + let mask = TrieUpdatesSorted::new( + vec![], + B256Map::from_iter([( + hashed_address, + StorageTrieUpdatesSorted { + is_deleted: false, + storage_nodes: vec![ + (grandparent, Some(BranchNodeCompact::default())), + (parent, Some(BranchNodeCompact::default())), + ], + }, + )]), + ); + + let result = TrieUpdatesSorted::disjointed_merge_batch(vec![&batch], vec![&mask]); + + assert_eq!( + result.storage_tries.get(&hashed_address), + Some(&StorageTrieUpdatesSorted { + is_deleted: false, + storage_nodes: vec![ + (grandparent, Some(BranchNodeCompact::default())), + (parent, Some(BranchNodeCompact::default())), + (child, Some(BranchNodeCompact::default())), + ], + }) + ); + } + /// Test extending with storage tries adds both nodes and removed nodes correctly #[test] fn test_trie_updates_extend_from_sorted_with_storage_tries() { From 1598784b083f0312763c4cd6e785c4d61a7eb23d Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Fri, 1 May 2026 06:24:20 +0000 Subject: [PATCH 41/83] test: add hoodi partial persistence repro script Automates the hoodi snapshot restore, partial-persistence replay, crash, and restart flow so the unwind outcome is captured in one run. Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019de22f-c77c-7640-8c34-5c80d4d85c1d Co-authored-by: Amp --- .../repro-hoodi-partial-persistence-unwind.sh | 569 ++++++++++++++++++ 1 file changed, 569 insertions(+) create mode 100755 scripts/repro-hoodi-partial-persistence-unwind.sh diff --git a/scripts/repro-hoodi-partial-persistence-unwind.sh b/scripts/repro-hoodi-partial-persistence-unwind.sh new file mode 100755 index 00000000000..d15e2222d7c --- /dev/null +++ b/scripts/repro-hoodi-partial-persistence-unwind.sh @@ -0,0 +1,569 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: repro-hoodi-partial-persistence-unwind.sh [options] + +Restores a hoodi datadir snapshot, runs a partial-persistence sync replay with +reth-bench, kill -9s the node during the post-target persistence window, then +restarts the node and reports whether restart led to an unwind failure. + +Options: + --snapshot PATH Tar.zst snapshot to restore + (default: /mnt/data/hoodi.tar.zst) + --datadir PATH Restored reth datadir + (default: /mnt/data/hoodi) + --jwt-secret PATH JWT secret path + (default: /jwt.hex) + --rpc-url URL Remote hoodi RPC used by reth-bench + (default: https://rpc.hoodi.ethpandaops.io) + --expected-head N Expected local head after restore + (default: 2613962) + --start-block N First block expected to be replayed + (default: 2613963) + --target-block N Last block to replay before crashing + (default: 2614300) + --artifacts-dir PATH Directory for logs and summary output + (default: /tmp/reth-hoodi-unwind-) + --start-timeout SECONDS Seconds to wait for node RPC startup + (default: 180) + --target-timeout SECONDS Seconds to wait for local head to reach target + (default: 900) + --persistence-timeout SEC Seconds to wait for a persistence marker after + the target head is reached (default: 120) + --restart-timeout SECONDS Seconds to classify restart behavior + (default: 180) + -h, --help Show this help + +Exit codes: + 0 Script ran to completion. See result.txt for whether unwind succeeded, + failed, or was not triggered. + 2 Setup/runtime failure prevented a conclusive result. +EOF +} + +log() { + printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*" >&2 +} + +regex_escape() { + printf '%s' "$1" | sed 's/[][(){}.^$+*?|\\/]/\\&/g' +} + +head_hex() { + local response + response=$(curl -fsS \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \ + http://127.0.0.1:8545 2>/dev/null) || return 1 + response=${response//$'\n'/} + sed -n 's/.*"result"[[:space:]]*:[[:space:]]*"\(0x[0-9a-fA-F]\+\)".*/\1/p' <<<"$response" +} + +hex_to_dec() { + printf '%d\n' "$((16#${1#0x}))" +} + +wait_for_pid_exit() { + local pid="$1" + local timeout="$2" + local elapsed=0 + + while (( elapsed < timeout )); do + if ! kill -0 "$pid" 2>/dev/null; then + return 0 + fi + sleep 1 + ((elapsed += 1)) + done + + return 1 +} + +stop_pid() { + local pid="$1" + local signal="$2" + local label="$3" + + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + log "Sending SIG${signal} to ${label} (pid ${pid})" + kill "-${signal}" "$pid" 2>/dev/null || true + fi +} + +kill_matching_processes() { + local signal="$1" + local pattern="$2" + local label="$3" + local -a pids=() + local pid + + while IFS= read -r pid; do + [[ -n "$pid" ]] && pids+=("$pid") + done < <(pgrep -f "$pattern" || true) + + if ((${#pids[@]} > 0)); then + log "Sending SIG${signal} to stale ${label} processes: ${pids[*]}" + kill "-${signal}" "${pids[@]}" 2>/dev/null || true + fi +} + +capture_command() { + local name="$1" + shift + { + printf '%s=' "$name" + printf '%q ' "$@" + printf '\n' + } >>"$COMMANDS_FILE" +} + +write_summary() { + { + printf 'result=%s\n' "${RESULT:-unknown}" + printf 'snapshot=%s\n' "$SNAPSHOT" + printf 'datadir=%s\n' "$DATADIR" + printf 'jwt_secret=%s\n' "$JWT_SECRET" + printf 'remote_rpc_url=%s\n' "$REMOTE_RPC_URL" + printf 'expected_head=%s\n' "$EXPECTED_HEAD" + printf 'start_block=%s\n' "$START_BLOCK" + printf 'target_block=%s\n' "$TARGET_BLOCK" + printf 'advance=%s\n' "${ADVANCE:-unknown}" + printf 'head_before=%s\n' "${HEAD_BEFORE:-unknown}" + printf 'head_after_crash=%s\n' "${HEAD_AT_CRASH:-unknown}" + printf 'head_after_restart=%s\n' "${HEAD_AFTER_RESTART:-unknown}" + printf 'artifacts_dir=%s\n' "$ARTIFACTS_DIR" + printf 'node1_log=%s\n' "$NODE1_LOG" + printf 'bench_log=%s\n' "$BENCH_LOG" + printf 'node2_log=%s\n' "$NODE2_LOG" + } >"$SUMMARY_FILE" +} + +cleanup() { + stop_pid "${BENCH_PID:-}" TERM "reth-bench" + if [[ -n "${BENCH_PID:-}" ]]; then + wait "${BENCH_PID}" 2>/dev/null || true + fi + + stop_pid "${NODE2_PID:-}" TERM "reth restart node" + if [[ -n "${NODE2_PID:-}" ]]; then + wait "${NODE2_PID}" 2>/dev/null || true + fi + + stop_pid "${NODE1_PID:-}" TERM "reth crash node" + if [[ -n "${NODE1_PID:-}" ]]; then + wait "${NODE1_PID}" 2>/dev/null || true + fi + + write_summary +} + +SNAPSHOT="/mnt/data/hoodi.tar.zst" +DATADIR="/mnt/data/hoodi" +JWT_SECRET="" +REMOTE_RPC_URL="https://rpc.hoodi.ethpandaops.io" +EXPECTED_HEAD=2613962 +START_BLOCK=2613963 +TARGET_BLOCK=2614300 +START_TIMEOUT=180 +TARGET_TIMEOUT=900 +PERSISTENCE_TIMEOUT=120 +RESTART_TIMEOUT=180 +RETH_BIN="/repos/reth/target/debug/reth" +BENCH_BIN="/repos/reth/target/debug/reth-bench" +CHAIN="hoodi" +RESULT="script_error" +ADVANCE="" +HEAD_BEFORE="" +HEAD_AT_CRASH="" +HEAD_AFTER_RESTART="" +NODE1_PID="" +NODE2_PID="" +BENCH_PID="" +TIMESTAMP="$(date '+%Y%m%d-%H%M%S')" +ARTIFACTS_DIR="/tmp/reth-hoodi-unwind-${TIMESTAMP}" + +while (($# > 0)); do + case "$1" in + --snapshot) + SNAPSHOT="$2" + shift 2 + ;; + --datadir) + DATADIR="$2" + shift 2 + ;; + --jwt-secret) + JWT_SECRET="$2" + shift 2 + ;; + --rpc-url) + REMOTE_RPC_URL="$2" + shift 2 + ;; + --expected-head) + EXPECTED_HEAD="$2" + shift 2 + ;; + --start-block) + START_BLOCK="$2" + shift 2 + ;; + --target-block) + TARGET_BLOCK="$2" + shift 2 + ;; + --artifacts-dir) + ARTIFACTS_DIR="$2" + shift 2 + ;; + --start-timeout) + START_TIMEOUT="$2" + shift 2 + ;; + --target-timeout) + TARGET_TIMEOUT="$2" + shift 2 + ;; + --persistence-timeout) + PERSISTENCE_TIMEOUT="$2" + shift 2 + ;; + --restart-timeout) + RESTART_TIMEOUT="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 2 + ;; + esac +done + +if [[ -z "$JWT_SECRET" ]]; then + JWT_SECRET="${DATADIR}/jwt.hex" +fi + +mkdir -p "$ARTIFACTS_DIR" +COMMANDS_FILE="${ARTIFACTS_DIR}/commands.txt" +SUMMARY_FILE="${ARTIFACTS_DIR}/result.txt" +NODE1_LOG="${ARTIFACTS_DIR}/node1.log" +BENCH_LOG="${ARTIFACTS_DIR}/bench.log" +NODE2_LOG="${ARTIFACTS_DIR}/node2.log" + +trap cleanup EXIT + +if [[ ! -x "$RETH_BIN" ]]; then + log "Missing executable reth binary: $RETH_BIN" + exit 2 +fi + +if [[ ! -x "$BENCH_BIN" ]]; then + log "Missing executable reth-bench binary: $BENCH_BIN" + exit 2 +fi + +if [[ ! -f "$SNAPSHOT" ]]; then + log "Missing snapshot archive: $SNAPSHOT" + exit 2 +fi + +NODE_PATTERN="^$(regex_escape "$RETH_BIN") node --datadir $(regex_escape "$DATADIR")( |$)" +kill_matching_processes TERM "$NODE_PATTERN" "reth" +sleep 1 +kill_matching_processes KILL "$NODE_PATTERN" "reth" + +capture_command reth "$RETH_BIN" node \ + --datadir "$DATADIR" \ + --chain "$CHAIN" \ + --http --http.addr 127.0.0.1 --http.port 8545 --http.api eth,net,web3,reth \ + --ws --ws.addr 127.0.0.1 --ws.port 8546 --ws.api eth,net,web3,reth \ + --authrpc.addr 127.0.0.1 --authrpc.port 8551 --authrpc.jwtsecret "$JWT_SECRET" \ + --disable-discovery \ + --engine.persistence-threshold 10 \ + --engine.deferred-trie-blocks 3 \ + --engine.accept-execution-requests-hash \ + --log.stdout.filter 'info,reth::providers::database=debug,reth::providers::static_file=debug,reth::storage=debug,consensus::engine=debug' \ + --color never + +restore_snapshot() { + local parent_dir + local base_name + local extract_root + + parent_dir=$(dirname "$DATADIR") + base_name=$(basename "$DATADIR") + extract_root="${DATADIR}.extract.$$" + + log "Restoring snapshot ${SNAPSHOT} into ${DATADIR}" + rm -rf "$DATADIR" "$extract_root" + mkdir -p "$parent_dir" "$extract_root" + tar --zstd -xf "$SNAPSHOT" -C "$extract_root" + + if [[ -d "${extract_root}/${base_name}/db" && -d "${extract_root}/${base_name}/static_files" ]]; then + mv "${extract_root}/${base_name}" "$DATADIR" + rm -rf "$extract_root" + elif [[ -d "${extract_root}/db" && -d "${extract_root}/static_files" ]]; then + mv "$extract_root" "$DATADIR" + else + log "Snapshot layout did not produce an expected datadir under ${extract_root}" + exit 2 + fi + + if [[ ! -f "$JWT_SECRET" ]]; then + log "Restored datadir is missing jwt secret: $JWT_SECRET" + exit 2 + fi +} + +start_node() { + local log_file="$1" + + "$RETH_BIN" node \ + --datadir "$DATADIR" \ + --chain "$CHAIN" \ + --http --http.addr 127.0.0.1 --http.port 8545 --http.api eth,net,web3,reth \ + --ws --ws.addr 127.0.0.1 --ws.port 8546 --ws.api eth,net,web3,reth \ + --authrpc.addr 127.0.0.1 --authrpc.port 8551 --authrpc.jwtsecret "$JWT_SECRET" \ + --disable-discovery \ + --engine.persistence-threshold 10 \ + --engine.deferred-trie-blocks 3 \ + --engine.accept-execution-requests-hash \ + --log.stdout.filter 'info,reth::providers::database=debug,reth::providers::static_file=debug,reth::storage=debug,consensus::engine=debug' \ + --color never \ + >"$log_file" 2>&1 & + echo $! +} + +wait_for_rpc_start() { + local pid="$1" + local timeout="$2" + local label="$3" + local elapsed=0 + local block_hex + + while (( elapsed < timeout )); do + block_hex=$(head_hex || true) + if [[ -n "$block_hex" ]]; then + printf '%s\n' "$block_hex" + return 0 + fi + + if ! kill -0 "$pid" 2>/dev/null; then + log "${label} exited before RPC became ready" + return 1 + fi + + sleep 1 + ((elapsed += 1)) + done + + log "Timed out waiting for ${label} RPC readiness" + return 1 +} + +wait_for_target_head() { + local pid="$1" + local target="$2" + local timeout="$3" + local elapsed=0 + local block_hex + local block_dec + + while (( elapsed < timeout )); do + block_hex=$(head_hex || true) + if [[ -n "$block_hex" ]]; then + block_dec=$(hex_to_dec "$block_hex") + if (( block_dec >= target )); then + printf '%s\n' "$block_dec" + return 0 + fi + fi + + if ! kill -0 "$pid" 2>/dev/null; then + log "Node exited before reaching target head ${target}" + return 1 + fi + + sleep 1 + ((elapsed += 1)) + done + + log "Timed out waiting for local head to reach ${target}" + return 1 +} + +wait_for_persistence_marker() { + local pid="$1" + local log_file="$2" + local start_line="$3" + local timeout="$4" + local elapsed=0 + + while (( elapsed < timeout )); do + if tail -n "+${start_line}" "$log_file" | \ + grep -E -m1 'save_blocks step plan|save_blocks trie paths|write_trie_updates|Persisting canonical chain' \ + >/dev/null 2>&1; then + return 0 + fi + + if ! kill -0 "$pid" 2>/dev/null; then + log "Node exited before emitting a post-target persistence marker" + return 1 + fi + + sleep 1 + ((elapsed += 1)) + done + + log "Timed out waiting for a post-target persistence marker" + return 1 +} + +stop_bench() { + if [[ -n "$BENCH_PID" ]] && kill -0 "$BENCH_PID" 2>/dev/null; then + stop_pid "$BENCH_PID" TERM "reth-bench" + if ! wait_for_pid_exit "$BENCH_PID" 10; then + stop_pid "$BENCH_PID" KILL "reth-bench" + wait "$BENCH_PID" 2>/dev/null || true + else + wait "$BENCH_PID" 2>/dev/null || true + fi + elif [[ -n "$BENCH_PID" ]]; then + wait "$BENCH_PID" 2>/dev/null || true + fi + + BENCH_PID="" +} + +remove_stale_locks() { + rm -f "$DATADIR/db/lock" "$DATADIR/static_files/lock" "$DATADIR/rocksdb/LOCK" +} + +classify_restart() { + local pid="$1" + local log_file="$2" + local timeout="$3" + local elapsed=0 + local saw_unwind=0 + local rpc_ready_at=-1 + local block_hex + + while (( elapsed < timeout )); do + if grep -E -q 'Failed to verify block state root|failed to run unwind|mismatched block state root' "$log_file" 2>/dev/null; then + RESULT="unwind_failed" + return 0 + fi + + if grep -E -q 'Executing unwind after consistency check|inconsistency_source=partial state trie' "$log_file" 2>/dev/null; then + saw_unwind=1 + fi + + block_hex=$(head_hex || true) + if [[ -n "$block_hex" ]]; then + HEAD_AFTER_RESTART=$(hex_to_dec "$block_hex") + if (( rpc_ready_at < 0 )); then + rpc_ready_at=$elapsed + log "Restart RPC became ready at head ${HEAD_AFTER_RESTART}" + fi + fi + + if (( rpc_ready_at >= 0 && elapsed >= rpc_ready_at + 10 )); then + if (( saw_unwind == 1 )); then + RESULT="unwind_succeeded" + else + RESULT="no_unwind_detected" + fi + return 0 + fi + + if ! kill -0 "$pid" 2>/dev/null; then + if grep -E -q 'Failed to verify block state root|failed to run unwind|mismatched block state root' "$log_file" 2>/dev/null; then + RESULT="unwind_failed" + return 0 + fi + + RESULT="restart_exited_before_rpc_ready" + return 1 + fi + + sleep 1 + ((elapsed += 1)) + done + + RESULT="restart_timeout" + return 1 +} + +restore_snapshot +remove_stale_locks + +log "Starting reth for replay run" +NODE1_PID=$(start_node "$NODE1_LOG") + +HEAD_HEX=$(wait_for_rpc_start "$NODE1_PID" "$START_TIMEOUT" "initial node") || exit 2 +HEAD_BEFORE=$(hex_to_dec "$HEAD_HEX") +printf '%s\n' "$HEAD_BEFORE" >"${ARTIFACTS_DIR}/current_head_before.txt" + +if (( HEAD_BEFORE != EXPECTED_HEAD )); then + log "Expected restored head ${EXPECTED_HEAD}, got ${HEAD_BEFORE}" + exit 2 +fi + +if (( HEAD_BEFORE + 1 != START_BLOCK )); then + log "Expected first replay block ${START_BLOCK}, but restored head implies ${HEAD_BEFORE} -> $((HEAD_BEFORE + 1))" + exit 2 +fi + +ADVANCE=$((TARGET_BLOCK - HEAD_BEFORE)) +if (( ADVANCE <= 0 )); then + log "Target block ${TARGET_BLOCK} must be greater than restored head ${HEAD_BEFORE}" + exit 2 +fi + +capture_command reth_bench "$BENCH_BIN" -vvv new-payload-fcu \ + --rpc-url "$REMOTE_RPC_URL" \ + --advance "$ADVANCE" \ + --jwt-secret "$JWT_SECRET" \ + --engine-rpc-url http://127.0.0.1:8551 \ + --local-rpc-url http://127.0.0.1:8545 \ + --ws-rpc-url ws://127.0.0.1:8546 + +log "Running reth-bench with --advance ${ADVANCE} so replay begins at block ${START_BLOCK}" +"$BENCH_BIN" -vvv new-payload-fcu \ + --rpc-url "$REMOTE_RPC_URL" \ + --advance "$ADVANCE" \ + --jwt-secret "$JWT_SECRET" \ + --engine-rpc-url http://127.0.0.1:8551 \ + --local-rpc-url http://127.0.0.1:8545 \ + --ws-rpc-url ws://127.0.0.1:8546 \ + >"$BENCH_LOG" 2>&1 & +BENCH_PID=$! + +HEAD_AT_CRASH=$(wait_for_target_head "$NODE1_PID" "$TARGET_BLOCK" "$TARGET_TIMEOUT") || exit 2 +printf '%s\n' "$HEAD_AT_CRASH" >"${ARTIFACTS_DIR}/current_head_at_crash.txt" +POST_TARGET_LINE=$(( $(wc -l <"$NODE1_LOG") + 1 )) + +log "Target head ${TARGET_BLOCK} reached; waiting for the next persistence marker before crashing" +wait_for_persistence_marker "$NODE1_PID" "$NODE1_LOG" "$POST_TARGET_LINE" "$PERSISTENCE_TIMEOUT" || exit 2 + +log "Crashing reth with SIGKILL" +stop_pid "$NODE1_PID" KILL "reth crash node" +wait "$NODE1_PID" 2>/dev/null || true +NODE1_PID="" + +stop_bench +remove_stale_locks + +log "Restarting reth to classify unwind behavior" +NODE2_PID=$(start_node "$NODE2_LOG") +classify_restart "$NODE2_PID" "$NODE2_LOG" "$RESTART_TIMEOUT" || exit 2 + +log "Restart result: ${RESULT}" From d11b02bc4b84439eab798d933104972062170c82 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Fri, 1 May 2026 13:07:15 +0000 Subject: [PATCH 42/83] test: harden hoodi partial persistence repro script Adjust the hoodi repro helper to match current provider logging, generate a JWT secret when the snapshot omits one, trigger the shutdown-time persistence flush before kill -9, and clean stale lock files before restart. Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019de22f-c77c-7640-8c34-5c80d4d85c1d Co-authored-by: Amp --- .../repro-hoodi-partial-persistence-unwind.sh | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/scripts/repro-hoodi-partial-persistence-unwind.sh b/scripts/repro-hoodi-partial-persistence-unwind.sh index d15e2222d7c..d887f3288f4 100755 --- a/scripts/repro-hoodi-partial-persistence-unwind.sh +++ b/scripts/repro-hoodi-partial-persistence-unwind.sh @@ -32,7 +32,7 @@ Options: --target-timeout SECONDS Seconds to wait for local head to reach target (default: 900) --persistence-timeout SEC Seconds to wait for a persistence marker after - the target head is reached (default: 120) + the target head is reached (default: 300) --restart-timeout SECONDS Seconds to classify restart behavior (default: 180) -h, --help Show this help @@ -169,7 +169,7 @@ START_BLOCK=2613963 TARGET_BLOCK=2614300 START_TIMEOUT=180 TARGET_TIMEOUT=900 -PERSISTENCE_TIMEOUT=120 +PERSISTENCE_TIMEOUT=300 RESTART_TIMEOUT=180 RETH_BIN="/repos/reth/target/debug/reth" BENCH_BIN="/repos/reth/target/debug/reth-bench" @@ -290,7 +290,7 @@ capture_command reth "$RETH_BIN" node \ --engine.persistence-threshold 10 \ --engine.deferred-trie-blocks 3 \ --engine.accept-execution-requests-hash \ - --log.stdout.filter 'info,reth::providers::database=debug,reth::providers::static_file=debug,reth::storage=debug,consensus::engine=debug' \ + --log.stdout.filter 'info,providers::db=debug,reth::providers::static_file=debug,reth::storage=debug,consensus::engine=debug' \ --color never restore_snapshot() { @@ -318,8 +318,11 @@ restore_snapshot() { fi if [[ ! -f "$JWT_SECRET" ]]; then - log "Restored datadir is missing jwt secret: $JWT_SECRET" - exit 2 + log "Restored datadir is missing jwt secret; generating ${JWT_SECRET}" + mkdir -p "$(dirname "$JWT_SECRET")" + umask 077 + head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' >"$JWT_SECRET" + printf '\n' >>"$JWT_SECRET" fi } @@ -336,7 +339,7 @@ start_node() { --engine.persistence-threshold 10 \ --engine.deferred-trie-blocks 3 \ --engine.accept-execution-requests-hash \ - --log.stdout.filter 'info,reth::providers::database=debug,reth::providers::static_file=debug,reth::storage=debug,consensus::engine=debug' \ + --log.stdout.filter 'info,providers::db=debug,reth::providers::static_file=debug,reth::storage=debug,consensus::engine=debug' \ --color never \ >"$log_file" 2>&1 & echo $! @@ -407,7 +410,7 @@ wait_for_persistence_marker() { local timeout="$4" local elapsed=0 - while (( elapsed < timeout )); do + while (( elapsed <= timeout )); do if tail -n "+${start_line}" "$log_file" | \ grep -E -m1 'save_blocks step plan|save_blocks trie paths|write_trie_updates|Persisting canonical chain' \ >/dev/null 2>&1; then @@ -503,7 +506,6 @@ classify_restart() { } restore_snapshot -remove_stale_locks log "Starting reth for replay run" NODE1_PID=$(start_node "$NODE1_LOG") @@ -551,7 +553,10 @@ HEAD_AT_CRASH=$(wait_for_target_head "$NODE1_PID" "$TARGET_BLOCK" "$TARGET_TIMEO printf '%s\n' "$HEAD_AT_CRASH" >"${ARTIFACTS_DIR}/current_head_at_crash.txt" POST_TARGET_LINE=$(( $(wc -l <"$NODE1_LOG") + 1 )) -log "Target head ${TARGET_BLOCK} reached; waiting for the next persistence marker before crashing" +log "Target head ${TARGET_BLOCK} reached; sending SIGTERM to trigger the final persistence flush" +stop_pid "$NODE1_PID" TERM "reth crash node" + +log "Waiting for the shutdown-triggered persistence marker before crashing" wait_for_persistence_marker "$NODE1_PID" "$NODE1_LOG" "$POST_TARGET_LINE" "$PERSISTENCE_TIMEOUT" || exit 2 log "Crashing reth with SIGKILL" From c0a8e8240f66f1d4de1fd74a35285c250d4c11bd Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Fri, 1 May 2026 14:21:59 +0000 Subject: [PATCH 43/83] test: capture unwind trace logs in hoodi repro Write trace-level file logs for the restart node into the repro artifact directory so unwind failures include a full trace log alongside stdout. Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019de22f-c77c-7640-8c34-5c80d4d85c1d Co-authored-by: Amp --- .../repro-hoodi-partial-persistence-unwind.sh | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/scripts/repro-hoodi-partial-persistence-unwind.sh b/scripts/repro-hoodi-partial-persistence-unwind.sh index d887f3288f4..2290a24e1c1 100755 --- a/scripts/repro-hoodi-partial-persistence-unwind.sh +++ b/scripts/repro-hoodi-partial-persistence-unwind.sh @@ -138,6 +138,8 @@ write_summary() { printf 'node1_log=%s\n' "$NODE1_LOG" printf 'bench_log=%s\n' "$BENCH_LOG" printf 'node2_log=%s\n' "$NODE2_LOG" + printf 'unwind_trace_dir=%s\n' "$UNWIND_TRACE_DIR" + printf 'unwind_trace_log_glob=%s/%s*\n' "$UNWIND_TRACE_DIR" "$UNWIND_TRACE_LOG_NAME" } >"$SUMMARY_FILE" } @@ -257,6 +259,8 @@ SUMMARY_FILE="${ARTIFACTS_DIR}/result.txt" NODE1_LOG="${ARTIFACTS_DIR}/node1.log" BENCH_LOG="${ARTIFACTS_DIR}/bench.log" NODE2_LOG="${ARTIFACTS_DIR}/node2.log" +UNWIND_TRACE_DIR="${ARTIFACTS_DIR}/unwind-trace-logs" +UNWIND_TRACE_LOG_NAME="unwind-trace.log" trap cleanup EXIT @@ -293,6 +297,23 @@ capture_command reth "$RETH_BIN" node \ --log.stdout.filter 'info,providers::db=debug,reth::providers::static_file=debug,reth::storage=debug,consensus::engine=debug' \ --color never +capture_command reth_restart "$RETH_BIN" node \ + --datadir "$DATADIR" \ + --chain "$CHAIN" \ + --http --http.addr 127.0.0.1 --http.port 8545 --http.api eth,net,web3,reth \ + --ws --ws.addr 127.0.0.1 --ws.port 8546 --ws.api eth,net,web3,reth \ + --authrpc.addr 127.0.0.1 --authrpc.port 8551 --authrpc.jwtsecret "$JWT_SECRET" \ + --disable-discovery \ + --engine.persistence-threshold 10 \ + --engine.deferred-trie-blocks 3 \ + --engine.accept-execution-requests-hash \ + --log.stdout.filter 'info,providers::db=debug,reth::providers::static_file=debug,reth::storage=debug,consensus::engine=debug' \ + --log.file.directory "$UNWIND_TRACE_DIR" \ + --log.file.name "$UNWIND_TRACE_LOG_NAME" \ + --log.file.filter trace \ + --log.file.max-files 1 \ + --color never + restore_snapshot() { local parent_dir local base_name @@ -345,6 +366,30 @@ start_node() { echo $! } +start_unwind_node() { + local log_file="$1" + + mkdir -p "$UNWIND_TRACE_DIR" + "$RETH_BIN" node \ + --datadir "$DATADIR" \ + --chain "$CHAIN" \ + --http --http.addr 127.0.0.1 --http.port 8545 --http.api eth,net,web3,reth \ + --ws --ws.addr 127.0.0.1 --ws.port 8546 --ws.api eth,net,web3,reth \ + --authrpc.addr 127.0.0.1 --authrpc.port 8551 --authrpc.jwtsecret "$JWT_SECRET" \ + --disable-discovery \ + --engine.persistence-threshold 10 \ + --engine.deferred-trie-blocks 3 \ + --engine.accept-execution-requests-hash \ + --log.stdout.filter 'info,providers::db=debug,reth::providers::static_file=debug,reth::storage=debug,consensus::engine=debug' \ + --log.file.directory "$UNWIND_TRACE_DIR" \ + --log.file.name "$UNWIND_TRACE_LOG_NAME" \ + --log.file.filter trace \ + --log.file.max-files 1 \ + --color never \ + >"$log_file" 2>&1 & + echo $! +} + wait_for_rpc_start() { local pid="$1" local timeout="$2" @@ -568,7 +613,7 @@ stop_bench remove_stale_locks log "Restarting reth to classify unwind behavior" -NODE2_PID=$(start_node "$NODE2_LOG") +NODE2_PID=$(start_unwind_node "$NODE2_LOG") classify_restart "$NODE2_PID" "$NODE2_LOG" "$RESTART_TIMEOUT" || exit 2 log "Restart result: ${RESULT}" From f680db74c85ee618ea320390918e190ff7093000 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Fri, 1 May 2026 15:47:46 +0000 Subject: [PATCH 44/83] fix(scripts): pipe hoodi unwind traces to artifacts Route the restart trace logs through stdout instead of reth log files, then run a postmortem Merkle drop and targeted stage unwind using the extracted unwind target. Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019de435-0e17-7676-bdc2-65c715f99428 Co-authored-by: Amp --- .../repro-hoodi-partial-persistence-unwind.sh | 125 +++++++++++++++--- 1 file changed, 108 insertions(+), 17 deletions(-) diff --git a/scripts/repro-hoodi-partial-persistence-unwind.sh b/scripts/repro-hoodi-partial-persistence-unwind.sh index 2290a24e1c1..a2fe7d0b369 100755 --- a/scripts/repro-hoodi-partial-persistence-unwind.sh +++ b/scripts/repro-hoodi-partial-persistence-unwind.sh @@ -138,8 +138,13 @@ write_summary() { printf 'node1_log=%s\n' "$NODE1_LOG" printf 'bench_log=%s\n' "$BENCH_LOG" printf 'node2_log=%s\n' "$NODE2_LOG" - printf 'unwind_trace_dir=%s\n' "$UNWIND_TRACE_DIR" - printf 'unwind_trace_log_glob=%s/%s*\n' "$UNWIND_TRACE_DIR" "$UNWIND_TRACE_LOG_NAME" + printf 'restart_trace_log=%s\n' "$RESTART_TRACE_LOG" + printf 'failed_unwind_target=%s\n' "${FAILED_UNWIND_TARGET:-unknown}" + printf 'drop_merkle_result=%s\n' "${DROP_MERKLE_RESULT:-not_run}" + printf 'drop_merkle_log=%s\n' "$DROP_MERKLE_LOG" + printf 'post_drop_unwind_result=%s\n' "${POST_DROP_UNWIND_RESULT:-not_run}" + printf 'post_drop_unwind_log=%s\n' "$POST_DROP_UNWIND_LOG" + printf 'post_drop_unwind_trace_log=%s\n' "$POST_DROP_UNWIND_TRACE_LOG" } >"$SUMMARY_FILE" } @@ -184,6 +189,9 @@ HEAD_AFTER_RESTART="" NODE1_PID="" NODE2_PID="" BENCH_PID="" +FAILED_UNWIND_TARGET="" +DROP_MERKLE_RESULT="not_run" +POST_DROP_UNWIND_RESULT="not_run" TIMESTAMP="$(date '+%Y%m%d-%H%M%S')" ARTIFACTS_DIR="/tmp/reth-hoodi-unwind-${TIMESTAMP}" @@ -259,8 +267,10 @@ SUMMARY_FILE="${ARTIFACTS_DIR}/result.txt" NODE1_LOG="${ARTIFACTS_DIR}/node1.log" BENCH_LOG="${ARTIFACTS_DIR}/bench.log" NODE2_LOG="${ARTIFACTS_DIR}/node2.log" -UNWIND_TRACE_DIR="${ARTIFACTS_DIR}/unwind-trace-logs" -UNWIND_TRACE_LOG_NAME="unwind-trace.log" +RESTART_TRACE_LOG="${ARTIFACTS_DIR}/restart-trace.log" +DROP_MERKLE_LOG="${ARTIFACTS_DIR}/drop-merkle.log" +POST_DROP_UNWIND_LOG="${ARTIFACTS_DIR}/post-drop-unwind.log" +POST_DROP_UNWIND_TRACE_LOG="${ARTIFACTS_DIR}/post-drop-unwind-trace.log" trap cleanup EXIT @@ -307,13 +317,16 @@ capture_command reth_restart "$RETH_BIN" node \ --engine.persistence-threshold 10 \ --engine.deferred-trie-blocks 3 \ --engine.accept-execution-requests-hash \ - --log.stdout.filter 'info,providers::db=debug,reth::providers::static_file=debug,reth::storage=debug,consensus::engine=debug' \ - --log.file.directory "$UNWIND_TRACE_DIR" \ - --log.file.name "$UNWIND_TRACE_LOG_NAME" \ - --log.file.filter trace \ - --log.file.max-files 1 \ + --log.stdout.filter trace \ --color never +capture_command reth_stage_drop_merkle "$RETH_BIN" stage drop \ + --datadir "$DATADIR" \ + --chain "$CHAIN" \ + --log.stdout.filter info \ + --color never \ + merkle + restore_snapshot() { local parent_dir local base_name @@ -368,8 +381,8 @@ start_node() { start_unwind_node() { local log_file="$1" + local trace_log="$2" - mkdir -p "$UNWIND_TRACE_DIR" "$RETH_BIN" node \ --datadir "$DATADIR" \ --chain "$CHAIN" \ @@ -380,13 +393,9 @@ start_unwind_node() { --engine.persistence-threshold 10 \ --engine.deferred-trie-blocks 3 \ --engine.accept-execution-requests-hash \ - --log.stdout.filter 'info,providers::db=debug,reth::providers::static_file=debug,reth::storage=debug,consensus::engine=debug' \ - --log.file.directory "$UNWIND_TRACE_DIR" \ - --log.file.name "$UNWIND_TRACE_LOG_NAME" \ - --log.file.filter trace \ - --log.file.max-files 1 \ + --log.stdout.filter trace \ --color never \ - >"$log_file" 2>&1 & + > >(tee "$trace_log" >"$log_file") 2>&1 & echo $! } @@ -495,6 +504,51 @@ remove_stale_locks() { rm -f "$DATADIR/db/lock" "$DATADIR/static_files/lock" "$DATADIR/rocksdb/LOCK" } +stop_restart_node() { + if [[ -n "$NODE2_PID" ]] && kill -0 "$NODE2_PID" 2>/dev/null; then + stop_pid "$NODE2_PID" TERM "reth restart node" + if ! wait_for_pid_exit "$NODE2_PID" 30; then + stop_pid "$NODE2_PID" KILL "reth restart node" + fi + fi + + if [[ -n "$NODE2_PID" ]]; then + wait "$NODE2_PID" 2>/dev/null || true + fi + + NODE2_PID="" +} + +extract_unwind_target() { + local log_file="$1" + + sed -n 's/.*unwind_target=Unwind(\([0-9]\+\)).*/\1/p' "$log_file" | head -n1 +} + +run_drop_merkle() { + log "Dropping the Merkle stage before the targeted unwind rerun" + "$RETH_BIN" stage drop \ + --datadir "$DATADIR" \ + --chain "$CHAIN" \ + --log.stdout.filter info \ + --color never \ + merkle \ + >"$DROP_MERKLE_LOG" 2>&1 +} + +run_post_drop_unwind() { + local target="$1" + + log "Re-running unwind with trace logs piped to ${POST_DROP_UNWIND_TRACE_LOG}" + "$RETH_BIN" stage unwind \ + --datadir "$DATADIR" \ + --chain "$CHAIN" \ + --log.stdout.filter trace \ + --color never \ + to-block "$target" \ + > >(tee "$POST_DROP_UNWIND_TRACE_LOG" >"$POST_DROP_UNWIND_LOG") 2>&1 +} + classify_restart() { local pid="$1" local log_file="$2" @@ -613,7 +667,44 @@ stop_bench remove_stale_locks log "Restarting reth to classify unwind behavior" -NODE2_PID=$(start_unwind_node "$NODE2_LOG") +NODE2_PID=$(start_unwind_node "$NODE2_LOG" "$RESTART_TRACE_LOG") classify_restart "$NODE2_PID" "$NODE2_LOG" "$RESTART_TIMEOUT" || exit 2 +FAILED_UNWIND_TARGET=$(extract_unwind_target "$NODE2_LOG" || true) +if [[ -n "$FAILED_UNWIND_TARGET" ]]; then + printf '%s\n' "$FAILED_UNWIND_TARGET" >"${ARTIFACTS_DIR}/failed_unwind_target.txt" +fi + +stop_restart_node +remove_stale_locks + +if [[ "$RESULT" == "unwind_failed" || "$RESULT" == "unwind_succeeded" ]]; then + if [[ -z "$FAILED_UNWIND_TARGET" ]]; then + log "Failed to extract unwind_target from ${NODE2_LOG}" + exit 2 + fi + + capture_command reth_stage_unwind "$RETH_BIN" stage unwind \ + --datadir "$DATADIR" \ + --chain "$CHAIN" \ + --log.stdout.filter trace \ + --color never \ + to-block "$FAILED_UNWIND_TARGET" + + if ! run_drop_merkle; then + DROP_MERKLE_RESULT="failed" + exit 2 + fi + DROP_MERKLE_RESULT="ok" + + if ! run_post_drop_unwind "$FAILED_UNWIND_TARGET"; then + POST_DROP_UNWIND_RESULT="failed" + exit 2 + fi + POST_DROP_UNWIND_RESULT="ok" +else + DROP_MERKLE_RESULT="skipped_no_unwind_target" + POST_DROP_UNWIND_RESULT="skipped_no_unwind_target" +fi + log "Restart result: ${RESULT}" From b810c778d2bf853b1427c3ebdc5f49e443a0a127 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Fri, 1 May 2026 16:51:04 +0000 Subject: [PATCH 45/83] feat(scripts): add merkle trace touch extractor Add a trace parser that extracts Merkle-stage account leaves, storage leaves, trie branch nodes, and state/storage root summaries from a reth trace log. Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019de435-0e17-7676-bdc2-65c715f99428 Co-authored-by: Amp --- scripts/extract-merkle-trace-touches.py | 335 ++++++++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100755 scripts/extract-merkle-trace-touches.py diff --git a/scripts/extract-merkle-trace-touches.py b/scripts/extract-merkle-trace-touches.py new file mode 100755 index 00000000000..cafcea7af8c --- /dev/null +++ b/scripts/extract-merkle-trace-touches.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 + +"""Extract Merkle-stage leaves and trie nodes from a reth trace log. + +The trace logs emitted by `reth` only include hashed addresses and hashed storage +slots, so this script reports the hashed values exactly as they appear in the +trace. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + + +STAGE_RE = re.compile(r"stage=(Merkle[^}]+)") +STORAGE_TRIE_ADDR_RE = re.compile(r"storage_trie\{addr=(0x[0-9a-f]+)(?: [^}]*)?\}") +ACCOUNT_LEAF_RE = re.compile( + r"Leaf\(" + r"(0x[0-9a-f]+), " + r"Account \{ nonce: ([0-9]+), balance: ([0-9]+), bytecode_hash: " + r"(?:None|Some\((0x[0-9a-f]+)\))" + r" \}\)" +) +STORAGE_LEAF_RE = re.compile(r"Leaf\((0x[0-9a-f]+), (.+)\)\)\)$") +BRANCH_RE = re.compile( + r"Branch\(TrieBranchNode \{ " + r"key: Nibbles\((0x[0-9a-f]*)\), " + r"value: (0x[0-9a-f]+), " + r"children_are_in_trie: (true|false) " + r"\}\)" +) +STATE_ROOT_RE = re.compile( + r"calculated state root " + r"root=(0x[0-9a-f]+) " + r"duration=([^ ]+) " + r"branches_added=([0-9]+) " + r"leaves_added=([0-9]+)" +) +STORAGE_ROOT_RE = re.compile( + r"calculated storage root " + r"root=(0x[0-9a-f]+) " + r"hashed_address=(0x[0-9a-f]+) " + r"duration=([^ ]+) " + r"branches_added=([0-9]+) " + r"leaves_added=([0-9]+)" +) + + +def bool_from_string(value: str) -> bool: + return value == "true" + + +def add_unique(bucket: dict[tuple[str, ...], dict[str, object]], key: tuple[str, ...], payload: dict[str, object], line_number: int) -> None: + record = bucket.get(key) + if record is None: + bucket[key] = { + **payload, + "occurrences": 1, + "first_line": line_number, + "last_line": line_number, + } + return + + record["occurrences"] = int(record["occurrences"]) + 1 + record["last_line"] = line_number + + +def sorted_records(bucket: dict[tuple[str, ...], dict[str, object]]) -> list[dict[str, object]]: + return sorted(bucket.values(), key=lambda item: (int(item["first_line"]), int(item["last_line"]))) + + +def parse_trace(trace_path: Path) -> dict[str, object]: + account_leaves: dict[tuple[str, ...], dict[str, object]] = {} + storage_leaves: dict[tuple[str, ...], dict[str, object]] = {} + state_trie_nodes: dict[tuple[str, ...], dict[str, object]] = {} + storage_trie_nodes: dict[tuple[str, ...], dict[str, object]] = {} + state_roots: list[dict[str, object]] = [] + storage_roots: list[dict[str, object]] = [] + stages_seen: set[str] = set() + + account_leaf_occurrences = 0 + storage_leaf_occurrences = 0 + state_trie_node_occurrences = 0 + storage_trie_node_occurrences = 0 + + with trace_path.open("r", encoding="utf-8", errors="replace") as handle: + for line_number, line in enumerate(handle, start=1): + stage_match = STAGE_RE.search(line) + if stage_match is None: + continue + + stage = stage_match.group(1) + if not stage.startswith("Merkle"): + continue + stages_seen.add(stage) + + storage_addr_match = STORAGE_TRIE_ADDR_RE.search(line) + storage_addr = storage_addr_match.group(1) if storage_addr_match else None + + if "trie::state_root: calculated state root" in line: + state_root_match = STATE_ROOT_RE.search(line) + if state_root_match: + state_roots.append( + { + "line": line_number, + "stage": stage, + "root": state_root_match.group(1), + "duration": state_root_match.group(2), + "branches_added": int(state_root_match.group(3)), + "leaves_added": int(state_root_match.group(4)), + } + ) + continue + + if "trie::storage_root: calculated storage root" in line: + storage_root_match = STORAGE_ROOT_RE.search(line) + if storage_root_match: + storage_roots.append( + { + "line": line_number, + "stage": stage, + "hashed_address": storage_root_match.group(2), + "root": storage_root_match.group(1), + "duration": storage_root_match.group(3), + "branches_added": int(storage_root_match.group(4)), + "leaves_added": int(storage_root_match.group(5)), + } + ) + continue + + if "trie::node_iter: return=Ok(Some(" not in line: + continue + + branch_match = BRANCH_RE.search(line) + if branch_match: + node_payload = { + "stage": stage, + "key": branch_match.group(1), + "node_hash": branch_match.group(2), + "children_are_in_trie": bool_from_string(branch_match.group(3)), + } + if "trie_type=Storage" in line and storage_addr is not None: + storage_trie_node_occurrences += 1 + add_unique( + storage_trie_nodes, + ( + stage, + storage_addr, + node_payload["key"], + node_payload["node_hash"], + str(node_payload["children_are_in_trie"]), + ), + {**node_payload, "hashed_address": storage_addr}, + line_number, + ) + elif "trie_type=State" in line: + state_trie_node_occurrences += 1 + add_unique( + state_trie_nodes, + ( + stage, + node_payload["key"], + node_payload["node_hash"], + str(node_payload["children_are_in_trie"]), + ), + node_payload, + line_number, + ) + continue + + account_leaf_match = ACCOUNT_LEAF_RE.search(line) + if account_leaf_match and "trie_type=State" in line: + account_leaf_occurrences += 1 + bytecode_hash = account_leaf_match.group(4) + add_unique( + account_leaves, + ( + stage, + account_leaf_match.group(1), + account_leaf_match.group(2), + account_leaf_match.group(3), + bytecode_hash or "None", + ), + { + "stage": stage, + "hashed_address": account_leaf_match.group(1), + "account": { + "nonce": account_leaf_match.group(2), + "balance": account_leaf_match.group(3), + "bytecode_hash": bytecode_hash, + }, + }, + line_number, + ) + continue + + storage_leaf_match = STORAGE_LEAF_RE.search(line) + if storage_leaf_match and "trie_type=Storage" in line and storage_addr is not None: + storage_leaf_occurrences += 1 + add_unique( + storage_leaves, + ( + stage, + storage_addr, + storage_leaf_match.group(1), + storage_leaf_match.group(2), + ), + { + "stage": stage, + "hashed_address": storage_addr, + "hashed_slot": storage_leaf_match.group(1), + "value": storage_leaf_match.group(2), + }, + line_number, + ) + + return { + "trace_file": str(trace_path), + "summary": { + "stages": sorted(stages_seen), + "account_leaves": { + "unique": len(account_leaves), + "occurrences": account_leaf_occurrences, + }, + "storage_leaves": { + "unique": len(storage_leaves), + "occurrences": storage_leaf_occurrences, + }, + "state_trie_nodes": { + "unique": len(state_trie_nodes), + "occurrences": state_trie_node_occurrences, + }, + "storage_trie_nodes": { + "unique": len(storage_trie_nodes), + "occurrences": storage_trie_node_occurrences, + }, + "state_roots": len(state_roots), + "storage_roots": len(storage_roots), + }, + "account_leaves": sorted_records(account_leaves), + "storage_leaves": sorted_records(storage_leaves), + "state_trie_nodes": sorted_records(state_trie_nodes), + "storage_trie_nodes": sorted_records(storage_trie_nodes), + "state_roots": state_roots, + "storage_roots": storage_roots, + } + + +def print_summary(result: dict[str, object]) -> None: + summary = result["summary"] + if not isinstance(summary, dict): + raise TypeError("summary must be a dictionary") + + print(f"trace_file: {result['trace_file']}") + print(f"stages: {', '.join(summary['stages'])}") + print( + "account_leaves: " + f"unique={summary['account_leaves']['unique']} " + f"occurrences={summary['account_leaves']['occurrences']}" + ) + print( + "storage_leaves: " + f"unique={summary['storage_leaves']['unique']} " + f"occurrences={summary['storage_leaves']['occurrences']}" + ) + print( + "state_trie_nodes: " + f"unique={summary['state_trie_nodes']['unique']} " + f"occurrences={summary['state_trie_nodes']['occurrences']}" + ) + print( + "storage_trie_nodes: " + f"unique={summary['storage_trie_nodes']['unique']} " + f"occurrences={summary['storage_trie_nodes']['occurrences']}" + ) + print(f"state_roots: {summary['state_roots']}") + print(f"storage_roots: {summary['storage_roots']}") + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Extract touched Merkle-stage account/storage leaves and trie nodes from a trace log.", + ) + parser.add_argument("trace_file", type=Path, help="Path to the trace log file") + parser.add_argument( + "--summary", + action="store_true", + help="Print only high-level counts instead of the full JSON payload", + ) + parser.add_argument( + "--output", + type=Path, + help="Write the extracted payload to a file instead of stdout", + ) + return parser + + +def main() -> int: + parser = build_arg_parser() + args = parser.parse_args() + + if not args.trace_file.is_file(): + parser.error(f"trace file does not exist: {args.trace_file}") + + result = parse_trace(args.trace_file) + + if args.summary: + if args.output is not None: + with args.output.open("w", encoding="utf-8") as handle: + original_stdout = sys.stdout + try: + sys.stdout = handle + print_summary(result) + finally: + sys.stdout = original_stdout + return 0 + + print_summary(result) + return 0 + + payload = json.dumps(result, indent=2, sort_keys=False) + if args.output is not None: + args.output.write_text(payload + "\n", encoding="utf-8") + return 0 + + print(payload) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 02044b51c1b34f5d4c080b0f7e6f5b9f0f66eb90 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Sat, 2 May 2026 18:44:04 +0000 Subject: [PATCH 46/83] fix(scripts): use profiling binaries for hoodi repro Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019de47f-0de0-704c-9778-0b236feb368d Co-authored-by: Amp --- scripts/extract-merkle-trace-touches.py | 27 ++++- .../repro-hoodi-partial-persistence-unwind.sh | 107 ++++++++++++++++-- 2 files changed, 116 insertions(+), 18 deletions(-) diff --git a/scripts/extract-merkle-trace-touches.py b/scripts/extract-merkle-trace-touches.py index cafcea7af8c..10b8404ed94 100755 --- a/scripts/extract-merkle-trace-touches.py +++ b/scripts/extract-merkle-trace-touches.py @@ -4,7 +4,8 @@ The trace logs emitted by `reth` only include hashed addresses and hashed storage slots, so this script reports the hashed values exactly as they appear in the -trace. +trace. It supports both pipeline logs with `stage=Merkle...` spans and standalone +`reth stage run merkle` logs. """ from __future__ import annotations @@ -48,6 +49,11 @@ r"branches_added=([0-9]+) " r"leaves_added=([0-9]+)" ) +MERKLE_LINE_MARKERS = ( + "trie::node_iter:", + "trie::state_root:", + "trie::storage_root:", +) def bool_from_string(value: str) -> bool: @@ -73,6 +79,18 @@ def sorted_records(bucket: dict[tuple[str, ...], dict[str, object]]) -> list[dic return sorted(bucket.values(), key=lambda item: (int(item["first_line"]), int(item["last_line"]))) +def detect_stage(line: str) -> str | None: + stage_match = STAGE_RE.search(line) + if stage_match is not None: + stage = stage_match.group(1).strip() + return stage if stage.startswith("Merkle") else None + + if any(marker in line for marker in MERKLE_LINE_MARKERS): + return "Merkle" + + return None + + def parse_trace(trace_path: Path) -> dict[str, object]: account_leaves: dict[tuple[str, ...], dict[str, object]] = {} storage_leaves: dict[tuple[str, ...], dict[str, object]] = {} @@ -89,13 +107,10 @@ def parse_trace(trace_path: Path) -> dict[str, object]: with trace_path.open("r", encoding="utf-8", errors="replace") as handle: for line_number, line in enumerate(handle, start=1): - stage_match = STAGE_RE.search(line) - if stage_match is None: + stage = detect_stage(line) + if stage is None: continue - stage = stage_match.group(1) - if not stage.startswith("Merkle"): - continue stages_seen.add(stage) storage_addr_match = STORAGE_TRIE_ADDR_RE.search(line) diff --git a/scripts/repro-hoodi-partial-persistence-unwind.sh b/scripts/repro-hoodi-partial-persistence-unwind.sh index a2fe7d0b369..3216752c01d 100755 --- a/scripts/repro-hoodi-partial-persistence-unwind.sh +++ b/scripts/repro-hoodi-partial-persistence-unwind.sh @@ -144,7 +144,9 @@ write_summary() { printf 'drop_merkle_log=%s\n' "$DROP_MERKLE_LOG" printf 'post_drop_unwind_result=%s\n' "${POST_DROP_UNWIND_RESULT:-not_run}" printf 'post_drop_unwind_log=%s\n' "$POST_DROP_UNWIND_LOG" - printf 'post_drop_unwind_trace_log=%s\n' "$POST_DROP_UNWIND_TRACE_LOG" + printf 'post_drop_merkle_run_result=%s\n' "${POST_DROP_MERKLE_RUN_RESULT:-not_run}" + printf 'post_drop_merkle_run_log=%s\n' "$POST_DROP_MERKLE_RUN_LOG" + printf 'post_drop_merkle_run_trace_log=%s\n' "$POST_DROP_MERKLE_RUN_TRACE_LOG" } >"$SUMMARY_FILE" } @@ -178,9 +180,11 @@ START_TIMEOUT=180 TARGET_TIMEOUT=900 PERSISTENCE_TIMEOUT=300 RESTART_TIMEOUT=180 -RETH_BIN="/repos/reth/target/debug/reth" -BENCH_BIN="/repos/reth/target/debug/reth-bench" +RETH_BIN="/repos/reth/target/profiling/reth" +BENCH_BIN="/repos/reth/target/profiling/reth-bench" CHAIN="hoodi" +MERKLE_TRACE_FILTER='info,sync::pipeline=debug,reth::cli=info,trie::node_iter=trace,trie::state_root=trace,trie::storage_root=trace' +MERKLE_TRACE_CAPTURE_PATTERN='trie::node_iter: return=Ok\(Some\(Leaf\(|trie::state_root: calculated state root|trie::storage_root: calculated storage root' RESULT="script_error" ADVANCE="" HEAD_BEFORE="" @@ -192,6 +196,7 @@ BENCH_PID="" FAILED_UNWIND_TARGET="" DROP_MERKLE_RESULT="not_run" POST_DROP_UNWIND_RESULT="not_run" +POST_DROP_MERKLE_RUN_RESULT="not_run" TIMESTAMP="$(date '+%Y%m%d-%H%M%S')" ARTIFACTS_DIR="/tmp/reth-hoodi-unwind-${TIMESTAMP}" @@ -270,7 +275,8 @@ NODE2_LOG="${ARTIFACTS_DIR}/node2.log" RESTART_TRACE_LOG="${ARTIFACTS_DIR}/restart-trace.log" DROP_MERKLE_LOG="${ARTIFACTS_DIR}/drop-merkle.log" POST_DROP_UNWIND_LOG="${ARTIFACTS_DIR}/post-drop-unwind.log" -POST_DROP_UNWIND_TRACE_LOG="${ARTIFACTS_DIR}/post-drop-unwind-trace.log" +POST_DROP_MERKLE_RUN_LOG="${ARTIFACTS_DIR}/post-drop-merkle-run.log" +POST_DROP_MERKLE_RUN_TRACE_LOG="${ARTIFACTS_DIR}/post-drop-merkle-run-trace.log" trap cleanup EXIT @@ -331,6 +337,9 @@ restore_snapshot() { local parent_dir local base_name local extract_root + local candidate_datadir="" + local -a nested_candidates=() + local nested_dir parent_dir=$(dirname "$DATADIR") base_name=$(basename "$DATADIR") @@ -342,15 +351,36 @@ restore_snapshot() { tar --zstd -xf "$SNAPSHOT" -C "$extract_root" if [[ -d "${extract_root}/${base_name}/db" && -d "${extract_root}/${base_name}/static_files" ]]; then - mv "${extract_root}/${base_name}" "$DATADIR" - rm -rf "$extract_root" - elif [[ -d "${extract_root}/db" && -d "${extract_root}/static_files" ]]; then - mv "$extract_root" "$DATADIR" + candidate_datadir="${extract_root}/${base_name}" else + while IFS= read -r nested_dir; do + if [[ -d "${nested_dir}/db" && -d "${nested_dir}/static_files" ]]; then + nested_candidates+=("$nested_dir") + fi + done < <(find "$extract_root" -mindepth 1 -maxdepth 1 -type d | sort) + + if ((${#nested_candidates[@]} == 1)); then + candidate_datadir="${nested_candidates[0]}" + elif ((${#nested_candidates[@]} > 1)); then + log "Snapshot layout produced multiple nested datadir candidates under ${extract_root}: ${nested_candidates[*]}" + exit 2 + elif [[ -d "${extract_root}/db" && -d "${extract_root}/static_files" ]]; then + candidate_datadir="$extract_root" + fi + fi + + if [[ -z "$candidate_datadir" ]]; then log "Snapshot layout did not produce an expected datadir under ${extract_root}" exit 2 fi + if [[ "$candidate_datadir" == "$extract_root" ]]; then + mv "$extract_root" "$DATADIR" + else + mv "$candidate_datadir" "$DATADIR" + rm -rf "$extract_root" + fi + if [[ ! -f "$JWT_SECRET" ]]; then log "Restored datadir is missing jwt secret; generating ${JWT_SECRET}" mkdir -p "$(dirname "$JWT_SECRET")" @@ -539,14 +569,45 @@ run_drop_merkle() { run_post_drop_unwind() { local target="$1" - log "Re-running unwind with trace logs piped to ${POST_DROP_UNWIND_TRACE_LOG}" + log "Re-running unwind without trace capture to restore the pre-failure head" "$RETH_BIN" stage unwind \ --datadir "$DATADIR" \ --chain "$CHAIN" \ - --log.stdout.filter trace \ + --log.stdout.filter info \ --color never \ to-block "$target" \ - > >(tee "$POST_DROP_UNWIND_TRACE_LOG" >"$POST_DROP_UNWIND_LOG") 2>&1 + >"$POST_DROP_UNWIND_LOG" 2>&1 +} + +run_post_drop_merkle() { + local target="$1" + local merkle_pid + + log "Rebuilding the Merkle stage with filtered trace logs piped to ${POST_DROP_MERKLE_RUN_TRACE_LOG}" + ( + "$RETH_BIN" stage run \ + --datadir "$DATADIR" \ + --chain "$CHAIN" \ + --from 0 \ + --to "$target" \ + --skip-unwind \ + --checkpoints \ + --commit \ + --disable-discovery \ + --log.stdout.filter "$MERKLE_TRACE_FILTER" \ + --color never \ + merkle 2>&1 | \ + rg --line-buffered "$MERKLE_TRACE_CAPTURE_PATTERN" | \ + tee "$POST_DROP_MERKLE_RUN_TRACE_LOG" >"$POST_DROP_MERKLE_RUN_LOG" + ) & + merkle_pid=$! + + while kill -0 "$merkle_pid" 2>/dev/null; do + log "Waiting for the post-drop Merkle rebuild to finish" + sleep 300 + done + + wait "$merkle_pid" } classify_restart() { @@ -687,10 +748,23 @@ if [[ "$RESULT" == "unwind_failed" || "$RESULT" == "unwind_succeeded" ]]; then capture_command reth_stage_unwind "$RETH_BIN" stage unwind \ --datadir "$DATADIR" \ --chain "$CHAIN" \ - --log.stdout.filter trace \ + --log.stdout.filter info \ --color never \ to-block "$FAILED_UNWIND_TARGET" + capture_command reth_stage_run_merkle "$RETH_BIN" stage run \ + --datadir "$DATADIR" \ + --chain "$CHAIN" \ + --from 0 \ + --to "$FAILED_UNWIND_TARGET" \ + --skip-unwind \ + --checkpoints \ + --commit \ + --disable-discovery \ + --log.stdout.filter "$MERKLE_TRACE_FILTER" \ + --color never \ + merkle + if ! run_drop_merkle; then DROP_MERKLE_RESULT="failed" exit 2 @@ -702,9 +776,18 @@ if [[ "$RESULT" == "unwind_failed" || "$RESULT" == "unwind_succeeded" ]]; then exit 2 fi POST_DROP_UNWIND_RESULT="ok" + + remove_stale_locks + + if ! run_post_drop_merkle "$FAILED_UNWIND_TARGET"; then + POST_DROP_MERKLE_RUN_RESULT="failed" + exit 2 + fi + POST_DROP_MERKLE_RUN_RESULT="ok" else DROP_MERKLE_RESULT="skipped_no_unwind_target" POST_DROP_UNWIND_RESULT="skipped_no_unwind_target" + POST_DROP_MERKLE_RUN_RESULT="skipped_no_unwind_target" fi log "Restart result: ${RESULT}" From 858e6c2b075bd88dd29a991d6067cd5544be8663 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Mon, 4 May 2026 07:47:54 +0000 Subject: [PATCH 47/83] fix(scripts): capture full merkle trace logs Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019df1ed-3d05-725f-9d07-41e68205ca0e Co-authored-by: Amp --- scripts/repro-hoodi-partial-persistence-unwind.sh | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/scripts/repro-hoodi-partial-persistence-unwind.sh b/scripts/repro-hoodi-partial-persistence-unwind.sh index 3216752c01d..400ff6aefbd 100755 --- a/scripts/repro-hoodi-partial-persistence-unwind.sh +++ b/scripts/repro-hoodi-partial-persistence-unwind.sh @@ -183,8 +183,7 @@ RESTART_TIMEOUT=180 RETH_BIN="/repos/reth/target/profiling/reth" BENCH_BIN="/repos/reth/target/profiling/reth-bench" CHAIN="hoodi" -MERKLE_TRACE_FILTER='info,sync::pipeline=debug,reth::cli=info,trie::node_iter=trace,trie::state_root=trace,trie::storage_root=trace' -MERKLE_TRACE_CAPTURE_PATTERN='trie::node_iter: return=Ok\(Some\(Leaf\(|trie::state_root: calculated state root|trie::storage_root: calculated storage root' +MERKLE_TRACE_FILTER='trace' RESULT="script_error" ADVANCE="" HEAD_BEFORE="" @@ -583,7 +582,7 @@ run_post_drop_merkle() { local target="$1" local merkle_pid - log "Rebuilding the Merkle stage with filtered trace logs piped to ${POST_DROP_MERKLE_RUN_TRACE_LOG}" + log "Rebuilding the Merkle stage with full trace logs piped to ${POST_DROP_MERKLE_RUN_TRACE_LOG}" ( "$RETH_BIN" stage run \ --datadir "$DATADIR" \ @@ -597,7 +596,6 @@ run_post_drop_merkle() { --log.stdout.filter "$MERKLE_TRACE_FILTER" \ --color never \ merkle 2>&1 | \ - rg --line-buffered "$MERKLE_TRACE_CAPTURE_PATTERN" | \ tee "$POST_DROP_MERKLE_RUN_TRACE_LOG" >"$POST_DROP_MERKLE_RUN_LOG" ) & merkle_pid=$! From 231b4f4fd4425ca8a32cca9e67db51eac0010939 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Mon, 4 May 2026 10:53:55 +0000 Subject: [PATCH 48/83] fix(trie): simplify merkle trace DB comparer Replace the compare_merkle_trace_to_db helper with direct trie table and hashed-state lookups driven by the trace log. This drops the multiproof reconstruction path and keeps storage root mismatches separate so they do not suppress deeper branch or leaf mismatches. Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019df291-fde0-73c9-ad81-f516190fc3ad Co-authored-by: Amp --- Cargo.lock | 6 + examples/db-access/Cargo.toml | 6 + .../src/bin/compare_merkle_trace_to_db.rs | 940 ++++++++++++++++++ scripts/compare-merkle-trace-to-db.sh | 7 + 4 files changed, 959 insertions(+) create mode 100644 examples/db-access/src/bin/compare_merkle_trace_to_db.rs create mode 100755 scripts/compare-merkle-trace-to-db.sh diff --git a/Cargo.lock b/Cargo.lock index d185cfdcc14..404a3ae689c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3692,8 +3692,14 @@ name = "example-db-access" version = "0.0.0" dependencies = [ "alloy-primitives", + "clap", "eyre", + "reth-chainspec", + "reth-db-api", "reth-ethereum", + "reth-storage-api", + "reth-trie", + "reth-trie-db", ] [[package]] diff --git a/examples/db-access/Cargo.toml b/examples/db-access/Cargo.toml index 07687e5c465..422411345a3 100644 --- a/examples/db-access/Cargo.toml +++ b/examples/db-access/Cargo.toml @@ -7,6 +7,12 @@ license.workspace = true [dependencies] reth-ethereum = { workspace = true, features = ["node"] } +reth-chainspec.workspace = true +reth-db-api.workspace = true +reth-storage-api.workspace = true +reth-trie.workspace = true +reth-trie-db.workspace = true alloy-primitives.workspace = true +clap = { workspace = true, features = ["derive"] } eyre.workspace = true diff --git a/examples/db-access/src/bin/compare_merkle_trace_to_db.rs b/examples/db-access/src/bin/compare_merkle_trace_to_db.rs new file mode 100644 index 00000000000..2417d4ea132 --- /dev/null +++ b/examples/db-access/src/bin/compare_merkle_trace_to_db.rs @@ -0,0 +1,940 @@ +#![warn(unused_crate_dependencies)] + +use alloy_primitives::{B256, U256}; +use clap::{Parser, ValueEnum}; +use eyre::{bail, eyre, Context, Result}; +use reth_chainspec::{ChainSpec, DEV, HOLESKY, HOODI, MAINNET, SEPOLIA}; +use reth_db_api::{ + cursor::{DbCursorRO, DbDupCursorRO}, + tables, + transaction::DbTx, +}; +use reth_ethereum::{node::EthereumNode, provider::providers::ReadOnlyConfig, tasks::Runtime}; +use reth_storage_api::{DBProvider, StorageSettingsCache}; +use reth_trie::{ + trie_cursor::{TrieCursor, TrieCursorFactory}, + Nibbles, StorageRoot, +}; +use reth_trie_db::{ + DatabaseHashedCursorFactory, DatabaseStorageRoot, DatabaseTrieCursorFactory, TrieTableAdapter, +}; +use std::{ + collections::{BTreeMap, HashMap}, + fs::File, + io::{BufRead, BufReader}, + path::{Path, PathBuf}, + sync::Arc, +}; + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum ChainArg { + Mainnet, + Sepolia, + Holesky, + Hoodi, + Dev, +} + +impl ChainArg { + const fn as_str(self) -> &'static str { + match self { + Self::Mainnet => "mainnet", + Self::Sepolia => "sepolia", + Self::Holesky => "holesky", + Self::Hoodi => "hoodi", + Self::Dev => "dev", + } + } + + fn chain_spec(self) -> Arc { + match self { + Self::Mainnet => MAINNET.clone(), + Self::Sepolia => SEPOLIA.clone(), + Self::Holesky => HOLESKY.clone(), + Self::Hoodi => HOODI.clone(), + Self::Dev => DEV.clone(), + } + } +} + +#[derive(Debug, Parser)] +#[command( + about = "Compare a failed Merkle unwind trace against DB-backed trie/account/storage state." +)] +struct Args { + /// Path to the failed restart trace log. + trace_file: PathBuf, + + /// Reth datadir to treat as DB ground truth. + datadir: PathBuf, + + /// Chain spec used to open the datadir. + #[arg(long, value_enum, default_value_t = ChainArg::Hoodi)] + chain: ChainArg, + + /// Cap the number of reported results per section. + #[arg(long)] + max_results: Option, +} + +#[derive(Debug, Clone)] +struct ObservedStateBranch { + path: Nibbles, + observed_hash: B256, + children_are_in_trie: bool, + first_line: usize, + occurrences: usize, +} + +#[derive(Debug, Clone)] +struct ObservedAccountLeaf { + hashed_address: B256, + nonce: u64, + balance: U256, + bytecode_hash: Option, + first_line: usize, + occurrences: usize, +} + +#[derive(Debug, Clone)] +struct ObservedStorageBranch { + hashed_address: B256, + path: Nibbles, + observed_hash: B256, + children_are_in_trie: bool, + first_line: usize, + occurrences: usize, +} + +#[derive(Debug, Clone)] +struct ObservedStorageLeaf { + hashed_address: B256, + hashed_slot: B256, + value: U256, + first_line: usize, + occurrences: usize, +} + +#[derive(Debug, Clone)] +struct ObservedStorageRoot { + hashed_address: B256, + root: B256, + first_line: usize, + occurrences: usize, +} + +#[derive(Debug, Default)] +struct TraceData { + state_branches: Vec, + account_leaves: Vec, + storage_branches: Vec, + storage_leaves: Vec, + storage_roots: Vec, +} + +#[derive(Debug, Clone)] +struct BranchCandidate { + location: String, + expected_hash: Option, + children_are_in_trie: bool, +} + +impl BranchCandidate { + fn detail(&self) -> String { + format!( + "db_candidate={} expected_hash={} expected_children_are_in_trie={}", + self.location, + option_b256(self.expected_hash), + self.children_are_in_trie + ) + } +} + +#[derive(Debug, Default)] +struct BranchLookup { + candidates: Vec, + notes: Vec, +} + +impl BranchLookup { + fn matches(&self, observed_hash: B256, children_are_in_trie: bool) -> bool { + self.candidates.iter().any(|candidate| { + candidate.expected_hash == Some(observed_hash) && + candidate.children_are_in_trie == children_are_in_trie + }) + } + + fn details(&self) -> Vec { + let mut details = Vec::new(); + if self.candidates.is_empty() { + details.push("db_candidates=none".to_string()); + } else { + details.extend(self.candidates.iter().map(BranchCandidate::detail)); + } + details.extend(self.notes.iter().cloned()); + details + } +} + +#[derive(Debug, Clone)] +struct Mismatch { + context: MismatchContext, + path: Nibbles, + first_line: usize, + kind_rank: u8, + headline: String, + details: Vec, + suppressed_descendants: usize, +} + +#[derive(Debug, Clone)] +struct Diagnostic { + context: MismatchContext, + first_line: usize, + headline: String, + details: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum MismatchContext { + State, + Storage(B256), +} + +impl std::fmt::Display for MismatchContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::State => f.write_str("state"), + Self::Storage(hashed_address) => write!(f, "storage:{hashed_address}"), + } + } +} + +#[derive(Debug, Default)] +struct ComparisonResults { + direct_mismatches: Vec, + diagnostics: Vec, +} + +fn main() -> Result<()> { + let args = Args::parse(); + + if !args.trace_file.is_file() { + bail!("trace file does not exist: {}", args.trace_file.display()); + } + if !args.datadir.is_dir() { + bail!("datadir does not exist: {}", args.datadir.display()); + } + + let trace = parse_trace(&args.trace_file)?; + + let runtime = Runtime::test(); + let factory = EthereumNode::provider_factory_builder().open_read_only( + args.chain.chain_spec(), + ReadOnlyConfig::from_datadir(args.datadir.clone()), + runtime, + )?; + let provider = factory.provider()?; + + let results = + reth_trie_db::with_adapter!(provider, |A| compare_with_adapter::<_, A>(&provider, &trace))?; + + let total_direct_mismatches = results.direct_mismatches.len(); + let mut outermost = retain_outermost_mismatches(results.direct_mismatches); + let suppressed_direct_descendants = + outermost.iter().map(|mismatch| mismatch.suppressed_descendants).sum::(); + let diagnostic_count = results.diagnostics.len(); + let max_results = args.max_results.unwrap_or(usize::MAX); + + println!("trace_file={}", args.trace_file.display()); + println!("datadir={}", args.datadir.display()); + println!("chain={}", args.chain.as_str()); + println!( + "trace_observations state_branches={} account_leaves={} storage_branches={} storage_leaves={} storage_roots={}", + trace.state_branches.len(), + trace.account_leaves.len(), + trace.storage_branches.len(), + trace.storage_leaves.len(), + trace.storage_roots.len(), + ); + println!( + "direct_mismatches total={} outermost={} suppressed_descendants={}", + total_direct_mismatches, + outermost.len(), + suppressed_direct_descendants, + ); + println!("reported_direct_mismatches={}", outermost.len().min(max_results)); + println!("storage_root_diagnostics={diagnostic_count}"); + println!("reported_storage_root_diagnostics={}", diagnostic_count.min(max_results)); + + for mismatch in outermost.drain(..).take(max_results) { + println!(); + println!("[{}] {}", mismatch.context, mismatch.headline); + for detail in mismatch.details { + println!(" {detail}"); + } + if mismatch.suppressed_descendants > 0 { + println!(" suppressed_descendants={}", mismatch.suppressed_descendants); + } + } + + for diagnostic in results.diagnostics.into_iter().take(max_results) { + println!(); + println!("[{}] {}", diagnostic.context, diagnostic.headline); + for detail in diagnostic.details { + println!(" {detail}"); + } + } + + Ok(()) +} + +fn compare_with_adapter(provider: &P, trace: &TraceData) -> Result +where + P: DBProvider, + A: TrieTableAdapter, +{ + let tx = provider.tx_ref(); + let trie_factory = DatabaseTrieCursorFactory::<_, A>::new(tx); + let mut state_trie_cursor = trie_factory.account_trie_cursor()?; + let mut hashed_accounts_cursor = tx.cursor_read::()?; + let mut hashed_storage_cursor = tx.cursor_dup_read::()?; + + let mut results = ComparisonResults::default(); + + for observed in &trace.state_branches { + let lookup = lookup_branch(&mut state_trie_cursor, &observed.path)?; + if lookup.matches(observed.observed_hash, observed.children_are_in_trie) { + continue; + } + + let mut details = vec![ + format!("observed_hash={}", observed.observed_hash), + format!("observed_children_are_in_trie={}", observed.children_are_in_trie), + format!("trace_occurrences={}", observed.occurrences), + ]; + details.extend(lookup.details()); + + results.direct_mismatches.push(Mismatch { + context: MismatchContext::State, + path: observed.path, + first_line: observed.first_line, + kind_rank: 0, + headline: format!( + "branch mismatch path={} line={}", + nibbles_hex(&observed.path), + observed.first_line + ), + details, + suppressed_descendants: 0, + }); + } + + for observed in &trace.account_leaves { + match hashed_accounts_cursor.seek_exact(observed.hashed_address)? { + Some((_, account)) => { + let mut diffs = Vec::new(); + if observed.nonce != account.nonce { + diffs.push(format!( + "nonce observed={} expected={}", + observed.nonce, account.nonce + )); + } + if observed.balance != account.balance { + diffs.push(format!( + "balance observed={} expected={}", + observed.balance, account.balance + )); + } + if observed.bytecode_hash != account.bytecode_hash { + diffs.push(format!( + "bytecode_hash observed={} expected={}", + option_b256(observed.bytecode_hash), + option_b256(account.bytecode_hash) + )); + } + if diffs.is_empty() { + continue; + } + + diffs.push(format!("trace_occurrences={}", observed.occurrences)); + results.direct_mismatches.push(Mismatch { + context: MismatchContext::State, + path: Nibbles::unpack(observed.hashed_address), + first_line: observed.first_line, + kind_rank: 1, + headline: format!( + "account leaf mismatch hashed_address={} line={}", + observed.hashed_address, observed.first_line + ), + details: diffs, + suppressed_descendants: 0, + }); + } + None => results.direct_mismatches.push(Mismatch { + context: MismatchContext::State, + path: Nibbles::unpack(observed.hashed_address), + first_line: observed.first_line, + kind_rank: 1, + headline: format!( + "account leaf missing from DB hashed state hashed_address={} line={}", + observed.hashed_address, observed.first_line + ), + details: vec![ + format!("nonce={}", observed.nonce), + format!("balance={}", observed.balance), + format!("bytecode_hash={}", option_b256(observed.bytecode_hash)), + format!("trace_occurrences={}", observed.occurrences), + ], + suppressed_descendants: 0, + }), + } + } + + let mut storage_branches_by_address = BTreeMap::>::new(); + for observed in &trace.storage_branches { + storage_branches_by_address.entry(observed.hashed_address).or_default().push(observed); + } + + for (hashed_address, branches) in storage_branches_by_address { + let mut storage_trie_cursor = trie_factory.storage_trie_cursor(hashed_address)?; + for observed in branches { + let lookup = lookup_branch(&mut storage_trie_cursor, &observed.path)?; + if lookup.matches(observed.observed_hash, observed.children_are_in_trie) { + continue; + } + + let mut details = vec![ + format!("observed_hash={}", observed.observed_hash), + format!("observed_children_are_in_trie={}", observed.children_are_in_trie), + format!("trace_occurrences={}", observed.occurrences), + ]; + details.extend(lookup.details()); + + results.direct_mismatches.push(Mismatch { + context: MismatchContext::Storage(observed.hashed_address), + path: observed.path, + first_line: observed.first_line, + kind_rank: 0, + headline: format!( + "branch mismatch hashed_address={} path={} line={}", + observed.hashed_address, + nibbles_hex(&observed.path), + observed.first_line + ), + details, + suppressed_descendants: 0, + }); + } + } + + for observed in &trace.storage_leaves { + match hashed_storage_cursor.seek_by_key_subkey(observed.hashed_address, observed.hashed_slot)? { + Some(entry) if entry.key == observed.hashed_slot => { + if observed.value == entry.value { + continue; + } + + results.direct_mismatches.push(Mismatch { + context: MismatchContext::Storage(observed.hashed_address), + path: Nibbles::unpack(observed.hashed_slot), + first_line: observed.first_line, + kind_rank: 1, + headline: format!( + "storage leaf mismatch hashed_address={} hashed_slot={} line={}", + observed.hashed_address, observed.hashed_slot, observed.first_line + ), + details: vec![ + format!("observed_value={}", observed.value), + format!("expected_value={}", entry.value), + format!("trace_occurrences={}", observed.occurrences), + ], + suppressed_descendants: 0, + }); + } + _ => results.direct_mismatches.push(Mismatch { + context: MismatchContext::Storage(observed.hashed_address), + path: Nibbles::unpack(observed.hashed_slot), + first_line: observed.first_line, + kind_rank: 1, + headline: format!( + "storage leaf missing from DB hashed state hashed_address={} hashed_slot={} line={}", + observed.hashed_address, observed.hashed_slot, observed.first_line + ), + details: vec![ + format!("observed_value={}", observed.value), + format!("trace_occurrences={}", observed.occurrences), + ], + suppressed_descendants: 0, + }), + } + } + + let mut storage_root_cache = HashMap::::new(); + for observed in &trace.storage_roots { + let expected_root = match storage_root_cache.get(&observed.hashed_address) { + Some(root) => *root, + None => { + let root = storage_root_for_hashed_address::<_, A>(tx, observed.hashed_address)?; + storage_root_cache.insert(observed.hashed_address, root); + root + } + }; + + if observed.root == expected_root { + continue; + } + + results.diagnostics.push(Diagnostic { + context: MismatchContext::Storage(observed.hashed_address), + first_line: observed.first_line, + headline: format!( + "storage root mismatch hashed_address={} line={}", + observed.hashed_address, observed.first_line + ), + details: vec![ + format!("observed_root={}", observed.root), + format!("expected_root={expected_root}"), + format!("trace_occurrences={}", observed.occurrences), + ], + }); + } + + results.diagnostics.sort_by(|left, right| { + left.first_line.cmp(&right.first_line).then(left.context.cmp(&right.context)) + }); + + Ok(results) +} + +fn storage_root_for_hashed_address(tx: &TX, hashed_address: B256) -> Result +where + TX: DbTx, + A: TrieTableAdapter, +{ + as DatabaseStorageRoot<_>>::from_tx_hashed(tx, hashed_address) + .root() + .with_context(|| format!("compute storage root for hashed address {hashed_address}")) +} + +fn lookup_branch( + cursor: &mut C, + path: &Nibbles, +) -> Result +where + C: TrieCursor, +{ + let mut lookup = BranchLookup::default(); + + if let Some((_, node)) = cursor.seek_exact(*path)? { + if let Some(root_hash) = node.root_hash { + lookup.candidates.push(BranchCandidate { + location: format!("parent_root path={}", nibbles_hex(path)), + expected_hash: Some(root_hash), + children_are_in_trie: true, + }); + } else { + lookup.notes.push(format!( + "exact_branch_node_present path={} root_hash=None", + nibbles_hex(path) + )); + } + } + + if !path.is_empty() { + let parent_path = path.slice(..path.len() - 1); + let nibble = path.get_unchecked(path.len() - 1); + match cursor.seek_exact(parent_path)? { + Some((_, node)) => { + if node.state_mask.is_bit_set(nibble) { + lookup.candidates.push(BranchCandidate { + location: format!( + "child parent_path={} nibble={}", + nibbles_hex(&parent_path), + nibble_hex(nibble) + ), + expected_hash: node + .hash_mask + .is_bit_set(nibble) + .then(|| node.hash_for_nibble(nibble)), + children_are_in_trie: node.tree_mask.is_bit_set(nibble), + }); + } else { + lookup.notes.push(format!( + "parent_branch_present path={} missing_state_nibble={}", + nibbles_hex(&parent_path), + nibble_hex(nibble) + )); + } + } + None => lookup + .notes + .push(format!("parent_branch_missing path={}", nibbles_hex(&parent_path))), + } + } + + Ok(lookup) +} + +fn retain_outermost_mismatches(mut mismatches: Vec) -> Vec { + mismatches.sort_by(|a, b| { + a.context + .cmp(&b.context) + .then(a.path.len().cmp(&b.path.len())) + .then(a.kind_rank.cmp(&b.kind_rank)) + .then(a.first_line.cmp(&b.first_line)) + }); + + let mut kept = Vec::::new(); + 'outer: for mismatch in mismatches { + for existing in &mut kept { + if existing.context == mismatch.context && mismatch.path.starts_with(&existing.path) { + existing.suppressed_descendants += 1; + continue 'outer; + } + } + kept.push(mismatch); + } + + kept.sort_by(|a, b| { + a.path + .len() + .cmp(&b.path.len()) + .then(a.kind_rank.cmp(&b.kind_rank)) + .then(a.first_line.cmp(&b.first_line)) + }); + kept +} + +fn parse_trace(path: &Path) -> Result { + let file = File::open(path).with_context(|| format!("open trace file {}", path.display()))?; + let reader = BufReader::new(file); + + let mut state_branches = HashMap::<(Nibbles, B256, bool), ObservedStateBranch>::new(); + let mut account_leaves = HashMap::<(B256, u64, U256, Option), ObservedAccountLeaf>::new(); + let mut storage_branches = HashMap::<(B256, Nibbles, B256, bool), ObservedStorageBranch>::new(); + let mut storage_leaves = HashMap::<(B256, B256, U256), ObservedStorageLeaf>::new(); + let mut storage_roots = HashMap::<(B256, B256), ObservedStorageRoot>::new(); + + for (index, line) in reader.lines().enumerate() { + let line_number = index + 1; + let line = line?; + + if line.contains("trie::storage_root: calculated storage root") { + let root = parse_b256( + extract_after(&line, "calculated storage root root=")?.split(' ').next().unwrap(), + )?; + let hashed_address = + parse_b256(extract_after(&line, "hashed_address=")?.split(' ').next().unwrap())?; + upsert_storage_root(&mut storage_roots, hashed_address, root, line_number); + continue; + } + + if !line.contains("trie::node_iter: return=Ok(Some(") { + continue; + } + + if line.contains("trie_type=State") && line.contains("Branch(TrieBranchNode {") { + let path = parse_trace_nibbles(extract_between(&line, "key: Nibbles(", "), value:")?)?; + let observed_hash = + parse_b256(extract_between(&line, "value: ", ", children_are_in_trie:")?)?; + let children_are_in_trie = + parse_bool(extract_between(&line, "children_are_in_trie: ", " })))")?)?; + upsert_state_branch( + &mut state_branches, + path, + observed_hash, + children_are_in_trie, + line_number, + ); + continue; + } + + if line.contains("trie_type=Storage") && line.contains("Branch(TrieBranchNode {") { + let hashed_address = parse_b256(extract_storage_address(&line)?)?; + let path = parse_trace_nibbles(extract_between(&line, "key: Nibbles(", "), value:")?)?; + let observed_hash = + parse_b256(extract_between(&line, "value: ", ", children_are_in_trie:")?)?; + let children_are_in_trie = + parse_bool(extract_between(&line, "children_are_in_trie: ", " })))")?)?; + upsert_storage_branch( + &mut storage_branches, + hashed_address, + path, + observed_hash, + children_are_in_trie, + line_number, + ); + continue; + } + + if line.contains("trie_type=State") && line.contains("Leaf(") && line.contains("Account {") + { + let hashed_address = parse_b256(extract_between(&line, "Leaf(", ", Account {")?)?; + let nonce = extract_between(&line, "nonce: ", ", balance:")? + .parse::() + .with_context(|| format!("parse nonce on line {line_number}"))?; + let balance = extract_between(&line, "balance: ", ", bytecode_hash:")? + .parse::() + .with_context(|| format!("parse balance on line {line_number}"))?; + let bytecode_hash = + parse_optional_b256(extract_between(&line, "bytecode_hash: ", " })))")?)?; + upsert_account_leaf( + &mut account_leaves, + hashed_address, + nonce, + balance, + bytecode_hash, + line_number, + ); + continue; + } + + if line.contains("trie_type=Storage") && line.contains("Leaf(") { + let hashed_address = parse_b256(extract_storage_address(&line)?)?; + let leaf = extract_after(&line, "Leaf(")?; + let (hashed_slot, value) = leaf + .split_once(", ") + .ok_or_else(|| eyre!("invalid storage leaf on line {line_number}"))?; + let hashed_slot = parse_b256(hashed_slot)?; + let value = value + .split_once(")))") + .map(|(value, _)| value) + .ok_or_else(|| eyre!("invalid storage leaf terminator on line {line_number}"))? + .parse::() + .with_context(|| format!("parse storage value on line {line_number}"))?; + upsert_storage_leaf( + &mut storage_leaves, + hashed_address, + hashed_slot, + value, + line_number, + ); + } + } + + Ok(TraceData { + state_branches: into_sorted(state_branches), + account_leaves: into_sorted(account_leaves), + storage_branches: into_sorted(storage_branches), + storage_leaves: into_sorted(storage_leaves), + storage_roots: into_sorted(storage_roots), + }) +} + +fn upsert_state_branch( + state_branches: &mut HashMap<(Nibbles, B256, bool), ObservedStateBranch>, + path: Nibbles, + observed_hash: B256, + children_are_in_trie: bool, + line_number: usize, +) { + state_branches + .entry((path, observed_hash, children_are_in_trie)) + .and_modify(|entry| entry.occurrences += 1) + .or_insert(ObservedStateBranch { + path, + observed_hash, + children_are_in_trie, + first_line: line_number, + occurrences: 1, + }); +} + +fn upsert_account_leaf( + account_leaves: &mut HashMap<(B256, u64, U256, Option), ObservedAccountLeaf>, + hashed_address: B256, + nonce: u64, + balance: U256, + bytecode_hash: Option, + line_number: usize, +) { + account_leaves + .entry((hashed_address, nonce, balance, bytecode_hash)) + .and_modify(|entry| entry.occurrences += 1) + .or_insert(ObservedAccountLeaf { + hashed_address, + nonce, + balance, + bytecode_hash, + first_line: line_number, + occurrences: 1, + }); +} + +fn upsert_storage_branch( + storage_branches: &mut HashMap<(B256, Nibbles, B256, bool), ObservedStorageBranch>, + hashed_address: B256, + path: Nibbles, + observed_hash: B256, + children_are_in_trie: bool, + line_number: usize, +) { + storage_branches + .entry((hashed_address, path, observed_hash, children_are_in_trie)) + .and_modify(|entry| entry.occurrences += 1) + .or_insert(ObservedStorageBranch { + hashed_address, + path, + observed_hash, + children_are_in_trie, + first_line: line_number, + occurrences: 1, + }); +} + +fn upsert_storage_leaf( + storage_leaves: &mut HashMap<(B256, B256, U256), ObservedStorageLeaf>, + hashed_address: B256, + hashed_slot: B256, + value: U256, + line_number: usize, +) { + storage_leaves + .entry((hashed_address, hashed_slot, value)) + .and_modify(|entry| entry.occurrences += 1) + .or_insert(ObservedStorageLeaf { + hashed_address, + hashed_slot, + value, + first_line: line_number, + occurrences: 1, + }); +} + +fn upsert_storage_root( + storage_roots: &mut HashMap<(B256, B256), ObservedStorageRoot>, + hashed_address: B256, + root: B256, + line_number: usize, +) { + storage_roots + .entry((hashed_address, root)) + .and_modify(|entry| entry.occurrences += 1) + .or_insert(ObservedStorageRoot { + hashed_address, + root, + first_line: line_number, + occurrences: 1, + }); +} + +fn into_sorted(bucket: HashMap) -> Vec +where + K: std::hash::Hash + Eq, + T: TraceLine, +{ + let mut values = bucket.into_values().collect::>(); + values.sort_by_key(|left| left.first_line()); + values +} + +fn parse_b256(value: &str) -> Result { + value.parse::().with_context(|| format!("parse B256 from {value}")) +} + +fn parse_optional_b256(value: &str) -> Result> { + if value == "None" { + return Ok(None) + } + let inner = value + .strip_prefix("Some(") + .and_then(|rest| rest.strip_suffix(')')) + .ok_or_else(|| eyre!("invalid optional B256: {value}"))?; + Ok(Some(parse_b256(inner)?)) +} + +fn parse_trace_nibbles(value: &str) -> Result { + let hex = value + .strip_prefix("0x") + .ok_or_else(|| eyre!("expected nibble string with 0x prefix: {value}"))?; + let mut nibbles = Vec::with_capacity(hex.len()); + for ch in hex.bytes() { + let nibble = match ch { + b'0'..=b'9' => ch - b'0', + b'a'..=b'f' => ch - b'a' + 10, + b'A'..=b'F' => ch - b'A' + 10, + _ => bail!("invalid hex nibble in {value}"), + }; + nibbles.push(nibble); + } + Ok(Nibbles::from_nibbles_unchecked(nibbles)) +} + +fn parse_bool(value: &str) -> Result { + match value { + "true" => Ok(true), + "false" => Ok(false), + _ => bail!("invalid boolean value: {value}"), + } +} + +fn extract_after<'a>(line: &'a str, prefix: &str) -> Result<&'a str> { + line.split_once(prefix).map(|(_, rest)| rest).ok_or_else(|| eyre!("missing {prefix:?} in line")) +} + +fn extract_between<'a>(line: &'a str, start: &str, end: &str) -> Result<&'a str> { + let rest = extract_after(line, start)?; + rest.split_once(end) + .map(|(matched, _)| matched) + .ok_or_else(|| eyre!("missing end marker {end:?} after {start:?}")) +} + +fn extract_storage_address(line: &str) -> Result<&str> { + let rest = extract_after(line, "storage_trie{addr=")?; + let end = rest.find([' ', '}']).ok_or_else(|| eyre!("missing end of storage address"))?; + Ok(&rest[..end]) +} + +fn nibble_hex(nibble: u8) -> char { + char::from_digit(nibble as u32, 16).expect("valid nibble") +} + +fn nibbles_hex(path: &Nibbles) -> String { + let mut out = String::from("0x"); + for nibble in path.iter() { + out.push(nibble_hex(nibble)); + } + out +} + +fn option_b256(value: Option) -> String { + value.map_or_else(|| "None".to_string(), |hash| hash.to_string()) +} + +trait TraceLine { + fn first_line(&self) -> usize; +} + +impl TraceLine for ObservedStateBranch { + fn first_line(&self) -> usize { + self.first_line + } +} + +impl TraceLine for ObservedAccountLeaf { + fn first_line(&self) -> usize { + self.first_line + } +} + +impl TraceLine for ObservedStorageBranch { + fn first_line(&self) -> usize { + self.first_line + } +} + +impl TraceLine for ObservedStorageLeaf { + fn first_line(&self) -> usize { + self.first_line + } +} + +impl TraceLine for ObservedStorageRoot { + fn first_line(&self) -> usize { + self.first_line + } +} + +type DbStorageRoot<'a, TX, A> = + StorageRoot, DatabaseHashedCursorFactory<&'a TX>>; diff --git a/scripts/compare-merkle-trace-to-db.sh b/scripts/compare-merkle-trace-to-db.sh new file mode 100755 index 00000000000..a333d46a23a --- /dev/null +++ b/scripts/compare-merkle-trace-to-db.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) + +exec cargo run --profile profiling -p example-db-access --bin compare_merkle_trace_to_db -- "$@" From c7d9fd6cbca5da30ba0e5ec342d3175293b430ae Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Mon, 4 May 2026 10:56:19 +0000 Subject: [PATCH 49/83] fix(trie): stop tracing post-drop merkle rebuild Keep restart trace capture on the failed unwind path, but run the post-drop Merkle rebuild at info level without a separate trace artifact. This keeps the repro focused on restart-trace.log while preserving the existing post-drop verification step. Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019df291-fde0-73c9-ad81-f516190fc3ad Co-authored-by: Amp --- scripts/repro-hoodi-partial-persistence-unwind.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/repro-hoodi-partial-persistence-unwind.sh b/scripts/repro-hoodi-partial-persistence-unwind.sh index 400ff6aefbd..12520544f35 100755 --- a/scripts/repro-hoodi-partial-persistence-unwind.sh +++ b/scripts/repro-hoodi-partial-persistence-unwind.sh @@ -183,7 +183,7 @@ RESTART_TIMEOUT=180 RETH_BIN="/repos/reth/target/profiling/reth" BENCH_BIN="/repos/reth/target/profiling/reth-bench" CHAIN="hoodi" -MERKLE_TRACE_FILTER='trace' +MERKLE_TRACE_FILTER='info' RESULT="script_error" ADVANCE="" HEAD_BEFORE="" @@ -275,7 +275,7 @@ RESTART_TRACE_LOG="${ARTIFACTS_DIR}/restart-trace.log" DROP_MERKLE_LOG="${ARTIFACTS_DIR}/drop-merkle.log" POST_DROP_UNWIND_LOG="${ARTIFACTS_DIR}/post-drop-unwind.log" POST_DROP_MERKLE_RUN_LOG="${ARTIFACTS_DIR}/post-drop-merkle-run.log" -POST_DROP_MERKLE_RUN_TRACE_LOG="${ARTIFACTS_DIR}/post-drop-merkle-run-trace.log" +POST_DROP_MERKLE_RUN_TRACE_LOG="not_captured" trap cleanup EXIT @@ -582,7 +582,7 @@ run_post_drop_merkle() { local target="$1" local merkle_pid - log "Rebuilding the Merkle stage with full trace logs piped to ${POST_DROP_MERKLE_RUN_TRACE_LOG}" + log "Rebuilding the Merkle stage without trace capture in ${POST_DROP_MERKLE_RUN_LOG}" ( "$RETH_BIN" stage run \ --datadir "$DATADIR" \ @@ -595,8 +595,8 @@ run_post_drop_merkle() { --disable-discovery \ --log.stdout.filter "$MERKLE_TRACE_FILTER" \ --color never \ - merkle 2>&1 | \ - tee "$POST_DROP_MERKLE_RUN_TRACE_LOG" >"$POST_DROP_MERKLE_RUN_LOG" + merkle \ + >"$POST_DROP_MERKLE_RUN_LOG" 2>&1 ) & merkle_pid=$! From 3c667b16a4a533a4e9c7dc5b80e9a9e24480cace Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Mon, 4 May 2026 16:51:56 +0000 Subject: [PATCH 50/83] chore(provider): improve save_blocks trie debug logging Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019df3d9-cf72-713e-8a8e-9d3ab36f83f0 Co-authored-by: Amp --- .../src/providers/database/provider.rs | 128 +++++++++++------- 1 file changed, 79 insertions(+), 49 deletions(-) diff --git a/crates/storage/provider/src/providers/database/provider.rs b/crates/storage/provider/src/providers/database/provider.rs index fc05f3315c6..8350daccedd 100644 --- a/crates/storage/provider/src/providers/database/provider.rs +++ b/crates/storage/provider/src/providers/database/provider.rs @@ -110,29 +110,29 @@ fn format_trie_node_path(path: &Nibbles) -> String { formatted } -fn collect_account_trie_node_paths(trie_updates: &TrieUpdatesSorted) -> Vec { - trie_updates - .account_nodes_ref() - .iter() - .map(|(path, node)| { - format!( - "{} ({})", - format_trie_node_path(path), - if node.is_some() { "upsert" } else { "remove" } - ) - }) - .collect() +fn format_branch_node_compact(node: &reth_trie::BranchNodeCompact) -> String { + format!( + "state_mask={:?} tree_mask={:?} hash_mask={:?} hashes={:?} root_hash={:?}", + node.state_mask, node.tree_mask, node.hash_mask, node.hashes, node.root_hash + ) } -fn collect_all_trie_node_paths(trie_updates: &TrieUpdatesSorted) -> Vec { - let mut paths = trie_updates +fn format_trie_node_update(node: Option<&reth_trie::BranchNodeCompact>) -> String { + match node { + Some(node) => format!("upsert {}", format_branch_node_compact(node)), + None => "remove".to_string(), + } +} + +fn collect_all_trie_nodes(trie_updates: &TrieUpdatesSorted) -> Vec { + let mut nodes = trie_updates .account_nodes_ref() .iter() .map(|(path, node)| { format!( - "account {} ({})", + "account {} {}", format_trie_node_path(path), - if node.is_some() { "upsert" } else { "remove" } + format_trie_node_update(node.as_ref()) ) }) .collect::>(); @@ -141,19 +141,19 @@ fn collect_all_trie_node_paths(trie_updates: &TrieUpdatesSorted) -> Vec trie_updates.storage_tries_ref().iter().sorted_by_key(|(hashed_address, _)| *hashed_address) { if storage_trie.is_deleted() { - paths.push(format!("storage {hashed_address:#x} (delete trie)")); + nodes.push(format!("storage {hashed_address:#x} delete trie")); } - paths.extend(storage_trie.storage_nodes_ref().iter().map(|(path, node)| { + nodes.extend(storage_trie.storage_nodes_ref().iter().map(|(path, node)| { format!( - "storage {hashed_address:#x}@{} ({})", + "storage {hashed_address:#x}@{} {}", format_trie_node_path(path), - if node.is_some() { "upsert" } else { "remove" } + format_trie_node_update(node.as_ref()) ) })); } - paths + nodes } /// A [`DatabaseProvider`] that holds a read-only database transaction. @@ -663,32 +663,58 @@ impl DatabaseProvider>(); - let masking_blocks = step - .state_trie_masking_range - .as_ref() - .map(|range| { - blocks[range.clone()] + .enumerate() + .map(|(step_index, step)| { + let step_blocks = blocks[step.block_range.clone()] .iter() .map(|block| block.recovered_block().num_hash()) - .collect::>() + .collect::>(); + let masking_blocks = step + .state_trie_masking_range + .as_ref() + .map(|range| { + blocks[range.clone()] + .iter() + .map(|block| block.recovered_block().num_hash()) + .collect::>() + }) + .unwrap_or_default(); + + ( + step_index, + step.block_range.clone(), + step.persist_rest, + step.state_trie_masking_range.clone(), + step_blocks, + masking_blocks, + ) }) - .unwrap_or_default(); + .collect::>(); - debug!( - target: "providers::db", - step = step_index, - block_range = ?step.block_range, - persist_rest = step.persist_rest, - state_trie_masking_range = ?step.state_trie_masking_range, - step_blocks = ?step_blocks, - masking_blocks = ?masking_blocks, - "save_blocks step plan" - ); + debug!(target: "providers::db", ?step_plan, "save_blocks step plan"); + + if save_mode.with_state() { + let per_block_trie_updates = blocks + .iter() + .map(|block| { + ( + block.recovered_block().number(), + collect_all_trie_nodes(block.trie_data().trie_updates.as_ref()), + ) + }) + .collect::>(); + + debug!( + target: "providers::db", + range = ?first_number..=last_block_number, + per_block_trie_updates = ?per_block_trie_updates, + "save_blocks per-block trie updates" + ); + } } let tx_nums: Vec = if persist_rest_blocks.is_empty() { @@ -903,13 +929,17 @@ impl DatabaseProvider Date: Mon, 4 May 2026 17:44:00 +0000 Subject: [PATCH 51/83] fix(trie): preserve equivalent masked trie nodes Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019df406-d380-704b-8376-32b9bbcbce7c Co-authored-by: Amp --- crates/trie/common/src/updates.rs | 130 +++++++++++++++++++++++++++--- 1 file changed, 118 insertions(+), 12 deletions(-) diff --git a/crates/trie/common/src/updates.rs b/crates/trie/common/src/updates.rs index 04cd21c2de3..331c4c1deae 100644 --- a/crates/trie/common/src/updates.rs +++ b/crates/trie/common/src/updates.rs @@ -717,7 +717,8 @@ impl TrieUpdatesSorted { /// Account trie nodes are masked at the top level, while storage trie entries are only masked /// at the node level unless the mask deletes the entire storage trie. For duplicate keys in /// the batch, later items take precedence over earlier ones. The order of the mask does not - /// matter. + /// matter. Overlapping nodes are preserved when any masked value for a key is equivalent to + /// the merged batch value for that same key. pub fn disjointed_merge_batch<'a>(batch: Vec<&'a Self>, mask: Vec<&'a Self>) -> Self { let account_nodes = merge_masked_trie_nodes_preserving_ancestors( batch.iter().rev().map(|item| item.account_nodes.as_slice()), @@ -808,26 +809,50 @@ fn merge_masked_trie_nodes_preserving_ancestors<'a>( mask_slices: impl IntoIterator)]>, ) -> Vec<(Nibbles, Option)> { let merged_batch_nodes = kway_merge_sorted(batch_slices); - let masked_node_keys = mask_slices + let merged_masked_nodes = mask_slices .into_iter() .filter(|slice| !slice.is_empty()) - .map(|slice| slice.iter().map(|(key, _)| key)) - .kmerge() - .dedup() - .cloned() + .map(|slice| slice.iter()) + .kmerge_by(|(left_key, _), (right_key, _)| left_key < right_key) .collect::>(); - let directly_unmasked_node_keys = merged_batch_nodes + let mut masked_node_index = 0; + let directly_kept_node_keys = merged_batch_nodes .iter() - .filter(|(key, _)| masked_node_keys.binary_search(key).is_err()) - .map(|(key, _)| key.clone()) + .filter_map(|(key, batch_value)| { + while let Some((masked_key, _)) = merged_masked_nodes.get(masked_node_index).copied() { + if masked_key < key { + masked_node_index += 1; + continue; + } + + if masked_key > key { + return Some(key.clone()) + } + + let mut has_equivalent_value = false; + while let Some((masked_key, masked_value)) = + merged_masked_nodes.get(masked_node_index).copied() + { + if masked_key != key { + break; + } + has_equivalent_value |= masked_value == batch_value; + masked_node_index += 1; + } + + return has_equivalent_value.then(|| key.clone()) + } + + Some(key.clone()) + }) .collect::>(); merged_batch_nodes .into_iter() .filter(|(key, _)| { - masked_node_keys.binary_search(key).is_err() || - directly_unmasked_node_keys - .get(directly_unmasked_node_keys.partition_point(|candidate| candidate <= key)) + directly_kept_node_keys.binary_search(key).is_ok() || + directly_kept_node_keys + .get(directly_kept_node_keys.partition_point(|candidate| candidate <= key)) .is_some_and(|candidate| candidate.starts_with(key)) }) .collect() @@ -1216,6 +1241,87 @@ mod tests { assert!(result.storage_tries.is_empty()); } + #[test] + fn test_trie_updates_sorted_disjointed_merge_batch_keeps_equivalent_overlapping_nodes() { + fn branch(mask: u16) -> BranchNodeCompact { + BranchNodeCompact::new(mask, 0, 0, vec![], None) + } + + let overlapping_node = Nibbles::from_nibbles_unchecked([0x03]); + let overlapping_storage = B256::from([5; 32]); + let slot = Nibbles::from_nibbles_unchecked([0x0c]); + let node = branch(0b1010_0101); + let storage_node = branch(0b0011_1100); + + let batch = TrieUpdatesSorted::new( + vec![(overlapping_node, Some(node.clone()))], + B256Map::from_iter([( + overlapping_storage, + StorageTrieUpdatesSorted { + is_deleted: false, + storage_nodes: vec![(slot, Some(storage_node.clone()))], + }, + )]), + ); + + let mask = TrieUpdatesSorted::new( + vec![(overlapping_node, Some(node.clone()))], + B256Map::from_iter([( + overlapping_storage, + StorageTrieUpdatesSorted { + is_deleted: false, + storage_nodes: vec![(slot, Some(storage_node.clone()))], + }, + )]), + ); + + let result = TrieUpdatesSorted::disjointed_merge_batch(vec![&batch], vec![&mask]); + + assert_eq!(result.account_nodes, vec![(overlapping_node, Some(node))]); + assert_eq!( + result.storage_tries.get(&overlapping_storage), + Some(&StorageTrieUpdatesSorted { + is_deleted: false, + storage_nodes: vec![(slot, Some(storage_node))], + }) + ); + } + + #[test] + fn test_trie_updates_sorted_disjointed_merge_batch_keeps_node_if_any_masked_value_matches() { + fn branch(mask: u16) -> BranchNodeCompact { + BranchNodeCompact::new(mask, 0, 0, vec![], None) + } + + let overlapping_node = Nibbles::from_nibbles_unchecked([0x04]); + let node = branch(0b1010_0101); + + let batch = TrieUpdatesSorted::new( + vec![(overlapping_node, Some(node.clone()))], + B256Map::default(), + ); + let equivalent_mask = TrieUpdatesSorted::new( + vec![(overlapping_node, Some(node.clone()))], + B256Map::default(), + ); + let different_mask = TrieUpdatesSorted::new( + vec![(overlapping_node, Some(branch(0b0101_1010)))], + B256Map::default(), + ); + + let result = TrieUpdatesSorted::disjointed_merge_batch( + vec![&batch], + vec![&different_mask, &equivalent_mask], + ); + let reversed = TrieUpdatesSorted::disjointed_merge_batch( + vec![&batch], + vec![&equivalent_mask, &different_mask], + ); + + assert_eq!(result.account_nodes, vec![(overlapping_node, Some(node.clone()))]); + assert_eq!(reversed.account_nodes, vec![(overlapping_node, Some(node))]); + } + #[test] fn test_trie_updates_sorted_disjointed_merge_batch_ignores_empty_storage_mask() { let storage = B256::from([6; 32]); From 9040aaeb9c4dfd8c41cf9c59f36c2893a2cf5ef9 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Mon, 4 May 2026 19:14:31 +0000 Subject: [PATCH 52/83] fix(scripts): make Hoodi persistence repro marker detection robust Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019df406-d380-704b-8376-32b9bbcbce7c Co-authored-by: Amp --- scripts/repro-hoodi-partial-persistence-unwind.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/repro-hoodi-partial-persistence-unwind.sh b/scripts/repro-hoodi-partial-persistence-unwind.sh index 12520544f35..4fd68fc515c 100755 --- a/scripts/repro-hoodi-partial-persistence-unwind.sh +++ b/scripts/repro-hoodi-partial-persistence-unwind.sh @@ -494,8 +494,9 @@ wait_for_persistence_marker() { local elapsed=0 while (( elapsed <= timeout )); do - if tail -n "+${start_line}" "$log_file" | \ - grep -E -m1 'save_blocks step plan|save_blocks trie paths|write_trie_updates|Persisting canonical chain' \ + if grep -E -m1 \ + 'save_blocks step plan|save_blocks trie paths|write_trie_updates|Persisting canonical chain|Appended block data range' \ + < <(tail -n "+${start_line}" "$log_file") \ >/dev/null 2>&1; then return 0 fi @@ -709,7 +710,10 @@ BENCH_PID=$! HEAD_AT_CRASH=$(wait_for_target_head "$NODE1_PID" "$TARGET_BLOCK" "$TARGET_TIMEOUT") || exit 2 printf '%s\n' "$HEAD_AT_CRASH" >"${ARTIFACTS_DIR}/current_head_at_crash.txt" -POST_TARGET_LINE=$(( $(wc -l <"$NODE1_LOG") + 1 )) +# Allow for a small race where the target-head poll returns after the relevant +# persistence logs were already emitted. +POST_TARGET_LINE=$(( $(wc -l <"$NODE1_LOG") - 50 )) +(( POST_TARGET_LINE < 1 )) && POST_TARGET_LINE=1 log "Target head ${TARGET_BLOCK} reached; sending SIGTERM to trigger the final persistence flush" stop_pid "$NODE1_PID" TERM "reth crash node" From 72b0666cfe98ab64e59f42b97f20456959c4bc55 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Mon, 4 May 2026 21:54:25 +0000 Subject: [PATCH 53/83] fix(scripts): randomize Hoodi crash target block Co-Authored-By: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019df4c4-1820-728c-a2c2-bae7d3a17ae3 --- .../repro-hoodi-partial-persistence-unwind.sh | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/scripts/repro-hoodi-partial-persistence-unwind.sh b/scripts/repro-hoodi-partial-persistence-unwind.sh index 4fd68fc515c..0c7df9d9ddc 100755 --- a/scripts/repro-hoodi-partial-persistence-unwind.sh +++ b/scripts/repro-hoodi-partial-persistence-unwind.sh @@ -25,6 +25,8 @@ Options: (default: 2613963) --target-block N Last block to replay before crashing (default: 2614300) + --randomize-target-block Pick a random crash target between + --start-block and --target-block --artifacts-dir PATH Directory for logs and summary output (default: /tmp/reth-hoodi-unwind-) --start-timeout SECONDS Seconds to wait for node RPC startup @@ -130,6 +132,9 @@ write_summary() { printf 'expected_head=%s\n' "$EXPECTED_HEAD" printf 'start_block=%s\n' "$START_BLOCK" printf 'target_block=%s\n' "$TARGET_BLOCK" + printf 'target_block_mode=%s\n' "$TARGET_BLOCK_MODE" + printf 'target_block_lower_bound=%s\n' "$TARGET_BLOCK_LOWER_BOUND" + printf 'target_block_upper_bound=%s\n' "$TARGET_BLOCK_UPPER_BOUND" printf 'advance=%s\n' "${ADVANCE:-unknown}" printf 'head_before=%s\n' "${HEAD_BEFORE:-unknown}" printf 'head_after_crash=%s\n' "${HEAD_AT_CRASH:-unknown}" @@ -176,6 +181,7 @@ REMOTE_RPC_URL="https://rpc.hoodi.ethpandaops.io" EXPECTED_HEAD=2613962 START_BLOCK=2613963 TARGET_BLOCK=2614300 +RANDOMIZE_TARGET_BLOCK=0 START_TIMEOUT=180 TARGET_TIMEOUT=900 PERSISTENCE_TIMEOUT=300 @@ -186,6 +192,9 @@ CHAIN="hoodi" MERKLE_TRACE_FILTER='info' RESULT="script_error" ADVANCE="" +TARGET_BLOCK_MODE="fixed" +TARGET_BLOCK_LOWER_BOUND="$TARGET_BLOCK" +TARGET_BLOCK_UPPER_BOUND="$TARGET_BLOCK" HEAD_BEFORE="" HEAD_AT_CRASH="" HEAD_AFTER_RESTART="" @@ -229,6 +238,10 @@ while (($# > 0)); do TARGET_BLOCK="$2" shift 2 ;; + --randomize-target-block) + RANDOMIZE_TARGET_BLOCK=1 + shift + ;; --artifacts-dir) ARTIFACTS_DIR="$2" shift 2 @@ -683,6 +696,24 @@ if (( HEAD_BEFORE + 1 != START_BLOCK )); then exit 2 fi +if (( RANDOMIZE_TARGET_BLOCK == 1 )); then + TARGET_BLOCK_MODE="randomized" + TARGET_BLOCK_LOWER_BOUND="$START_BLOCK" + TARGET_BLOCK_UPPER_BOUND="$TARGET_BLOCK" + + if (( TARGET_BLOCK_UPPER_BOUND < TARGET_BLOCK_LOWER_BOUND )); then + log "Randomized target range ${TARGET_BLOCK_LOWER_BOUND}-${TARGET_BLOCK_UPPER_BOUND} is invalid" + exit 2 + fi + + TARGET_BLOCK=$((TARGET_BLOCK_LOWER_BOUND + RANDOM % (TARGET_BLOCK_UPPER_BOUND - TARGET_BLOCK_LOWER_BOUND + 1))) + log "Randomized crash target block ${TARGET_BLOCK} (range ${TARGET_BLOCK_LOWER_BOUND}-${TARGET_BLOCK_UPPER_BOUND})" +else + TARGET_BLOCK_MODE="fixed" + TARGET_BLOCK_LOWER_BOUND="$TARGET_BLOCK" + TARGET_BLOCK_UPPER_BOUND="$TARGET_BLOCK" +fi + ADVANCE=$((TARGET_BLOCK - HEAD_BEFORE)) if (( ADVANCE <= 0 )); then log "Target block ${TARGET_BLOCK} must be greater than restored head ${HEAD_BEFORE}" @@ -697,7 +728,7 @@ capture_command reth_bench "$BENCH_BIN" -vvv new-payload-fcu \ --local-rpc-url http://127.0.0.1:8545 \ --ws-rpc-url ws://127.0.0.1:8546 -log "Running reth-bench with --advance ${ADVANCE} so replay begins at block ${START_BLOCK}" +log "Running reth-bench with --advance ${ADVANCE} so replay begins at block ${START_BLOCK} and crashes at ${TARGET_BLOCK}" "$BENCH_BIN" -vvv new-payload-fcu \ --rpc-url "$REMOTE_RPC_URL" \ --advance "$ADVANCE" \ From f9e3da0c6966a233b831cdda3a2e003644fd5d6d Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Tue, 5 May 2026 11:22:01 +0000 Subject: [PATCH 54/83] feat(trie): add walker option for matching branch children Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019df7cd-9370-777b-9be4-b151c795829b Co-authored-by: Amp --- crates/trie/trie/src/walker.rs | 117 +++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/crates/trie/trie/src/walker.rs b/crates/trie/trie/src/walker.rs index f12bf46f748..85f3b72c746 100644 --- a/crates/trie/trie/src/walker.rs +++ b/crates/trie/trie/src/walker.rs @@ -8,6 +8,18 @@ use alloy_trie::proof::AddedRemovedKeys; use reth_storage_errors::db::DatabaseError; use tracing::{instrument, trace}; +#[cfg(test)] +use crate::trie_cursor::{mock::MockTrieCursorFactory, TrieCursorFactory}; + +#[cfg(test)] +use alloy_primitives::map::B256Map; + +#[cfg(test)] +use alloy_trie::TrieMask; + +#[cfg(test)] +use std::collections::BTreeMap; + #[cfg(feature = "metrics")] use crate::metrics::WalkerMetrics; @@ -26,6 +38,9 @@ pub struct TrieWalker { pub can_skip_current_node: bool, /// A `PrefixSet` representing the changes to be applied to the trie. pub changes: PrefixSet, + /// When enabled, all children of a branch become unskippable if the branch path itself + /// matches the prefix set, even if a given child path does not. + all_children_unskippable_if_path_matches_prefix_set: bool, /// The retained trie node keys that need to be removed. removed_keys: Option>, /// Provided when it's necessary not to skip certain nodes during proof generation. @@ -76,6 +91,7 @@ impl> TrieWalker { changes, stack, can_skip_current_node: false, + all_children_unskippable_if_path_matches_prefix_set: false, removed_keys: None, added_removed_keys: None, #[cfg(feature = "metrics")] @@ -101,6 +117,8 @@ impl> TrieWalker { stack: self.stack, can_skip_current_node: self.can_skip_current_node, changes: self.changes, + all_children_unskippable_if_path_matches_prefix_set: self + .all_children_unskippable_if_path_matches_prefix_set, removed_keys: self.removed_keys, added_removed_keys, #[cfg(feature = "metrics")] @@ -108,6 +126,15 @@ impl> TrieWalker { } } + /// Configures the walker to treat every child of a matching branch path as unskippable. + pub const fn with_all_children_unskippable_if_path_matches_prefix_set( + mut self, + enabled: bool, + ) -> Self { + self.all_children_unskippable_if_path_matches_prefix_set = enabled; + self + } + /// Split the walker into stack and trie updates. pub fn split(mut self) -> (Vec, HashSet) { let keys = self.take_removed_keys(); @@ -188,7 +215,14 @@ impl> TrieWalker { "Checked for only non-removed child", ); + let branch_path_matches_prefix_set = self + .all_children_unskippable_if_path_matches_prefix_set + .then(|| node.position().is_child()) + .unwrap_or(false) && + self.changes.contains(&node.key); + !self.changes.contains(node.full_key()) && + !branch_path_matches_prefix_set && node.hash_flag() && !key_is_only_nonremoved_child }); @@ -233,6 +267,7 @@ impl> TrieWalker { changes, stack: vec![CursorSubNode::default()], can_skip_current_node: false, + all_children_unskippable_if_path_matches_prefix_set: false, removed_keys: None, added_removed_keys: Default::default(), #[cfg(feature = "metrics")] @@ -387,3 +422,85 @@ impl> TrieWalker { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::prefix_set::PrefixSetMut; + use alloy_primitives::B256; + + fn branch_node(state_mask: u16, tree_mask: u16, hash_mask: u16) -> BranchNodeCompact { + let hash_count = hash_mask.count_ones() as usize; + BranchNodeCompact::new( + TrieMask::new(state_mask), + TrieMask::new(tree_mask), + TrieMask::new(hash_mask), + vec![B256::ZERO; hash_count], + None, + ) + } + + fn root_branch_node(state_mask: u16, tree_mask: u16, hash_mask: u16) -> BranchNodeCompact { + let hash_count = hash_mask.count_ones() as usize; + BranchNodeCompact::new( + TrieMask::new(state_mask), + TrieMask::new(tree_mask), + TrieMask::new(hash_mask), + vec![B256::ZERO; hash_count], + Some(B256::ZERO), + ) + } + + fn walker_for_matching_branch_children_test( + enable_all_children_unskippable: bool, + ) -> TrieWalker { + let trie_nodes = BTreeMap::from([ + (Nibbles::default(), root_branch_node(1 << 2, 1 << 2, 1 << 2)), + ( + Nibbles::from_nibbles([0x2]), + branch_node((1 << 3) | (1 << 4), 0, (1 << 3) | (1 << 4)), + ), + ]); + let factory = MockTrieCursorFactory::new(trie_nodes, B256Map::default()); + + let mut prefix_set = PrefixSetMut::default(); + prefix_set.insert(Nibbles::from_nibbles([0x2, 0x3, 0x1])); + + TrieWalker::state_trie(factory.account_trie_cursor().unwrap(), prefix_set.freeze()) + .with_all_children_unskippable_if_path_matches_prefix_set( + enable_all_children_unskippable, + ) + } + + #[test] + fn branch_siblings_remain_skippable_by_default() { + let mut walker = walker_for_matching_branch_children_test(false); + + assert_eq!(walker.key().copied(), Some(Nibbles::default())); + assert!(!walker.can_skip_current_node); + + walker.advance().unwrap(); + assert_eq!(walker.key().copied(), Some(Nibbles::from_nibbles([0x2]))); + assert!(!walker.can_skip_current_node); + + walker.advance().unwrap(); + assert_eq!(walker.key().copied(), Some(Nibbles::from_nibbles([0x2, 0x3]))); + assert_eq!(walker.stack.last().unwrap().position(), SubNodePosition::Child(0x3)); + assert!(!walker.can_skip_current_node); + + walker.advance().unwrap(); + assert_eq!(walker.key().copied(), Some(Nibbles::from_nibbles([0x2, 0x4]))); + assert!(walker.can_skip_current_node); + } + + #[test] + fn matching_branch_path_can_make_all_children_unskippable() { + let mut walker = walker_for_matching_branch_children_test(true); + + walker.advance().unwrap(); + walker.advance().unwrap(); + walker.advance().unwrap(); + assert_eq!(walker.key().copied(), Some(Nibbles::from_nibbles([0x2, 0x4]))); + assert!(!walker.can_skip_current_node); + } +} From 3fab9bf08c7fce97ba44c9bcfe80d8822f04dd03 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Tue, 5 May 2026 11:26:26 +0000 Subject: [PATCH 55/83] refactor(trie): rename walker branch child option Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019df7cd-9370-777b-9be4-b151c795829b Co-authored-by: Amp --- crates/trie/trie/src/walker.rs | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/crates/trie/trie/src/walker.rs b/crates/trie/trie/src/walker.rs index 85f3b72c746..a6f62b2d7fc 100644 --- a/crates/trie/trie/src/walker.rs +++ b/crates/trie/trie/src/walker.rs @@ -40,7 +40,7 @@ pub struct TrieWalker { pub changes: PrefixSet, /// When enabled, all children of a branch become unskippable if the branch path itself /// matches the prefix set, even if a given child path does not. - all_children_unskippable_if_path_matches_prefix_set: bool, + walk_all_changed_branch_children: bool, /// The retained trie node keys that need to be removed. removed_keys: Option>, /// Provided when it's necessary not to skip certain nodes during proof generation. @@ -91,7 +91,7 @@ impl> TrieWalker { changes, stack, can_skip_current_node: false, - all_children_unskippable_if_path_matches_prefix_set: false, + walk_all_changed_branch_children: false, removed_keys: None, added_removed_keys: None, #[cfg(feature = "metrics")] @@ -117,8 +117,7 @@ impl> TrieWalker { stack: self.stack, can_skip_current_node: self.can_skip_current_node, changes: self.changes, - all_children_unskippable_if_path_matches_prefix_set: self - .all_children_unskippable_if_path_matches_prefix_set, + walk_all_changed_branch_children: self.walk_all_changed_branch_children, removed_keys: self.removed_keys, added_removed_keys, #[cfg(feature = "metrics")] @@ -127,11 +126,8 @@ impl> TrieWalker { } /// Configures the walker to treat every child of a matching branch path as unskippable. - pub const fn with_all_children_unskippable_if_path_matches_prefix_set( - mut self, - enabled: bool, - ) -> Self { - self.all_children_unskippable_if_path_matches_prefix_set = enabled; + pub const fn with_walk_all_changed_branch_children(mut self, enabled: bool) -> Self { + self.walk_all_changed_branch_children = enabled; self } @@ -216,7 +212,7 @@ impl> TrieWalker { ); let branch_path_matches_prefix_set = self - .all_children_unskippable_if_path_matches_prefix_set + .walk_all_changed_branch_children .then(|| node.position().is_child()) .unwrap_or(false) && self.changes.contains(&node.key); @@ -267,7 +263,7 @@ impl> TrieWalker { changes, stack: vec![CursorSubNode::default()], can_skip_current_node: false, - all_children_unskippable_if_path_matches_prefix_set: false, + walk_all_changed_branch_children: false, removed_keys: None, added_removed_keys: Default::default(), #[cfg(feature = "metrics")] @@ -467,9 +463,7 @@ mod tests { prefix_set.insert(Nibbles::from_nibbles([0x2, 0x3, 0x1])); TrieWalker::state_trie(factory.account_trie_cursor().unwrap(), prefix_set.freeze()) - .with_all_children_unskippable_if_path_matches_prefix_set( - enable_all_children_unskippable, - ) + .with_walk_all_changed_branch_children(enable_all_children_unskippable) } #[test] From 91c25f4ddc257fecbcd8656637b85dfcbb9aab9d Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Tue, 5 May 2026 11:34:38 +0000 Subject: [PATCH 56/83] feat(stages): enable full changed-branch walks during merkle unwind Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019df7cd-9370-777b-9be4-b151c795829b Co-authored-by: Amp --- crates/stages/stages/src/stages/merkle.rs | 6 ++++- crates/trie/trie/src/trie.rs | 28 +++++++++++++++++++++++ crates/trie/trie/src/walker.rs | 4 ++-- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/stages/stages/src/stages/merkle.rs b/crates/stages/stages/src/stages/merkle.rs index cd2c130f780..f8fa720faa5 100644 --- a/crates/stages/stages/src/stages/merkle.rs +++ b/crates/stages/stages/src/stages/merkle.rs @@ -402,7 +402,11 @@ where info!(target: "sync::stages::merkle::unwind", "Nothing to unwind"); } else { let (block_root, updates) = reth_trie_db::with_adapter!(provider, |A| { - DbStateRoot::<_, A>::incremental_root_with_updates(provider, range) + DbStateRoot::<_, A>::incremental_root_calculator(provider, range).and_then( + |calculator| { + calculator.with_walk_all_changed_branch_children(true).root_with_updates() + }, + ) }) .map_err(|e| StageError::Fatal(Box::new(e)))?; diff --git a/crates/trie/trie/src/trie.rs b/crates/trie/trie/src/trie.rs index 9ea219f73ab..e8bcb440ed5 100644 --- a/crates/trie/trie/src/trie.rs +++ b/crates/trie/trie/src/trie.rs @@ -36,6 +36,8 @@ pub struct StateRoot { pub hashed_cursor_factory: H, /// A set of prefix sets that have changed. pub prefix_sets: TriePrefixSets, + /// Whether every child under a branch whose path matches the prefix set should be walked. + walk_all_changed_branch_children: bool, /// Previous intermediate state. previous_state: Option, /// The number of updates after which the intermediate progress should be returned. @@ -56,6 +58,7 @@ impl StateRoot { trie_cursor_factory, hashed_cursor_factory, prefix_sets: TriePrefixSets::default(), + walk_all_changed_branch_children: false, previous_state: None, threshold: DEFAULT_INTERMEDIATE_THRESHOLD, #[cfg(feature = "metrics")] @@ -69,6 +72,12 @@ impl StateRoot { self } + /// Configures the state root walker to visit all children of changed branch paths. + pub const fn with_walk_all_changed_branch_children(mut self, enabled: bool) -> Self { + self.walk_all_changed_branch_children = enabled; + self + } + /// Set the threshold. pub const fn with_threshold(mut self, threshold: u64) -> Self { self.threshold = threshold; @@ -93,6 +102,7 @@ impl StateRoot { trie_cursor_factory: self.trie_cursor_factory, hashed_cursor_factory, prefix_sets: self.prefix_sets, + walk_all_changed_branch_children: self.walk_all_changed_branch_children, threshold: self.threshold, previous_state: self.previous_state, #[cfg(feature = "metrics")] @@ -106,6 +116,7 @@ impl StateRoot { trie_cursor_factory, hashed_cursor_factory: self.hashed_cursor_factory, prefix_sets: self.prefix_sets, + walk_all_changed_branch_children: self.walk_all_changed_branch_children, threshold: self.threshold, previous_state: self.previous_state, #[cfg(feature = "metrics")] @@ -178,6 +189,7 @@ where account_root_state.walker_stack, self.prefix_sets.account_prefix_set, ) + .with_walk_all_changed_branch_children(self.walk_all_changed_branch_children) .with_deletions_retained(retain_updates); let account_node_iter = TrieNodeIter::state_trie(walker, hashed_account_cursor) .with_last_hashed_key(account_root_state.last_hashed_key); @@ -213,6 +225,7 @@ where self.metrics.storage_trie.clone(), ) .with_intermediate_state(Some(storage_state.state)) + .with_walk_all_changed_branch_children(self.walk_all_changed_branch_children) .with_threshold(remaining_threshold); let storage_result = storage_root_calculator.calculate(retain_updates)?; @@ -239,6 +252,7 @@ where // calculation let hash_builder = HashBuilder::default().with_updates(retain_updates); let walker = TrieWalker::state_trie(trie_cursor, self.prefix_sets.account_prefix_set) + .with_walk_all_changed_branch_children(self.walk_all_changed_branch_children) .with_deletions_retained(retain_updates); let node_iter = TrieNodeIter::state_trie(walker, hashed_account_cursor); (hash_builder, node_iter) @@ -272,6 +286,7 @@ where #[cfg(feature = "metrics")] self.metrics.storage_trie.clone(), ) + .with_walk_all_changed_branch_children(self.walk_all_changed_branch_children) .with_threshold(remaining_threshold); let storage_result = storage_root_calculator.calculate(retain_updates)?; @@ -465,6 +480,8 @@ pub struct StorageRoot { pub hashed_address: B256, /// The set of storage slot prefixes that have changed. pub prefix_set: PrefixSet, + /// Whether every child under a branch whose path matches the prefix set should be walked. + walk_all_changed_branch_children: bool, /// Previous intermediate state. previous_state: Option, /// The number of updates after which the intermediate progress should be returned. @@ -506,6 +523,7 @@ impl StorageRoot { hashed_cursor_factory, hashed_address, prefix_set, + walk_all_changed_branch_children: false, previous_state: None, threshold: DEFAULT_INTERMEDIATE_THRESHOLD, #[cfg(feature = "metrics")] @@ -519,6 +537,12 @@ impl StorageRoot { self } + /// Configures the storage root walker to visit all children of changed branch paths. + pub const fn with_walk_all_changed_branch_children(mut self, enabled: bool) -> Self { + self.walk_all_changed_branch_children = enabled; + self + } + /// Set the threshold. pub const fn with_threshold(mut self, threshold: u64) -> Self { self.threshold = threshold; @@ -544,6 +568,7 @@ impl StorageRoot { hashed_cursor_factory, hashed_address: self.hashed_address, prefix_set: self.prefix_set, + walk_all_changed_branch_children: self.walk_all_changed_branch_children, previous_state: self.previous_state, threshold: self.threshold, #[cfg(feature = "metrics")] @@ -558,6 +583,7 @@ impl StorageRoot { hashed_cursor_factory: self.hashed_cursor_factory, hashed_address: self.hashed_address, prefix_set: self.prefix_set, + walk_all_changed_branch_children: self.walk_all_changed_branch_children, previous_state: self.previous_state, threshold: self.threshold, #[cfg(feature = "metrics")] @@ -641,6 +667,7 @@ where state.walker_stack, self.prefix_set, ) + .with_walk_all_changed_branch_children(self.walk_all_changed_branch_children) .with_deletions_retained(retain_updates); let node_iter = TrieNodeIter::storage_trie(walker, hashed_storage_cursor) .with_last_hashed_key(state.last_hashed_key); @@ -649,6 +676,7 @@ where None => { let hash_builder = HashBuilder::default().with_updates(retain_updates); let walker = TrieWalker::storage_trie(trie_cursor, self.prefix_set) + .with_walk_all_changed_branch_children(self.walk_all_changed_branch_children) .with_deletions_retained(retain_updates); let node_iter = TrieNodeIter::storage_trie(walker, hashed_storage_cursor); (hash_builder, node_iter) diff --git a/crates/trie/trie/src/walker.rs b/crates/trie/trie/src/walker.rs index a6f62b2d7fc..7936663f3a1 100644 --- a/crates/trie/trie/src/walker.rs +++ b/crates/trie/trie/src/walker.rs @@ -448,7 +448,7 @@ mod tests { } fn walker_for_matching_branch_children_test( - enable_all_children_unskippable: bool, + walk_all_changed_branch_children: bool, ) -> TrieWalker { let trie_nodes = BTreeMap::from([ (Nibbles::default(), root_branch_node(1 << 2, 1 << 2, 1 << 2)), @@ -463,7 +463,7 @@ mod tests { prefix_set.insert(Nibbles::from_nibbles([0x2, 0x3, 0x1])); TrieWalker::state_trie(factory.account_trie_cursor().unwrap(), prefix_set.freeze()) - .with_walk_all_changed_branch_children(enable_all_children_unskippable) + .with_walk_all_changed_branch_children(walk_all_changed_branch_children) } #[test] From 8db3ac63a5972ea757e75c3e08045d550be8150d Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Tue, 5 May 2026 13:26:28 +0000 Subject: [PATCH 57/83] fix(trie): simplify disjointed merge masking Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019df84d-0048-705b-97b4-e199ea9ddf6f Co-authored-by: Amp --- crates/trie/common/src/updates.rs | 237 +++++------------------------- 1 file changed, 37 insertions(+), 200 deletions(-) diff --git a/crates/trie/common/src/updates.rs b/crates/trie/common/src/updates.rs index 331c4c1deae..698b065b02b 100644 --- a/crates/trie/common/src/updates.rs +++ b/crates/trie/common/src/updates.rs @@ -1,5 +1,5 @@ use crate::{ - utils::{extend_sorted_vec, kway_merge_sorted}, + utils::{extend_sorted_vec, kway_merge_disjoint_sorted, kway_merge_sorted}, BranchNodeCompact, HashBuilder, Nibbles, }; use alloc::{ @@ -10,7 +10,6 @@ use alloy_primitives::{ map::{B256Map, B256Set, HashMap, HashSet}, FixedBytes, B256, }; -use itertools::Itertools; /// The aggregation of trie updates. #[derive(PartialEq, Eq, Clone, Default, Debug)] @@ -717,10 +716,10 @@ impl TrieUpdatesSorted { /// Account trie nodes are masked at the top level, while storage trie entries are only masked /// at the node level unless the mask deletes the entire storage trie. For duplicate keys in /// the batch, later items take precedence over earlier ones. The order of the mask does not - /// matter. Overlapping nodes are preserved when any masked value for a key is equivalent to - /// the merged batch value for that same key. + /// matter. pub fn disjointed_merge_batch<'a>(batch: Vec<&'a Self>, mask: Vec<&'a Self>) -> Self { - let account_nodes = merge_masked_trie_nodes_preserving_ancestors( + let account_nodes = kway_merge_disjoint_sorted( + batch.iter().map(|item| item.account_nodes.len()).sum(), batch.iter().rev().map(|item| item.account_nodes.as_slice()), mask.iter().map(|item| item.account_nodes.as_slice()), ); @@ -728,6 +727,7 @@ impl TrieUpdatesSorted { struct StorageAcc<'a> { is_deleted: bool, sealed: bool, + node_count: usize, slices: Vec<&'a [(Nibbles, Option)]>, } @@ -747,6 +747,7 @@ impl TrieUpdatesSorted { let entry = storage_tries.entry(*hashed_address).or_insert_with(|| StorageAcc { is_deleted: false, sealed: false, + node_count: 0, slices: Vec::new(), }); @@ -755,6 +756,7 @@ impl TrieUpdatesSorted { } entry.slices.push(storage_trie.storage_nodes.as_slice()); + entry.node_count += storage_trie.storage_nodes.len(); if storage_trie.is_deleted { entry.is_deleted = true; entry.sealed = true; @@ -786,7 +788,8 @@ impl TrieUpdatesSorted { .filter_map(|(hashed_address, entry)| { let storage_nodes = match storage_masks.get(&hashed_address) { Some(mask_entry) if mask_entry.is_deleted => return None, - Some(mask_entry) => merge_masked_trie_nodes_preserving_ancestors( + Some(mask_entry) => kway_merge_disjoint_sorted( + entry.node_count, entry.slices, mask_entry.slices.iter().copied(), ), @@ -804,60 +807,6 @@ impl TrieUpdatesSorted { } } -fn merge_masked_trie_nodes_preserving_ancestors<'a>( - batch_slices: impl IntoIterator)]>, - mask_slices: impl IntoIterator)]>, -) -> Vec<(Nibbles, Option)> { - let merged_batch_nodes = kway_merge_sorted(batch_slices); - let merged_masked_nodes = mask_slices - .into_iter() - .filter(|slice| !slice.is_empty()) - .map(|slice| slice.iter()) - .kmerge_by(|(left_key, _), (right_key, _)| left_key < right_key) - .collect::>(); - let mut masked_node_index = 0; - let directly_kept_node_keys = merged_batch_nodes - .iter() - .filter_map(|(key, batch_value)| { - while let Some((masked_key, _)) = merged_masked_nodes.get(masked_node_index).copied() { - if masked_key < key { - masked_node_index += 1; - continue; - } - - if masked_key > key { - return Some(key.clone()) - } - - let mut has_equivalent_value = false; - while let Some((masked_key, masked_value)) = - merged_masked_nodes.get(masked_node_index).copied() - { - if masked_key != key { - break; - } - has_equivalent_value |= masked_value == batch_value; - masked_node_index += 1; - } - - return has_equivalent_value.then(|| key.clone()) - } - - Some(key.clone()) - }) - .collect::>(); - - merged_batch_nodes - .into_iter() - .filter(|(key, _)| { - directly_kept_node_keys.binary_search(key).is_ok() || - directly_kept_node_keys - .get(directly_kept_node_keys.partition_point(|candidate| candidate <= key)) - .is_some_and(|candidate| candidate.starts_with(key)) - }) - .collect() -} - impl AsRef for TrieUpdatesSorted { fn as_ref(&self) -> &Self { self @@ -1242,86 +1191,59 @@ mod tests { } #[test] - fn test_trie_updates_sorted_disjointed_merge_batch_keeps_equivalent_overlapping_nodes() { - fn branch(mask: u16) -> BranchNodeCompact { - BranchNodeCompact::new(mask, 0, 0, vec![], None) - } - - let overlapping_node = Nibbles::from_nibbles_unchecked([0x03]); - let overlapping_storage = B256::from([5; 32]); - let slot = Nibbles::from_nibbles_unchecked([0x0c]); - let node = branch(0b1010_0101); - let storage_node = branch(0b0011_1100); + fn test_trie_updates_sorted_disjointed_merge_batch_uses_exact_key_masking() { + let hashed_address = B256::from([7; 32]); + let grandparent = Nibbles::from_nibbles_unchecked([0x05]); + let parent = Nibbles::from_nibbles_unchecked([0x05, 0x04]); + let child = Nibbles::from_nibbles_unchecked([0x05, 0x04, 0x03]); let batch = TrieUpdatesSorted::new( - vec![(overlapping_node, Some(node.clone()))], + vec![ + (grandparent, Some(BranchNodeCompact::default())), + (parent, Some(BranchNodeCompact::default())), + (child, Some(BranchNodeCompact::default())), + ], B256Map::from_iter([( - overlapping_storage, + hashed_address, StorageTrieUpdatesSorted { is_deleted: false, - storage_nodes: vec![(slot, Some(storage_node.clone()))], + storage_nodes: vec![ + (grandparent, Some(BranchNodeCompact::default())), + (parent, Some(BranchNodeCompact::default())), + (child, Some(BranchNodeCompact::default())), + ], }, )]), ); - let mask = TrieUpdatesSorted::new( - vec![(overlapping_node, Some(node.clone()))], + vec![ + (grandparent, Some(BranchNodeCompact::default())), + (parent, Some(BranchNodeCompact::default())), + ], B256Map::from_iter([( - overlapping_storage, + hashed_address, StorageTrieUpdatesSorted { is_deleted: false, - storage_nodes: vec![(slot, Some(storage_node.clone()))], + storage_nodes: vec![ + (grandparent, Some(BranchNodeCompact::default())), + (parent, Some(BranchNodeCompact::default())), + ], }, )]), ); let result = TrieUpdatesSorted::disjointed_merge_batch(vec![&batch], vec![&mask]); - assert_eq!(result.account_nodes, vec![(overlapping_node, Some(node))]); + assert_eq!(result.account_nodes, vec![(child, Some(BranchNodeCompact::default()))]); assert_eq!( - result.storage_tries.get(&overlapping_storage), + result.storage_tries.get(&hashed_address), Some(&StorageTrieUpdatesSorted { is_deleted: false, - storage_nodes: vec![(slot, Some(storage_node))], + storage_nodes: vec![(child, Some(BranchNodeCompact::default()))], }) ); } - #[test] - fn test_trie_updates_sorted_disjointed_merge_batch_keeps_node_if_any_masked_value_matches() { - fn branch(mask: u16) -> BranchNodeCompact { - BranchNodeCompact::new(mask, 0, 0, vec![], None) - } - - let overlapping_node = Nibbles::from_nibbles_unchecked([0x04]); - let node = branch(0b1010_0101); - - let batch = TrieUpdatesSorted::new( - vec![(overlapping_node, Some(node.clone()))], - B256Map::default(), - ); - let equivalent_mask = TrieUpdatesSorted::new( - vec![(overlapping_node, Some(node.clone()))], - B256Map::default(), - ); - let different_mask = TrieUpdatesSorted::new( - vec![(overlapping_node, Some(branch(0b0101_1010)))], - B256Map::default(), - ); - - let result = TrieUpdatesSorted::disjointed_merge_batch( - vec![&batch], - vec![&different_mask, &equivalent_mask], - ); - let reversed = TrieUpdatesSorted::disjointed_merge_batch( - vec![&batch], - vec![&equivalent_mask, &different_mask], - ); - - assert_eq!(result.account_nodes, vec![(overlapping_node, Some(node.clone()))]); - assert_eq!(reversed.account_nodes, vec![(overlapping_node, Some(node))]); - } - #[test] fn test_trie_updates_sorted_disjointed_merge_batch_ignores_empty_storage_mask() { let storage = B256::from([6; 32]); @@ -1356,91 +1278,6 @@ mod tests { ); } - #[test] - fn test_trie_updates_sorted_disjointed_merge_batch_keeps_masked_ancestors_of_unmasked_nodes() { - let grandparent = Nibbles::from_nibbles_unchecked([0x05]); - let parent = Nibbles::from_nibbles_unchecked([0x05, 0x04]); - let child = Nibbles::from_nibbles_unchecked([0x05, 0x04, 0x03]); - - let batch = TrieUpdatesSorted::new( - vec![ - (grandparent, Some(BranchNodeCompact::default())), - (parent, Some(BranchNodeCompact::default())), - (child, Some(BranchNodeCompact::default())), - ], - B256Map::default(), - ); - let mask = TrieUpdatesSorted::new( - vec![ - (grandparent, Some(BranchNodeCompact::default())), - (parent, Some(BranchNodeCompact::default())), - ], - B256Map::default(), - ); - - let result = TrieUpdatesSorted::disjointed_merge_batch(vec![&batch], vec![&mask]); - - assert_eq!( - result.account_nodes, - vec![ - (grandparent, Some(BranchNodeCompact::default())), - (parent, Some(BranchNodeCompact::default())), - (child, Some(BranchNodeCompact::default())), - ] - ); - } - - #[test] - fn test_trie_updates_sorted_disjointed_merge_batch_keeps_masked_storage_ancestors_of_unmasked_nodes( - ) { - let hashed_address = B256::from([7; 32]); - let grandparent = Nibbles::from_nibbles_unchecked([0x05]); - let parent = Nibbles::from_nibbles_unchecked([0x05, 0x04]); - let child = Nibbles::from_nibbles_unchecked([0x05, 0x04, 0x03]); - - let batch = TrieUpdatesSorted::new( - vec![], - B256Map::from_iter([( - hashed_address, - StorageTrieUpdatesSorted { - is_deleted: false, - storage_nodes: vec![ - (grandparent, Some(BranchNodeCompact::default())), - (parent, Some(BranchNodeCompact::default())), - (child, Some(BranchNodeCompact::default())), - ], - }, - )]), - ); - let mask = TrieUpdatesSorted::new( - vec![], - B256Map::from_iter([( - hashed_address, - StorageTrieUpdatesSorted { - is_deleted: false, - storage_nodes: vec![ - (grandparent, Some(BranchNodeCompact::default())), - (parent, Some(BranchNodeCompact::default())), - ], - }, - )]), - ); - - let result = TrieUpdatesSorted::disjointed_merge_batch(vec![&batch], vec![&mask]); - - assert_eq!( - result.storage_tries.get(&hashed_address), - Some(&StorageTrieUpdatesSorted { - is_deleted: false, - storage_nodes: vec![ - (grandparent, Some(BranchNodeCompact::default())), - (parent, Some(BranchNodeCompact::default())), - (child, Some(BranchNodeCompact::default())), - ], - }) - ); - } - /// Test extending with storage tries adds both nodes and removed nodes correctly #[test] fn test_trie_updates_extend_from_sorted_with_storage_tries() { From a0dc39c28f13f9a3b5c13ef3d8ce4377d5768281 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Tue, 5 May 2026 18:05:21 +0000 Subject: [PATCH 58/83] test(scripts): add hoodi reorg repro Co-Authored-By: Brian Picciano <933154+mediocregopher@users.noreply.github.com> --- .../repro-hoodi-partial-persistence-reorg.sh | 518 ++++++++++++++++++ 1 file changed, 518 insertions(+) create mode 100755 scripts/repro-hoodi-partial-persistence-reorg.sh diff --git a/scripts/repro-hoodi-partial-persistence-reorg.sh b/scripts/repro-hoodi-partial-persistence-reorg.sh new file mode 100755 index 00000000000..51a83b578d7 --- /dev/null +++ b/scripts/repro-hoodi-partial-persistence-reorg.sh @@ -0,0 +1,518 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: repro-hoodi-partial-persistence-reorg.sh [options] + +Restores a hoodi datadir snapshot, starts reth with partial persistence, then +runs reth-bench new-payload-fcu with --reorg until a state-root mismatch is +observed, the benchmark exits, or an optional timeout is reached. + +Unlike repro-hoodi-partial-persistence-unwind.sh, this script does not crash the +node and does not run restart, unwind, or Merkle-stage follow-up steps. + +Options: + --snapshot PATH Tar.zst snapshot to restore + (default: /mnt/data/hoodi.tar.zst) + --datadir PATH Restored reth datadir + (default: /mnt/data/hoodi) + --jwt-secret PATH JWT secret path + (default: /jwt.hex) + --rpc-url URL Remote hoodi RPC used by reth-bench + (default: https://rpc.hoodi.ethpandaops.io) + --expected-head N Expected local head after restore + (default: 2613962) + --start-block N First block expected to be replayed + (default: 2613963) + --to-block N Last block to replay before declaring no mismatch + (default: unset, run continuously from restored head) + --reorg-depth N reth-bench --reorg depth + (default: 8) + --artifacts-dir PATH Directory for logs and summary output + (default: /tmp/reth-hoodi-reorg-) + --start-timeout SECONDS Seconds to wait for node RPC startup + (default: 180) + --mismatch-timeout SECONDS Seconds to wait for a state-root mismatch after + reth-bench starts (default: 0, no timeout) + -h, --help Show this help + +Exit codes: + 0 Script ran to completion. See result.txt for whether a state-root mismatch + was observed. + 2 Setup/runtime failure prevented a conclusive run. +EOF +} + +log() { + printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*" >&2 +} + +regex_escape() { + printf '%s' "$1" | sed 's/[][(){}.^$+*?|\\/]/\\&/g' +} + +head_hex() { + local response + response=$(curl -fsS \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \ + http://127.0.0.1:8545 2>/dev/null) || return 1 + response=${response//$'\n'/} + sed -n 's/.*"result"[[:space:]]*:[[:space:]]*"\(0x[0-9a-fA-F]\+\)".*/\1/p' <<<"$response" +} + +hex_to_dec() { + printf '%d\n' "$((16#${1#0x}))" +} + +wait_for_pid_exit() { + local pid="$1" + local timeout="$2" + local elapsed=0 + + while (( elapsed < timeout )); do + if ! kill -0 "$pid" 2>/dev/null; then + return 0 + fi + sleep 1 + ((elapsed += 1)) + done + + return 1 +} + +stop_pid() { + local pid="$1" + local label="$2" + + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + log "Sending SIGTERM to ${label} (pid ${pid})" + kill -TERM "$pid" 2>/dev/null || true + fi +} + +stop_matching_processes() { + local pattern="$1" + local label="$2" + local -a pids=() + local pid + + while IFS= read -r pid; do + [[ -n "$pid" ]] && pids+=("$pid") + done < <(pgrep -f "$pattern" || true) + + if ((${#pids[@]} > 0)); then + log "Sending SIGTERM to stale ${label} processes: ${pids[*]}" + kill -TERM "${pids[@]}" 2>/dev/null || true + fi +} + +capture_command() { + local name="$1" + shift + { + printf '%s=' "$name" + printf '%q ' "$@" + printf '\n' + } >>"$COMMANDS_FILE" +} + +write_summary() { + { + printf 'result=%s\n' "${RESULT:-unknown}" + printf 'snapshot=%s\n' "$SNAPSHOT" + printf 'datadir=%s\n' "$DATADIR" + printf 'jwt_secret=%s\n' "$JWT_SECRET" + printf 'remote_rpc_url=%s\n' "$REMOTE_RPC_URL" + printf 'expected_head=%s\n' "$EXPECTED_HEAD" + printf 'start_block=%s\n' "$START_BLOCK" + printf 'to_block=%s\n' "${TO_BLOCK:-unset}" + printf 'reorg_depth=%s\n' "$REORG_DEPTH" + printf 'head_before=%s\n' "${HEAD_BEFORE:-unknown}" + printf 'head_after=%s\n' "${HEAD_AFTER:-unknown}" + printf 'bench_exit_code=%s\n' "${BENCH_EXIT_CODE:-unknown}" + printf 'mismatch_source=%s\n' "${MISMATCH_SOURCE:-not_found}" + printf 'mismatch_line=%s\n' "${MISMATCH_LINE:-not_found}" + printf 'artifacts_dir=%s\n' "$ARTIFACTS_DIR" + printf 'node_log=%s\n' "$NODE_LOG" + printf 'bench_log=%s\n' "$BENCH_LOG" + } >"$SUMMARY_FILE" +} + +cleanup() { + stop_pid "${BENCH_PID:-}" "reth-bench" + if [[ -n "${BENCH_PID:-}" ]]; then + wait "${BENCH_PID}" 2>/dev/null || true + fi + + stop_pid "${NODE_PID:-}" "reth node" + if [[ -n "${NODE_PID:-}" ]]; then + wait "${NODE_PID}" 2>/dev/null || true + fi + + write_summary +} + +SNAPSHOT="/mnt/data/hoodi.tar.zst" +DATADIR="/mnt/data/hoodi" +JWT_SECRET="" +REMOTE_RPC_URL="https://rpc.hoodi.ethpandaops.io" +EXPECTED_HEAD=2613962 +START_BLOCK=2613963 +TO_BLOCK="" +REORG_DEPTH=8 +START_TIMEOUT=180 +MISMATCH_TIMEOUT=0 +RETH_BIN="/repos/reth/target/profiling/reth" +BENCH_BIN="/repos/reth/target/profiling/reth-bench" +CHAIN="hoodi" +RESULT="script_error" +HEAD_BEFORE="" +HEAD_AFTER="" +NODE_PID="" +BENCH_PID="" +BENCH_EXIT_CODE="" +MISMATCH_SOURCE="" +MISMATCH_LINE="" +TIMESTAMP="$(date '+%Y%m%d-%H%M%S')" +ARTIFACTS_DIR="/tmp/reth-hoodi-reorg-${TIMESTAMP}" + +while (($# > 0)); do + case "$1" in + --snapshot) + SNAPSHOT="$2" + shift 2 + ;; + --datadir) + DATADIR="$2" + shift 2 + ;; + --jwt-secret) + JWT_SECRET="$2" + shift 2 + ;; + --rpc-url) + REMOTE_RPC_URL="$2" + shift 2 + ;; + --expected-head) + EXPECTED_HEAD="$2" + shift 2 + ;; + --start-block) + START_BLOCK="$2" + shift 2 + ;; + --to-block) + TO_BLOCK="$2" + shift 2 + ;; + --reorg-depth) + REORG_DEPTH="$2" + shift 2 + ;; + --artifacts-dir) + ARTIFACTS_DIR="$2" + shift 2 + ;; + --start-timeout) + START_TIMEOUT="$2" + shift 2 + ;; + --mismatch-timeout) + MISMATCH_TIMEOUT="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 2 + ;; + esac +done + +if [[ -z "$JWT_SECRET" ]]; then + JWT_SECRET="${DATADIR}/jwt.hex" +fi + +mkdir -p "$ARTIFACTS_DIR" +COMMANDS_FILE="${ARTIFACTS_DIR}/commands.txt" +SUMMARY_FILE="${ARTIFACTS_DIR}/result.txt" +NODE_LOG="${ARTIFACTS_DIR}/node.log" +BENCH_LOG="${ARTIFACTS_DIR}/bench.log" + +trap cleanup EXIT + +if [[ ! -x "$RETH_BIN" ]]; then + log "Missing executable reth binary: $RETH_BIN" + exit 2 +fi + +if [[ ! -x "$BENCH_BIN" ]]; then + log "Missing executable reth-bench binary: $BENCH_BIN" + exit 2 +fi + +if [[ ! -f "$SNAPSHOT" ]]; then + log "Missing snapshot archive: $SNAPSHOT" + exit 2 +fi + +if (( REORG_DEPTH <= 0 )); then + log "--reorg-depth must be greater than 0" + exit 2 +fi + +if [[ -n "$TO_BLOCK" ]] && (( TO_BLOCK < START_BLOCK )); then + log "--to-block ${TO_BLOCK} must be greater than or equal to --start-block ${START_BLOCK}" + exit 2 +fi + +NODE_PATTERN="^$(regex_escape "$RETH_BIN") node --datadir $(regex_escape "$DATADIR")( |$)" +stop_matching_processes "$NODE_PATTERN" "reth" +sleep 1 + +capture_command reth "$RETH_BIN" node \ + --datadir "$DATADIR" \ + --chain "$CHAIN" \ + --http --http.addr 127.0.0.1 --http.port 8545 --http.api eth,net,web3,reth,testing \ + --ws --ws.addr 127.0.0.1 --ws.port 8546 --ws.api eth,net,web3,reth \ + --authrpc.addr 127.0.0.1 --authrpc.port 8551 --authrpc.jwtsecret "$JWT_SECRET" \ + --disable-discovery \ + --engine.persistence-threshold 10 \ + --engine.deferred-trie-blocks 3 \ + --engine.accept-execution-requests-hash \ + --log.stdout.filter 'info,providers::db=debug,reth::providers::static_file=debug,reth::storage=debug,consensus::engine=debug,reth-bench=debug' \ + --color never + +restore_snapshot() { + local parent_dir + local base_name + local extract_root + local candidate_datadir="" + local -a nested_candidates=() + local nested_dir + + parent_dir=$(dirname "$DATADIR") + base_name=$(basename "$DATADIR") + extract_root="${DATADIR}.extract.$$" + + log "Restoring snapshot ${SNAPSHOT} into ${DATADIR}" + rm -rf "$DATADIR" "$extract_root" + mkdir -p "$parent_dir" "$extract_root" + tar --zstd -xf "$SNAPSHOT" -C "$extract_root" + + if [[ -d "${extract_root}/${base_name}/db" && -d "${extract_root}/${base_name}/static_files" ]]; then + candidate_datadir="${extract_root}/${base_name}" + else + while IFS= read -r nested_dir; do + if [[ -d "${nested_dir}/db" && -d "${nested_dir}/static_files" ]]; then + nested_candidates+=("$nested_dir") + fi + done < <(find "$extract_root" -mindepth 1 -maxdepth 1 -type d | sort) + + if ((${#nested_candidates[@]} == 1)); then + candidate_datadir="${nested_candidates[0]}" + elif ((${#nested_candidates[@]} > 1)); then + log "Snapshot layout produced multiple nested datadir candidates under ${extract_root}: ${nested_candidates[*]}" + exit 2 + elif [[ -d "${extract_root}/db" && -d "${extract_root}/static_files" ]]; then + candidate_datadir="$extract_root" + fi + fi + + if [[ -z "$candidate_datadir" ]]; then + log "Snapshot layout did not produce an expected datadir under ${extract_root}" + exit 2 + fi + + if [[ "$candidate_datadir" == "$extract_root" ]]; then + mv "$extract_root" "$DATADIR" + else + mv "$candidate_datadir" "$DATADIR" + rm -rf "$extract_root" + fi + + if [[ ! -f "$JWT_SECRET" ]]; then + log "Restored datadir is missing jwt secret; generating ${JWT_SECRET}" + mkdir -p "$(dirname "$JWT_SECRET")" + umask 077 + head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' >"$JWT_SECRET" + printf '\n' >>"$JWT_SECRET" + fi +} + +start_node() { + "$RETH_BIN" node \ + --datadir "$DATADIR" \ + --chain "$CHAIN" \ + --http --http.addr 127.0.0.1 --http.port 8545 --http.api eth,net,web3,reth,testing \ + --ws --ws.addr 127.0.0.1 --ws.port 8546 --ws.api eth,net,web3,reth \ + --authrpc.addr 127.0.0.1 --authrpc.port 8551 --authrpc.jwtsecret "$JWT_SECRET" \ + --disable-discovery \ + --engine.persistence-threshold 10 \ + --engine.deferred-trie-blocks 3 \ + --engine.accept-execution-requests-hash \ + --log.stdout.filter 'info,providers::db=debug,reth::providers::static_file=debug,reth::storage=debug,consensus::engine=debug,reth-bench=debug' \ + --color never \ + >"$NODE_LOG" 2>&1 & + echo $! +} + +wait_for_rpc_start() { + local pid="$1" + local timeout="$2" + local elapsed=0 + local block_hex + + while (( elapsed < timeout )); do + block_hex=$(head_hex || true) + if [[ -n "$block_hex" ]]; then + printf '%s\n' "$block_hex" + return 0 + fi + + if ! kill -0 "$pid" 2>/dev/null; then + log "Node exited before RPC became ready" + return 1 + fi + + sleep 1 + ((elapsed += 1)) + done + + log "Timed out waiting for node RPC readiness" + return 1 +} + +remove_stale_locks() { + rm -f "$DATADIR/db/lock" "$DATADIR/static_files/lock" "$DATADIR/rocksdb/LOCK" +} + +find_mismatch() { + local source="$1" + local log_file="$2" + local line + + line=$(grep -Ei -m1 \ + 'state[ -]?root.*mismatch|mismatch.*state[ -]?root|mismatched block state root|Failed to verify block state root' \ + "$log_file" 2>/dev/null || true) + if [[ -n "$line" ]]; then + MISMATCH_SOURCE="$source" + MISMATCH_LINE="$line" + RESULT="state_root_mismatch" + return 0 + fi + + return 1 +} + +monitor_for_mismatch() { + local start_epoch + local elapsed + local block_hex + + start_epoch=$(date +%s) + while true; do + if find_mismatch node "$NODE_LOG" || find_mismatch bench "$BENCH_LOG"; then + log "Observed state-root mismatch in ${MISMATCH_SOURCE} log" + return 0 + fi + + block_hex=$(head_hex || true) + if [[ -n "$block_hex" ]]; then + HEAD_AFTER=$(hex_to_dec "$block_hex") + fi + + if [[ -n "$BENCH_PID" ]] && ! kill -0 "$BENCH_PID" 2>/dev/null; then + if wait "$BENCH_PID"; then + BENCH_EXIT_CODE=0 + RESULT="bench_completed_no_mismatch" + else + BENCH_EXIT_CODE=$? + if find_mismatch node "$NODE_LOG" || find_mismatch bench "$BENCH_LOG"; then + log "Observed state-root mismatch after reth-bench exit" + return 0 + fi + RESULT="bench_failed_no_mismatch" + fi + BENCH_PID="" + return 0 + fi + + if [[ -n "$NODE_PID" ]] && ! kill -0 "$NODE_PID" 2>/dev/null; then + if find_mismatch node "$NODE_LOG" || find_mismatch bench "$BENCH_LOG"; then + log "Observed state-root mismatch after node exit" + return 0 + fi + RESULT="node_exited_no_mismatch" + return 0 + fi + + if (( MISMATCH_TIMEOUT > 0 )); then + elapsed=$(($(date +%s) - start_epoch)) + if (( elapsed >= MISMATCH_TIMEOUT )); then + RESULT="timeout_no_mismatch" + return 0 + fi + fi + + sleep 1 + done +} + +restore_snapshot + +log "Starting reth for reorg replay run" +NODE_PID=$(start_node) + +HEAD_HEX=$(wait_for_rpc_start "$NODE_PID" "$START_TIMEOUT") || exit 2 +HEAD_BEFORE=$(hex_to_dec "$HEAD_HEX") +HEAD_AFTER="$HEAD_BEFORE" +printf '%s\n' "$HEAD_BEFORE" >"${ARTIFACTS_DIR}/current_head_before.txt" + +if (( HEAD_BEFORE != EXPECTED_HEAD )); then + log "Expected restored head ${EXPECTED_HEAD}, got ${HEAD_BEFORE}" + exit 2 +fi + +if (( HEAD_BEFORE + 1 != START_BLOCK )); then + log "Expected first replay block ${START_BLOCK}, but restored head implies ${HEAD_BEFORE} -> $((HEAD_BEFORE + 1))" + exit 2 +fi + +BENCH_ARGS=( + "$BENCH_BIN" -vvv new-payload-fcu + --rpc-url "$REMOTE_RPC_URL" + --from "$HEAD_BEFORE" + --jwt-secret "$JWT_SECRET" + --engine-rpc-url http://127.0.0.1:8551 + --local-rpc-url http://127.0.0.1:8545 + --ws-rpc-url ws://127.0.0.1:8546 + --reorg "$REORG_DEPTH" +) + +if [[ -n "$TO_BLOCK" ]]; then + BENCH_ARGS+=(--to "$TO_BLOCK") +fi + +capture_command reth_bench "${BENCH_ARGS[@]}" + +if [[ -n "$TO_BLOCK" ]]; then + log "Running reth-bench with --reorg ${REORG_DEPTH} from block ${START_BLOCK} through ${TO_BLOCK}" +else + log "Running reth-bench with --reorg ${REORG_DEPTH} continuously from block ${START_BLOCK}" +fi + +"${BENCH_ARGS[@]}" >"$BENCH_LOG" 2>&1 & +BENCH_PID=$! + +monitor_for_mismatch + +log "Reorg repro result: ${RESULT}" From 636d8f7bc9eb91caad3a69c9d94fc5d6964dca33 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Tue, 5 May 2026 21:40:03 +0000 Subject: [PATCH 59/83] chore(engine): log overlay construction details Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019df965-c32c-7188-831c-c5d6ea37e036 Co-authored-by: Amp --- crates/chain-state/src/lazy_overlay.rs | 60 +++++- .../provider/src/providers/state/overlay.rs | 200 ++++++++++++++++-- .../repro-hoodi-partial-persistence-reorg.sh | 4 +- 3 files changed, 239 insertions(+), 25 deletions(-) diff --git a/crates/chain-state/src/lazy_overlay.rs b/crates/chain-state/src/lazy_overlay.rs index ecf9a9c92ac..132b2e7a995 100644 --- a/crates/chain-state/src/lazy_overlay.rs +++ b/crates/chain-state/src/lazy_overlay.rs @@ -12,7 +12,7 @@ use reth_primitives_traits::{ }; use reth_trie::{updates::TrieUpdatesSorted, HashedPostStateSorted, TrieInputSorted}; use std::sync::Arc; -use tracing::{debug, trace}; +use tracing::debug; /// Inputs captured for lazy overlay computation. #[derive(Clone)] @@ -71,6 +71,18 @@ impl LazyOverlay { "LazyOverlay blocks must be ordered newest to oldest along a single chain" ); + if tracing::enabled!(target: "chain_state::lazy_overlay", tracing::Level::DEBUG) { + debug!( + target: "chain_state::lazy_overlay", + num_blocks = blocks.len(), + tip = ?blocks.first().map(block_summary), + oldest = ?blocks.last().map(block_summary), + anchor_hash = ?blocks.last().map(|block| block.recovered_block().parent_hash()), + blocks = ?blocks.iter().map(block_summary).collect::>(), + "Creating lazy overlay" + ); + } + Self { inner: Default::default(), inputs: LazyOverlayInputs { blocks } } } @@ -79,6 +91,11 @@ impl LazyOverlay { self.inputs.blocks.len() } + /// Returns a compact summary of the blocks captured by this overlay. + pub fn block_summaries(&self) -> Vec { + self.inputs.blocks.iter().map(block_summary).collect() + } + /// Returns the oldest anchor hash this overlay can serve. /// /// This is the parent hash of the oldest block in the stored newest-to-oldest chain segment. @@ -105,7 +122,15 @@ impl LazyOverlay { /// Subsequent calls for the same anchor return the cached result immediately. pub fn get(&self, anchor_hash: B256) -> Arc { match self.inner.entry(anchor_hash) { - dashmap::Entry::Occupied(entry) => Arc::clone(entry.get()), + dashmap::Entry::Occupied(entry) => { + debug!( + target: "chain_state::lazy_overlay", + %anchor_hash, + num_blocks = self.inputs.blocks.len(), + "Using cached lazy overlay result" + ); + Arc::clone(entry.get()) + } dashmap::Entry::Vacant(entry) => { let input = self.compute(anchor_hash); entry.insert(Arc::clone(&input)); @@ -133,12 +158,28 @@ impl LazyOverlay { let Some(last_index) = blocks.iter().position(|block| block.recovered_block().parent_hash() == anchor_hash) else { + debug!( + target: "chain_state::lazy_overlay", + %anchor_hash, + available_blocks = ?blocks.iter().map(block_summary).collect::>(), + "Lazy overlay requested missing anchor" + ); panic!( "LazyOverlay does not contain a block whose parent hash matches requested anchor {anchor_hash}" ); }; let blocks = &blocks[..=last_index]; + if tracing::enabled!(target: "chain_state::lazy_overlay", tracing::Level::DEBUG) { + debug!( + target: "chain_state::lazy_overlay", + %anchor_hash, + num_selected_blocks = blocks.len(), + selected_blocks = ?blocks.iter().map(block_summary).collect::>(), + "Computing lazy overlay for anchor" + ); + } + // Fast path: Check if tip block's overlay is ready and anchor matches. // The tip block (first in list) has the cumulative overlay from all ancestors up to the // requested anchor. @@ -146,7 +187,14 @@ impl LazyOverlay { let data = tip.trie_data(); if let Some(anchored) = &data.anchored_trie_input { if anchored.anchor_hash == anchor_hash { - trace!(target: "chain_state::lazy_overlay", %anchor_hash, "Reusing tip block's cached overlay (fast path)"); + debug!( + target: "chain_state::lazy_overlay", + %anchor_hash, + tip = ?block_summary(tip), + trie_updates = anchored.trie_input.nodes.total_len(), + hashed_state = anchored.trie_input.state.total_len(), + "Reusing tip block's cached overlay (fast path)" + ); return Arc::clone(&anchored.trie_input); } debug!( @@ -163,6 +211,7 @@ impl LazyOverlay { target: "chain_state::lazy_overlay", %anchor_hash, num_blocks = blocks.len(), + blocks = ?blocks.iter().map(block_summary).collect::>(), "Merging blocks (slow path)" ); Arc::new(Self::merge_blocks(blocks)) @@ -187,6 +236,11 @@ impl LazyOverlay { } } +fn block_summary(block: &ExecutedBlock) -> String { + let recovered = block.recovered_block(); + format!("#{} hash={} parent={}", recovered.number(), recovered.hash(), recovered.parent_hash()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/storage/provider/src/providers/state/overlay.rs b/crates/storage/provider/src/providers/state/overlay.rs index 19ddcd6fc86..232389dfaa9 100644 --- a/crates/storage/provider/src/providers/state/overlay.rs +++ b/crates/storage/provider/src/providers/state/overlay.rs @@ -112,6 +112,14 @@ impl OverlayBuilder { if let Some(OverlaySource::Lazy(lazy_overlay)) = source.as_ref() { self.assert_lazy_overlay_anchor(lazy_overlay); } + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.anchor_hash, + source = overlay_source_kind(source.as_ref()), + source_anchor = ?source.as_ref().and_then(overlay_source_anchor), + source_blocks = ?source.as_ref().and_then(overlay_source_blocks), + "Configuring overlay source" + ); self.overlay_source = source; self } @@ -133,6 +141,13 @@ impl OverlayBuilder { if let Some(lazy_overlay) = lazy_overlay.as_ref() { self.assert_lazy_overlay_anchor(lazy_overlay); } + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.anchor_hash, + lazy_anchor = ?lazy_overlay.as_ref().and_then(LazyOverlay::anchor_hash), + lazy_blocks = ?lazy_overlay.as_ref().map(LazyOverlay::block_summaries), + "Configuring lazy overlay" + ); self.overlay_source = lazy_overlay.map(OverlaySource::Lazy); self } @@ -143,10 +158,22 @@ impl OverlayBuilder { hashed_state_overlay: Option>, ) -> Self { if let Some(state) = hashed_state_overlay { + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.anchor_hash, + hashed_state_updates = state.total_len(), + "Configuring immediate hashed-state overlay" + ); self.overlay_source = Some(OverlaySource::Immediate { trie: Arc::new(TrieUpdatesSorted::default()), state, }); + } else { + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.anchor_hash, + "Clearing hashed-state overlay" + ); } self } @@ -156,6 +183,14 @@ impl OverlayBuilder { /// If no overlay exists, creates a new immediate overlay with the given state. /// If a lazy overlay exists, it is resolved first then extended. pub fn with_extended_hashed_state_overlay(mut self, other: HashedPostStateSorted) -> Self { + let other_len = other.total_len(); + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.anchor_hash, + existing_source = overlay_source_kind(self.overlay_source.as_ref()), + added_hashed_state_updates = other_len, + "Extending hashed-state overlay" + ); match &mut self.overlay_source { Some(OverlaySource::Immediate { state, .. }) => { Arc::make_mut(state).extend_ref_and_sort(&other); @@ -184,8 +219,8 @@ impl OverlayBuilder { &self, anchor_hash: BlockHash, ) -> ProviderResult<(Arc, Arc)> { - match &self.overlay_source { - Some(OverlaySource::Lazy(lazy_overlay)) => Ok(lazy_overlay.as_overlay(anchor_hash)), + let result = match &self.overlay_source { + Some(OverlaySource::Lazy(lazy_overlay)) => lazy_overlay.as_overlay(anchor_hash), Some(OverlaySource::Immediate { trie, state }) => { if anchor_hash != self.anchor_hash { return Err(ProviderError::other(std::io::Error::other(format!( @@ -193,13 +228,26 @@ impl OverlayBuilder { self.anchor_hash )))) } - Ok((Arc::clone(trie), Arc::clone(state))) + (Arc::clone(trie), Arc::clone(state)) } - None => Ok(( - Arc::new(TrieUpdatesSorted::default()), - Arc::new(HashedPostStateSorted::default()), - )), - } + None => { + (Arc::new(TrieUpdatesSorted::default()), Arc::new(HashedPostStateSorted::default())) + } + }; + + debug!( + target: "providers::state::overlay", + requested_anchor_hash = ?anchor_hash, + builder_anchor_hash = ?self.anchor_hash, + source = overlay_source_kind(self.overlay_source.as_ref()), + source_anchor = ?self.overlay_source.as_ref().and_then(overlay_source_anchor), + source_blocks = ?self.overlay_source.as_ref().and_then(overlay_source_blocks), + resolved_trie_updates = result.0.total_len(), + resolved_hashed_state = result.1.total_len(), + "Resolved overlay source" + ); + + Ok(result) } /// Returns the block number for [`Self`]'s `anchor_hash` field. @@ -235,6 +283,15 @@ impl OverlayBuilder { let finish_tip_hash = provider .convert_number(finish_tip_number.into())? .ok_or_else(|| ProviderError::HeaderNotFound(finish_tip_number.into()))?; + debug!( + target: "providers::state::overlay", + state_trie_tip_number = block_number, + state_trie_tip_hash = ?state_trie_tip_hash, + finish_tip_number, + finish_tip_hash = ?finish_tip_hash, + anchor_hash = ?self.anchor_hash, + "Loaded database overlay frontiers" + ); Ok(( BlockNumHash::new(block_number, state_trie_tip_hash), BlockNumHash::new(finish_tip_number, finish_tip_hash), @@ -259,6 +316,13 @@ impl OverlayBuilder { // reverts // necessary. if state_trie_tip_block.hash == self.anchor_hash { + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.anchor_hash, + ?state_trie_tip_block, + ?finish_tip_block, + "Overlay anchor matches durable state/trie frontier; no reverts required" + ); return Ok(None) } @@ -275,6 +339,18 @@ impl OverlayBuilder { let available_range = lower_bound..=finish_tip_block.number; + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.anchor_hash, + anchor_number, + ?state_trie_tip_block, + ?finish_tip_block, + prune_lower_bound = lower_bound, + available_start = *available_range.start(), + available_end = *available_range.end(), + "Checking overlay revert requirements" + ); + // Check if the requested block is within the available range if !available_range.contains(&anchor_number) { return Err(ProviderError::InsufficientChangesets { @@ -290,7 +366,17 @@ impl OverlayBuilder { }) } - Ok(Some(anchor_number + 1..=finish_tip_block.number)) + let revert_range = anchor_number + 1..=finish_tip_block.number; + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.anchor_hash, + anchor_number, + revert_start = *revert_range.start(), + revert_end = *revert_range.end(), + "Overlay reverts required" + ); + + Ok(Some(revert_range)) } /// Calculates a new [`Overlay`] given a transaction and the current durable state/trie @@ -331,6 +417,9 @@ impl OverlayBuilder { debug!( target: "providers::state::overlay", ?revert_blocks, + source = overlay_source_kind(self.overlay_source.as_ref()), + source_anchor = ?self.overlay_source.as_ref().and_then(overlay_source_anchor), + source_blocks = ?self.overlay_source.as_ref().and_then(overlay_source_blocks), "Collecting trie reverts for overlay state provider" ); @@ -390,7 +479,8 @@ impl OverlayBuilder { target: "providers::state::overlay", num_trie_updates = ?trie_updates_total_len, num_state_updates = ?hashed_state_updates_total_len, - "Reverted to target block", + source = overlay_source_kind(self.overlay_source.as_ref()), + "Built overlay after reverting to anchor", ); (trie_updates, hashed_state_updates) @@ -404,6 +494,14 @@ impl OverlayBuilder { trie_updates_total_len = trie_updates.total_len(); hashed_state_updates_total_len = hashed_state.total_len(); + debug!( + target: "providers::state::overlay", + num_trie_updates = trie_updates_total_len, + num_state_updates = hashed_state_updates_total_len, + source = overlay_source_kind(self.overlay_source.as_ref()), + "Built overlay directly from durable frontier" + ); + (trie_updates, hashed_state) }; @@ -433,10 +531,42 @@ impl OverlayBuilder { + StorageSettingsCache, { let (state_trie_tip_block, finish_tip_block) = self.get_db_tip_blocks(provider)?; + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.anchor_hash, + ?state_trie_tip_block, + ?finish_tip_block, + source = overlay_source_kind(self.overlay_source.as_ref()), + source_anchor = ?self.overlay_source.as_ref().and_then(overlay_source_anchor), + source_blocks = ?self.overlay_source.as_ref().and_then(overlay_source_blocks), + "Building overlay" + ); self.calculate_overlay(provider, state_trie_tip_block, finish_tip_block) } } +fn overlay_source_kind(source: Option<&OverlaySource>) -> &'static str { + match source { + Some(OverlaySource::Immediate { .. }) => "immediate", + Some(OverlaySource::Lazy(_)) => "lazy", + None => "none", + } +} + +fn overlay_source_anchor(source: &OverlaySource) -> Option { + match source { + OverlaySource::Immediate { .. } => None, + OverlaySource::Lazy(lazy) => lazy.anchor_hash(), + } +} + +fn overlay_source_blocks(source: &OverlaySource) -> Option> { + match source { + OverlaySource::Immediate { .. } => None, + OverlaySource::Lazy(lazy) => Some(lazy.block_summaries()), + } +} + /// Factory for creating overlay state providers with optional reverts and overlays. /// /// This factory allows building an `OverlayStateProvider` whose DB state has been reverted to a @@ -500,16 +630,38 @@ impl OverlayStateProviderFactory { let (state_trie_tip_block, finish_tip_block) = self.overlay_builder.get_db_tip_blocks(provider)?; - let overlay = - match self.overlay_cache.entry((state_trie_tip_block.hash, finish_tip_block.hash)) { - dashmap::Entry::Occupied(entry) => entry.get().clone(), - dashmap::Entry::Vacant(entry) => { - self.overlay_builder.metrics.overlay_cache_misses.increment(1); - let overlay = self.overlay_builder.build_overlay(provider)?; - entry.insert(overlay.clone()); - overlay - } - }; + let overlay = match self + .overlay_cache + .entry((state_trie_tip_block.hash, finish_tip_block.hash)) + { + dashmap::Entry::Occupied(entry) => { + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.overlay_builder.anchor_hash, + ?state_trie_tip_block, + ?finish_tip_block, + source = overlay_source_kind(self.overlay_builder.overlay_source.as_ref()), + "Using cached overlay" + ); + entry.get().clone() + } + dashmap::Entry::Vacant(entry) => { + self.overlay_builder.metrics.overlay_cache_misses.increment(1); + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.overlay_builder.anchor_hash, + ?state_trie_tip_block, + ?finish_tip_block, + source = overlay_source_kind(self.overlay_builder.overlay_source.as_ref()), + source_anchor = ?self.overlay_builder.overlay_source.as_ref().and_then(overlay_source_anchor), + source_blocks = ?self.overlay_builder.overlay_source.as_ref().and_then(overlay_source_blocks), + "Overlay cache miss" + ); + let overlay = self.overlay_builder.build_overlay(provider)?; + entry.insert(overlay.clone()); + overlay + } + }; Ok(overlay) } @@ -545,6 +697,14 @@ where let Overlay { trie_updates, hashed_post_state } = self.get_overlay(&provider)?; let is_v2 = provider.cached_storage_settings().is_v2(); + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.overlay_builder.anchor_hash, + trie_updates = trie_updates.total_len(), + hashed_state = hashed_post_state.total_len(), + is_v2, + "Created overlay state provider" + ); self.overlay_builder.metrics.database_provider_ro_duration.record(overall_start.elapsed()); Ok(OverlayStateProvider::new(provider, trie_updates, hashed_post_state, is_v2)) } diff --git a/scripts/repro-hoodi-partial-persistence-reorg.sh b/scripts/repro-hoodi-partial-persistence-reorg.sh index 51a83b578d7..542f6a3c96f 100755 --- a/scripts/repro-hoodi-partial-persistence-reorg.sh +++ b/scripts/repro-hoodi-partial-persistence-reorg.sh @@ -288,7 +288,7 @@ capture_command reth "$RETH_BIN" node \ --engine.persistence-threshold 10 \ --engine.deferred-trie-blocks 3 \ --engine.accept-execution-requests-hash \ - --log.stdout.filter 'info,providers::db=debug,reth::providers::static_file=debug,reth::storage=debug,consensus::engine=debug,reth-bench=debug' \ + --log.stdout.filter 'info,providers::state::overlay=debug,chain_state::lazy_overlay=debug,engine::tree::payload_validator=debug,providers::db=debug,reth::providers::static_file=debug,reth::storage=debug,consensus::engine=debug,reth-bench=debug' \ --color never restore_snapshot() { @@ -359,7 +359,7 @@ start_node() { --engine.persistence-threshold 10 \ --engine.deferred-trie-blocks 3 \ --engine.accept-execution-requests-hash \ - --log.stdout.filter 'info,providers::db=debug,reth::providers::static_file=debug,reth::storage=debug,consensus::engine=debug,reth-bench=debug' \ + --log.stdout.filter 'info,providers::state::overlay=debug,chain_state::lazy_overlay=debug,engine::tree::payload_validator=debug,providers::db=debug,reth::providers::static_file=debug,reth::storage=debug,consensus::engine=debug,reth-bench=debug' \ --color never \ >"$NODE_LOG" 2>&1 & echo $! From 92e4a15c946aa771fac116494939503271a671f5 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Wed, 6 May 2026 08:44:56 +0000 Subject: [PATCH 60/83] fix(provider): revert overlays from finish frontier Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019df965-c32c-7188-831c-c5d6ea37e036 Co-authored-by: Amp --- .../provider/src/providers/state/overlay.rs | 119 ++++++++++++------ 1 file changed, 82 insertions(+), 37 deletions(-) diff --git a/crates/storage/provider/src/providers/state/overlay.rs b/crates/storage/provider/src/providers/state/overlay.rs index 232389dfaa9..198882140f0 100644 --- a/crates/storage/provider/src/providers/state/overlay.rs +++ b/crates/storage/provider/src/providers/state/overlay.rs @@ -61,6 +61,12 @@ pub(super) struct Overlay { pub(super) hashed_post_state: Arc, } +#[derive(Debug)] +struct OverlayRevertPlan { + revert_blocks: Option>, + overlay_anchor_hash: BlockHash, +} + /// Source of overlay data for [`OverlayStateProviderFactory`]. /// /// Either provides immediate pre-computed overlay data, or a lazy overlay that computes @@ -226,7 +232,7 @@ impl OverlayBuilder { return Err(ProviderError::other(std::io::Error::other(format!( "anchor_hash {anchor_hash} doesn't match OverlayBuilder's configured anchor ({})", self.anchor_hash - )))) + )))); } (Arc::clone(trie), Arc::clone(state)) } @@ -298,32 +304,59 @@ impl OverlayBuilder { )) } - /// Returns whether or not it is required to collect reverts, and validates that there are - /// sufficient changesets to revert to the requested block number if so. + /// Returns the revert plan required to expose the requested overlay base state, and validates + /// that there are sufficient changesets to revert to the requested block number if so. /// /// Takes into account both the stage checkpoint and the prune checkpoint to determine the /// available data range. - fn reverts_required( + fn revert_plan( &self, provider: &Provider, state_trie_tip_block: BlockNumHash, finish_tip_block: BlockNumHash, - ) -> ProviderResult>> + ) -> ProviderResult where Provider: BlockNumReader + PruneCheckpointReader, { - // If the anchor is the current durable state/trie frontier then there won't be any - // reverts - // necessary. - if state_trie_tip_block.hash == self.anchor_hash { + // If the requested anchor is the current durable Finish frontier, the database already + // exposes a consistent logical state for the overlay base. + if finish_tip_block.hash == self.anchor_hash { debug!( target: "providers::state::overlay", anchor_hash = ?self.anchor_hash, ?state_trie_tip_block, ?finish_tip_block, - "Overlay anchor matches durable state/trie frontier; no reverts required" + overlay_anchor_hash = ?finish_tip_block.hash, + "Overlay anchor matches durable finish frontier; no reverts required" ); - return Ok(None) + return Ok(OverlayRevertPlan { + revert_blocks: None, + overlay_anchor_hash: finish_tip_block.hash, + }); + } + + // If a lazy overlay can start from the durable Finish frontier, prefer that base and avoid + // changeset reverts entirely. This is valid even when the configured anchor is older than + // Finish because the database is already at the Finish logical state and the lazy overlay + // covers Finish -> target. + if self.overlay_source.as_ref().is_some_and(|source| { + matches!(source, OverlaySource::Lazy(lazy) if lazy.has_anchor_hash(finish_tip_block.hash)) + }) { + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.anchor_hash, + ?state_trie_tip_block, + ?finish_tip_block, + overlay_anchor_hash = ?finish_tip_block.hash, + source = overlay_source_kind(self.overlay_source.as_ref()), + source_anchor = ?self.overlay_source.as_ref().and_then(overlay_source_anchor), + source_blocks = ?self.overlay_source.as_ref().and_then(overlay_source_blocks), + "Lazy overlay covers durable finish frontier; no reverts required" + ); + return Ok(OverlayRevertPlan { + revert_blocks: None, + overlay_anchor_hash: finish_tip_block.hash, + }) } let anchor_number = self.get_block_number(provider)?; @@ -351,6 +384,16 @@ impl OverlayBuilder { "Checking overlay revert requirements" ); + let anchor_hash_at_number = provider + .convert_number(anchor_number.into())? + .ok_or_else(|| ProviderError::HeaderNotFound(anchor_number.into()))?; + if anchor_hash_at_number != self.anchor_hash { + return Err(ProviderError::other(std::io::Error::other(format!( + "anchor hash {} is not on the durable finish chain at block {} (found {})", + self.anchor_hash, anchor_number, anchor_hash_at_number, + )))); + } + // Check if the requested block is within the available range if !available_range.contains(&anchor_number) { return Err(ProviderError::InsufficientChangesets { @@ -359,13 +402,6 @@ impl OverlayBuilder { }); } - if anchor_number > state_trie_tip_block.number { - return Err(ProviderError::InsufficientChangesets { - requested: anchor_number, - available: lower_bound..=state_trie_tip_block.number, - }) - } - let revert_range = anchor_number + 1..=finish_tip_block.number; debug!( target: "providers::state::overlay", @@ -373,10 +409,14 @@ impl OverlayBuilder { anchor_number, revert_start = *revert_range.start(), revert_end = *revert_range.end(), + overlay_anchor_hash = ?self.anchor_hash, "Overlay reverts required" ); - Ok(Some(revert_range)) + Ok(OverlayRevertPlan { + revert_blocks: Some(revert_range), + overlay_anchor_hash: self.anchor_hash, + }) } /// Calculates a new [`Overlay`] given a transaction and the current durable state/trie @@ -410,13 +450,16 @@ impl OverlayBuilder { let trie_updates_total_len; let hashed_state_updates_total_len; - // Collect any reverts which are required to bring the DB view back to the anchor hash. - let (trie_updates, hashed_post_state) = if let Some(revert_blocks) = - self.reverts_required(provider, state_trie_tip_block, finish_tip_block)? - { + let OverlayRevertPlan { revert_blocks, overlay_anchor_hash } = + self.revert_plan(provider, state_trie_tip_block, finish_tip_block)?; + + // Collect any reverts which are required to bring the DB view back to the overlay anchor + // hash. + let (trie_updates, hashed_post_state) = if let Some(revert_blocks) = revert_blocks { debug!( target: "providers::state::overlay", ?revert_blocks, + overlay_anchor_hash = ?overlay_anchor_hash, source = overlay_source_kind(self.overlay_source.as_ref()), source_anchor = ?self.overlay_source.as_ref().and_then(overlay_source_anchor), source_blocks = ?self.overlay_source.as_ref().and_then(overlay_source_blocks), @@ -452,7 +495,7 @@ impl OverlayBuilder { // Resolve overlays (lazy or immediate) and extend reverts with them. // If reverts are empty, use overlays directly to avoid cloning. - let (overlay_trie, overlay_state) = self.resolve_overlays(self.anchor_hash)?; + let (overlay_trie, overlay_state) = self.resolve_overlays(overlay_anchor_hash)?; let trie_updates = if trie_reverts.is_empty() { overlay_trie @@ -479,15 +522,16 @@ impl OverlayBuilder { target: "providers::state::overlay", num_trie_updates = ?trie_updates_total_len, num_state_updates = ?hashed_state_updates_total_len, + overlay_anchor_hash = ?overlay_anchor_hash, source = overlay_source_kind(self.overlay_source.as_ref()), "Built overlay after reverting to anchor", ); (trie_updates, hashed_state_updates) } else { - // If no reverts are needed then the requested anchor is exactly the durable - // state/trie frontier. Use overlays directly from that frontier. - let (trie_updates, hashed_state) = self.resolve_overlays(state_trie_tip_block.hash)?; + // If no reverts are needed then the overlay can be resolved directly from the durable + // logical frontier selected by the revert plan. + let (trie_updates, hashed_state) = self.resolve_overlays(overlay_anchor_hash)?; retrieve_trie_reverts_duration = Duration::ZERO; retrieve_hashed_state_reverts_duration = Duration::ZERO; @@ -498,6 +542,7 @@ impl OverlayBuilder { target: "providers::state::overlay", num_trie_updates = trie_updates_total_len, num_state_updates = hashed_state_updates_total_len, + overlay_anchor_hash = ?overlay_anchor_hash, source = overlay_source_kind(self.overlay_source.as_ref()), "Built overlay directly from durable frontier" ); @@ -890,7 +935,7 @@ mod tests { } #[test] - fn build_overlay_uses_partial_trie_frontier_as_lazy_overlay_base() { + fn build_overlay_uses_finish_frontier_as_lazy_overlay_base_when_available() { let factory = create_test_provider_factory(); let mut block_builder = TestBlockBuilder::eth(); let blocks = block_builder @@ -927,11 +972,11 @@ mod tests { .build_overlay(&provider) .unwrap(); - assert_eq!(overlay.hashed_post_state.accounts.len(), 3); + assert_eq!(overlay.hashed_post_state.accounts.len(), 1); } #[test] - fn build_overlay_rejects_anchor_between_state_trie_frontier_and_finish() { + fn build_overlay_reverts_from_finish_for_anchor_after_state_trie_frontier() { let factory = create_test_provider_factory(); let mut block_builder = TestBlockBuilder::eth().with_state(); @@ -964,16 +1009,16 @@ mod tests { let provider = factory.provider().unwrap(); let anchor = blocks[1].recovered_block().hash(); - let err = OverlayBuilder::::new(anchor, ChangesetCache::new()) + let overlay = OverlayBuilder::::new(anchor, ChangesetCache::new()) .with_lazy_overlay(Some(LazyOverlay::new(vec![blocks[2].clone()]))) .build_overlay(&provider) - .unwrap_err(); + .unwrap(); - assert!(matches!(err, ProviderError::InsufficientChangesets { .. })); + assert!(!overlay.hashed_post_state.is_empty()); } #[test] - fn build_overlay_rejects_finish_anchor_without_trie_bridge() { + fn build_overlay_accepts_finish_anchor_without_trie_bridge() { let factory = create_test_provider_factory(); let mut block_builder = TestBlockBuilder::eth().with_state(); @@ -1007,11 +1052,11 @@ mod tests { let provider = factory.provider().unwrap(); let finish_anchor = blocks[2].recovered_block().hash(); - let err = OverlayBuilder::::new(finish_anchor, ChangesetCache::new()) + let overlay = OverlayBuilder::::new(finish_anchor, ChangesetCache::new()) .with_lazy_overlay(None) .build_overlay(&provider) - .unwrap_err(); + .unwrap(); - assert!(matches!(err, ProviderError::InsufficientChangesets { .. })); + assert!(overlay.hashed_post_state.is_empty()); } } From 6e597b5cbb7b23745d5d4133dd7d440c41b378ba Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Wed, 6 May 2026 10:48:07 +0000 Subject: [PATCH 61/83] fix(provider): reject overlays ahead of partial trie Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019df965-c32c-7188-831c-c5d6ea37e036 Co-authored-by: Amp --- .../engine/tree/src/tree/payload_validator.rs | 16 ++++ .../provider/src/providers/state/overlay.rs | 77 ++++++++++++------- 2 files changed, 64 insertions(+), 29 deletions(-) diff --git a/crates/engine/tree/src/tree/payload_validator.rs b/crates/engine/tree/src/tree/payload_validator.rs index 43e1d5a5703..e99040ee575 100644 --- a/crates/engine/tree/src/tree/payload_validator.rs +++ b/crates/engine/tree/src/tree/payload_validator.rs @@ -2049,6 +2049,22 @@ where state: &EngineApiTreeState, ) -> Option { let (lazy_overlay, anchor_hash) = Self::get_parent_lazy_overlay(parent_hash, state); + let lazy_anchor = lazy_overlay.as_ref().and_then(LazyOverlay::anchor_hash); + let lazy_blocks = lazy_overlay.as_ref().map(LazyOverlay::block_summaries); + let span = debug_span!( + target: "engine::tree::payload_validator", + "payload_builder_sparse_trie_overlay", + %parent_hash, + %parent_state_root, + %anchor_hash, + ?lazy_anchor, + ?lazy_blocks, + ); + let _guard = span.enter(); + debug!( + target: "engine::tree::payload_validator", + "Preparing payload builder sparse trie overlay" + ); let overlay_factory = OverlayStateProviderFactory::new( self.provider.clone(), OverlayBuilder::::new(anchor_hash, self.changeset_cache.clone()) diff --git a/crates/storage/provider/src/providers/state/overlay.rs b/crates/storage/provider/src/providers/state/overlay.rs index 198882140f0..223c761d092 100644 --- a/crates/storage/provider/src/providers/state/overlay.rs +++ b/crates/storage/provider/src/providers/state/overlay.rs @@ -318,6 +318,29 @@ impl OverlayBuilder { where Provider: BlockNumReader + PruneCheckpointReader, { + let anchor_number = self.get_block_number(provider)?; + let anchor_hash_at_number = provider + .convert_number(anchor_number.into())? + .ok_or_else(|| ProviderError::HeaderNotFound(anchor_number.into()))?; + if anchor_hash_at_number != self.anchor_hash { + return Err(ProviderError::other(std::io::Error::other(format!( + "anchor hash {} is not on the durable finish chain at block {} (found {})", + self.anchor_hash, anchor_number, anchor_hash_at_number, + )))); + } + + if anchor_number > state_trie_tip_block.number { + return Err(ProviderError::other(std::io::Error::other(format!( + "overlay anchor #{} ({}) is after partial state trie frontier #{} ({}); missing trie updates for blocks #{}..=#{}", + anchor_number, + self.anchor_hash, + state_trie_tip_block.number, + state_trie_tip_block.hash, + state_trie_tip_block.number + 1, + anchor_number, + )))); + } + // If the requested anchor is the current durable Finish frontier, the database already // exposes a consistent logical state for the overlay base. if finish_tip_block.hash == self.anchor_hash { @@ -335,13 +358,15 @@ impl OverlayBuilder { }); } - // If a lazy overlay can start from the durable Finish frontier, prefer that base and avoid - // changeset reverts entirely. This is valid even when the configured anchor is older than - // Finish because the database is already at the Finish logical state and the lazy overlay - // covers Finish -> target. - if self.overlay_source.as_ref().is_some_and(|source| { - matches!(source, OverlaySource::Lazy(lazy) if lazy.has_anchor_hash(finish_tip_block.hash)) - }) { + // If a lazy overlay can start from the durable Finish frontier and the trie is already at + // that frontier, prefer that base and avoid changeset reverts entirely. If the trie is only + // partially persisted, the trie updates for `partial_state_trie + 1..=Finish` are not in + // the database and must come from the revert path instead. + if state_trie_tip_block.number >= finish_tip_block.number && + self.overlay_source.as_ref().is_some_and(|source| { + matches!(source, OverlaySource::Lazy(lazy) if lazy.has_anchor_hash(finish_tip_block.hash)) + }) + { debug!( target: "providers::state::overlay", anchor_hash = ?self.anchor_hash, @@ -359,8 +384,6 @@ impl OverlayBuilder { }) } - let anchor_number = self.get_block_number(provider)?; - // Check account history prune checkpoint to determine the lower bound of available data. // The prune checkpoint's block_number is the highest pruned block, so data is available // starting from the next block. @@ -384,16 +407,6 @@ impl OverlayBuilder { "Checking overlay revert requirements" ); - let anchor_hash_at_number = provider - .convert_number(anchor_number.into())? - .ok_or_else(|| ProviderError::HeaderNotFound(anchor_number.into()))?; - if anchor_hash_at_number != self.anchor_hash { - return Err(ProviderError::other(std::io::Error::other(format!( - "anchor hash {} is not on the durable finish chain at block {} (found {})", - self.anchor_hash, anchor_number, anchor_hash_at_number, - )))); - } - // Check if the requested block is within the available range if !available_range.contains(&anchor_number) { return Err(ProviderError::InsufficientChangesets { @@ -935,7 +948,7 @@ mod tests { } #[test] - fn build_overlay_uses_finish_frontier_as_lazy_overlay_base_when_available() { + fn build_overlay_reverts_when_finish_frontier_is_after_state_trie_frontier() { let factory = create_test_provider_factory(); let mut block_builder = TestBlockBuilder::eth(); let blocks = block_builder @@ -972,11 +985,11 @@ mod tests { .build_overlay(&provider) .unwrap(); - assert_eq!(overlay.hashed_post_state.accounts.len(), 1); + assert_eq!(overlay.hashed_post_state.accounts.len(), 3); } #[test] - fn build_overlay_reverts_from_finish_for_anchor_after_state_trie_frontier() { + fn build_overlay_errors_for_anchor_after_state_trie_frontier() { let factory = create_test_provider_factory(); let mut block_builder = TestBlockBuilder::eth().with_state(); @@ -1009,16 +1022,19 @@ mod tests { let provider = factory.provider().unwrap(); let anchor = blocks[1].recovered_block().hash(); - let overlay = OverlayBuilder::::new(anchor, ChangesetCache::new()) + let error = OverlayBuilder::::new(anchor, ChangesetCache::new()) .with_lazy_overlay(Some(LazyOverlay::new(vec![blocks[2].clone()]))) .build_overlay(&provider) - .unwrap(); + .unwrap_err(); - assert!(!overlay.hashed_post_state.is_empty()); + assert!( + error.to_string().contains("is after partial state trie frontier"), + "unexpected error: {error}" + ); } #[test] - fn build_overlay_accepts_finish_anchor_without_trie_bridge() { + fn build_overlay_errors_for_finish_anchor_after_state_trie_frontier() { let factory = create_test_provider_factory(); let mut block_builder = TestBlockBuilder::eth().with_state(); @@ -1052,11 +1068,14 @@ mod tests { let provider = factory.provider().unwrap(); let finish_anchor = blocks[2].recovered_block().hash(); - let overlay = OverlayBuilder::::new(finish_anchor, ChangesetCache::new()) + let error = OverlayBuilder::::new(finish_anchor, ChangesetCache::new()) .with_lazy_overlay(None) .build_overlay(&provider) - .unwrap(); + .unwrap_err(); - assert!(overlay.hashed_post_state.is_empty()); + assert!( + error.to_string().contains("is after partial state trie frontier"), + "unexpected error: {error}" + ); } } From 33f55b96ffce075b16c725208f3bc6f09786ab3f Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Wed, 6 May 2026 10:49:41 +0000 Subject: [PATCH 62/83] chore(engine): log payload builder overlay frontiers Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019df965-c32c-7188-831c-c5d6ea37e036 Co-authored-by: Amp --- .../engine/tree/src/tree/payload_validator.rs | 52 +++++++++++++++++-- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/crates/engine/tree/src/tree/payload_validator.rs b/crates/engine/tree/src/tree/payload_validator.rs index e99040ee575..f7c3cd048b4 100644 --- a/crates/engine/tree/src/tree/payload_validator.rs +++ b/crates/engine/tree/src/tree/payload_validator.rs @@ -88,6 +88,7 @@ use reth_provider::{ StorageChangeSetReader, StorageSettingsCache, }; use reth_revm::db::{states::bundle_state::BundleRetention, BundleAccount, State}; +use reth_stages_api::StageId; use reth_trie::{trie_cursor::TrieCursorFactory, updates::TrieUpdates, HashedPostState}; use reth_trie_db::ChangesetCache; use reth_trie_parallel::root::{ParallelStateRoot, ParallelStateRootError}; @@ -2061,10 +2062,53 @@ where ?lazy_blocks, ); let _guard = span.enter(); - debug!( - target: "engine::tree::payload_validator", - "Preparing payload builder sparse trie overlay" - ); + if tracing::enabled!(target: "engine::tree::payload_validator", tracing::Level::DEBUG) { + match self.provider.database_provider_ro() { + Ok(provider) => match provider.get_stage_checkpoint(StageId::Finish) { + Ok(Some(checkpoint)) => { + let finish_tip_number = checkpoint.block_number; + let partial_state_trie_number = checkpoint + .finish_stage_checkpoint() + .and_then(|finish| finish.partial_state_trie) + .unwrap_or(finish_tip_number); + let partial_state_trie_hash = provider + .convert_number(partial_state_trie_number.into()) + .ok() + .flatten(); + let finish_tip_hash = + provider.convert_number(finish_tip_number.into()).ok().flatten(); + debug!( + target: "engine::tree::payload_validator", + partial_state_trie_number, + ?partial_state_trie_hash, + finish_tip_number, + ?finish_tip_hash, + "Preparing payload builder sparse trie overlay" + ); + } + Ok(None) => { + debug!( + target: "engine::tree::payload_validator", + "Preparing payload builder sparse trie overlay without finish checkpoint" + ); + } + Err(err) => { + debug!( + target: "engine::tree::payload_validator", + %err, + "Preparing payload builder sparse trie overlay without database frontiers" + ); + } + }, + Err(err) => { + debug!( + target: "engine::tree::payload_validator", + %err, + "Preparing payload builder sparse trie overlay without database frontiers" + ); + } + } + } let overlay_factory = OverlayStateProviderFactory::new( self.provider.clone(), OverlayBuilder::::new(anchor_hash, self.changeset_cache.clone()) From 53a6b9f90b0a506afd930896f458cec3b78bb6c3 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Wed, 6 May 2026 11:06:41 +0000 Subject: [PATCH 63/83] fix(provider): allow lazy overlays from partial trie Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019df965-c32c-7188-831c-c5d6ea37e036 Co-authored-by: Amp --- .../provider/src/providers/state/overlay.rs | 116 ++++++++++++------ 1 file changed, 79 insertions(+), 37 deletions(-) diff --git a/crates/storage/provider/src/providers/state/overlay.rs b/crates/storage/provider/src/providers/state/overlay.rs index 223c761d092..328035b240b 100644 --- a/crates/storage/provider/src/providers/state/overlay.rs +++ b/crates/storage/provider/src/providers/state/overlay.rs @@ -329,21 +329,11 @@ impl OverlayBuilder { )))); } - if anchor_number > state_trie_tip_block.number { - return Err(ProviderError::other(std::io::Error::other(format!( - "overlay anchor #{} ({}) is after partial state trie frontier #{} ({}); missing trie updates for blocks #{}..=#{}", - anchor_number, - self.anchor_hash, - state_trie_tip_block.number, - state_trie_tip_block.hash, - state_trie_tip_block.number + 1, - anchor_number, - )))); - } - // If the requested anchor is the current durable Finish frontier, the database already // exposes a consistent logical state for the overlay base. - if finish_tip_block.hash == self.anchor_hash { + if state_trie_tip_block.hash == finish_tip_block.hash && + finish_tip_block.hash == self.anchor_hash + { debug!( target: "providers::state::overlay", anchor_hash = ?self.anchor_hash, @@ -358,30 +348,40 @@ impl OverlayBuilder { }); } - // If a lazy overlay can start from the durable Finish frontier and the trie is already at - // that frontier, prefer that base and avoid changeset reverts entirely. If the trie is only - // partially persisted, the trie updates for `partial_state_trie + 1..=Finish` are not in - // the database and must come from the revert path instead. - if state_trie_tip_block.number >= finish_tip_block.number && - self.overlay_source.as_ref().is_some_and(|source| { - matches!(source, OverlaySource::Lazy(lazy) if lazy.has_anchor_hash(finish_tip_block.hash)) - }) - { - debug!( - target: "providers::state::overlay", - anchor_hash = ?self.anchor_hash, - ?state_trie_tip_block, - ?finish_tip_block, - overlay_anchor_hash = ?finish_tip_block.hash, - source = overlay_source_kind(self.overlay_source.as_ref()), - source_anchor = ?self.overlay_source.as_ref().and_then(overlay_source_anchor), - source_blocks = ?self.overlay_source.as_ref().and_then(overlay_source_blocks), - "Lazy overlay covers durable finish frontier; no reverts required" - ); - return Ok(OverlayRevertPlan { - revert_blocks: None, - overlay_anchor_hash: finish_tip_block.hash, - }) + if let Some(OverlaySource::Lazy(lazy)) = self.overlay_source.as_ref() { + let lazy_covers_state_trie_tip = lazy.has_anchor_hash(state_trie_tip_block.hash); + let lazy_covers_finish_gap = state_trie_tip_block.hash == finish_tip_block.hash || + lazy.has_anchor_hash(finish_tip_block.hash); + + if lazy_covers_state_trie_tip && lazy_covers_finish_gap { + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.anchor_hash, + ?state_trie_tip_block, + ?finish_tip_block, + overlay_anchor_hash = ?state_trie_tip_block.hash, + source = overlay_source_kind(self.overlay_source.as_ref()), + source_anchor = ?self.overlay_source.as_ref().and_then(overlay_source_anchor), + source_blocks = ?self.overlay_source.as_ref().and_then(overlay_source_blocks), + "Lazy overlay covers partial state trie frontier; no reverts required" + ); + return Ok(OverlayRevertPlan { + revert_blocks: None, + overlay_anchor_hash: state_trie_tip_block.hash, + }) + } + } + + if anchor_number > state_trie_tip_block.number { + return Err(ProviderError::other(std::io::Error::other(format!( + "overlay anchor #{} ({}) is after partial state trie frontier #{} ({}); missing trie updates for blocks #{}..=#{}", + anchor_number, + self.anchor_hash, + state_trie_tip_block.number, + state_trie_tip_block.hash, + state_trie_tip_block.number + 1, + anchor_number, + )))); } // Check account history prune checkpoint to determine the lower bound of available data. @@ -1033,6 +1033,48 @@ mod tests { ); } + #[test] + fn build_overlay_uses_lazy_superset_for_anchor_after_state_trie_frontier() { + let factory = create_test_provider_factory(); + let mut block_builder = TestBlockBuilder::eth(); + let blocks = block_builder + .get_executed_blocks(0..5) + .enumerate() + .map(|(index, block)| with_unique_state(&block, index as u8 + 1)) + .collect::>(); + + let state_trie_tip = &blocks[1]; + let finish_tip = &blocks[3]; + let lazy_overlay_blocks = + vec![blocks[4].clone(), blocks[3].clone(), blocks[2].clone(), blocks[1].clone()]; + + let provider_rw = factory.provider_rw().unwrap(); + provider_rw.insert_block(blocks[0].recovered_block()).unwrap(); + provider_rw.insert_block(state_trie_tip.recovered_block()).unwrap(); + provider_rw.insert_block(blocks[2].recovered_block()).unwrap(); + provider_rw.insert_block(finish_tip.recovered_block()).unwrap(); + provider_rw + .save_stage_checkpoint( + StageId::Finish, + StageCheckpoint::new(finish_tip.block_number()).with_finish_stage_checkpoint( + FinishCheckpoint { partial_state_trie: Some(state_trie_tip.block_number()) }, + ), + ) + .unwrap(); + provider_rw.commit().unwrap(); + + let provider = factory.provider().unwrap(); + let overlay = OverlayBuilder::::new( + blocks[0].recovered_block().hash(), + ChangesetCache::new(), + ) + .with_lazy_overlay(Some(LazyOverlay::new(lazy_overlay_blocks))) + .build_overlay(&provider) + .unwrap(); + + assert_eq!(overlay.hashed_post_state.accounts.len(), 3); + } + #[test] fn build_overlay_errors_for_finish_anchor_after_state_trie_frontier() { let factory = create_test_provider_factory(); From a27db17bc1cde6888588aba2a23f3e1126e5f781 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Wed, 6 May 2026 11:47:16 +0000 Subject: [PATCH 64/83] chore(engine): log payload builder state providers Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019df965-c32c-7188-831c-c5d6ea37e036 Co-authored-by: Amp --- crates/chain-state/src/memory_overlay.rs | 78 +++++++++++++++++++ crates/engine/tree/src/tree/mod.rs | 17 ++++ crates/ethereum/payload/src/lib.rs | 25 ++++++ .../src/providers/state/historical.rs | 25 ++++++ 4 files changed, 145 insertions(+) diff --git a/crates/chain-state/src/memory_overlay.rs b/crates/chain-state/src/memory_overlay.rs index 7e31ec06fee..8cb5435387a 100644 --- a/crates/chain-state/src/memory_overlay.rs +++ b/crates/chain-state/src/memory_overlay.rs @@ -13,6 +13,7 @@ use reth_trie::{ }; use revm_database::BundleState; use std::{borrow::Cow, sync::OnceLock}; +use tracing::debug; /// A state provider that stores references to in-memory blocks along with their state as well as a /// reference of the historical state provider for fallback lookups. @@ -38,6 +39,12 @@ impl<'a, N: NodePrimitives> MemoryOverlayStateProviderRef<'a, N> { /// - `historical` - a historical state provider for the latest ancestor block stored in the /// database. pub fn new(historical: Box, in_memory: Vec>) -> Self { + debug!( + target: "chain_state::memory_overlay", + in_memory_blocks = ?block_summaries(&in_memory), + num_in_memory_blocks = in_memory.len(), + "Creating borrowed memory overlay state provider" + ); Self { historical, in_memory: Cow::Owned(in_memory), trie_input: OnceLock::new() } } @@ -50,12 +57,24 @@ impl<'a, N: NodePrimitives> MemoryOverlayStateProviderRef<'a, N> { fn trie_input(&self) -> &TrieInput { self.trie_input.get_or_init(|| { let mut input = TrieInput::default(); + let mut trie_updates = 0; + let mut hashed_state = 0; // Iterate from oldest to newest for block in self.in_memory.iter().rev() { let data = block.trie_data(); + trie_updates += data.trie_updates.total_len(); + hashed_state += data.hashed_state.total_len(); input.nodes.extend_from_sorted(&data.trie_updates); input.state.extend_from_sorted(&data.hashed_state); } + debug!( + target: "chain_state::memory_overlay", + in_memory_blocks = ?block_summaries(&self.in_memory), + num_in_memory_blocks = self.in_memory.len(), + trie_updates, + hashed_state, + "Built memory overlay trie input" + ); input }) } @@ -127,6 +146,15 @@ impl StateRootProvider for MemoryOverlayStateProviderRef<'_, } fn state_root_from_nodes(&self, mut input: TrieInput) -> ProviderResult { + debug!( + target: "chain_state::memory_overlay", + in_memory_blocks = ?block_summaries(&self.in_memory), + num_in_memory_blocks = self.in_memory.len(), + prefix_account_updates = input.prefix_sets.account_prefix_set.len(), + prefix_storage_tries = input.prefix_sets.storage_prefix_sets.len(), + prefix_destroyed_accounts = input.prefix_sets.destroyed_accounts.len(), + "Calculating state root through memory overlay provider" + ); input.prepend_self(self.trie_input().clone()); self.historical.state_root_from_nodes(input) } @@ -142,6 +170,15 @@ impl StateRootProvider for MemoryOverlayStateProviderRef<'_, &self, mut input: TrieInput, ) -> ProviderResult<(B256, TrieUpdates)> { + debug!( + target: "chain_state::memory_overlay", + in_memory_blocks = ?block_summaries(&self.in_memory), + num_in_memory_blocks = self.in_memory.len(), + prefix_account_updates = input.prefix_sets.account_prefix_set.len(), + prefix_storage_tries = input.prefix_sets.storage_prefix_sets.len(), + prefix_destroyed_accounts = input.prefix_sets.destroyed_accounts.len(), + "Calculating state root with updates through memory overlay provider" + ); input.prepend_self(self.trie_input().clone()); self.historical.state_root_from_nodes_with_updates(input) } @@ -184,6 +221,17 @@ impl StateProofProvider for MemoryOverlayStateProviderRef<'_, address: Address, slots: &[B256], ) -> ProviderResult { + debug!( + target: "chain_state::memory_overlay", + in_memory_blocks = ?block_summaries(&self.in_memory), + num_in_memory_blocks = self.in_memory.len(), + %address, + num_slots = slots.len(), + prefix_account_updates = input.prefix_sets.account_prefix_set.len(), + prefix_storage_tries = input.prefix_sets.storage_prefix_sets.len(), + prefix_destroyed_accounts = input.prefix_sets.destroyed_accounts.len(), + "Generating proof through memory overlay provider" + ); input.prepend_self(self.trie_input().clone()); self.historical.proof(input, address, slots) } @@ -193,6 +241,15 @@ impl StateProofProvider for MemoryOverlayStateProviderRef<'_, mut input: TrieInput, targets: MultiProofTargets, ) -> ProviderResult { + debug!( + target: "chain_state::memory_overlay", + in_memory_blocks = ?block_summaries(&self.in_memory), + num_in_memory_blocks = self.in_memory.len(), + prefix_account_updates = input.prefix_sets.account_prefix_set.len(), + prefix_storage_tries = input.prefix_sets.storage_prefix_sets.len(), + prefix_destroyed_accounts = input.prefix_sets.destroyed_accounts.len(), + "Generating multiproof through memory overlay provider" + ); input.prepend_self(self.trie_input().clone()); self.historical.multiproof(input, targets) } @@ -263,6 +320,12 @@ impl MemoryOverlayStateProvider { /// - `historical` - a historical state provider for the latest ancestor block stored in the /// database. pub fn new(historical: StateProviderBox, in_memory: Vec>) -> Self { + debug!( + target: "chain_state::memory_overlay", + in_memory_blocks = ?block_summaries(&in_memory), + num_in_memory_blocks = in_memory.len(), + "Creating owned memory overlay state provider" + ); Self { historical, in_memory, trie_input: OnceLock::new() } } @@ -284,3 +347,18 @@ impl MemoryOverlayStateProvider { // Delegates all provider impls to [`MemoryOverlayStateProviderRef`] reth_storage_api::macros::delegate_provider_impls!(MemoryOverlayStateProvider where [N: NodePrimitives]); + +fn block_summaries(blocks: &[ExecutedBlock]) -> Vec { + blocks + .iter() + .map(|block| { + let recovered = block.recovered_block(); + format!( + "#{} hash={} parent={}", + recovered.number(), + recovered.hash(), + recovered.parent_hash() + ) + }) + .collect() +} diff --git a/crates/engine/tree/src/tree/mod.rs b/crates/engine/tree/src/tree/mod.rs index 6461ce26950..5b421b24b99 100644 --- a/crates/engine/tree/src/tree/mod.rs +++ b/crates/engine/tree/src/tree/mod.rs @@ -3331,9 +3331,26 @@ where &self.state, ) } else { + debug!( + target: "engine::tree", + parent_hash = %state.head_block_hash, + parent_number = head.number(), + parent_state_root = %head.state_root(), + "Payload builder sparse trie sharing disabled" + ); None }; + debug!( + target: "engine::tree", + parent_hash = %state.head_block_hash, + parent_number = head.number(), + parent_state_root = %head.state_root(), + has_execution_cache = cache.is_some(), + has_sparse_trie_handle = trie_handle.is_some(), + "Sending new payload job to payload builder" + ); + // send the payload to the builder and return the receiver for the pending payload // id, initiating payload job is handled asynchronously let pending_payload_id = self.payload_builder.send_new_payload(BuildNewPayload { diff --git a/crates/ethereum/payload/src/lib.rs b/crates/ethereum/payload/src/lib.rs index 2d6c2e2d022..82dc6061dcf 100644 --- a/crates/ethereum/payload/src/lib.rs +++ b/crates/ethereum/payload/src/lib.rs @@ -169,6 +169,16 @@ where let PayloadConfig { parent_header, attributes, payload_id } = config; let mut state_provider = client.state_by_block_hash(parent_header.hash())?; + debug!( + target: "payload_builder", + id = %payload_id, + parent_hash = %parent_header.hash(), + parent_number = parent_header.number, + parent_state_root = %parent_header.state_root, + has_execution_cache = execution_cache.is_some(), + has_sparse_trie_handle = trie_handle.is_some(), + "Created payload builder parent state provider" + ); if let Some(execution_cache) = execution_cache { state_provider = Box::new(CachedStateProvider::new( state_provider, @@ -221,7 +231,22 @@ where // If we have a sparse trie handle, wire a state hook that streams per-tx state diffs // to the background trie pipeline for incremental state root computation. if let Some(ref handle) = trie_handle { + debug!( + target: "payload_builder", + id = %payload_id, + parent_hash = %parent_header.hash(), + parent_number = parent_header.number, + "Using shared sparse trie handle for payload builder state root" + ); builder.executor_mut().set_state_hook(Some(Box::new(handle.state_hook()))); + } else { + debug!( + target: "payload_builder", + id = %payload_id, + parent_hash = %parent_header.hash(), + parent_number = parent_header.number, + "Payload builder will compute state root through its state provider" + ); } builder.apply_pre_execution_changes().map_err(|err| { diff --git a/crates/storage/provider/src/providers/state/historical.rs b/crates/storage/provider/src/providers/state/historical.rs index 7999c8795da..8c748179342 100644 --- a/crates/storage/provider/src/providers/state/historical.rs +++ b/crates/storage/provider/src/providers/state/historical.rs @@ -34,6 +34,7 @@ use reth_trie_db::{ }; use std::{fmt::Debug, marker::PhantomData, sync::Arc}; +use tracing::debug; type DbStateRoot<'a, TX, A> = StateRoot< reth_trie_db::DatabaseTrieCursorFactory<&'a TX, A>, @@ -309,11 +310,35 @@ where .ok_or_else(|| ProviderError::HeaderNotFound(target_block.into()))?; let TrieInputSorted { nodes, state, prefix_sets } = input; + let input_trie_updates = nodes.total_len(); + let input_hashed_state = state.total_len(); + debug!( + target: "providers::historical_sp", + historical_block_number = self.block_number, + target_block, + %anchor_hash, + input_trie_updates, + input_hashed_state, + prefix_account_updates = prefix_sets.account_prefix_set.len(), + prefix_storage_tries = prefix_sets.storage_prefix_sets.len(), + prefix_destroyed_accounts = prefix_sets.destroyed_accounts.len(), + "Building historical state provider overlay" + ); let overlay_builder = OverlayBuilder::::new(anchor_hash, self.changeset_cache.clone()) .with_overlay_source(Some(OverlaySource::Immediate { trie: nodes, state })); let Overlay { trie_updates, hashed_post_state } = overlay_builder.build_overlay(self.provider)?; + debug!( + target: "providers::historical_sp", + historical_block_number = self.block_number, + target_block, + %anchor_hash, + output_trie_updates = trie_updates.total_len(), + output_hashed_state = hashed_post_state.total_len(), + "Built historical state provider overlay" + ); + Ok(TrieInputSorted::new(trie_updates, hashed_post_state, prefix_sets)) } From 4f1f8e33437521810af681d74ea86ced79330572 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Wed, 6 May 2026 11:57:49 +0000 Subject: [PATCH 65/83] chore(scripts): capture provider overlay logs Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019df965-c32c-7188-831c-c5d6ea37e036 Co-authored-by: Amp --- scripts/repro-hoodi-partial-persistence-reorg.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/repro-hoodi-partial-persistence-reorg.sh b/scripts/repro-hoodi-partial-persistence-reorg.sh index 542f6a3c96f..494a43b403c 100755 --- a/scripts/repro-hoodi-partial-persistence-reorg.sh +++ b/scripts/repro-hoodi-partial-persistence-reorg.sh @@ -359,7 +359,7 @@ start_node() { --engine.persistence-threshold 10 \ --engine.deferred-trie-blocks 3 \ --engine.accept-execution-requests-hash \ - --log.stdout.filter 'info,providers::state::overlay=debug,chain_state::lazy_overlay=debug,engine::tree::payload_validator=debug,providers::db=debug,reth::providers::static_file=debug,reth::storage=debug,consensus::engine=debug,reth-bench=debug' \ + --log.stdout.filter 'info,providers::state::overlay=debug,providers::historical_sp=debug,chain_state::lazy_overlay=debug,chain_state::memory_overlay=debug,engine::tree=debug,engine::tree::payload_validator=debug,payload_builder=debug,providers::db=debug,reth::providers::static_file=debug,reth::storage=debug,consensus::engine=debug,reth-bench=debug' \ --color never \ >"$NODE_LOG" 2>&1 & echo $! From 08e61b1a79817cd6e033397d02d69cb4f859b46f Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Wed, 6 May 2026 12:01:19 +0000 Subject: [PATCH 66/83] fix(scripts): narrow reorg mismatch detection Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019df965-c32c-7188-831c-c5d6ea37e036 Co-authored-by: Amp --- scripts/repro-hoodi-partial-persistence-reorg.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/repro-hoodi-partial-persistence-reorg.sh b/scripts/repro-hoodi-partial-persistence-reorg.sh index 494a43b403c..ae8deb9d4be 100755 --- a/scripts/repro-hoodi-partial-persistence-reorg.sh +++ b/scripts/repro-hoodi-partial-persistence-reorg.sh @@ -401,7 +401,7 @@ find_mismatch() { local line line=$(grep -Ei -m1 \ - 'state[ -]?root.*mismatch|mismatch.*state[ -]?root|mismatched block state root|Failed to verify block state root' \ + 'State root task returned incorrect state root|mismatched block state root|Failed to verify block state root' \ "$log_file" 2>/dev/null || true) if [[ -n "$line" ]]; then MISMATCH_SOURCE="$source" From ad06261746c869f0a62da2769b6e4e2e1a81806b Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Wed, 6 May 2026 16:39:35 +0000 Subject: [PATCH 67/83] chore: expand overlay repro logging Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019df965-c32c-7188-831c-c5d6ea37e036 Co-authored-by: Amp --- crates/rpc/rpc-eth-api/src/helpers/call.rs | 4 +- crates/rpc/rpc-eth-api/src/helpers/state.rs | 7 +- crates/rpc/rpc/src/testing.rs | 31 ++++++++ .../provider/src/providers/consistent.rs | 71 +++++++++++++++++-- .../src/providers/state/historical.rs | 44 ++++++++++++ .../repro-hoodi-partial-persistence-reorg.sh | 4 +- 6 files changed, 151 insertions(+), 10 deletions(-) diff --git a/crates/rpc/rpc-eth-api/src/helpers/call.rs b/crates/rpc/rpc-eth-api/src/helpers/call.rs index 2ab5bf2d074..9088060e596 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/call.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/call.rs @@ -44,7 +44,7 @@ use revm::{ Database, DatabaseCommit, }; use revm_inspectors::{access_list::AccessListInspector, transfer::TransferInspector}; -use tracing::{trace, warn}; +use tracing::{debug, trace, warn}; /// Result type for `eth_simulateV1` RPC method. pub type SimulatedBlocksResult = Result>>, E>; @@ -665,7 +665,9 @@ pub trait Call: { let at = at.into(); self.spawn_blocking_io_fut(async move |this| { + debug!(target: "rpc::eth::call", ?at, "Resolving state provider for block"); let state = this.state_at_block_id(at).await?; + debug!(target: "rpc::eth::call", ?at, "Resolved state provider for block"); let db = State::builder() .with_database(StateProviderDatabase::new(StateProviderTraitObjWrapper(state))) .build(); diff --git a/crates/rpc/rpc-eth-api/src/helpers/state.rs b/crates/rpc/rpc-eth-api/src/helpers/state.rs index 322e070458c..c35597f8af9 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/state.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/state.rs @@ -23,6 +23,7 @@ use reth_storage_api::{ }; use reth_transaction_pool::TransactionPool; use std::collections::HashMap; +use tracing::debug; /// Helper methods for `eth_` methods relating to state (accounts). pub trait EthState: LoadState + SpawnBlocking { @@ -279,10 +280,14 @@ pub trait LoadState: if at.is_pending() && let Ok(Some(state)) = self.local_pending_state().await { + debug!(target: "rpc::eth::state", ?at, "Using local pending state provider"); return Ok(state) } - self.provider().state_by_block_id(at).map_err(Self::Error::from_eth_err) + debug!(target: "rpc::eth::state", ?at, "Loading state provider by block id"); + let state = self.provider().state_by_block_id(at).map_err(Self::Error::from_eth_err)?; + debug!(target: "rpc::eth::state", ?at, "Loaded state provider by block id"); + Ok(state) } } diff --git a/crates/rpc/rpc/src/testing.rs b/crates/rpc/rpc/src/testing.rs index e7d2e45826c..96749838154 100644 --- a/crates/rpc/rpc/src/testing.rs +++ b/crates/rpc/rpc/src/testing.rs @@ -90,6 +90,14 @@ where let evm_config = self.evm_config.clone(); let skip_invalid_transactions = self.skip_invalid_transactions; let gas_limit_override = self.gas_limit_override; + debug!( + target: "rpc::testing", + parent_block_hash = %request.parent_block_hash, + transaction_count = request.transactions.len(), + timestamp = request.payload_attributes.timestamp, + ?gas_limit_override, + "Starting testing_buildBlockV1" + ); self.eth_api .spawn_with_state_at_block(request.parent_block_hash, move |eth_api, state| { let state = state.database.0; @@ -103,6 +111,14 @@ where .ok_or_else(|| { EthApiError::HeaderNotFound(request.parent_block_hash.into()) })?; + debug!( + target: "rpc::testing", + parent_block_hash = %request.parent_block_hash, + parent_number = parent.number(), + parent_state_root = %parent.state_root(), + transaction_count = request.transactions.len(), + "Resolved testing_buildBlockV1 parent and state provider" + ); let chain_spec = eth_api.provider().chain_spec(); let is_osaka = @@ -206,10 +222,25 @@ where block_transactions_rlp_length += tx_rlp_len; total_fees += U256::from(tip) * U256::from(gas_used); } + debug!( + target: "rpc::testing", + parent_block_hash = %request.parent_block_hash, + parent_number = parent.number(), + total_fees = %total_fees, + "Finishing testing_buildBlockV1 with state provider root" + ); let outcome = builder.finish(&state, None).map_err(Eth::Error::from_eth_err)?; let has_requests = outcome.block.requests_hash().is_some(); let sealed_block = Arc::new(outcome.block.into_sealed_block()); + debug!( + target: "rpc::testing", + parent_block_hash = %request.parent_block_hash, + built_block_hash = %sealed_block.hash(), + built_block_number = sealed_block.number(), + built_state_root = %sealed_block.state_root(), + "Finished testing_buildBlockV1" + ); let requests = has_requests.then_some(outcome.execution_result.requests); diff --git a/crates/storage/provider/src/providers/consistent.rs b/crates/storage/provider/src/providers/consistent.rs index 9402a8734e6..f7aee11b0ef 100644 --- a/crates/storage/provider/src/providers/consistent.rs +++ b/crates/storage/provider/src/providers/consistent.rs @@ -18,7 +18,9 @@ use reth_chainspec::ChainInfo; use reth_db_api::models::{AccountBeforeTx, BlockNumberAddress, StoredBlockBodyIndices}; use reth_execution_types::ExecutionOutcome; use reth_node_types::{BlockTy, HeaderTy, ReceiptTy, TxTy}; -use reth_primitives_traits::{Account, BlockBody, RecoveredBlock, SealedHeader, StorageEntry}; +use reth_primitives_traits::{ + Account, BlockBody, NodePrimitives, RecoveredBlock, SealedHeader, StorageEntry, +}; use reth_prune_types::{PruneCheckpoint, PruneSegment}; use reth_stages_types::{StageCheckpoint, StageId}; use reth_static_file_types::StaticFileSegment; @@ -32,7 +34,7 @@ use std::{ ops::{Add, Bound, RangeBounds, RangeInclusive, Sub}, sync::Arc, }; -use tracing::trace; +use tracing::{debug, trace}; /// Type that interacts with a snapshot view of the blockchain (storage and in-memory) at time of /// instantiation, EXCEPT for pending, safe and finalized block which might change while holding @@ -115,12 +117,26 @@ impl ConsistentProvider { &'a self, block_hash: BlockHash, ) -> ProviderResult> { - trace!(target: "providers::blockchain", ?block_hash, "Getting history by block hash"); + debug!(target: "providers::blockchain", %block_hash, "Resolving borrowed historical state provider by block hash"); self.get_in_memory_or_storage_by_block( block_hash.into(), - |_| self.storage_provider.history_by_block_hash(block_hash), + |_| { + debug!(target: "providers::blockchain", %block_hash, "Borrowed historical state provider falling back to database"); + self.storage_provider.history_by_block_hash(block_hash) + }, |block_state| { + let anchor = block_state.anchor(); + debug!( + target: "providers::blockchain", + %block_hash, + block_number = block_state.number(), + block_hash = %block_state.hash(), + anchor_number = anchor.number, + anchor_hash = %anchor.hash, + in_memory_blocks = ?block_state_summaries(block_state), + "Borrowed historical state provider using in-memory overlay" + ); let state_provider = self.block_state_provider_ref(block_state)?; Ok(Box::new(state_provider)) }, @@ -245,9 +261,19 @@ impl ConsistentProvider { &self, state: &BlockState, ) -> ProviderResult> { - let anchor_hash = state.anchor().hash; + let anchor = state.anchor(); + let anchor_hash = anchor.hash; let latest_historical = self.history_by_block_hash_ref(anchor_hash)?; let in_memory = state.chain().map(|block_state| block_state.block()).collect(); + debug!( + target: "providers::blockchain", + block_number = state.number(), + block_hash = %state.hash(), + anchor_number = anchor.number, + anchor_hash = %anchor.hash, + in_memory_blocks = ?block_state_summaries(state), + "Creating borrowed memory overlay state provider from block state" + ); Ok(MemoryOverlayStateProviderRef::new(latest_historical, in_memory)) } @@ -448,22 +474,55 @@ impl ConsistentProvider { let block_number = self.block_number(block_hash)?.ok_or(ProviderError::BlockHashNotFound(block_hash))?; self.ensure_canonical_block(block_number)?; + debug!( + target: "providers::blockchain", + %block_hash, + block_number, + "Resolving owned state provider at block hash" + ); let Self { storage_provider, head_block, .. } = self; if let Some(Some(block_state)) = head_block.as_ref().map(|b| b.block_on_chain(block_hash.into())) { - let anchor_hash = block_state.anchor().hash; + let anchor = block_state.anchor(); + let anchor_hash = anchor.hash; let block_number = storage_provider .block_number(anchor_hash)? .ok_or(ProviderError::BlockHashNotFound(anchor_hash))?; + debug!( + target: "providers::blockchain", + requested_block_hash = %block_hash, + requested_block_number = block_state.number(), + anchor_number = anchor.number, + anchor_hash = %anchor.hash, + historical_block_number = block_number, + in_memory_blocks = ?block_state_summaries(block_state), + "Owned state provider using in-memory overlay" + ); let latest_historical = storage_provider.try_into_history_at_block(block_number)?; return Ok(Box::new(block_state.state_provider(latest_historical))); } + debug!( + target: "providers::blockchain", + %block_hash, + block_number, + "Owned state provider falling back to database historical provider" + ); storage_provider.try_into_history_at_block(block_number) } } +fn block_state_summaries(state: &BlockState) -> Vec { + state + .chain() + .map(|block_state| { + let block = block_state.block_ref().recovered_block(); + format!("#{} hash={} parent={}", block.number(), block.hash(), block.parent_hash()) + }) + .collect() +} + impl ConsistentProvider { /// Ensures that the given block number is canonical (synced) /// diff --git a/crates/storage/provider/src/providers/state/historical.rs b/crates/storage/provider/src/providers/state/historical.rs index 8c748179342..33792c4a229 100644 --- a/crates/storage/provider/src/providers/state/historical.rs +++ b/crates/storage/provider/src/providers/state/historical.rs @@ -13,6 +13,7 @@ use reth_db_api::{ BlockNumberList, }; use reth_primitives_traits::{Account, Bytecode, NodePrimitives}; +use reth_stages_types::StageId; use reth_storage_api::{ BlockNumReader, BytecodeReader, DBProvider, NodePrimitivesProvider, PruneCheckpointReader, StageCheckpointReader, StateProofProvider, StorageChangeSetReader, StorageRootProvider, @@ -309,6 +310,49 @@ where .block_hash(target_block)? .ok_or_else(|| ProviderError::HeaderNotFound(target_block.into()))?; + match self.provider.get_stage_checkpoint(StageId::Finish) { + Ok(Some(checkpoint)) => { + let finish_tip_number = checkpoint.block_number; + let partial_state_trie_number = checkpoint + .finish_stage_checkpoint() + .and_then(|finish| finish.partial_state_trie) + .unwrap_or(finish_tip_number); + let finish_tip_hash = self.provider.block_hash(finish_tip_number)?; + let partial_state_trie_hash = + self.provider.block_hash(partial_state_trie_number)?; + debug!( + target: "providers::historical_sp", + historical_block_number = self.block_number, + target_block, + %anchor_hash, + finish_tip_number, + ?finish_tip_hash, + partial_state_trie_number, + ?partial_state_trie_hash, + "Historical state provider overlay frontiers" + ); + } + Ok(None) => { + debug!( + target: "providers::historical_sp", + historical_block_number = self.block_number, + target_block, + %anchor_hash, + "Historical state provider overlay without finish checkpoint" + ); + } + Err(err) => { + debug!( + target: "providers::historical_sp", + historical_block_number = self.block_number, + target_block, + %anchor_hash, + %err, + "Historical state provider overlay could not load finish checkpoint" + ); + } + } + let TrieInputSorted { nodes, state, prefix_sets } = input; let input_trie_updates = nodes.total_len(); let input_hashed_state = state.total_len(); diff --git a/scripts/repro-hoodi-partial-persistence-reorg.sh b/scripts/repro-hoodi-partial-persistence-reorg.sh index ae8deb9d4be..de9e47bcd12 100755 --- a/scripts/repro-hoodi-partial-persistence-reorg.sh +++ b/scripts/repro-hoodi-partial-persistence-reorg.sh @@ -288,7 +288,7 @@ capture_command reth "$RETH_BIN" node \ --engine.persistence-threshold 10 \ --engine.deferred-trie-blocks 3 \ --engine.accept-execution-requests-hash \ - --log.stdout.filter 'info,providers::state::overlay=debug,chain_state::lazy_overlay=debug,engine::tree::payload_validator=debug,providers::db=debug,reth::providers::static_file=debug,reth::storage=debug,consensus::engine=debug,reth-bench=debug' \ + --log.stdout.filter debug \ --color never restore_snapshot() { @@ -359,7 +359,7 @@ start_node() { --engine.persistence-threshold 10 \ --engine.deferred-trie-blocks 3 \ --engine.accept-execution-requests-hash \ - --log.stdout.filter 'info,providers::state::overlay=debug,providers::historical_sp=debug,chain_state::lazy_overlay=debug,chain_state::memory_overlay=debug,engine::tree=debug,engine::tree::payload_validator=debug,payload_builder=debug,providers::db=debug,reth::providers::static_file=debug,reth::storage=debug,consensus::engine=debug,reth-bench=debug' \ + --log.stdout.filter debug \ --color never \ >"$NODE_LOG" 2>&1 & echo $! From 6e574bf467249422263d3b297c3da77e0ad0a0c6 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Thu, 7 May 2026 15:36:17 +0000 Subject: [PATCH 68/83] chore: add Hoodi reorg diagnostics Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019e02e7-d893-749b-9ba7-fde95abec62f Co-authored-by: Amp --- bin/reth-bench/src/bench/new_payload_fcu.rs | 123 ++++++++++++++++-- .../engine/tree/src/tree/payload_validator.rs | 5 +- crates/evm/evm/src/execute.rs | 13 +- crates/rpc/rpc-api/src/lib.rs | 4 +- crates/rpc/rpc-api/src/testing.rs | 15 ++- crates/rpc/rpc/src/testing.rs | 36 +++-- .../repro-hoodi-partial-persistence-reorg.sh | 97 +++++++++++--- 7 files changed, 243 insertions(+), 50 deletions(-) diff --git a/bin/reth-bench/src/bench/new_payload_fcu.rs b/bin/reth-bench/src/bench/new_payload_fcu.rs index ee7ab42f037..8c652a65941 100644 --- a/bin/reth-bench/src/bench/new_payload_fcu.rs +++ b/bin/reth-bench/src/bench/new_payload_fcu.rs @@ -15,24 +15,25 @@ use crate::{ }, }; use alloy_consensus::TxEnvelope; -use alloy_eips::Encodable2718; +use alloy_eips::{eip7928::BlockAccessList, Encodable2718}; use alloy_primitives::B256; use alloy_provider::{ ext::DebugApi, network::{AnyNetwork, AnyRpcBlock}, Provider, RootProvider, }; -use alloy_rpc_types_engine::{ - ExecutionData, ExecutionPayloadEnvelopeV5, ForkchoiceState, PayloadAttributes, -}; +use alloy_rpc_types_engine::{ExecutionData, ForkchoiceState, PayloadAttributes}; use clap::Parser; use eyre::{bail, ensure, Context, OptionExt}; use futures::{stream, StreamExt, TryStreamExt}; use reth_cli_runner::CliContext; use reth_engine_primitives::config::DEFAULT_PERSISTENCE_THRESHOLD; use reth_node_core::args::BenchmarkArgs; -use reth_rpc_api::{RethNewPayloadInput, TestingBuildBlockRequestV1}; -use std::time::{Duration, Instant}; +use reth_rpc_api::{RethNewPayloadInput, TestingBuildBlockRequestV1, TestingBuildBlockResponseV1}; +use std::{ + path::Path, + time::{Duration, Instant}, +}; use tracing::{debug, info, warn}; /// `reth benchmark new-payload-fcu` command @@ -175,6 +176,7 @@ impl Command { if let Some(depth) = self.reorg { info!(target: "reth-bench", depth, "Using testing_buildBlockV1 reorg mode"); } + let output_dir = self.benchmark.output.clone(); let BenchContext { benchmark_mode, @@ -270,6 +272,7 @@ impl Command { let mut total_wait_time = Duration::ZERO; let mut reorg_state = self.reorg.map(ReorgState::new); let mut queued_fork_block = None; + let mut fetch_real_bal_artifacts = true; while let Some((block, head, safe, finalized, rlp)) = { let wait_start = Instant::now(); let result = blocks.try_next().await?; @@ -291,10 +294,57 @@ impl Command { finalized_block_hash: finalized, }; - let bal = if rlp.is_none() && - (block.header.block_access_list_hash.is_some() || self.enable_bal) - { - Some(fetch_block_access_list(&block_provider, block.header.number).await?) + let fetched_bal = if rlp.is_none() && fetch_real_bal_artifacts { + match fetch_block_access_list(&block_provider, block.header.number).await { + Ok(bal) => { + write_bal_artifact( + output_dir.as_deref(), + "real", + block.header.number, + block.header.hash, + Some(&bal), + )?; + Some(bal) + } + Err(err) => { + warn!( + target: "reth-bench", + block_number = block.header.number, + block_hash = %block.header.hash, + %err, + "Failed to fetch real block BAL artifact" + ); + if is_unsupported_bal_rpc_error(&err) { + fetch_real_bal_artifacts = false; + warn!( + target: "reth-bench", + "Remote RPC does not support BAL fetching; writing null real-block BAL artifacts for the remainder of the run" + ); + } + write_bal_artifact( + output_dir.as_deref(), + "real", + block.header.number, + block.header.hash, + None, + )?; + None + } + } + } else { + if rlp.is_none() { + write_bal_artifact( + output_dir.as_deref(), + "real", + block.header.number, + block.header.hash, + None, + )?; + } + None + }; + let bal = if block.header.block_access_list_hash.is_some() || self.enable_bal { + fetched_bal } else { None }; @@ -361,6 +411,7 @@ impl Command { block, canonical_parent_hash, no_wait_for_caches, + output_dir.as_deref(), ) .await?, }); @@ -405,6 +456,7 @@ impl Command { next_fork_block_number, Some(prepared.block_hash), no_wait_for_caches, + output_dir.as_deref(), ) .await?; } else { @@ -491,12 +543,13 @@ async fn prepare_built_block( block: &AnyRpcBlock, parent_block_hash: B256, no_wait_for_caches: bool, + output_dir: Option<&Path>, ) -> eyre::Result { const MAX_BUILD_ATTEMPTS: usize = 10; const BUILD_RETRY_INTERVAL: Duration = Duration::from_millis(100); let request = build_block_request(block, parent_block_hash)?; - let built_payload: ExecutionPayloadEnvelopeV5 = { + let built_response: TestingBuildBlockResponseV1 = { let mut attempts_remaining = MAX_BUILD_ATTEMPTS; loop { @@ -526,8 +579,16 @@ async fn prepare_built_block( } }; + let built_payload = built_response.execution_payload_envelope; let payload = &built_payload.execution_payload.payload_inner.payload_inner; let block_hash = payload.block_hash; + write_bal_artifact( + output_dir, + "fork", + payload.block_number, + block_hash, + built_response.block_access_list.as_ref(), + )?; let (payload, sidecar) = built_payload .into_payload_and_sidecar(block.header.parent_beacon_block_root.unwrap_or_default()); // Fork payloads are built immediately before the next `testing_buildBlockV1` call. Leaving @@ -550,9 +611,10 @@ async fn queue_fork_block( block_number: u64, parent_block_hash: Option, no_wait_for_caches: bool, + output_dir: Option<&Path>, ) -> eyre::Result> { if !benchmark_mode.contains(block_number) { - return Ok(None) + return Ok(None); } let future_block = block_provider @@ -570,17 +632,50 @@ async fn queue_fork_block( &future_block, parent_block_hash, no_wait_for_caches, + output_dir, ) .await?, })) } +fn write_bal_artifact( + output_dir: Option<&Path>, + kind: &str, + block_number: u64, + block_hash: B256, + block_access_list: Option<&BlockAccessList>, +) -> eyre::Result<()> { + let Some(output_dir) = output_dir else { return Ok(()) }; + + let bal_dir = output_dir.join("block-access-lists"); + std::fs::create_dir_all(&bal_dir)?; + let path = bal_dir.join(format!("bal-{kind}-{block_number}-{block_hash}.json")); + let value = serde_json::json!({ + "kind": kind, + "blockNumber": block_number, + "blockHash": block_hash, + "blockAccessList": block_access_list, + }); + let file = std::fs::File::create(&path)?; + serde_json::to_writer_pretty(file, &value)?; + debug!(target: "reth-bench", %kind, block_number, %block_hash, path = %path.display(), "Wrote BAL artifact"); + Ok(()) +} + fn is_retryable_build_block_error(err: &alloy_transport::TransportError) -> bool { let message = err.to_string(); message.contains("block not found: hash") || message.contains("block hash not found for block number") } +fn is_unsupported_bal_rpc_error(err: &eyre::Report) -> bool { + let message = err.to_string(); + message.contains("method ignored") || + message.contains("Method not found") || + message.contains("method not found") || + message.contains("-32601") +} + fn build_block_request( block: &AnyRpcBlock, parent_block_hash: B256, @@ -594,7 +689,7 @@ fn build_block_request( let tx: TxEnvelope = tx.try_into().map_err(|_| eyre::eyre!("unsupported tx type in RPC block"))?; if tx.is_eip4844() { - return Ok(None) + return Ok(None); } Ok(Some(tx.encoded_2718().into())) }) @@ -632,7 +727,7 @@ fn parse_reorg_depth(value: &str) -> Result { .map_err(|_| format!("invalid reorg depth {value:?}, expected a positive integer"))?; if depth == 0 { - return Err("reorg depth must be greater than 0".to_string()) + return Err("reorg depth must be greater than 0".to_string()); } Ok(depth) diff --git a/crates/engine/tree/src/tree/payload_validator.rs b/crates/engine/tree/src/tree/payload_validator.rs index f7c3cd048b4..8d752c61c88 100644 --- a/crates/engine/tree/src/tree/payload_validator.rs +++ b/crates/engine/tree/src/tree/payload_validator.rs @@ -712,10 +712,11 @@ where if state_root == block.header().state_root() { maybe_state_root = Some((state_root, trie_updates, elapsed)) } else { + let block_state_root = block.header().state_root(); warn!( target: "engine::tree::payload_validator", ?state_root, - block_state_root = ?block.header().state_root(), + ?block_state_root, "State root task returned incorrect state root" ); #[cfg(feature = "trie-debug")] @@ -723,7 +724,7 @@ where block.header().number(), &trie_debug_recorders, ); - state_root_task_failed = true; + std::process::abort(); } } Err(error) => { diff --git a/crates/evm/evm/src/execute.rs b/crates/evm/evm/src/execute.rs index eb1c70c3deb..24881691e08 100644 --- a/crates/evm/evm/src/execute.rs +++ b/crates/evm/evm/src/execute.rs @@ -3,7 +3,7 @@ use crate::{ConfigureEvm, Database, OnStateHook, TxEnvFor}; use alloc::{boxed::Box, sync::Arc, vec::Vec}; use alloy_consensus::{BlockHeader, Header}; -use alloy_eips::eip2718::WithEncoded; +use alloy_eips::{eip2718::WithEncoded, eip7928::BlockAccessList}; pub use alloy_evm::block::{BlockExecutor, BlockExecutorFactory, GasOutput}; use alloy_evm::{ block::{CommitChanges, ExecutableTxParts}, @@ -295,6 +295,8 @@ pub trait BlockAssembler { pub struct BlockBuilderOutcome { /// Result of block execution. pub execution_result: BlockExecutionResult, + /// Block access list built while executing the block, if BAL collection was enabled. + pub block_access_list: Option, /// Hashed state after execution. pub hashed_state: HashedPostState, /// Trie updates collected during state root calculation. @@ -482,6 +484,7 @@ where // merge all transitions into bundle state db.merge_transitions(BundleRetention::Reverts); + let block_access_list = db.take_built_alloy_bal(); let hashed_state = state.hashed_post_state(&db.bundle_state); let (state_root, trie_updates) = match state_root_precomputed { @@ -507,7 +510,13 @@ where let block = RecoveredBlock::new_unhashed(block, senders); - Ok(BlockBuilderOutcome { execution_result: result, hashed_state, trie_updates, block }) + Ok(BlockBuilderOutcome { + execution_result: result, + block_access_list, + hashed_state, + trie_updates, + block, + }) } fn executor_mut(&mut self) -> &mut Self::Executor { diff --git a/crates/rpc/rpc-api/src/lib.rs b/crates/rpc/rpc-api/src/lib.rs index 43dfc065e28..a1a91cee7dc 100644 --- a/crates/rpc/rpc-api/src/lib.rs +++ b/crates/rpc/rpc-api/src/lib.rs @@ -32,7 +32,9 @@ mod txpool; mod validation; mod web3; -pub use testing::{TestingBuildBlockRequestV1, TESTING_BUILD_BLOCK_V1}; +pub use testing::{ + TestingBuildBlockRequestV1, TestingBuildBlockResponseV1, TESTING_BUILD_BLOCK_V1, +}; /// re-export of all server traits pub use servers::*; diff --git a/crates/rpc/rpc-api/src/testing.rs b/crates/rpc/rpc-api/src/testing.rs index e7dbeb853d4..c6bf12ba93c 100644 --- a/crates/rpc/rpc-api/src/testing.rs +++ b/crates/rpc/rpc-api/src/testing.rs @@ -5,11 +5,24 @@ //! disabled by default and never be exposed on public-facing RPC without an //! explicit operator flag. +use alloy_eips::eip7928::BlockAccessList; use alloy_rpc_types_engine::ExecutionPayloadEnvelopeV5; use jsonrpsee::proc_macros::rpc; +use serde::{Deserialize, Serialize}; pub use alloy_rpc_types_engine::{TestingBuildBlockRequestV1, TESTING_BUILD_BLOCK_V1}; +/// Temporary diagnostic response for `testing_buildBlockV1` that includes the BAL built while +/// executing the block. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TestingBuildBlockResponseV1 { + /// The execution payload envelope produced by the testing builder. + pub execution_payload_envelope: ExecutionPayloadEnvelopeV5, + /// The diagnostic block access list built while executing this payload. + pub block_access_list: Option, +} + /// Testing RPC interface for building a block in a single call. /// /// # Enabling @@ -33,5 +46,5 @@ pub trait TestingApi { async fn build_block_v1( &self, request: TestingBuildBlockRequestV1, - ) -> jsonrpsee::core::RpcResult; + ) -> jsonrpsee::core::RpcResult; } diff --git a/crates/rpc/rpc/src/testing.rs b/crates/rpc/rpc/src/testing.rs index 96749838154..4ac75d7a6a2 100644 --- a/crates/rpc/rpc/src/testing.rs +++ b/crates/rpc/rpc/src/testing.rs @@ -15,11 +15,10 @@ //! on public-facing RPC endpoints without proper authentication. use alloy_consensus::{Header, Transaction}; -use alloy_eips::eip2718::Decodable2718; +use alloy_eips::{eip2718::Decodable2718, eip7928::total_bal_items}; use alloy_evm::{Evm, RecoveredTx}; use alloy_primitives::{map::HashSet, Address, U256}; use alloy_rlp::Encodable; -use alloy_rpc_types_engine::ExecutionPayloadEnvelopeV5; use async_trait::async_trait; use jsonrpsee::core::RpcResult; use reth_chainspec::{ChainSpecProvider, EthereumHardforks}; @@ -33,7 +32,7 @@ use reth_primitives_traits::{ AlloyBlockHeader as BlockTrait, TxTy, }; use reth_revm::{database::StateProviderDatabase, db::State}; -use reth_rpc_api::{TestingApiServer, TestingBuildBlockRequestV1}; +use reth_rpc_api::{TestingApiServer, TestingBuildBlockRequestV1, TestingBuildBlockResponseV1}; use reth_rpc_eth_api::{helpers::Call, FromEthApiError}; use reth_rpc_eth_types::EthApiError; use reth_storage_api::{BlockReader, HeaderProvider}; @@ -86,7 +85,7 @@ where async fn build_block_v1( &self, request: TestingBuildBlockRequestV1, - ) -> Result { + ) -> Result { let evm_config = self.evm_config.clone(); let skip_invalid_transactions = self.skip_invalid_transactions; let gas_limit_override = self.gas_limit_override; @@ -101,10 +100,6 @@ where self.eth_api .spawn_with_state_at_block(request.parent_block_hash, move |eth_api, state| { let state = state.database.0; - let mut db = State::builder() - .with_bundle_update() - .with_database(StateProviderDatabase::new(&state)) - .build(); let parent = eth_api .provider() .sealed_header_by_hash(request.parent_block_hash)? @@ -123,6 +118,11 @@ where let chain_spec = eth_api.provider().chain_spec(); let is_osaka = chain_spec.is_osaka_active_at_timestamp(request.payload_attributes.timestamp); + let mut db = State::builder() + .with_bundle_update() + .with_database(StateProviderDatabase::new(&state)) + .with_bal_builder() + .build(); let withdrawals = request.payload_attributes.withdrawals.clone(); let withdrawals_rlp_length = withdrawals.as_ref().map(|w| w.length()).unwrap_or(0); @@ -143,6 +143,7 @@ where .map_err(RethError::other) .map_err(Eth::Error::from_eth_err)?; builder.apply_pre_execution_changes().map_err(Eth::Error::from_eth_err)?; + builder.evm_mut().db_mut().bump_bal_index(); let mut total_fees = U256::ZERO; let base_fee = builder.evm_mut().block().basefee(); @@ -218,6 +219,7 @@ where return Err(Eth::Error::from_eth_err(err)); } }; + builder.evm_mut().db_mut().bump_bal_index(); block_transactions_rlp_length += tx_rlp_len; total_fees += U256::from(tip) * U256::from(gas_used); @@ -230,6 +232,7 @@ where "Finishing testing_buildBlockV1 with state provider root" ); let outcome = builder.finish(&state, None).map_err(Eth::Error::from_eth_err)?; + let block_access_list = outcome.block_access_list; let has_requests = outcome.block.requests_hash().is_some(); let sealed_block = Arc::new(outcome.block.into_sealed_block()); @@ -244,10 +247,21 @@ where let requests = has_requests.then_some(outcome.execution_result.requests); - EthBuiltPayload::new(sealed_block, total_fees, requests, None) + let execution_payload_envelope = EthBuiltPayload::new(sealed_block, total_fees, requests, None) .try_into_v5() .map_err(RethError::other) - .map_err(Eth::Error::from_eth_err) + .map_err(Eth::Error::from_eth_err)?; + + debug!( + target: "rpc::testing", + parent_block_hash = %request.parent_block_hash, + has_block_access_list = block_access_list.is_some(), + block_access_list_accounts = block_access_list.as_ref().map(|bal| bal.len()), + block_access_list_items = block_access_list.as_ref().map(|bal| total_bal_items(bal)), + "Returning testing_buildBlockV1 payload with diagnostic BAL" + ); + + Ok(TestingBuildBlockResponseV1 { execution_payload_envelope, block_access_list }) }) .await } @@ -267,7 +281,7 @@ where async fn build_block_v1( &self, request: TestingBuildBlockRequestV1, - ) -> RpcResult { + ) -> RpcResult { self.build_block_v1(request).await.map_err(Into::into) } } diff --git a/scripts/repro-hoodi-partial-persistence-reorg.sh b/scripts/repro-hoodi-partial-persistence-reorg.sh index de9e47bcd12..9321f65587d 100755 --- a/scripts/repro-hoodi-partial-persistence-reorg.sh +++ b/scripts/repro-hoodi-partial-persistence-reorg.sh @@ -67,13 +67,25 @@ hex_to_dec() { printf '%d\n' "$((16#${1#0x}))" } +pid_has_exited() { + local pid="$1" + local stat + + if ! kill -0 "$pid" 2>/dev/null; then + return 0 + fi + + stat=$(ps -o stat= -p "$pid" 2>/dev/null || true) + [[ "$stat" == *Z* ]] +} + wait_for_pid_exit() { local pid="$1" local timeout="$2" local elapsed=0 while (( elapsed < timeout )); do - if ! kill -0 "$pid" 2>/dev/null; then + if pid_has_exited "$pid"; then return 0 fi sleep 1 @@ -83,6 +95,20 @@ wait_for_pid_exit() { return 1 } +record_node_exit() { + if [[ -z "${NODE_PID:-}" ]]; then + return 0 + fi + + if wait "$NODE_PID"; then + NODE_EXIT_CODE=0 + else + NODE_EXIT_CODE=$? + fi + log "reth node exited with code ${NODE_EXIT_CODE}" + NODE_PID="" +} + stop_pid() { local pid="$1" local label="$2" @@ -133,6 +159,7 @@ write_summary() { printf 'head_before=%s\n' "${HEAD_BEFORE:-unknown}" printf 'head_after=%s\n' "${HEAD_AFTER:-unknown}" printf 'bench_exit_code=%s\n' "${BENCH_EXIT_CODE:-unknown}" + printf 'node_exit_code=%s\n' "${NODE_EXIT_CODE:-unknown}" printf 'mismatch_source=%s\n' "${MISMATCH_SOURCE:-not_found}" printf 'mismatch_line=%s\n' "${MISMATCH_LINE:-not_found}" printf 'artifacts_dir=%s\n' "$ARTIFACTS_DIR" @@ -142,14 +169,33 @@ write_summary() { } cleanup() { + if [[ -n "${BENCH_PID:-}" ]] && pid_has_exited "$BENCH_PID"; then + if wait "$BENCH_PID"; then + BENCH_EXIT_CODE=0 + else + BENCH_EXIT_CODE=$? + fi + BENCH_PID="" + fi + stop_pid "${BENCH_PID:-}" "reth-bench" if [[ -n "${BENCH_PID:-}" ]]; then - wait "${BENCH_PID}" 2>/dev/null || true + if wait "${BENCH_PID}" 2>/dev/null; then + BENCH_EXIT_CODE=0 + else + BENCH_EXIT_CODE=$? + fi + BENCH_PID="" + fi + + if [[ -n "${NODE_PID:-}" ]] && pid_has_exited "$NODE_PID"; then + record_node_exit fi stop_pid "${NODE_PID:-}" "reth node" if [[ -n "${NODE_PID:-}" ]]; then wait "${NODE_PID}" 2>/dev/null || true + NODE_PID="" fi write_summary @@ -174,6 +220,7 @@ HEAD_AFTER="" NODE_PID="" BENCH_PID="" BENCH_EXIT_CODE="" +NODE_EXIT_CODE="" MISMATCH_SOURCE="" MISMATCH_LINE="" TIMESTAMP="$(date '+%Y%m%d-%H%M%S')" @@ -349,20 +396,22 @@ restore_snapshot() { } start_node() { - "$RETH_BIN" node \ - --datadir "$DATADIR" \ - --chain "$CHAIN" \ - --http --http.addr 127.0.0.1 --http.port 8545 --http.api eth,net,web3,reth,testing \ - --ws --ws.addr 127.0.0.1 --ws.port 8546 --ws.api eth,net,web3,reth \ - --authrpc.addr 127.0.0.1 --authrpc.port 8551 --authrpc.jwtsecret "$JWT_SECRET" \ - --disable-discovery \ - --engine.persistence-threshold 10 \ - --engine.deferred-trie-blocks 3 \ - --engine.accept-execution-requests-hash \ - --log.stdout.filter debug \ - --color never \ - >"$NODE_LOG" 2>&1 & - echo $! + ( + ulimit -c 0 + "$RETH_BIN" node \ + --datadir "$DATADIR" \ + --chain "$CHAIN" \ + --http --http.addr 127.0.0.1 --http.port 8545 --http.api eth,net,web3,reth,testing \ + --ws --ws.addr 127.0.0.1 --ws.port 8546 --ws.api eth,net,web3,reth \ + --authrpc.addr 127.0.0.1 --authrpc.port 8551 --authrpc.jwtsecret "$JWT_SECRET" \ + --disable-discovery \ + --engine.persistence-threshold 10 \ + --engine.deferred-trie-blocks 3 \ + --engine.accept-execution-requests-hash \ + --log.stdout.filter debug \ + --color never + ) >"$NODE_LOG" 2>&1 & + NODE_PID=$! } wait_for_rpc_start() { @@ -422,6 +471,13 @@ monitor_for_mismatch() { while true; do if find_mismatch node "$NODE_LOG" || find_mismatch bench "$BENCH_LOG"; then log "Observed state-root mismatch in ${MISMATCH_SOURCE} log" + if [[ -n "$NODE_PID" ]]; then + if wait_for_pid_exit "$NODE_PID" 5; then + record_node_exit + else + log "reth node is still running 5s after mismatch" + fi + fi return 0 fi @@ -430,7 +486,7 @@ monitor_for_mismatch() { HEAD_AFTER=$(hex_to_dec "$block_hex") fi - if [[ -n "$BENCH_PID" ]] && ! kill -0 "$BENCH_PID" 2>/dev/null; then + if [[ -n "$BENCH_PID" ]] && pid_has_exited "$BENCH_PID"; then if wait "$BENCH_PID"; then BENCH_EXIT_CODE=0 RESULT="bench_completed_no_mismatch" @@ -446,12 +502,14 @@ monitor_for_mismatch() { return 0 fi - if [[ -n "$NODE_PID" ]] && ! kill -0 "$NODE_PID" 2>/dev/null; then + if [[ -n "$NODE_PID" ]] && pid_has_exited "$NODE_PID"; then if find_mismatch node "$NODE_LOG" || find_mismatch bench "$BENCH_LOG"; then log "Observed state-root mismatch after node exit" + record_node_exit return 0 fi RESULT="node_exited_no_mismatch" + record_node_exit return 0 fi @@ -470,7 +528,7 @@ monitor_for_mismatch() { restore_snapshot log "Starting reth for reorg replay run" -NODE_PID=$(start_node) +start_node HEAD_HEX=$(wait_for_rpc_start "$NODE_PID" "$START_TIMEOUT") || exit 2 HEAD_BEFORE=$(hex_to_dec "$HEAD_HEX") @@ -495,6 +553,7 @@ BENCH_ARGS=( --engine-rpc-url http://127.0.0.1:8551 --local-rpc-url http://127.0.0.1:8545 --ws-rpc-url ws://127.0.0.1:8546 + --output "$ARTIFACTS_DIR/reth-bench" --reorg "$REORG_DEPTH" ) From eb3f527558c434378bed9d1eb7efe613afc4a716 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Fri, 8 May 2026 18:02:34 +0000 Subject: [PATCH 69/83] fix(reth-bench): preserve replay payload topology Co-Authored-By: Brian Picciano <933154+mediocregopher@users.noreply.github.com> --- bin/reth-bench/src/bench/replay_payloads.rs | 31 ++++++++++++--------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/bin/reth-bench/src/bench/replay_payloads.rs b/bin/reth-bench/src/bench/replay_payloads.rs index 7dfeb6d8fcc..e47f54a9ead 100644 --- a/bin/reth-bench/src/bench/replay_payloads.rs +++ b/bin/reth-bench/src/bench/replay_payloads.rs @@ -29,6 +29,7 @@ use reth_node_api::EngineApiMessageVersion; use reth_node_core::args::WaitForPersistence; use reth_rpc_api::RethNewPayloadInput; use std::{ + collections::HashMap, path::PathBuf, time::{Duration, Instant}, }; @@ -228,7 +229,7 @@ impl Command { ); } - let mut parent_hash = initial_parent_hash; + let mut replayed_hashes = HashMap::from([(initial_parent_hash, initial_parent_hash)]); let mut results = Vec::new(); let total_benchmark_duration = Instant::now(); @@ -236,6 +237,7 @@ impl Command { for (i, payload) in payloads.iter().enumerate() { let execution_data = &payload.execution_data; let mut block_hash = payload.block_hash; + let original_block_hash = block_hash; let v1 = execution_data.payload.as_v1(); let gas_used = v1.gas_used; @@ -274,11 +276,16 @@ impl Command { .unwrap_or(WaitForPersistence::Never) .rpc_value(block_number); - // Inject sidecar BAL into the inline V4 payload field when --bal is set. - // If the payload is not already V4 we upgrade it (V3→V4) so the BAL - // can be carried inline. This changes the block hash, so we recompute - // it and patch parent_hash to maintain the chain. let mut execution_data = execution_data.clone(); + let original_parent_hash = execution_data.payload.as_v1().parent_hash; + let mut payload_modified = false; + if let Some(remapped_parent_hash) = replayed_hashes.get(&original_parent_hash) { + if *remapped_parent_hash != original_parent_hash { + execution_data.payload.as_v1_mut().parent_hash = *remapped_parent_hash; + payload_modified = true; + } + } + if self.bal && let Some(bal) = &payload.block_access_list { @@ -292,12 +299,10 @@ impl Command { execution_data.payload.as_v4_mut().unwrap().block_access_list = encoded_bal; } - // Patch parent_hash so this block chains off the (possibly - // rehashed) previous block. - execution_data.payload.as_v1_mut().parent_hash = parent_hash; + payload_modified = true; + } - // Recompute block hash after payload modification and update - // the hash stored in the payload itself. + if payload_modified { block_hash = compute_payload_block_hash(&execution_data)?; execution_data.payload.as_v1_mut().block_hash = block_hash; } @@ -349,8 +354,8 @@ impl Command { let fcu_state = ForkchoiceState { head_block_hash: block_hash, - safe_block_hash: parent_hash, - finalized_block_hash: parent_hash, + safe_block_hash: initial_parent_hash, + finalized_block_hash: initial_parent_hash, }; let fcu_start = Instant::now(); @@ -390,7 +395,7 @@ impl Command { TotalGasRow { block_number, transaction_count, gas_used, time: current_duration }; results.push((gas_row, combined_result)); - parent_hash = block_hash; + replayed_hashes.insert(original_block_hash, block_hash); } let (gas_output_results, combined_results): (Vec, Vec) = From 3327f0de7bb47f723fffd70f077a74aef7c94308 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Fri, 8 May 2026 18:20:06 +0000 Subject: [PATCH 70/83] chore: replay Hoodi reorg artifacts in repro script Co-Authored-By: Brian Picciano <933154+mediocregopher@users.noreply.github.com> --- .../repro-hoodi-partial-persistence-reorg.sh | 69 +++++++------------ 1 file changed, 26 insertions(+), 43 deletions(-) diff --git a/scripts/repro-hoodi-partial-persistence-reorg.sh b/scripts/repro-hoodi-partial-persistence-reorg.sh index 9321f65587d..6853c9180df 100755 --- a/scripts/repro-hoodi-partial-persistence-reorg.sh +++ b/scripts/repro-hoodi-partial-persistence-reorg.sh @@ -7,8 +7,8 @@ usage() { Usage: repro-hoodi-partial-persistence-reorg.sh [options] Restores a hoodi datadir snapshot, starts reth with partial persistence, then -runs reth-bench new-payload-fcu with --reorg until a state-root mismatch is -observed, the benchmark exits, or an optional timeout is reached. +replays pre-generated reorg payload artifacts until a state-root mismatch is +observed, replay exits, or an optional timeout is reached. Unlike repro-hoodi-partial-persistence-unwind.sh, this script does not crash the node and does not run restart, unwind, or Merkle-stage follow-up steps. @@ -20,16 +20,14 @@ Options: (default: /mnt/data/hoodi) --jwt-secret PATH JWT secret path (default: /jwt.hex) - --rpc-url URL Remote hoodi RPC used by reth-bench - (default: https://rpc.hoodi.ethpandaops.io) + --payload-dir PATH Directory containing payload_block_*.json files + (default: /mnt/data/hoodi-bal-payload-artifacts-10k-reorg5/payloads) + --payload-count N Number of payload artifacts to replay + (default: 20000) --expected-head N Expected local head after restore (default: 2613962) --start-block N First block expected to be replayed (default: 2613963) - --to-block N Last block to replay before declaring no mismatch - (default: unset, run continuously from restored head) - --reorg-depth N reth-bench --reorg depth - (default: 8) --artifacts-dir PATH Directory for logs and summary output (default: /tmp/reth-hoodi-reorg-) --start-timeout SECONDS Seconds to wait for node RPC startup @@ -151,11 +149,10 @@ write_summary() { printf 'snapshot=%s\n' "$SNAPSHOT" printf 'datadir=%s\n' "$DATADIR" printf 'jwt_secret=%s\n' "$JWT_SECRET" - printf 'remote_rpc_url=%s\n' "$REMOTE_RPC_URL" + printf 'payload_dir=%s\n' "$PAYLOAD_DIR" + printf 'payload_count=%s\n' "$PAYLOAD_COUNT" printf 'expected_head=%s\n' "$EXPECTED_HEAD" printf 'start_block=%s\n' "$START_BLOCK" - printf 'to_block=%s\n' "${TO_BLOCK:-unset}" - printf 'reorg_depth=%s\n' "$REORG_DEPTH" printf 'head_before=%s\n' "${HEAD_BEFORE:-unknown}" printf 'head_after=%s\n' "${HEAD_AFTER:-unknown}" printf 'bench_exit_code=%s\n' "${BENCH_EXIT_CODE:-unknown}" @@ -204,11 +201,10 @@ cleanup() { SNAPSHOT="/mnt/data/hoodi.tar.zst" DATADIR="/mnt/data/hoodi" JWT_SECRET="" -REMOTE_RPC_URL="https://rpc.hoodi.ethpandaops.io" +PAYLOAD_DIR="/mnt/data/hoodi-bal-payload-artifacts-10k-reorg5/payloads" +PAYLOAD_COUNT=20000 EXPECTED_HEAD=2613962 START_BLOCK=2613963 -TO_BLOCK="" -REORG_DEPTH=8 START_TIMEOUT=180 MISMATCH_TIMEOUT=0 RETH_BIN="/repos/reth/target/profiling/reth" @@ -240,8 +236,12 @@ while (($# > 0)); do JWT_SECRET="$2" shift 2 ;; - --rpc-url) - REMOTE_RPC_URL="$2" + --payload-dir) + PAYLOAD_DIR="$2" + shift 2 + ;; + --payload-count) + PAYLOAD_COUNT="$2" shift 2 ;; --expected-head) @@ -252,14 +252,6 @@ while (($# > 0)); do START_BLOCK="$2" shift 2 ;; - --to-block) - TO_BLOCK="$2" - shift 2 - ;; - --reorg-depth) - REORG_DEPTH="$2" - shift 2 - ;; --artifacts-dir) ARTIFACTS_DIR="$2" shift 2 @@ -311,13 +303,13 @@ if [[ ! -f "$SNAPSHOT" ]]; then exit 2 fi -if (( REORG_DEPTH <= 0 )); then - log "--reorg-depth must be greater than 0" +if [[ ! -d "$PAYLOAD_DIR" ]]; then + log "Missing payload directory: $PAYLOAD_DIR" exit 2 fi -if [[ -n "$TO_BLOCK" ]] && (( TO_BLOCK < START_BLOCK )); then - log "--to-block ${TO_BLOCK} must be greater than or equal to --start-block ${START_BLOCK}" +if (( PAYLOAD_COUNT <= 0 )); then + log "--payload-count must be greater than 0" exit 2 fi @@ -546,28 +538,19 @@ if (( HEAD_BEFORE + 1 != START_BLOCK )); then fi BENCH_ARGS=( - "$BENCH_BIN" -vvv new-payload-fcu - --rpc-url "$REMOTE_RPC_URL" - --from "$HEAD_BEFORE" + "$BENCH_BIN" -vvv replay-payloads + --reth-new-payload + --wait-for-persistence always --jwt-secret "$JWT_SECRET" --engine-rpc-url http://127.0.0.1:8551 - --local-rpc-url http://127.0.0.1:8545 - --ws-rpc-url ws://127.0.0.1:8546 + --payload-dir "$PAYLOAD_DIR" + --count "$PAYLOAD_COUNT" --output "$ARTIFACTS_DIR/reth-bench" - --reorg "$REORG_DEPTH" ) -if [[ -n "$TO_BLOCK" ]]; then - BENCH_ARGS+=(--to "$TO_BLOCK") -fi - capture_command reth_bench "${BENCH_ARGS[@]}" -if [[ -n "$TO_BLOCK" ]]; then - log "Running reth-bench with --reorg ${REORG_DEPTH} from block ${START_BLOCK} through ${TO_BLOCK}" -else - log "Running reth-bench with --reorg ${REORG_DEPTH} continuously from block ${START_BLOCK}" -fi +log "Running reth-bench replay-payloads for ${PAYLOAD_COUNT} payloads from ${PAYLOAD_DIR}" "${BENCH_ARGS[@]}" >"$BENCH_LOG" 2>&1 & BENCH_PID=$! From 694b6b49272cd13d6c96006b391e15228c6a4c3e Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Sun, 10 May 2026 08:59:01 +0000 Subject: [PATCH 71/83] fix(engine): catch up trie state before disk reorg removal Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019e108f-c77e-712f-a377-a0148ea5feb9 Co-authored-by: Amp --- crates/engine/tree/src/persistence.rs | 107 +++++++- crates/engine/tree/src/tree/mod.rs | 44 +++- .../src/providers/database/provider.rs | 231 ++++-------------- 3 files changed, 188 insertions(+), 194 deletions(-) diff --git a/crates/engine/tree/src/persistence.rs b/crates/engine/tree/src/persistence.rs index 82839d9a947..0bbddf36f78 100644 --- a/crates/engine/tree/src/persistence.rs +++ b/crates/engine/tree/src/persistence.rs @@ -1,16 +1,20 @@ use crate::metrics::PersistenceMetrics; +use alloy_consensus::BlockHeader; use alloy_eips::BlockNumHash; use crossbeam_channel::Sender as CrossbeamSender; +use reth_chain_state::ExecutedBlock; use reth_errors::ProviderError; use reth_ethereum_primitives::EthPrimitives; use reth_primitives_traits::{FastInstant as Instant, NodePrimitives}; use reth_provider::{ providers::ProviderNodeTypes, BlockExecutionWriter, BlockHashReader, ChainStateBlockWriter, DBProvider, DatabaseProviderFactory, ProviderFactory, SaveBlocksMode, SaveBlocksPlan, - StageCheckpointReader, + SaveBlocksPlanStep, StageCheckpointReader, StageCheckpointWriter, }; use reth_prune::{PrunerError, PrunerWithFactory}; -use reth_stages_api::{MetricEvent, MetricEventsSender, StageId}; +use reth_stages_api::{ + FinishCheckpoint, MetricEvent, MetricEventsSender, StageCheckpoint, StageId, +}; use reth_tasks::spawn_os_thread; use std::{ sync::{ @@ -100,8 +104,8 @@ where // If the receiver errors then senders have disconnected, so the loop should then end. while let Ok(action) = self.incoming.recv() { match action { - PersistenceAction::RemoveBlocksAbove(new_tip_num, sender) => { - let result = self.on_remove_blocks_above(new_tip_num)?; + PersistenceAction::RemoveBlocksAbove(new_tip_num, trie_state_blocks, sender) => { + let result = self.on_remove_blocks_above(new_tip_num, trie_state_blocks)?; // send new sync metrics based on removed blocks let _ = self.sync_metrics_tx.send(MetricEvent::SyncHeight { height: new_tip_num }); @@ -136,12 +140,87 @@ where fn on_remove_blocks_above( &self, new_tip_num: u64, + trie_state_blocks: Vec>, ) -> Result { debug!(target: "engine::persistence", ?new_tip_num, "Removing blocks"); let start_time = Instant::now(); let provider_rw = self.provider.database_provider_rw()?; let new_tip_hash = provider_rw.block_hash(new_tip_num)?; + + let finish_checkpoint = provider_rw.get_stage_checkpoint(StageId::Finish)?; + if let Some(checkpoint) = finish_checkpoint.as_ref() { + let partial_state_trie = checkpoint + .finish_stage_checkpoint() + .and_then(|finish| finish.partial_state_trie) + .unwrap_or(checkpoint.block_number); + + if new_tip_num > partial_state_trie { + let expected_start = partial_state_trie + 1; + let expected_len = (new_tip_num - partial_state_trie) as usize; + if trie_state_blocks.len() != expected_len { + return Err(ProviderError::HeaderNotFound(expected_start.into()).into()) + } + + for (index, block) in trie_state_blocks.iter().enumerate() { + let expected_number = expected_start + index as u64; + let num_hash = block.recovered_block().num_hash(); + if num_hash.number != expected_number { + return Err(ProviderError::HeaderNotFound(expected_number.into()).into()) + } + + let expected_hash = provider_rw + .block_hash(expected_number)? + .ok_or_else(|| ProviderError::HeaderNotFound(expected_number.into()))?; + if num_hash.hash != expected_hash { + return Err(ProviderError::BlockHashNotFound(expected_hash).into()) + } + + if index == 0 { + let expected_parent = + provider_rw.block_hash(partial_state_trie)?.ok_or_else(|| { + ProviderError::HeaderNotFound(partial_state_trie.into()) + })?; + if block.recovered_block().parent_num_hash().hash != expected_parent { + return Err(ProviderError::BlockHashNotFound(expected_parent).into()) + } + } else if block.recovered_block().parent_num_hash().hash != + trie_state_blocks[index - 1].recovered_block().num_hash().hash + { + return Err(ProviderError::HeaderNotFound(expected_number.into()).into()) + } + } + + let new_tip_hash = new_tip_hash + .ok_or_else(|| ProviderError::HeaderNotFound(new_tip_num.into()))?; + if trie_state_blocks + .last() + .is_none_or(|block| block.recovered_block().hash() != new_tip_hash) + { + return Err(ProviderError::BlockHashNotFound(new_tip_hash).into()) + } + + let catchup_len = trie_state_blocks.len(); + provider_rw.save_blocks( + &SaveBlocksPlan::new( + trie_state_blocks, + vec![SaveBlocksPlanStep::new( + 0..catchup_len, + Some(catchup_len..catchup_len), + false, + )], + ), + SaveBlocksMode::Full, + )?; + provider_rw.save_stage_checkpoint( + StageId::Finish, + StageCheckpoint::new(checkpoint.block_number).with_finish_stage_checkpoint( + FinishCheckpoint { partial_state_trie: Some(new_tip_num) }, + ), + )?; + } + } + provider_rw.remove_block_and_execution_above(new_tip_num)?; let last_state_trie_block = provider_rw.get_stage_checkpoint(StageId::Finish)?.map(|checkpoint| { @@ -256,9 +335,12 @@ pub enum PersistenceAction { /// Removes block data above the given block number from the database. /// + /// If the durable trie frontier is below the new tip, the supplied blocks are first used to + /// catch trie/state persistence up to the new tip before the unwind removes the old suffix. + /// /// This will first update checkpoints from the database, then remove actual block data from /// static files. - RemoveBlocksAbove(u64, CrossbeamSender), + RemoveBlocksAbove(u64, Vec>, CrossbeamSender), /// Update the persisted finalized block on disk SaveFinalizedBlock(u64), @@ -367,14 +449,18 @@ impl PersistenceHandle { /// Tells the persistence service to remove blocks above a certain block number. The removed /// blocks are returned by the service. /// + /// `trie_state_blocks` must contain canonical in-memory blocks from the current trie frontier + + /// 1 through `block_num`, if that frontier is below `block_num`. + /// /// When the operation completes, the new tip hash is returned in the receiver end of the sender /// argument. pub fn remove_blocks_above( &self, block_num: u64, + trie_state_blocks: Vec>, tx: CrossbeamSender, ) -> Result<(), SendError>> { - self.send_action(PersistenceAction::RemoveBlocksAbove(block_num, tx)) + self.send_action(PersistenceAction::RemoveBlocksAbove(block_num, trie_state_blocks, tx)) } } @@ -522,12 +608,13 @@ mod tests { } #[test] - fn test_remove_blocks_above_preserves_partial_state_trie() { + fn test_remove_blocks_above_catches_up_partial_state_trie() { reth_tracing::init_test_tracing(); let provider = create_test_provider_factory(); let mut test_block_builder = TestBlockBuilder::eth().with_state(); let blocks = test_block_builder.get_executed_blocks(0..4).collect::>(); + let trie_state_blocks = vec![blocks[2].clone()]; let provider_rw = provider.database_provider_rw().unwrap(); provider_rw @@ -547,19 +634,19 @@ mod tests { let handle = persistence_handle(provider.clone()); let (tx, rx) = crossbeam_channel::bounded(1); - handle.remove_blocks_above(2, tx).unwrap(); + handle.remove_blocks_above(2, trie_state_blocks, tx).unwrap(); let result = rx.recv_timeout(std::time::Duration::from_secs(10)).expect("test timed out"); let last_block = result.last_block.unwrap(); assert_eq!(last_block.number, 2); - assert_eq!(result.last_state_trie_block, Some(1)); + assert_eq!(result.last_state_trie_block, Some(2)); let finish_checkpoint = provider.provider().unwrap().get_stage_checkpoint(StageId::Finish).unwrap().unwrap(); assert_eq!(finish_checkpoint.block_number, 2); assert_eq!( finish_checkpoint.finish_stage_checkpoint().unwrap().partial_state_trie, - Some(1) + Some(2) ); } diff --git a/crates/engine/tree/src/tree/mod.rs b/crates/engine/tree/src/tree/mod.rs index b7ea62740ce..dffc7989ea9 100644 --- a/crates/engine/tree/src/tree/mod.rs +++ b/crates/engine/tree/src/tree/mod.rs @@ -1354,12 +1354,54 @@ where debug!(target: "engine::tree", ?new_tip_num, last_persisted_block=?self.persistence_state.last_persisted_block.number, "Removing blocks using persistence task"); if new_tip_num < self.persistence_state.last_persisted_block.number { debug!(target: "engine::tree", ?new_tip_num, "Starting remove blocks job"); + let Some(trie_state_blocks) = self.remove_blocks_trie_state_catchup_blocks(new_tip_num) + else { + warn!( + target: "engine::tree", + ?new_tip_num, + last_state_trie_persisted_block = ?self.persistence_state.last_state_trie_persisted_block.number, + "Cannot remove blocks: missing in-memory block needed for trie catchup" + ); + return + }; let (tx, rx) = crossbeam_channel::bounded(1); - let _ = self.persistence.remove_blocks_above(new_tip_num, tx); + let _ = self.persistence.remove_blocks_above(new_tip_num, trie_state_blocks, tx); self.persistence_state.start_remove(new_tip_num, rx); } } + /// Returns canonical in-memory blocks whose state/trie data must be materialized before an + /// on-disk removal can unwind from the persisted block-data tip down to `new_tip_num`. + fn remove_blocks_trie_state_catchup_blocks( + &self, + new_tip_num: u64, + ) -> Option>> { + let last_state_trie_persisted_block_number = + self.persistence_state.last_state_trie_persisted_block.number; + if new_tip_num <= last_state_trie_persisted_block_number { + return Some(Vec::new()) + } + + let mut blocks = + Vec::with_capacity((new_tip_num - last_state_trie_persisted_block_number) as usize); + for block_number in last_state_trie_persisted_block_number + 1..=new_tip_num { + let Some(block_state) = self.canonical_in_memory_state.state_by_number(block_number) + else { + debug!( + target: "engine::tree", + block_number, + ?new_tip_num, + ?last_state_trie_persisted_block_number, + "missing in-memory block needed for remove-blocks trie catchup" + ); + return None + }; + blocks.push(block_state.block()); + } + + Some(blocks) + } + /// Helper method to save blocks and set the persistence state. This ensures we keep track of /// the current persistence action while we're saving blocks. fn persist_blocks(&mut self, plan: SaveBlocksPlan) { diff --git a/crates/storage/provider/src/providers/database/provider.rs b/crates/storage/provider/src/providers/database/provider.rs index 8350daccedd..c8e4c22f144 100644 --- a/crates/storage/provider/src/providers/database/provider.rs +++ b/crates/storage/provider/src/providers/database/provider.rs @@ -1083,32 +1083,32 @@ impl DatabaseProvider ProviderResult<()> { let changed_accounts = self.account_changesets_range(from..)?; + // Unwind account hashes. + self.unwind_account_hashing(changed_accounts.iter())?; + // Unwind account history indices. self.unwind_account_history_indices(changed_accounts.iter())?; let changed_storages = self.storage_changesets_range(from..)?; + // Unwind storage hashes. + self.unwind_storage_hashing(changed_storages.iter().copied())?; + // Unwind storage history indices. self.unwind_storage_history_indices(changed_storages.iter().copied())?; - let Some(trie_persisted_range) = self.durable_trie_range_from(from)? else { return Ok(()) }; - - // Only hashed state and trie tables up to the durable trie frontier are materialized. - self.unwind_account_hashing( - changed_accounts - .iter() - .filter(|(block_number, _)| trie_persisted_range.contains(block_number)), - )?; - - self.unwind_storage_hashing( - changed_storages - .iter() - .copied() - .filter(|(index, _)| trie_persisted_range.contains(&index.block_number())), - )?; - // Unwind accounts/storages trie tables using the revert. - let trie_revert = self.changeset_cache.get_or_compute_range(self, trie_persisted_range)?; + // Get the database tip block number + let db_tip_block = self + .get_stage_checkpoint(reth_stages_types::StageId::Finish)? + .as_ref() + .map(|chk| chk.block_number) + .ok_or_else(|| ProviderError::InsufficientChangesets { + requested: from, + available: 0..=0, + })?; + + let trie_revert = self.changeset_cache.get_or_compute_range(self, from..=db_tip_block)?; self.write_trie_updates_sorted(&trie_revert)?; Ok(()) @@ -3012,51 +3012,41 @@ impl StateWriter }; if self.cached_storage_settings().use_hashed_state() { - if let Some(trie_persisted_range) = self.durable_trie_range_from(block + 1)? { - let mut hashed_accounts_cursor = - self.tx.cursor_write::()?; - let mut hashed_storage_cursor = - self.tx.cursor_dup_write::()?; - - let (state, _) = self.populate_bundle_state_hashed( - account_changeset - .into_iter() - .filter(|(block_number, _)| trie_persisted_range.contains(block_number)) - .collect::>(), - storage_changeset - .into_iter() - .filter(|(index, _)| trie_persisted_range.contains(&index.block_number())) - .collect::>(), - &mut hashed_accounts_cursor, - &mut hashed_storage_cursor, - )?; + let mut hashed_accounts_cursor = self.tx.cursor_write::()?; + let mut hashed_storage_cursor = self.tx.cursor_dup_write::()?; - for (address, (old_account, new_account, storage)) in &state { - if old_account != new_account { - let hashed_address = keccak256(address); - let existing_entry = hashed_accounts_cursor.seek_exact(hashed_address)?; - if let Some(account) = old_account { - hashed_accounts_cursor.upsert(hashed_address, account)?; - } else if existing_entry.is_some() { - hashed_accounts_cursor.delete_current()?; - } + let (state, _) = self.populate_bundle_state_hashed( + account_changeset, + storage_changeset, + &mut hashed_accounts_cursor, + &mut hashed_storage_cursor, + )?; + + for (address, (old_account, new_account, storage)) in &state { + if old_account != new_account { + let hashed_address = keccak256(address); + let existing_entry = hashed_accounts_cursor.seek_exact(hashed_address)?; + if let Some(account) = old_account { + hashed_accounts_cursor.upsert(hashed_address, account)?; + } else if existing_entry.is_some() { + hashed_accounts_cursor.delete_current()?; } + } - for (storage_key, (old_storage_value, _new_storage_value)) in storage { - let hashed_address = keccak256(address); - let hashed_storage_key = keccak256(storage_key); - let storage_entry = - StorageEntry { key: hashed_storage_key, value: *old_storage_value }; - if hashed_storage_cursor - .seek_by_key_subkey(hashed_address, hashed_storage_key)? - .is_some_and(|s| s.key == hashed_storage_key) - { - hashed_storage_cursor.delete_current()? - } + for (storage_key, (old_storage_value, _new_storage_value)) in storage { + let hashed_address = keccak256(address); + let hashed_storage_key = keccak256(storage_key); + let storage_entry = + StorageEntry { key: hashed_storage_key, value: *old_storage_value }; + if hashed_storage_cursor + .seek_by_key_subkey(hashed_address, hashed_storage_key)? + .is_some_and(|s| s.key == hashed_storage_key) + { + hashed_storage_cursor.delete_current()? + } - if !old_storage_value.is_zero() { - hashed_storage_cursor.upsert(hashed_address, &storage_entry)?; - } + if !old_storage_value.is_zero() { + hashed_storage_cursor.upsert(hashed_address, &storage_entry)?; } } } @@ -3746,21 +3736,6 @@ impl DatabaseProvider ProviderResult>> { - let Some(trie_persisted_tip) = self.trie_persisted_tip_block_number()? else { - return Ok(None) - }; - - if from > trie_persisted_tip { - return Ok(None) - } - - Ok(Some(from..=trie_persisted_tip)) - } - fn update_finish_checkpoint_after_remove(&self, block: BlockNumber) -> ProviderResult<()> { let partial_state_trie = self .trie_persisted_tip_block_number()? @@ -5152,116 +5127,6 @@ mod tests { ); } - #[test] - fn test_remove_blocks_above_keeps_masked_hashed_state_and_trie_unmaterialized() { - fn empty_execution_output() -> BlockExecutionOutput { - BlockExecutionOutput { - result: BlockExecutionResult { - receipts: vec![], - requests: Default::default(), - gas_used: 0, - blob_gas_used: 0, - }, - state: Default::default(), - } - } - - let factory = create_test_provider_factory(); - factory.set_storage_settings_cache(StorageSettings::v1()); - - let genesis = SealedBlock::::from_sealed_parts( - SealedHeader::new( - Header { number: 0, difficulty: U256::from(1), ..Default::default() }, - B256::ZERO, - ), - Default::default(), - ); - let genesis_executed = ExecutedBlock::new( - Arc::new(genesis.try_recover().unwrap()), - Arc::new(empty_execution_output()), - ComputedTrieData::default(), - ); - - let mut test_block_builder = TestBlockBuilder::eth().with_state(); - let blocks: Vec<_> = test_block_builder.get_executed_blocks(1..4).collect(); - let overlapping_trie_data = blocks[1].trie_data(); - let overlapping_hashed_account = overlapping_trie_data.hashed_state.accounts[0].0; - let (&overlapping_hashed_storage, overlapping_storage) = - overlapping_trie_data.hashed_state.storages.iter().next().unwrap(); - let overlapping_hashed_slot = overlapping_storage.storage_slots[0].0; - - let provider_rw = factory.provider_rw().unwrap(); - provider_rw - .save_blocks( - &full_save_plan(std::slice::from_ref(&genesis_executed).to_vec()), - SaveBlocksMode::Full, - ) - .unwrap(); - provider_rw.commit().unwrap(); - - let provider_rw = factory.provider_rw().unwrap(); - provider_rw - .save_blocks( - &partial_save_plan( - blocks, - vec![ - SaveBlocksPlanStep::new(0..1, Some(1..3), true), - SaveBlocksPlanStep::new(1..3, None, true), - ], - ), - SaveBlocksMode::Full, - ) - .unwrap(); - provider_rw.commit().unwrap(); - - let provider = factory.provider().unwrap(); - assert_eq!(provider.tx_ref().entries::().unwrap(), 0); - assert_eq!(provider.tx_ref().entries::().unwrap(), 0); - assert!(provider - .tx_ref() - .cursor_read::() - .unwrap() - .seek_exact(overlapping_hashed_account) - .unwrap() - .is_none()); - assert!(provider - .tx_ref() - .cursor_dup_read::() - .unwrap() - .seek_by_key_subkey(overlapping_hashed_storage, overlapping_hashed_slot) - .unwrap() - .is_none()); - drop(provider); - - let provider_rw = factory.provider_rw().unwrap(); - provider_rw.remove_block_and_execution_above(2).unwrap(); - provider_rw.commit().unwrap(); - - let provider = factory.provider().unwrap(); - let finish_checkpoint = provider.get_stage_checkpoint(StageId::Finish).unwrap().unwrap(); - assert_eq!(finish_checkpoint.block_number, 2); - assert_eq!( - finish_checkpoint.finish_stage_checkpoint().unwrap().partial_state_trie, - Some(1) - ); - assert_eq!(provider.tx_ref().entries::().unwrap(), 0); - assert_eq!(provider.tx_ref().entries::().unwrap(), 0); - assert!(provider - .tx_ref() - .cursor_read::() - .unwrap() - .seek_exact(overlapping_hashed_account) - .unwrap() - .is_none()); - assert!(provider - .tx_ref() - .cursor_dup_read::() - .unwrap() - .seek_by_key_subkey(overlapping_hashed_storage, overlapping_hashed_slot) - .unwrap() - .is_none()); - } - #[test] fn test_prunable_receipts_logic() { let insert_blocks = From c9becdd80c427244fbd956f5b1e59977875f8b3b Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Sun, 10 May 2026 10:05:26 +0000 Subject: [PATCH 72/83] chore(engine): remove partial persistence debug artifacts Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019e1154-168a-72ba-a57c-1d5831f60951 Co-authored-by: Amp --- Cargo.lock | 6 - bin/reth-bench/README.md | 2 +- bin/reth-bench/src/bench/new_payload_fcu.rs | 104 +- bin/reth-bench/src/bench/replay_payloads.rs | 31 +- crates/chain-state/src/lazy_overlay.rs | 49 +- crates/chain-state/src/memory_overlay.rs | 78 -- crates/engine/tree/src/tree/mod.rs | 16 +- .../engine/tree/src/tree/payload_validator.rs | 34 +- crates/ethereum/payload/src/lib.rs | 25 - crates/rpc/rpc-api/src/lib.rs | 4 +- crates/rpc/rpc-api/src/testing.rs | 15 +- crates/rpc/rpc-eth-api/src/helpers/call.rs | 4 +- crates/rpc/rpc-eth-api/src/helpers/state.rs | 7 +- crates/rpc/rpc/src/testing.rs | 67 +- .../provider/src/providers/consistent.rs | 71 +- .../src/providers/state/historical.rs | 69 -- .../provider/src/providers/state/overlay.rs | 89 +- examples/db-access/Cargo.toml | 6 - .../src/bin/compare_merkle_trace_to_db.rs | 940 ------------------ scripts/compare-merkle-trace-to-db.sh | 7 - scripts/extract-merkle-trace-touches.py | 350 ------- .../repro-hoodi-partial-persistence-reorg.sh | 560 ----------- .../repro-hoodi-partial-persistence-unwind.sh | 826 --------------- 23 files changed, 152 insertions(+), 3208 deletions(-) delete mode 100644 examples/db-access/src/bin/compare_merkle_trace_to_db.rs delete mode 100755 scripts/compare-merkle-trace-to-db.sh delete mode 100755 scripts/extract-merkle-trace-touches.py delete mode 100755 scripts/repro-hoodi-partial-persistence-reorg.sh delete mode 100755 scripts/repro-hoodi-partial-persistence-unwind.sh diff --git a/Cargo.lock b/Cargo.lock index e5a529eea09..4f2f22ecdd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3701,14 +3701,8 @@ name = "example-db-access" version = "0.0.0" dependencies = [ "alloy-primitives", - "clap", "eyre", - "reth-chainspec", - "reth-db-api", "reth-ethereum", - "reth-storage-api", - "reth-trie", - "reth-trie-db", ] [[package]] diff --git a/bin/reth-bench/README.md b/bin/reth-bench/README.md index be3ee00d094..f44245e82b0 100644 --- a/bin/reth-bench/README.md +++ b/bin/reth-bench/README.md @@ -39,7 +39,7 @@ Both `new-payload-fcu` and `new-payload-only` support `--rpc-block-fetch-retries to control how many times block fetches are retried after an RPC failure. The default is `10`. Use `--rpc-block-fetch-retries forever` to keep retrying indefinitely. -When using `--wait-for-persistence`, the benchmark waits after every `(threshold + 1)` blocks, where the threshold defaults to the engine's persistence threshold. This can be customized with `--persistence-threshold `. +When using `--wait-for-persistence`, the benchmark waits after every `(threshold + 1)` blocks, where the threshold defaults to the engine's persistence threshold (2). This can be customized with `--persistence-threshold `. By default, the WebSocket URL for persistence subscriptions is derived from `--engine-rpc-url` (converting to ws:// on port 8546). Use `--ws-rpc-url` to override this. diff --git a/bin/reth-bench/src/bench/new_payload_fcu.rs b/bin/reth-bench/src/bench/new_payload_fcu.rs index 1146871675f..d44f9b8f132 100644 --- a/bin/reth-bench/src/bench/new_payload_fcu.rs +++ b/bin/reth-bench/src/bench/new_payload_fcu.rs @@ -25,7 +25,9 @@ use alloy_provider::{ network::{AnyNetwork, AnyRpcBlock}, Provider, RootProvider, }; -use alloy_rpc_types_engine::{ExecutionData, ForkchoiceState, PayloadAttributes}; +use alloy_rpc_types_engine::{ + ExecutionData, ExecutionPayloadEnvelopeV5, ForkchoiceState, PayloadAttributes, +}; use clap::Parser; use eyre::{bail, ensure, Context, OptionExt}; use futures::{stream, Stream, StreamExt, TryStreamExt}; @@ -33,9 +35,8 @@ use reth_cli_runner::CliContext; use reth_engine_primitives::config::DEFAULT_PERSISTENCE_THRESHOLD; use reth_node_api::{EngineApiMessageVersion, ExecutionPayload}; use reth_node_core::args::{BenchmarkArgs, WaitForPersistence}; -use reth_rpc_api::{RethNewPayloadInput, TestingBuildBlockRequestV1, TestingBuildBlockResponseV1}; +use reth_rpc_api::{RethNewPayloadInput, TestingBuildBlockRequestV1}; use std::{ - path::Path, pin::Pin, time::{Duration, Instant}, }; @@ -188,7 +189,6 @@ impl Command { if let Some(depth) = self.reorg { info!(target: "reth-bench", depth, "Using testing_buildBlockV1 reorg mode"); } - let output_dir = self.benchmark.output.clone(); let BenchContext { benchmark_mode, @@ -213,7 +213,6 @@ impl Command { let buffer_size = self.rpc_block_buffer_size; let provider = block_provider.clone(); let bench_mode = benchmark_mode.clone(); - let artifact_output_dir = output_dir.clone(); let mut blocks: Pin> + Send>> = Box::pin( stream::iter((next_block..) .take_while(move |next_block| { @@ -221,7 +220,6 @@ impl Command { })) .map(move |next_block| { let block_provider = provider.clone(); - let artifact_output_dir = artifact_output_dir.clone(); async move { let block_res = block_provider .get_block_by_number(next_block.into()) @@ -240,47 +238,10 @@ impl Command { }; - let fetched_bal = if !rlp_blocks { - match fetch_block_access_list(&block_provider, block.header.number).await { - Ok(bal) => { - write_bal_artifact( - artifact_output_dir.as_deref(), - "real", - block.header.number, - block.header.hash, - Some(&bal), - )?; - Some(bal) - } - Err(err) => { - warn!( - target: "reth-bench", - block_number = block.header.number, - block_hash = %block.header.hash, - %err, - "Failed to fetch real block BAL artifact" - ); - if is_unsupported_bal_rpc_error(&err) { - warn!( - target: "reth-bench", - "Remote RPC does not support BAL fetching; writing null real-block BAL artifact" - ); - } - write_bal_artifact( - artifact_output_dir.as_deref(), - "real", - block.header.number, - block.header.hash, - None, - )?; - None - } - } - } else { - None - }; - let bal = if block.header.block_access_list_hash.is_some() || self.enable_bal { - fetched_bal + let bal = if !rlp_blocks && + (block.header.block_access_list_hash.is_some() || self.enable_bal) + { + Some(fetch_block_access_list(&block_provider, block.header.number).await?) } else { None }; @@ -443,7 +404,6 @@ impl Command { .ok_or_eyre("missing deferred fork block for reorg branch start")?, canonical_parent_hash, no_wait_for_caches, - output_dir.as_deref(), ) .await?, }); @@ -488,7 +448,6 @@ impl Command { next_fork_block_number, Some(prepared.block_hash), no_wait_for_caches, - output_dir.as_deref(), ) .await?; } else { @@ -575,13 +534,12 @@ async fn prepare_built_block( block: &AnyRpcBlock, parent_block_hash: B256, no_wait_for_caches: bool, - output_dir: Option<&Path>, ) -> eyre::Result { const MAX_BUILD_ATTEMPTS: usize = 10; const BUILD_RETRY_INTERVAL: Duration = Duration::from_millis(100); let request = build_block_request(block, parent_block_hash)?; - let built_response: TestingBuildBlockResponseV1 = { + let built_payload: ExecutionPayloadEnvelopeV5 = { let mut attempts_remaining = MAX_BUILD_ATTEMPTS; loop { @@ -611,16 +569,8 @@ async fn prepare_built_block( } }; - let built_payload = built_response.execution_payload_envelope; let payload = &built_payload.execution_payload.payload_inner.payload_inner; let block_hash = payload.block_hash; - write_bal_artifact( - output_dir, - "fork", - payload.block_number, - block_hash, - built_response.block_access_list.as_ref(), - )?; let (payload, sidecar) = built_payload .into_payload_and_sidecar(block.header.parent_beacon_block_root.unwrap_or_default()); // Fork payloads are built immediately before the next `testing_buildBlockV1` call. Leaving @@ -643,10 +593,9 @@ async fn queue_fork_block( block_number: u64, parent_block_hash: Option, no_wait_for_caches: bool, - output_dir: Option<&Path>, ) -> eyre::Result> { if !benchmark_mode.contains(block_number) { - return Ok(None); + return Ok(None) } let future_block = block_provider @@ -664,50 +613,17 @@ async fn queue_fork_block( &future_block, parent_block_hash, no_wait_for_caches, - output_dir, ) .await?, })) } -fn write_bal_artifact( - output_dir: Option<&Path>, - kind: &str, - block_number: u64, - block_hash: B256, - block_access_list: Option<&BlockAccessList>, -) -> eyre::Result<()> { - let Some(output_dir) = output_dir else { return Ok(()) }; - - let bal_dir = output_dir.join("block-access-lists"); - std::fs::create_dir_all(&bal_dir)?; - let path = bal_dir.join(format!("bal-{kind}-{block_number}-{block_hash}.json")); - let value = serde_json::json!({ - "kind": kind, - "blockNumber": block_number, - "blockHash": block_hash, - "blockAccessList": block_access_list, - }); - let file = std::fs::File::create(&path)?; - serde_json::to_writer_pretty(file, &value)?; - debug!(target: "reth-bench", %kind, block_number, %block_hash, path = %path.display(), "Wrote BAL artifact"); - Ok(()) -} - fn is_retryable_build_block_error(err: &alloy_transport::TransportError) -> bool { let message = err.to_string(); message.contains("block not found: hash") || message.contains("block hash not found for block number") } -fn is_unsupported_bal_rpc_error(err: &eyre::Report) -> bool { - let message = err.to_string(); - message.contains("method ignored") || - message.contains("Method not found") || - message.contains("method not found") || - message.contains("-32601") -} - fn build_block_request( block: &AnyRpcBlock, parent_block_hash: B256, diff --git a/bin/reth-bench/src/bench/replay_payloads.rs b/bin/reth-bench/src/bench/replay_payloads.rs index e47f54a9ead..7dfeb6d8fcc 100644 --- a/bin/reth-bench/src/bench/replay_payloads.rs +++ b/bin/reth-bench/src/bench/replay_payloads.rs @@ -29,7 +29,6 @@ use reth_node_api::EngineApiMessageVersion; use reth_node_core::args::WaitForPersistence; use reth_rpc_api::RethNewPayloadInput; use std::{ - collections::HashMap, path::PathBuf, time::{Duration, Instant}, }; @@ -229,7 +228,7 @@ impl Command { ); } - let mut replayed_hashes = HashMap::from([(initial_parent_hash, initial_parent_hash)]); + let mut parent_hash = initial_parent_hash; let mut results = Vec::new(); let total_benchmark_duration = Instant::now(); @@ -237,7 +236,6 @@ impl Command { for (i, payload) in payloads.iter().enumerate() { let execution_data = &payload.execution_data; let mut block_hash = payload.block_hash; - let original_block_hash = block_hash; let v1 = execution_data.payload.as_v1(); let gas_used = v1.gas_used; @@ -276,16 +274,11 @@ impl Command { .unwrap_or(WaitForPersistence::Never) .rpc_value(block_number); + // Inject sidecar BAL into the inline V4 payload field when --bal is set. + // If the payload is not already V4 we upgrade it (V3→V4) so the BAL + // can be carried inline. This changes the block hash, so we recompute + // it and patch parent_hash to maintain the chain. let mut execution_data = execution_data.clone(); - let original_parent_hash = execution_data.payload.as_v1().parent_hash; - let mut payload_modified = false; - if let Some(remapped_parent_hash) = replayed_hashes.get(&original_parent_hash) { - if *remapped_parent_hash != original_parent_hash { - execution_data.payload.as_v1_mut().parent_hash = *remapped_parent_hash; - payload_modified = true; - } - } - if self.bal && let Some(bal) = &payload.block_access_list { @@ -299,10 +292,12 @@ impl Command { execution_data.payload.as_v4_mut().unwrap().block_access_list = encoded_bal; } - payload_modified = true; - } + // Patch parent_hash so this block chains off the (possibly + // rehashed) previous block. + execution_data.payload.as_v1_mut().parent_hash = parent_hash; - if payload_modified { + // Recompute block hash after payload modification and update + // the hash stored in the payload itself. block_hash = compute_payload_block_hash(&execution_data)?; execution_data.payload.as_v1_mut().block_hash = block_hash; } @@ -354,8 +349,8 @@ impl Command { let fcu_state = ForkchoiceState { head_block_hash: block_hash, - safe_block_hash: initial_parent_hash, - finalized_block_hash: initial_parent_hash, + safe_block_hash: parent_hash, + finalized_block_hash: parent_hash, }; let fcu_start = Instant::now(); @@ -395,7 +390,7 @@ impl Command { TotalGasRow { block_number, transaction_count, gas_used, time: current_duration }; results.push((gas_row, combined_result)); - replayed_hashes.insert(original_block_hash, block_hash); + parent_hash = block_hash; } let (gas_output_results, combined_results): (Vec, Vec) = diff --git a/crates/chain-state/src/lazy_overlay.rs b/crates/chain-state/src/lazy_overlay.rs index 132b2e7a995..3ebe3f426d3 100644 --- a/crates/chain-state/src/lazy_overlay.rs +++ b/crates/chain-state/src/lazy_overlay.rs @@ -158,12 +158,14 @@ impl LazyOverlay { let Some(last_index) = blocks.iter().position(|block| block.recovered_block().parent_hash() == anchor_hash) else { - debug!( - target: "chain_state::lazy_overlay", - %anchor_hash, - available_blocks = ?blocks.iter().map(block_summary).collect::>(), - "Lazy overlay requested missing anchor" - ); + if tracing::enabled!(target: "chain_state::lazy_overlay", tracing::Level::DEBUG) { + debug!( + target: "chain_state::lazy_overlay", + %anchor_hash, + available_blocks = ?blocks.iter().map(block_summary).collect::>(), + "Lazy overlay requested missing anchor" + ); + } panic!( "LazyOverlay does not contain a block whose parent hash matches requested anchor {anchor_hash}" ); @@ -187,14 +189,17 @@ impl LazyOverlay { let data = tip.trie_data(); if let Some(anchored) = &data.anchored_trie_input { if anchored.anchor_hash == anchor_hash { - debug!( - target: "chain_state::lazy_overlay", - %anchor_hash, - tip = ?block_summary(tip), - trie_updates = anchored.trie_input.nodes.total_len(), - hashed_state = anchored.trie_input.state.total_len(), - "Reusing tip block's cached overlay (fast path)" - ); + if tracing::enabled!(target: "chain_state::lazy_overlay", tracing::Level::DEBUG) + { + debug!( + target: "chain_state::lazy_overlay", + %anchor_hash, + tip = ?block_summary(tip), + trie_updates = anchored.trie_input.nodes.total_len(), + hashed_state = anchored.trie_input.state.total_len(), + "Reusing tip block's cached overlay (fast path)" + ); + } return Arc::clone(&anchored.trie_input); } debug!( @@ -207,13 +212,15 @@ impl LazyOverlay { } // Slow path: Merge the prefix of blocks from the tip back to the requested anchor. - debug!( - target: "chain_state::lazy_overlay", - %anchor_hash, - num_blocks = blocks.len(), - blocks = ?blocks.iter().map(block_summary).collect::>(), - "Merging blocks (slow path)" - ); + if tracing::enabled!(target: "chain_state::lazy_overlay", tracing::Level::DEBUG) { + debug!( + target: "chain_state::lazy_overlay", + %anchor_hash, + num_blocks = blocks.len(), + blocks = ?blocks.iter().map(block_summary).collect::>(), + "Merging blocks (slow path)" + ); + } Arc::new(Self::merge_blocks(blocks)) } diff --git a/crates/chain-state/src/memory_overlay.rs b/crates/chain-state/src/memory_overlay.rs index 8cb5435387a..7e31ec06fee 100644 --- a/crates/chain-state/src/memory_overlay.rs +++ b/crates/chain-state/src/memory_overlay.rs @@ -13,7 +13,6 @@ use reth_trie::{ }; use revm_database::BundleState; use std::{borrow::Cow, sync::OnceLock}; -use tracing::debug; /// A state provider that stores references to in-memory blocks along with their state as well as a /// reference of the historical state provider for fallback lookups. @@ -39,12 +38,6 @@ impl<'a, N: NodePrimitives> MemoryOverlayStateProviderRef<'a, N> { /// - `historical` - a historical state provider for the latest ancestor block stored in the /// database. pub fn new(historical: Box, in_memory: Vec>) -> Self { - debug!( - target: "chain_state::memory_overlay", - in_memory_blocks = ?block_summaries(&in_memory), - num_in_memory_blocks = in_memory.len(), - "Creating borrowed memory overlay state provider" - ); Self { historical, in_memory: Cow::Owned(in_memory), trie_input: OnceLock::new() } } @@ -57,24 +50,12 @@ impl<'a, N: NodePrimitives> MemoryOverlayStateProviderRef<'a, N> { fn trie_input(&self) -> &TrieInput { self.trie_input.get_or_init(|| { let mut input = TrieInput::default(); - let mut trie_updates = 0; - let mut hashed_state = 0; // Iterate from oldest to newest for block in self.in_memory.iter().rev() { let data = block.trie_data(); - trie_updates += data.trie_updates.total_len(); - hashed_state += data.hashed_state.total_len(); input.nodes.extend_from_sorted(&data.trie_updates); input.state.extend_from_sorted(&data.hashed_state); } - debug!( - target: "chain_state::memory_overlay", - in_memory_blocks = ?block_summaries(&self.in_memory), - num_in_memory_blocks = self.in_memory.len(), - trie_updates, - hashed_state, - "Built memory overlay trie input" - ); input }) } @@ -146,15 +127,6 @@ impl StateRootProvider for MemoryOverlayStateProviderRef<'_, } fn state_root_from_nodes(&self, mut input: TrieInput) -> ProviderResult { - debug!( - target: "chain_state::memory_overlay", - in_memory_blocks = ?block_summaries(&self.in_memory), - num_in_memory_blocks = self.in_memory.len(), - prefix_account_updates = input.prefix_sets.account_prefix_set.len(), - prefix_storage_tries = input.prefix_sets.storage_prefix_sets.len(), - prefix_destroyed_accounts = input.prefix_sets.destroyed_accounts.len(), - "Calculating state root through memory overlay provider" - ); input.prepend_self(self.trie_input().clone()); self.historical.state_root_from_nodes(input) } @@ -170,15 +142,6 @@ impl StateRootProvider for MemoryOverlayStateProviderRef<'_, &self, mut input: TrieInput, ) -> ProviderResult<(B256, TrieUpdates)> { - debug!( - target: "chain_state::memory_overlay", - in_memory_blocks = ?block_summaries(&self.in_memory), - num_in_memory_blocks = self.in_memory.len(), - prefix_account_updates = input.prefix_sets.account_prefix_set.len(), - prefix_storage_tries = input.prefix_sets.storage_prefix_sets.len(), - prefix_destroyed_accounts = input.prefix_sets.destroyed_accounts.len(), - "Calculating state root with updates through memory overlay provider" - ); input.prepend_self(self.trie_input().clone()); self.historical.state_root_from_nodes_with_updates(input) } @@ -221,17 +184,6 @@ impl StateProofProvider for MemoryOverlayStateProviderRef<'_, address: Address, slots: &[B256], ) -> ProviderResult { - debug!( - target: "chain_state::memory_overlay", - in_memory_blocks = ?block_summaries(&self.in_memory), - num_in_memory_blocks = self.in_memory.len(), - %address, - num_slots = slots.len(), - prefix_account_updates = input.prefix_sets.account_prefix_set.len(), - prefix_storage_tries = input.prefix_sets.storage_prefix_sets.len(), - prefix_destroyed_accounts = input.prefix_sets.destroyed_accounts.len(), - "Generating proof through memory overlay provider" - ); input.prepend_self(self.trie_input().clone()); self.historical.proof(input, address, slots) } @@ -241,15 +193,6 @@ impl StateProofProvider for MemoryOverlayStateProviderRef<'_, mut input: TrieInput, targets: MultiProofTargets, ) -> ProviderResult { - debug!( - target: "chain_state::memory_overlay", - in_memory_blocks = ?block_summaries(&self.in_memory), - num_in_memory_blocks = self.in_memory.len(), - prefix_account_updates = input.prefix_sets.account_prefix_set.len(), - prefix_storage_tries = input.prefix_sets.storage_prefix_sets.len(), - prefix_destroyed_accounts = input.prefix_sets.destroyed_accounts.len(), - "Generating multiproof through memory overlay provider" - ); input.prepend_self(self.trie_input().clone()); self.historical.multiproof(input, targets) } @@ -320,12 +263,6 @@ impl MemoryOverlayStateProvider { /// - `historical` - a historical state provider for the latest ancestor block stored in the /// database. pub fn new(historical: StateProviderBox, in_memory: Vec>) -> Self { - debug!( - target: "chain_state::memory_overlay", - in_memory_blocks = ?block_summaries(&in_memory), - num_in_memory_blocks = in_memory.len(), - "Creating owned memory overlay state provider" - ); Self { historical, in_memory, trie_input: OnceLock::new() } } @@ -347,18 +284,3 @@ impl MemoryOverlayStateProvider { // Delegates all provider impls to [`MemoryOverlayStateProviderRef`] reth_storage_api::macros::delegate_provider_impls!(MemoryOverlayStateProvider where [N: NodePrimitives]); - -fn block_summaries(blocks: &[ExecutedBlock]) -> Vec { - blocks - .iter() - .map(|block| { - let recovered = block.recovered_block(); - format!( - "#{} hash={} parent={}", - recovered.number(), - recovered.hash(), - recovered.parent_hash() - ) - }) - .collect() -} diff --git a/crates/engine/tree/src/tree/mod.rs b/crates/engine/tree/src/tree/mod.rs index dffc7989ea9..f3a16246c5c 100644 --- a/crates/engine/tree/src/tree/mod.rs +++ b/crates/engine/tree/src/tree/mod.rs @@ -1412,13 +1412,15 @@ where let last_block = plan.last_block().expect("checked non-empty persisting blocks"); - debug!( - target: "engine::tree", - count = plan.blocks.len(), - steps = ?plan.steps, - blocks = ?plan.blocks.iter().map(|block| block.recovered_block().num_hash()).collect::>(), - "Persisting blocks" - ); + if tracing::enabled!(target: "engine::tree", tracing::Level::DEBUG) { + debug!( + target: "engine::tree", + count = plan.blocks.len(), + steps = ?plan.steps, + blocks = ?plan.blocks.iter().map(|block| block.recovered_block().num_hash()).collect::>(), + "Persisting blocks" + ); + } let (tx, rx) = crossbeam_channel::bounded(1); let _ = self.persistence.save_blocks(plan, tx); diff --git a/crates/engine/tree/src/tree/payload_validator.rs b/crates/engine/tree/src/tree/payload_validator.rs index b04c3123b63..b1b0abdca67 100644 --- a/crates/engine/tree/src/tree/payload_validator.rs +++ b/crates/engine/tree/src/tree/payload_validator.rs @@ -2057,19 +2057,9 @@ where state: &EngineApiTreeState, ) -> Option { let (lazy_overlay, anchor_hash) = Self::get_parent_lazy_overlay(parent_hash, state); - let lazy_anchor = lazy_overlay.as_ref().and_then(LazyOverlay::anchor_hash); - let lazy_blocks = lazy_overlay.as_ref().map(LazyOverlay::block_summaries); - let span = debug_span!( - target: "engine::tree::payload_validator", - "payload_builder_sparse_trie_overlay", - %parent_hash, - %parent_state_root, - %anchor_hash, - ?lazy_anchor, - ?lazy_blocks, - ); - let _guard = span.enter(); if tracing::enabled!(target: "engine::tree::payload_validator", tracing::Level::DEBUG) { + let lazy_anchor = lazy_overlay.as_ref().and_then(LazyOverlay::anchor_hash); + let lazy_blocks = lazy_overlay.as_ref().map(LazyOverlay::block_summaries); match self.provider.database_provider_ro() { Ok(provider) => match provider.get_stage_checkpoint(StageId::Finish) { Ok(Some(checkpoint)) => { @@ -2086,6 +2076,11 @@ where provider.convert_number(finish_tip_number.into()).ok().flatten(); debug!( target: "engine::tree::payload_validator", + %parent_hash, + %parent_state_root, + %anchor_hash, + ?lazy_anchor, + ?lazy_blocks, partial_state_trie_number, ?partial_state_trie_hash, finish_tip_number, @@ -2096,12 +2091,22 @@ where Ok(None) => { debug!( target: "engine::tree::payload_validator", + %parent_hash, + %parent_state_root, + %anchor_hash, + ?lazy_anchor, + ?lazy_blocks, "Preparing payload builder sparse trie overlay without finish checkpoint" ); } Err(err) => { debug!( target: "engine::tree::payload_validator", + %parent_hash, + %parent_state_root, + %anchor_hash, + ?lazy_anchor, + ?lazy_blocks, %err, "Preparing payload builder sparse trie overlay without database frontiers" ); @@ -2110,6 +2115,11 @@ where Err(err) => { debug!( target: "engine::tree::payload_validator", + %parent_hash, + %parent_state_root, + %anchor_hash, + ?lazy_anchor, + ?lazy_blocks, %err, "Preparing payload builder sparse trie overlay without database frontiers" ); diff --git a/crates/ethereum/payload/src/lib.rs b/crates/ethereum/payload/src/lib.rs index 82dc6061dcf..2d6c2e2d022 100644 --- a/crates/ethereum/payload/src/lib.rs +++ b/crates/ethereum/payload/src/lib.rs @@ -169,16 +169,6 @@ where let PayloadConfig { parent_header, attributes, payload_id } = config; let mut state_provider = client.state_by_block_hash(parent_header.hash())?; - debug!( - target: "payload_builder", - id = %payload_id, - parent_hash = %parent_header.hash(), - parent_number = parent_header.number, - parent_state_root = %parent_header.state_root, - has_execution_cache = execution_cache.is_some(), - has_sparse_trie_handle = trie_handle.is_some(), - "Created payload builder parent state provider" - ); if let Some(execution_cache) = execution_cache { state_provider = Box::new(CachedStateProvider::new( state_provider, @@ -231,22 +221,7 @@ where // If we have a sparse trie handle, wire a state hook that streams per-tx state diffs // to the background trie pipeline for incremental state root computation. if let Some(ref handle) = trie_handle { - debug!( - target: "payload_builder", - id = %payload_id, - parent_hash = %parent_header.hash(), - parent_number = parent_header.number, - "Using shared sparse trie handle for payload builder state root" - ); builder.executor_mut().set_state_hook(Some(Box::new(handle.state_hook()))); - } else { - debug!( - target: "payload_builder", - id = %payload_id, - parent_hash = %parent_header.hash(), - parent_number = parent_header.number, - "Payload builder will compute state root through its state provider" - ); } builder.apply_pre_execution_changes().map_err(|err| { diff --git a/crates/rpc/rpc-api/src/lib.rs b/crates/rpc/rpc-api/src/lib.rs index a1a91cee7dc..43dfc065e28 100644 --- a/crates/rpc/rpc-api/src/lib.rs +++ b/crates/rpc/rpc-api/src/lib.rs @@ -32,9 +32,7 @@ mod txpool; mod validation; mod web3; -pub use testing::{ - TestingBuildBlockRequestV1, TestingBuildBlockResponseV1, TESTING_BUILD_BLOCK_V1, -}; +pub use testing::{TestingBuildBlockRequestV1, TESTING_BUILD_BLOCK_V1}; /// re-export of all server traits pub use servers::*; diff --git a/crates/rpc/rpc-api/src/testing.rs b/crates/rpc/rpc-api/src/testing.rs index c6bf12ba93c..e7dbeb853d4 100644 --- a/crates/rpc/rpc-api/src/testing.rs +++ b/crates/rpc/rpc-api/src/testing.rs @@ -5,24 +5,11 @@ //! disabled by default and never be exposed on public-facing RPC without an //! explicit operator flag. -use alloy_eips::eip7928::BlockAccessList; use alloy_rpc_types_engine::ExecutionPayloadEnvelopeV5; use jsonrpsee::proc_macros::rpc; -use serde::{Deserialize, Serialize}; pub use alloy_rpc_types_engine::{TestingBuildBlockRequestV1, TESTING_BUILD_BLOCK_V1}; -/// Temporary diagnostic response for `testing_buildBlockV1` that includes the BAL built while -/// executing the block. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TestingBuildBlockResponseV1 { - /// The execution payload envelope produced by the testing builder. - pub execution_payload_envelope: ExecutionPayloadEnvelopeV5, - /// The diagnostic block access list built while executing this payload. - pub block_access_list: Option, -} - /// Testing RPC interface for building a block in a single call. /// /// # Enabling @@ -46,5 +33,5 @@ pub trait TestingApi { async fn build_block_v1( &self, request: TestingBuildBlockRequestV1, - ) -> jsonrpsee::core::RpcResult; + ) -> jsonrpsee::core::RpcResult; } diff --git a/crates/rpc/rpc-eth-api/src/helpers/call.rs b/crates/rpc/rpc-eth-api/src/helpers/call.rs index 9088060e596..2ab5bf2d074 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/call.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/call.rs @@ -44,7 +44,7 @@ use revm::{ Database, DatabaseCommit, }; use revm_inspectors::{access_list::AccessListInspector, transfer::TransferInspector}; -use tracing::{debug, trace, warn}; +use tracing::{trace, warn}; /// Result type for `eth_simulateV1` RPC method. pub type SimulatedBlocksResult = Result>>, E>; @@ -665,9 +665,7 @@ pub trait Call: { let at = at.into(); self.spawn_blocking_io_fut(async move |this| { - debug!(target: "rpc::eth::call", ?at, "Resolving state provider for block"); let state = this.state_at_block_id(at).await?; - debug!(target: "rpc::eth::call", ?at, "Resolved state provider for block"); let db = State::builder() .with_database(StateProviderDatabase::new(StateProviderTraitObjWrapper(state))) .build(); diff --git a/crates/rpc/rpc-eth-api/src/helpers/state.rs b/crates/rpc/rpc-eth-api/src/helpers/state.rs index c35597f8af9..322e070458c 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/state.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/state.rs @@ -23,7 +23,6 @@ use reth_storage_api::{ }; use reth_transaction_pool::TransactionPool; use std::collections::HashMap; -use tracing::debug; /// Helper methods for `eth_` methods relating to state (accounts). pub trait EthState: LoadState + SpawnBlocking { @@ -280,14 +279,10 @@ pub trait LoadState: if at.is_pending() && let Ok(Some(state)) = self.local_pending_state().await { - debug!(target: "rpc::eth::state", ?at, "Using local pending state provider"); return Ok(state) } - debug!(target: "rpc::eth::state", ?at, "Loading state provider by block id"); - let state = self.provider().state_by_block_id(at).map_err(Self::Error::from_eth_err)?; - debug!(target: "rpc::eth::state", ?at, "Loaded state provider by block id"); - Ok(state) + self.provider().state_by_block_id(at).map_err(Self::Error::from_eth_err) } } diff --git a/crates/rpc/rpc/src/testing.rs b/crates/rpc/rpc/src/testing.rs index 4ac75d7a6a2..e7d2e45826c 100644 --- a/crates/rpc/rpc/src/testing.rs +++ b/crates/rpc/rpc/src/testing.rs @@ -15,10 +15,11 @@ //! on public-facing RPC endpoints without proper authentication. use alloy_consensus::{Header, Transaction}; -use alloy_eips::{eip2718::Decodable2718, eip7928::total_bal_items}; +use alloy_eips::eip2718::Decodable2718; use alloy_evm::{Evm, RecoveredTx}; use alloy_primitives::{map::HashSet, Address, U256}; use alloy_rlp::Encodable; +use alloy_rpc_types_engine::ExecutionPayloadEnvelopeV5; use async_trait::async_trait; use jsonrpsee::core::RpcResult; use reth_chainspec::{ChainSpecProvider, EthereumHardforks}; @@ -32,7 +33,7 @@ use reth_primitives_traits::{ AlloyBlockHeader as BlockTrait, TxTy, }; use reth_revm::{database::StateProviderDatabase, db::State}; -use reth_rpc_api::{TestingApiServer, TestingBuildBlockRequestV1, TestingBuildBlockResponseV1}; +use reth_rpc_api::{TestingApiServer, TestingBuildBlockRequestV1}; use reth_rpc_eth_api::{helpers::Call, FromEthApiError}; use reth_rpc_eth_types::EthApiError; use reth_storage_api::{BlockReader, HeaderProvider}; @@ -85,44 +86,27 @@ where async fn build_block_v1( &self, request: TestingBuildBlockRequestV1, - ) -> Result { + ) -> Result { let evm_config = self.evm_config.clone(); let skip_invalid_transactions = self.skip_invalid_transactions; let gas_limit_override = self.gas_limit_override; - debug!( - target: "rpc::testing", - parent_block_hash = %request.parent_block_hash, - transaction_count = request.transactions.len(), - timestamp = request.payload_attributes.timestamp, - ?gas_limit_override, - "Starting testing_buildBlockV1" - ); self.eth_api .spawn_with_state_at_block(request.parent_block_hash, move |eth_api, state| { let state = state.database.0; + let mut db = State::builder() + .with_bundle_update() + .with_database(StateProviderDatabase::new(&state)) + .build(); let parent = eth_api .provider() .sealed_header_by_hash(request.parent_block_hash)? .ok_or_else(|| { EthApiError::HeaderNotFound(request.parent_block_hash.into()) })?; - debug!( - target: "rpc::testing", - parent_block_hash = %request.parent_block_hash, - parent_number = parent.number(), - parent_state_root = %parent.state_root(), - transaction_count = request.transactions.len(), - "Resolved testing_buildBlockV1 parent and state provider" - ); let chain_spec = eth_api.provider().chain_spec(); let is_osaka = chain_spec.is_osaka_active_at_timestamp(request.payload_attributes.timestamp); - let mut db = State::builder() - .with_bundle_update() - .with_database(StateProviderDatabase::new(&state)) - .with_bal_builder() - .build(); let withdrawals = request.payload_attributes.withdrawals.clone(); let withdrawals_rlp_length = withdrawals.as_ref().map(|w| w.length()).unwrap_or(0); @@ -143,7 +127,6 @@ where .map_err(RethError::other) .map_err(Eth::Error::from_eth_err)?; builder.apply_pre_execution_changes().map_err(Eth::Error::from_eth_err)?; - builder.evm_mut().db_mut().bump_bal_index(); let mut total_fees = U256::ZERO; let base_fee = builder.evm_mut().block().basefee(); @@ -219,49 +202,21 @@ where return Err(Eth::Error::from_eth_err(err)); } }; - builder.evm_mut().db_mut().bump_bal_index(); block_transactions_rlp_length += tx_rlp_len; total_fees += U256::from(tip) * U256::from(gas_used); } - debug!( - target: "rpc::testing", - parent_block_hash = %request.parent_block_hash, - parent_number = parent.number(), - total_fees = %total_fees, - "Finishing testing_buildBlockV1 with state provider root" - ); let outcome = builder.finish(&state, None).map_err(Eth::Error::from_eth_err)?; - let block_access_list = outcome.block_access_list; let has_requests = outcome.block.requests_hash().is_some(); let sealed_block = Arc::new(outcome.block.into_sealed_block()); - debug!( - target: "rpc::testing", - parent_block_hash = %request.parent_block_hash, - built_block_hash = %sealed_block.hash(), - built_block_number = sealed_block.number(), - built_state_root = %sealed_block.state_root(), - "Finished testing_buildBlockV1" - ); let requests = has_requests.then_some(outcome.execution_result.requests); - let execution_payload_envelope = EthBuiltPayload::new(sealed_block, total_fees, requests, None) + EthBuiltPayload::new(sealed_block, total_fees, requests, None) .try_into_v5() .map_err(RethError::other) - .map_err(Eth::Error::from_eth_err)?; - - debug!( - target: "rpc::testing", - parent_block_hash = %request.parent_block_hash, - has_block_access_list = block_access_list.is_some(), - block_access_list_accounts = block_access_list.as_ref().map(|bal| bal.len()), - block_access_list_items = block_access_list.as_ref().map(|bal| total_bal_items(bal)), - "Returning testing_buildBlockV1 payload with diagnostic BAL" - ); - - Ok(TestingBuildBlockResponseV1 { execution_payload_envelope, block_access_list }) + .map_err(Eth::Error::from_eth_err) }) .await } @@ -281,7 +236,7 @@ where async fn build_block_v1( &self, request: TestingBuildBlockRequestV1, - ) -> RpcResult { + ) -> RpcResult { self.build_block_v1(request).await.map_err(Into::into) } } diff --git a/crates/storage/provider/src/providers/consistent.rs b/crates/storage/provider/src/providers/consistent.rs index f7aee11b0ef..9402a8734e6 100644 --- a/crates/storage/provider/src/providers/consistent.rs +++ b/crates/storage/provider/src/providers/consistent.rs @@ -18,9 +18,7 @@ use reth_chainspec::ChainInfo; use reth_db_api::models::{AccountBeforeTx, BlockNumberAddress, StoredBlockBodyIndices}; use reth_execution_types::ExecutionOutcome; use reth_node_types::{BlockTy, HeaderTy, ReceiptTy, TxTy}; -use reth_primitives_traits::{ - Account, BlockBody, NodePrimitives, RecoveredBlock, SealedHeader, StorageEntry, -}; +use reth_primitives_traits::{Account, BlockBody, RecoveredBlock, SealedHeader, StorageEntry}; use reth_prune_types::{PruneCheckpoint, PruneSegment}; use reth_stages_types::{StageCheckpoint, StageId}; use reth_static_file_types::StaticFileSegment; @@ -34,7 +32,7 @@ use std::{ ops::{Add, Bound, RangeBounds, RangeInclusive, Sub}, sync::Arc, }; -use tracing::{debug, trace}; +use tracing::trace; /// Type that interacts with a snapshot view of the blockchain (storage and in-memory) at time of /// instantiation, EXCEPT for pending, safe and finalized block which might change while holding @@ -117,26 +115,12 @@ impl ConsistentProvider { &'a self, block_hash: BlockHash, ) -> ProviderResult> { - debug!(target: "providers::blockchain", %block_hash, "Resolving borrowed historical state provider by block hash"); + trace!(target: "providers::blockchain", ?block_hash, "Getting history by block hash"); self.get_in_memory_or_storage_by_block( block_hash.into(), - |_| { - debug!(target: "providers::blockchain", %block_hash, "Borrowed historical state provider falling back to database"); - self.storage_provider.history_by_block_hash(block_hash) - }, + |_| self.storage_provider.history_by_block_hash(block_hash), |block_state| { - let anchor = block_state.anchor(); - debug!( - target: "providers::blockchain", - %block_hash, - block_number = block_state.number(), - block_hash = %block_state.hash(), - anchor_number = anchor.number, - anchor_hash = %anchor.hash, - in_memory_blocks = ?block_state_summaries(block_state), - "Borrowed historical state provider using in-memory overlay" - ); let state_provider = self.block_state_provider_ref(block_state)?; Ok(Box::new(state_provider)) }, @@ -261,19 +245,9 @@ impl ConsistentProvider { &self, state: &BlockState, ) -> ProviderResult> { - let anchor = state.anchor(); - let anchor_hash = anchor.hash; + let anchor_hash = state.anchor().hash; let latest_historical = self.history_by_block_hash_ref(anchor_hash)?; let in_memory = state.chain().map(|block_state| block_state.block()).collect(); - debug!( - target: "providers::blockchain", - block_number = state.number(), - block_hash = %state.hash(), - anchor_number = anchor.number, - anchor_hash = %anchor.hash, - in_memory_blocks = ?block_state_summaries(state), - "Creating borrowed memory overlay state provider from block state" - ); Ok(MemoryOverlayStateProviderRef::new(latest_historical, in_memory)) } @@ -474,55 +448,22 @@ impl ConsistentProvider { let block_number = self.block_number(block_hash)?.ok_or(ProviderError::BlockHashNotFound(block_hash))?; self.ensure_canonical_block(block_number)?; - debug!( - target: "providers::blockchain", - %block_hash, - block_number, - "Resolving owned state provider at block hash" - ); let Self { storage_provider, head_block, .. } = self; if let Some(Some(block_state)) = head_block.as_ref().map(|b| b.block_on_chain(block_hash.into())) { - let anchor = block_state.anchor(); - let anchor_hash = anchor.hash; + let anchor_hash = block_state.anchor().hash; let block_number = storage_provider .block_number(anchor_hash)? .ok_or(ProviderError::BlockHashNotFound(anchor_hash))?; - debug!( - target: "providers::blockchain", - requested_block_hash = %block_hash, - requested_block_number = block_state.number(), - anchor_number = anchor.number, - anchor_hash = %anchor.hash, - historical_block_number = block_number, - in_memory_blocks = ?block_state_summaries(block_state), - "Owned state provider using in-memory overlay" - ); let latest_historical = storage_provider.try_into_history_at_block(block_number)?; return Ok(Box::new(block_state.state_provider(latest_historical))); } - debug!( - target: "providers::blockchain", - %block_hash, - block_number, - "Owned state provider falling back to database historical provider" - ); storage_provider.try_into_history_at_block(block_number) } } -fn block_state_summaries(state: &BlockState) -> Vec { - state - .chain() - .map(|block_state| { - let block = block_state.block_ref().recovered_block(); - format!("#{} hash={} parent={}", block.number(), block.hash(), block.parent_hash()) - }) - .collect() -} - impl ConsistentProvider { /// Ensures that the given block number is canonical (synced) /// diff --git a/crates/storage/provider/src/providers/state/historical.rs b/crates/storage/provider/src/providers/state/historical.rs index 33792c4a229..7999c8795da 100644 --- a/crates/storage/provider/src/providers/state/historical.rs +++ b/crates/storage/provider/src/providers/state/historical.rs @@ -13,7 +13,6 @@ use reth_db_api::{ BlockNumberList, }; use reth_primitives_traits::{Account, Bytecode, NodePrimitives}; -use reth_stages_types::StageId; use reth_storage_api::{ BlockNumReader, BytecodeReader, DBProvider, NodePrimitivesProvider, PruneCheckpointReader, StageCheckpointReader, StateProofProvider, StorageChangeSetReader, StorageRootProvider, @@ -35,7 +34,6 @@ use reth_trie_db::{ }; use std::{fmt::Debug, marker::PhantomData, sync::Arc}; -use tracing::debug; type DbStateRoot<'a, TX, A> = StateRoot< reth_trie_db::DatabaseTrieCursorFactory<&'a TX, A>, @@ -310,79 +308,12 @@ where .block_hash(target_block)? .ok_or_else(|| ProviderError::HeaderNotFound(target_block.into()))?; - match self.provider.get_stage_checkpoint(StageId::Finish) { - Ok(Some(checkpoint)) => { - let finish_tip_number = checkpoint.block_number; - let partial_state_trie_number = checkpoint - .finish_stage_checkpoint() - .and_then(|finish| finish.partial_state_trie) - .unwrap_or(finish_tip_number); - let finish_tip_hash = self.provider.block_hash(finish_tip_number)?; - let partial_state_trie_hash = - self.provider.block_hash(partial_state_trie_number)?; - debug!( - target: "providers::historical_sp", - historical_block_number = self.block_number, - target_block, - %anchor_hash, - finish_tip_number, - ?finish_tip_hash, - partial_state_trie_number, - ?partial_state_trie_hash, - "Historical state provider overlay frontiers" - ); - } - Ok(None) => { - debug!( - target: "providers::historical_sp", - historical_block_number = self.block_number, - target_block, - %anchor_hash, - "Historical state provider overlay without finish checkpoint" - ); - } - Err(err) => { - debug!( - target: "providers::historical_sp", - historical_block_number = self.block_number, - target_block, - %anchor_hash, - %err, - "Historical state provider overlay could not load finish checkpoint" - ); - } - } - let TrieInputSorted { nodes, state, prefix_sets } = input; - let input_trie_updates = nodes.total_len(); - let input_hashed_state = state.total_len(); - debug!( - target: "providers::historical_sp", - historical_block_number = self.block_number, - target_block, - %anchor_hash, - input_trie_updates, - input_hashed_state, - prefix_account_updates = prefix_sets.account_prefix_set.len(), - prefix_storage_tries = prefix_sets.storage_prefix_sets.len(), - prefix_destroyed_accounts = prefix_sets.destroyed_accounts.len(), - "Building historical state provider overlay" - ); let overlay_builder = OverlayBuilder::::new(anchor_hash, self.changeset_cache.clone()) .with_overlay_source(Some(OverlaySource::Immediate { trie: nodes, state })); let Overlay { trie_updates, hashed_post_state } = overlay_builder.build_overlay(self.provider)?; - debug!( - target: "providers::historical_sp", - historical_block_number = self.block_number, - target_block, - %anchor_hash, - output_trie_updates = trie_updates.total_len(), - output_hashed_state = hashed_post_state.total_len(), - "Built historical state provider overlay" - ); - Ok(TrieInputSorted::new(trie_updates, hashed_post_state, prefix_sets)) } diff --git a/crates/storage/provider/src/providers/state/overlay.rs b/crates/storage/provider/src/providers/state/overlay.rs index 328035b240b..ff319a13cbd 100644 --- a/crates/storage/provider/src/providers/state/overlay.rs +++ b/crates/storage/provider/src/providers/state/overlay.rs @@ -123,7 +123,7 @@ impl OverlayBuilder { anchor_hash = ?self.anchor_hash, source = overlay_source_kind(source.as_ref()), source_anchor = ?source.as_ref().and_then(overlay_source_anchor), - source_blocks = ?source.as_ref().and_then(overlay_source_blocks), + source_blocks = ?source.as_ref().and_then(overlay_source_num_blocks), "Configuring overlay source" ); self.overlay_source = source; @@ -151,7 +151,7 @@ impl OverlayBuilder { target: "providers::state::overlay", anchor_hash = ?self.anchor_hash, lazy_anchor = ?lazy_overlay.as_ref().and_then(LazyOverlay::anchor_hash), - lazy_blocks = ?lazy_overlay.as_ref().map(LazyOverlay::block_summaries), + lazy_blocks = ?lazy_overlay.as_ref().map(LazyOverlay::num_blocks), "Configuring lazy overlay" ); self.overlay_source = lazy_overlay.map(OverlaySource::Lazy); @@ -164,12 +164,14 @@ impl OverlayBuilder { hashed_state_overlay: Option>, ) -> Self { if let Some(state) = hashed_state_overlay { - debug!( - target: "providers::state::overlay", - anchor_hash = ?self.anchor_hash, - hashed_state_updates = state.total_len(), - "Configuring immediate hashed-state overlay" - ); + if tracing::enabled!(target: "providers::state::overlay", tracing::Level::DEBUG) { + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.anchor_hash, + hashed_state_updates = state.total_len(), + "Configuring immediate hashed-state overlay" + ); + } self.overlay_source = Some(OverlaySource::Immediate { trie: Arc::new(TrieUpdatesSorted::default()), state, @@ -189,14 +191,15 @@ impl OverlayBuilder { /// If no overlay exists, creates a new immediate overlay with the given state. /// If a lazy overlay exists, it is resolved first then extended. pub fn with_extended_hashed_state_overlay(mut self, other: HashedPostStateSorted) -> Self { - let other_len = other.total_len(); - debug!( - target: "providers::state::overlay", - anchor_hash = ?self.anchor_hash, - existing_source = overlay_source_kind(self.overlay_source.as_ref()), - added_hashed_state_updates = other_len, - "Extending hashed-state overlay" - ); + if tracing::enabled!(target: "providers::state::overlay", tracing::Level::DEBUG) { + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.anchor_hash, + existing_source = overlay_source_kind(self.overlay_source.as_ref()), + added_hashed_state_updates = other.total_len(), + "Extending hashed-state overlay" + ); + } match &mut self.overlay_source { Some(OverlaySource::Immediate { state, .. }) => { Arc::make_mut(state).extend_ref_and_sort(&other); @@ -241,17 +244,19 @@ impl OverlayBuilder { } }; - debug!( - target: "providers::state::overlay", - requested_anchor_hash = ?anchor_hash, - builder_anchor_hash = ?self.anchor_hash, - source = overlay_source_kind(self.overlay_source.as_ref()), - source_anchor = ?self.overlay_source.as_ref().and_then(overlay_source_anchor), - source_blocks = ?self.overlay_source.as_ref().and_then(overlay_source_blocks), - resolved_trie_updates = result.0.total_len(), - resolved_hashed_state = result.1.total_len(), - "Resolved overlay source" - ); + if tracing::enabled!(target: "providers::state::overlay", tracing::Level::DEBUG) { + debug!( + target: "providers::state::overlay", + requested_anchor_hash = ?anchor_hash, + builder_anchor_hash = ?self.anchor_hash, + source = overlay_source_kind(self.overlay_source.as_ref()), + source_anchor = ?self.overlay_source.as_ref().and_then(overlay_source_anchor), + source_blocks = ?self.overlay_source.as_ref().and_then(overlay_source_num_blocks), + resolved_trie_updates = result.0.total_len(), + resolved_hashed_state = result.1.total_len(), + "Resolved overlay source" + ); + } Ok(result) } @@ -362,7 +367,7 @@ impl OverlayBuilder { overlay_anchor_hash = ?state_trie_tip_block.hash, source = overlay_source_kind(self.overlay_source.as_ref()), source_anchor = ?self.overlay_source.as_ref().and_then(overlay_source_anchor), - source_blocks = ?self.overlay_source.as_ref().and_then(overlay_source_blocks), + source_blocks = ?self.overlay_source.as_ref().and_then(overlay_source_num_blocks), "Lazy overlay covers partial state trie frontier; no reverts required" ); return Ok(OverlayRevertPlan { @@ -475,7 +480,7 @@ impl OverlayBuilder { overlay_anchor_hash = ?overlay_anchor_hash, source = overlay_source_kind(self.overlay_source.as_ref()), source_anchor = ?self.overlay_source.as_ref().and_then(overlay_source_anchor), - source_blocks = ?self.overlay_source.as_ref().and_then(overlay_source_blocks), + source_blocks = ?self.overlay_source.as_ref().and_then(overlay_source_num_blocks), "Collecting trie reverts for overlay state provider" ); @@ -596,7 +601,7 @@ impl OverlayBuilder { ?finish_tip_block, source = overlay_source_kind(self.overlay_source.as_ref()), source_anchor = ?self.overlay_source.as_ref().and_then(overlay_source_anchor), - source_blocks = ?self.overlay_source.as_ref().and_then(overlay_source_blocks), + source_blocks = ?self.overlay_source.as_ref().and_then(overlay_source_num_blocks), "Building overlay" ); self.calculate_overlay(provider, state_trie_tip_block, finish_tip_block) @@ -618,10 +623,10 @@ fn overlay_source_anchor(source: &OverlaySource) -> Option } } -fn overlay_source_blocks(source: &OverlaySource) -> Option> { +fn overlay_source_num_blocks(source: &OverlaySource) -> Option { match source { OverlaySource::Immediate { .. } => None, - OverlaySource::Lazy(lazy) => Some(lazy.block_summaries()), + OverlaySource::Lazy(lazy) => Some(lazy.num_blocks()), } } @@ -712,7 +717,7 @@ impl OverlayStateProviderFactory { ?finish_tip_block, source = overlay_source_kind(self.overlay_builder.overlay_source.as_ref()), source_anchor = ?self.overlay_builder.overlay_source.as_ref().and_then(overlay_source_anchor), - source_blocks = ?self.overlay_builder.overlay_source.as_ref().and_then(overlay_source_blocks), + source_blocks = ?self.overlay_builder.overlay_source.as_ref().and_then(overlay_source_num_blocks), "Overlay cache miss" ); let overlay = self.overlay_builder.build_overlay(provider)?; @@ -755,14 +760,16 @@ where let Overlay { trie_updates, hashed_post_state } = self.get_overlay(&provider)?; let is_v2 = provider.cached_storage_settings().is_v2(); - debug!( - target: "providers::state::overlay", - anchor_hash = ?self.overlay_builder.anchor_hash, - trie_updates = trie_updates.total_len(), - hashed_state = hashed_post_state.total_len(), - is_v2, - "Created overlay state provider" - ); + if tracing::enabled!(target: "providers::state::overlay", tracing::Level::DEBUG) { + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.overlay_builder.anchor_hash, + trie_updates = trie_updates.total_len(), + hashed_state = hashed_post_state.total_len(), + is_v2, + "Created overlay state provider" + ); + } self.overlay_builder.metrics.database_provider_ro_duration.record(overall_start.elapsed()); Ok(OverlayStateProvider::new(provider, trie_updates, hashed_post_state, is_v2)) } diff --git a/examples/db-access/Cargo.toml b/examples/db-access/Cargo.toml index 422411345a3..07687e5c465 100644 --- a/examples/db-access/Cargo.toml +++ b/examples/db-access/Cargo.toml @@ -7,12 +7,6 @@ license.workspace = true [dependencies] reth-ethereum = { workspace = true, features = ["node"] } -reth-chainspec.workspace = true -reth-db-api.workspace = true -reth-storage-api.workspace = true -reth-trie.workspace = true -reth-trie-db.workspace = true alloy-primitives.workspace = true -clap = { workspace = true, features = ["derive"] } eyre.workspace = true diff --git a/examples/db-access/src/bin/compare_merkle_trace_to_db.rs b/examples/db-access/src/bin/compare_merkle_trace_to_db.rs deleted file mode 100644 index 2417d4ea132..00000000000 --- a/examples/db-access/src/bin/compare_merkle_trace_to_db.rs +++ /dev/null @@ -1,940 +0,0 @@ -#![warn(unused_crate_dependencies)] - -use alloy_primitives::{B256, U256}; -use clap::{Parser, ValueEnum}; -use eyre::{bail, eyre, Context, Result}; -use reth_chainspec::{ChainSpec, DEV, HOLESKY, HOODI, MAINNET, SEPOLIA}; -use reth_db_api::{ - cursor::{DbCursorRO, DbDupCursorRO}, - tables, - transaction::DbTx, -}; -use reth_ethereum::{node::EthereumNode, provider::providers::ReadOnlyConfig, tasks::Runtime}; -use reth_storage_api::{DBProvider, StorageSettingsCache}; -use reth_trie::{ - trie_cursor::{TrieCursor, TrieCursorFactory}, - Nibbles, StorageRoot, -}; -use reth_trie_db::{ - DatabaseHashedCursorFactory, DatabaseStorageRoot, DatabaseTrieCursorFactory, TrieTableAdapter, -}; -use std::{ - collections::{BTreeMap, HashMap}, - fs::File, - io::{BufRead, BufReader}, - path::{Path, PathBuf}, - sync::Arc, -}; - -#[derive(Debug, Clone, Copy, ValueEnum)] -enum ChainArg { - Mainnet, - Sepolia, - Holesky, - Hoodi, - Dev, -} - -impl ChainArg { - const fn as_str(self) -> &'static str { - match self { - Self::Mainnet => "mainnet", - Self::Sepolia => "sepolia", - Self::Holesky => "holesky", - Self::Hoodi => "hoodi", - Self::Dev => "dev", - } - } - - fn chain_spec(self) -> Arc { - match self { - Self::Mainnet => MAINNET.clone(), - Self::Sepolia => SEPOLIA.clone(), - Self::Holesky => HOLESKY.clone(), - Self::Hoodi => HOODI.clone(), - Self::Dev => DEV.clone(), - } - } -} - -#[derive(Debug, Parser)] -#[command( - about = "Compare a failed Merkle unwind trace against DB-backed trie/account/storage state." -)] -struct Args { - /// Path to the failed restart trace log. - trace_file: PathBuf, - - /// Reth datadir to treat as DB ground truth. - datadir: PathBuf, - - /// Chain spec used to open the datadir. - #[arg(long, value_enum, default_value_t = ChainArg::Hoodi)] - chain: ChainArg, - - /// Cap the number of reported results per section. - #[arg(long)] - max_results: Option, -} - -#[derive(Debug, Clone)] -struct ObservedStateBranch { - path: Nibbles, - observed_hash: B256, - children_are_in_trie: bool, - first_line: usize, - occurrences: usize, -} - -#[derive(Debug, Clone)] -struct ObservedAccountLeaf { - hashed_address: B256, - nonce: u64, - balance: U256, - bytecode_hash: Option, - first_line: usize, - occurrences: usize, -} - -#[derive(Debug, Clone)] -struct ObservedStorageBranch { - hashed_address: B256, - path: Nibbles, - observed_hash: B256, - children_are_in_trie: bool, - first_line: usize, - occurrences: usize, -} - -#[derive(Debug, Clone)] -struct ObservedStorageLeaf { - hashed_address: B256, - hashed_slot: B256, - value: U256, - first_line: usize, - occurrences: usize, -} - -#[derive(Debug, Clone)] -struct ObservedStorageRoot { - hashed_address: B256, - root: B256, - first_line: usize, - occurrences: usize, -} - -#[derive(Debug, Default)] -struct TraceData { - state_branches: Vec, - account_leaves: Vec, - storage_branches: Vec, - storage_leaves: Vec, - storage_roots: Vec, -} - -#[derive(Debug, Clone)] -struct BranchCandidate { - location: String, - expected_hash: Option, - children_are_in_trie: bool, -} - -impl BranchCandidate { - fn detail(&self) -> String { - format!( - "db_candidate={} expected_hash={} expected_children_are_in_trie={}", - self.location, - option_b256(self.expected_hash), - self.children_are_in_trie - ) - } -} - -#[derive(Debug, Default)] -struct BranchLookup { - candidates: Vec, - notes: Vec, -} - -impl BranchLookup { - fn matches(&self, observed_hash: B256, children_are_in_trie: bool) -> bool { - self.candidates.iter().any(|candidate| { - candidate.expected_hash == Some(observed_hash) && - candidate.children_are_in_trie == children_are_in_trie - }) - } - - fn details(&self) -> Vec { - let mut details = Vec::new(); - if self.candidates.is_empty() { - details.push("db_candidates=none".to_string()); - } else { - details.extend(self.candidates.iter().map(BranchCandidate::detail)); - } - details.extend(self.notes.iter().cloned()); - details - } -} - -#[derive(Debug, Clone)] -struct Mismatch { - context: MismatchContext, - path: Nibbles, - first_line: usize, - kind_rank: u8, - headline: String, - details: Vec, - suppressed_descendants: usize, -} - -#[derive(Debug, Clone)] -struct Diagnostic { - context: MismatchContext, - first_line: usize, - headline: String, - details: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -enum MismatchContext { - State, - Storage(B256), -} - -impl std::fmt::Display for MismatchContext { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::State => f.write_str("state"), - Self::Storage(hashed_address) => write!(f, "storage:{hashed_address}"), - } - } -} - -#[derive(Debug, Default)] -struct ComparisonResults { - direct_mismatches: Vec, - diagnostics: Vec, -} - -fn main() -> Result<()> { - let args = Args::parse(); - - if !args.trace_file.is_file() { - bail!("trace file does not exist: {}", args.trace_file.display()); - } - if !args.datadir.is_dir() { - bail!("datadir does not exist: {}", args.datadir.display()); - } - - let trace = parse_trace(&args.trace_file)?; - - let runtime = Runtime::test(); - let factory = EthereumNode::provider_factory_builder().open_read_only( - args.chain.chain_spec(), - ReadOnlyConfig::from_datadir(args.datadir.clone()), - runtime, - )?; - let provider = factory.provider()?; - - let results = - reth_trie_db::with_adapter!(provider, |A| compare_with_adapter::<_, A>(&provider, &trace))?; - - let total_direct_mismatches = results.direct_mismatches.len(); - let mut outermost = retain_outermost_mismatches(results.direct_mismatches); - let suppressed_direct_descendants = - outermost.iter().map(|mismatch| mismatch.suppressed_descendants).sum::(); - let diagnostic_count = results.diagnostics.len(); - let max_results = args.max_results.unwrap_or(usize::MAX); - - println!("trace_file={}", args.trace_file.display()); - println!("datadir={}", args.datadir.display()); - println!("chain={}", args.chain.as_str()); - println!( - "trace_observations state_branches={} account_leaves={} storage_branches={} storage_leaves={} storage_roots={}", - trace.state_branches.len(), - trace.account_leaves.len(), - trace.storage_branches.len(), - trace.storage_leaves.len(), - trace.storage_roots.len(), - ); - println!( - "direct_mismatches total={} outermost={} suppressed_descendants={}", - total_direct_mismatches, - outermost.len(), - suppressed_direct_descendants, - ); - println!("reported_direct_mismatches={}", outermost.len().min(max_results)); - println!("storage_root_diagnostics={diagnostic_count}"); - println!("reported_storage_root_diagnostics={}", diagnostic_count.min(max_results)); - - for mismatch in outermost.drain(..).take(max_results) { - println!(); - println!("[{}] {}", mismatch.context, mismatch.headline); - for detail in mismatch.details { - println!(" {detail}"); - } - if mismatch.suppressed_descendants > 0 { - println!(" suppressed_descendants={}", mismatch.suppressed_descendants); - } - } - - for diagnostic in results.diagnostics.into_iter().take(max_results) { - println!(); - println!("[{}] {}", diagnostic.context, diagnostic.headline); - for detail in diagnostic.details { - println!(" {detail}"); - } - } - - Ok(()) -} - -fn compare_with_adapter(provider: &P, trace: &TraceData) -> Result -where - P: DBProvider, - A: TrieTableAdapter, -{ - let tx = provider.tx_ref(); - let trie_factory = DatabaseTrieCursorFactory::<_, A>::new(tx); - let mut state_trie_cursor = trie_factory.account_trie_cursor()?; - let mut hashed_accounts_cursor = tx.cursor_read::()?; - let mut hashed_storage_cursor = tx.cursor_dup_read::()?; - - let mut results = ComparisonResults::default(); - - for observed in &trace.state_branches { - let lookup = lookup_branch(&mut state_trie_cursor, &observed.path)?; - if lookup.matches(observed.observed_hash, observed.children_are_in_trie) { - continue; - } - - let mut details = vec![ - format!("observed_hash={}", observed.observed_hash), - format!("observed_children_are_in_trie={}", observed.children_are_in_trie), - format!("trace_occurrences={}", observed.occurrences), - ]; - details.extend(lookup.details()); - - results.direct_mismatches.push(Mismatch { - context: MismatchContext::State, - path: observed.path, - first_line: observed.first_line, - kind_rank: 0, - headline: format!( - "branch mismatch path={} line={}", - nibbles_hex(&observed.path), - observed.first_line - ), - details, - suppressed_descendants: 0, - }); - } - - for observed in &trace.account_leaves { - match hashed_accounts_cursor.seek_exact(observed.hashed_address)? { - Some((_, account)) => { - let mut diffs = Vec::new(); - if observed.nonce != account.nonce { - diffs.push(format!( - "nonce observed={} expected={}", - observed.nonce, account.nonce - )); - } - if observed.balance != account.balance { - diffs.push(format!( - "balance observed={} expected={}", - observed.balance, account.balance - )); - } - if observed.bytecode_hash != account.bytecode_hash { - diffs.push(format!( - "bytecode_hash observed={} expected={}", - option_b256(observed.bytecode_hash), - option_b256(account.bytecode_hash) - )); - } - if diffs.is_empty() { - continue; - } - - diffs.push(format!("trace_occurrences={}", observed.occurrences)); - results.direct_mismatches.push(Mismatch { - context: MismatchContext::State, - path: Nibbles::unpack(observed.hashed_address), - first_line: observed.first_line, - kind_rank: 1, - headline: format!( - "account leaf mismatch hashed_address={} line={}", - observed.hashed_address, observed.first_line - ), - details: diffs, - suppressed_descendants: 0, - }); - } - None => results.direct_mismatches.push(Mismatch { - context: MismatchContext::State, - path: Nibbles::unpack(observed.hashed_address), - first_line: observed.first_line, - kind_rank: 1, - headline: format!( - "account leaf missing from DB hashed state hashed_address={} line={}", - observed.hashed_address, observed.first_line - ), - details: vec![ - format!("nonce={}", observed.nonce), - format!("balance={}", observed.balance), - format!("bytecode_hash={}", option_b256(observed.bytecode_hash)), - format!("trace_occurrences={}", observed.occurrences), - ], - suppressed_descendants: 0, - }), - } - } - - let mut storage_branches_by_address = BTreeMap::>::new(); - for observed in &trace.storage_branches { - storage_branches_by_address.entry(observed.hashed_address).or_default().push(observed); - } - - for (hashed_address, branches) in storage_branches_by_address { - let mut storage_trie_cursor = trie_factory.storage_trie_cursor(hashed_address)?; - for observed in branches { - let lookup = lookup_branch(&mut storage_trie_cursor, &observed.path)?; - if lookup.matches(observed.observed_hash, observed.children_are_in_trie) { - continue; - } - - let mut details = vec![ - format!("observed_hash={}", observed.observed_hash), - format!("observed_children_are_in_trie={}", observed.children_are_in_trie), - format!("trace_occurrences={}", observed.occurrences), - ]; - details.extend(lookup.details()); - - results.direct_mismatches.push(Mismatch { - context: MismatchContext::Storage(observed.hashed_address), - path: observed.path, - first_line: observed.first_line, - kind_rank: 0, - headline: format!( - "branch mismatch hashed_address={} path={} line={}", - observed.hashed_address, - nibbles_hex(&observed.path), - observed.first_line - ), - details, - suppressed_descendants: 0, - }); - } - } - - for observed in &trace.storage_leaves { - match hashed_storage_cursor.seek_by_key_subkey(observed.hashed_address, observed.hashed_slot)? { - Some(entry) if entry.key == observed.hashed_slot => { - if observed.value == entry.value { - continue; - } - - results.direct_mismatches.push(Mismatch { - context: MismatchContext::Storage(observed.hashed_address), - path: Nibbles::unpack(observed.hashed_slot), - first_line: observed.first_line, - kind_rank: 1, - headline: format!( - "storage leaf mismatch hashed_address={} hashed_slot={} line={}", - observed.hashed_address, observed.hashed_slot, observed.first_line - ), - details: vec![ - format!("observed_value={}", observed.value), - format!("expected_value={}", entry.value), - format!("trace_occurrences={}", observed.occurrences), - ], - suppressed_descendants: 0, - }); - } - _ => results.direct_mismatches.push(Mismatch { - context: MismatchContext::Storage(observed.hashed_address), - path: Nibbles::unpack(observed.hashed_slot), - first_line: observed.first_line, - kind_rank: 1, - headline: format!( - "storage leaf missing from DB hashed state hashed_address={} hashed_slot={} line={}", - observed.hashed_address, observed.hashed_slot, observed.first_line - ), - details: vec![ - format!("observed_value={}", observed.value), - format!("trace_occurrences={}", observed.occurrences), - ], - suppressed_descendants: 0, - }), - } - } - - let mut storage_root_cache = HashMap::::new(); - for observed in &trace.storage_roots { - let expected_root = match storage_root_cache.get(&observed.hashed_address) { - Some(root) => *root, - None => { - let root = storage_root_for_hashed_address::<_, A>(tx, observed.hashed_address)?; - storage_root_cache.insert(observed.hashed_address, root); - root - } - }; - - if observed.root == expected_root { - continue; - } - - results.diagnostics.push(Diagnostic { - context: MismatchContext::Storage(observed.hashed_address), - first_line: observed.first_line, - headline: format!( - "storage root mismatch hashed_address={} line={}", - observed.hashed_address, observed.first_line - ), - details: vec![ - format!("observed_root={}", observed.root), - format!("expected_root={expected_root}"), - format!("trace_occurrences={}", observed.occurrences), - ], - }); - } - - results.diagnostics.sort_by(|left, right| { - left.first_line.cmp(&right.first_line).then(left.context.cmp(&right.context)) - }); - - Ok(results) -} - -fn storage_root_for_hashed_address(tx: &TX, hashed_address: B256) -> Result -where - TX: DbTx, - A: TrieTableAdapter, -{ - as DatabaseStorageRoot<_>>::from_tx_hashed(tx, hashed_address) - .root() - .with_context(|| format!("compute storage root for hashed address {hashed_address}")) -} - -fn lookup_branch( - cursor: &mut C, - path: &Nibbles, -) -> Result -where - C: TrieCursor, -{ - let mut lookup = BranchLookup::default(); - - if let Some((_, node)) = cursor.seek_exact(*path)? { - if let Some(root_hash) = node.root_hash { - lookup.candidates.push(BranchCandidate { - location: format!("parent_root path={}", nibbles_hex(path)), - expected_hash: Some(root_hash), - children_are_in_trie: true, - }); - } else { - lookup.notes.push(format!( - "exact_branch_node_present path={} root_hash=None", - nibbles_hex(path) - )); - } - } - - if !path.is_empty() { - let parent_path = path.slice(..path.len() - 1); - let nibble = path.get_unchecked(path.len() - 1); - match cursor.seek_exact(parent_path)? { - Some((_, node)) => { - if node.state_mask.is_bit_set(nibble) { - lookup.candidates.push(BranchCandidate { - location: format!( - "child parent_path={} nibble={}", - nibbles_hex(&parent_path), - nibble_hex(nibble) - ), - expected_hash: node - .hash_mask - .is_bit_set(nibble) - .then(|| node.hash_for_nibble(nibble)), - children_are_in_trie: node.tree_mask.is_bit_set(nibble), - }); - } else { - lookup.notes.push(format!( - "parent_branch_present path={} missing_state_nibble={}", - nibbles_hex(&parent_path), - nibble_hex(nibble) - )); - } - } - None => lookup - .notes - .push(format!("parent_branch_missing path={}", nibbles_hex(&parent_path))), - } - } - - Ok(lookup) -} - -fn retain_outermost_mismatches(mut mismatches: Vec) -> Vec { - mismatches.sort_by(|a, b| { - a.context - .cmp(&b.context) - .then(a.path.len().cmp(&b.path.len())) - .then(a.kind_rank.cmp(&b.kind_rank)) - .then(a.first_line.cmp(&b.first_line)) - }); - - let mut kept = Vec::::new(); - 'outer: for mismatch in mismatches { - for existing in &mut kept { - if existing.context == mismatch.context && mismatch.path.starts_with(&existing.path) { - existing.suppressed_descendants += 1; - continue 'outer; - } - } - kept.push(mismatch); - } - - kept.sort_by(|a, b| { - a.path - .len() - .cmp(&b.path.len()) - .then(a.kind_rank.cmp(&b.kind_rank)) - .then(a.first_line.cmp(&b.first_line)) - }); - kept -} - -fn parse_trace(path: &Path) -> Result { - let file = File::open(path).with_context(|| format!("open trace file {}", path.display()))?; - let reader = BufReader::new(file); - - let mut state_branches = HashMap::<(Nibbles, B256, bool), ObservedStateBranch>::new(); - let mut account_leaves = HashMap::<(B256, u64, U256, Option), ObservedAccountLeaf>::new(); - let mut storage_branches = HashMap::<(B256, Nibbles, B256, bool), ObservedStorageBranch>::new(); - let mut storage_leaves = HashMap::<(B256, B256, U256), ObservedStorageLeaf>::new(); - let mut storage_roots = HashMap::<(B256, B256), ObservedStorageRoot>::new(); - - for (index, line) in reader.lines().enumerate() { - let line_number = index + 1; - let line = line?; - - if line.contains("trie::storage_root: calculated storage root") { - let root = parse_b256( - extract_after(&line, "calculated storage root root=")?.split(' ').next().unwrap(), - )?; - let hashed_address = - parse_b256(extract_after(&line, "hashed_address=")?.split(' ').next().unwrap())?; - upsert_storage_root(&mut storage_roots, hashed_address, root, line_number); - continue; - } - - if !line.contains("trie::node_iter: return=Ok(Some(") { - continue; - } - - if line.contains("trie_type=State") && line.contains("Branch(TrieBranchNode {") { - let path = parse_trace_nibbles(extract_between(&line, "key: Nibbles(", "), value:")?)?; - let observed_hash = - parse_b256(extract_between(&line, "value: ", ", children_are_in_trie:")?)?; - let children_are_in_trie = - parse_bool(extract_between(&line, "children_are_in_trie: ", " })))")?)?; - upsert_state_branch( - &mut state_branches, - path, - observed_hash, - children_are_in_trie, - line_number, - ); - continue; - } - - if line.contains("trie_type=Storage") && line.contains("Branch(TrieBranchNode {") { - let hashed_address = parse_b256(extract_storage_address(&line)?)?; - let path = parse_trace_nibbles(extract_between(&line, "key: Nibbles(", "), value:")?)?; - let observed_hash = - parse_b256(extract_between(&line, "value: ", ", children_are_in_trie:")?)?; - let children_are_in_trie = - parse_bool(extract_between(&line, "children_are_in_trie: ", " })))")?)?; - upsert_storage_branch( - &mut storage_branches, - hashed_address, - path, - observed_hash, - children_are_in_trie, - line_number, - ); - continue; - } - - if line.contains("trie_type=State") && line.contains("Leaf(") && line.contains("Account {") - { - let hashed_address = parse_b256(extract_between(&line, "Leaf(", ", Account {")?)?; - let nonce = extract_between(&line, "nonce: ", ", balance:")? - .parse::() - .with_context(|| format!("parse nonce on line {line_number}"))?; - let balance = extract_between(&line, "balance: ", ", bytecode_hash:")? - .parse::() - .with_context(|| format!("parse balance on line {line_number}"))?; - let bytecode_hash = - parse_optional_b256(extract_between(&line, "bytecode_hash: ", " })))")?)?; - upsert_account_leaf( - &mut account_leaves, - hashed_address, - nonce, - balance, - bytecode_hash, - line_number, - ); - continue; - } - - if line.contains("trie_type=Storage") && line.contains("Leaf(") { - let hashed_address = parse_b256(extract_storage_address(&line)?)?; - let leaf = extract_after(&line, "Leaf(")?; - let (hashed_slot, value) = leaf - .split_once(", ") - .ok_or_else(|| eyre!("invalid storage leaf on line {line_number}"))?; - let hashed_slot = parse_b256(hashed_slot)?; - let value = value - .split_once(")))") - .map(|(value, _)| value) - .ok_or_else(|| eyre!("invalid storage leaf terminator on line {line_number}"))? - .parse::() - .with_context(|| format!("parse storage value on line {line_number}"))?; - upsert_storage_leaf( - &mut storage_leaves, - hashed_address, - hashed_slot, - value, - line_number, - ); - } - } - - Ok(TraceData { - state_branches: into_sorted(state_branches), - account_leaves: into_sorted(account_leaves), - storage_branches: into_sorted(storage_branches), - storage_leaves: into_sorted(storage_leaves), - storage_roots: into_sorted(storage_roots), - }) -} - -fn upsert_state_branch( - state_branches: &mut HashMap<(Nibbles, B256, bool), ObservedStateBranch>, - path: Nibbles, - observed_hash: B256, - children_are_in_trie: bool, - line_number: usize, -) { - state_branches - .entry((path, observed_hash, children_are_in_trie)) - .and_modify(|entry| entry.occurrences += 1) - .or_insert(ObservedStateBranch { - path, - observed_hash, - children_are_in_trie, - first_line: line_number, - occurrences: 1, - }); -} - -fn upsert_account_leaf( - account_leaves: &mut HashMap<(B256, u64, U256, Option), ObservedAccountLeaf>, - hashed_address: B256, - nonce: u64, - balance: U256, - bytecode_hash: Option, - line_number: usize, -) { - account_leaves - .entry((hashed_address, nonce, balance, bytecode_hash)) - .and_modify(|entry| entry.occurrences += 1) - .or_insert(ObservedAccountLeaf { - hashed_address, - nonce, - balance, - bytecode_hash, - first_line: line_number, - occurrences: 1, - }); -} - -fn upsert_storage_branch( - storage_branches: &mut HashMap<(B256, Nibbles, B256, bool), ObservedStorageBranch>, - hashed_address: B256, - path: Nibbles, - observed_hash: B256, - children_are_in_trie: bool, - line_number: usize, -) { - storage_branches - .entry((hashed_address, path, observed_hash, children_are_in_trie)) - .and_modify(|entry| entry.occurrences += 1) - .or_insert(ObservedStorageBranch { - hashed_address, - path, - observed_hash, - children_are_in_trie, - first_line: line_number, - occurrences: 1, - }); -} - -fn upsert_storage_leaf( - storage_leaves: &mut HashMap<(B256, B256, U256), ObservedStorageLeaf>, - hashed_address: B256, - hashed_slot: B256, - value: U256, - line_number: usize, -) { - storage_leaves - .entry((hashed_address, hashed_slot, value)) - .and_modify(|entry| entry.occurrences += 1) - .or_insert(ObservedStorageLeaf { - hashed_address, - hashed_slot, - value, - first_line: line_number, - occurrences: 1, - }); -} - -fn upsert_storage_root( - storage_roots: &mut HashMap<(B256, B256), ObservedStorageRoot>, - hashed_address: B256, - root: B256, - line_number: usize, -) { - storage_roots - .entry((hashed_address, root)) - .and_modify(|entry| entry.occurrences += 1) - .or_insert(ObservedStorageRoot { - hashed_address, - root, - first_line: line_number, - occurrences: 1, - }); -} - -fn into_sorted(bucket: HashMap) -> Vec -where - K: std::hash::Hash + Eq, - T: TraceLine, -{ - let mut values = bucket.into_values().collect::>(); - values.sort_by_key(|left| left.first_line()); - values -} - -fn parse_b256(value: &str) -> Result { - value.parse::().with_context(|| format!("parse B256 from {value}")) -} - -fn parse_optional_b256(value: &str) -> Result> { - if value == "None" { - return Ok(None) - } - let inner = value - .strip_prefix("Some(") - .and_then(|rest| rest.strip_suffix(')')) - .ok_or_else(|| eyre!("invalid optional B256: {value}"))?; - Ok(Some(parse_b256(inner)?)) -} - -fn parse_trace_nibbles(value: &str) -> Result { - let hex = value - .strip_prefix("0x") - .ok_or_else(|| eyre!("expected nibble string with 0x prefix: {value}"))?; - let mut nibbles = Vec::with_capacity(hex.len()); - for ch in hex.bytes() { - let nibble = match ch { - b'0'..=b'9' => ch - b'0', - b'a'..=b'f' => ch - b'a' + 10, - b'A'..=b'F' => ch - b'A' + 10, - _ => bail!("invalid hex nibble in {value}"), - }; - nibbles.push(nibble); - } - Ok(Nibbles::from_nibbles_unchecked(nibbles)) -} - -fn parse_bool(value: &str) -> Result { - match value { - "true" => Ok(true), - "false" => Ok(false), - _ => bail!("invalid boolean value: {value}"), - } -} - -fn extract_after<'a>(line: &'a str, prefix: &str) -> Result<&'a str> { - line.split_once(prefix).map(|(_, rest)| rest).ok_or_else(|| eyre!("missing {prefix:?} in line")) -} - -fn extract_between<'a>(line: &'a str, start: &str, end: &str) -> Result<&'a str> { - let rest = extract_after(line, start)?; - rest.split_once(end) - .map(|(matched, _)| matched) - .ok_or_else(|| eyre!("missing end marker {end:?} after {start:?}")) -} - -fn extract_storage_address(line: &str) -> Result<&str> { - let rest = extract_after(line, "storage_trie{addr=")?; - let end = rest.find([' ', '}']).ok_or_else(|| eyre!("missing end of storage address"))?; - Ok(&rest[..end]) -} - -fn nibble_hex(nibble: u8) -> char { - char::from_digit(nibble as u32, 16).expect("valid nibble") -} - -fn nibbles_hex(path: &Nibbles) -> String { - let mut out = String::from("0x"); - for nibble in path.iter() { - out.push(nibble_hex(nibble)); - } - out -} - -fn option_b256(value: Option) -> String { - value.map_or_else(|| "None".to_string(), |hash| hash.to_string()) -} - -trait TraceLine { - fn first_line(&self) -> usize; -} - -impl TraceLine for ObservedStateBranch { - fn first_line(&self) -> usize { - self.first_line - } -} - -impl TraceLine for ObservedAccountLeaf { - fn first_line(&self) -> usize { - self.first_line - } -} - -impl TraceLine for ObservedStorageBranch { - fn first_line(&self) -> usize { - self.first_line - } -} - -impl TraceLine for ObservedStorageLeaf { - fn first_line(&self) -> usize { - self.first_line - } -} - -impl TraceLine for ObservedStorageRoot { - fn first_line(&self) -> usize { - self.first_line - } -} - -type DbStorageRoot<'a, TX, A> = - StorageRoot, DatabaseHashedCursorFactory<&'a TX>>; diff --git a/scripts/compare-merkle-trace-to-db.sh b/scripts/compare-merkle-trace-to-db.sh deleted file mode 100755 index a333d46a23a..00000000000 --- a/scripts/compare-merkle-trace-to-db.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) - -exec cargo run --profile profiling -p example-db-access --bin compare_merkle_trace_to_db -- "$@" diff --git a/scripts/extract-merkle-trace-touches.py b/scripts/extract-merkle-trace-touches.py deleted file mode 100755 index 10b8404ed94..00000000000 --- a/scripts/extract-merkle-trace-touches.py +++ /dev/null @@ -1,350 +0,0 @@ -#!/usr/bin/env python3 - -"""Extract Merkle-stage leaves and trie nodes from a reth trace log. - -The trace logs emitted by `reth` only include hashed addresses and hashed storage -slots, so this script reports the hashed values exactly as they appear in the -trace. It supports both pipeline logs with `stage=Merkle...` spans and standalone -`reth stage run merkle` logs. -""" - -from __future__ import annotations - -import argparse -import json -import re -import sys -from pathlib import Path - - -STAGE_RE = re.compile(r"stage=(Merkle[^}]+)") -STORAGE_TRIE_ADDR_RE = re.compile(r"storage_trie\{addr=(0x[0-9a-f]+)(?: [^}]*)?\}") -ACCOUNT_LEAF_RE = re.compile( - r"Leaf\(" - r"(0x[0-9a-f]+), " - r"Account \{ nonce: ([0-9]+), balance: ([0-9]+), bytecode_hash: " - r"(?:None|Some\((0x[0-9a-f]+)\))" - r" \}\)" -) -STORAGE_LEAF_RE = re.compile(r"Leaf\((0x[0-9a-f]+), (.+)\)\)\)$") -BRANCH_RE = re.compile( - r"Branch\(TrieBranchNode \{ " - r"key: Nibbles\((0x[0-9a-f]*)\), " - r"value: (0x[0-9a-f]+), " - r"children_are_in_trie: (true|false) " - r"\}\)" -) -STATE_ROOT_RE = re.compile( - r"calculated state root " - r"root=(0x[0-9a-f]+) " - r"duration=([^ ]+) " - r"branches_added=([0-9]+) " - r"leaves_added=([0-9]+)" -) -STORAGE_ROOT_RE = re.compile( - r"calculated storage root " - r"root=(0x[0-9a-f]+) " - r"hashed_address=(0x[0-9a-f]+) " - r"duration=([^ ]+) " - r"branches_added=([0-9]+) " - r"leaves_added=([0-9]+)" -) -MERKLE_LINE_MARKERS = ( - "trie::node_iter:", - "trie::state_root:", - "trie::storage_root:", -) - - -def bool_from_string(value: str) -> bool: - return value == "true" - - -def add_unique(bucket: dict[tuple[str, ...], dict[str, object]], key: tuple[str, ...], payload: dict[str, object], line_number: int) -> None: - record = bucket.get(key) - if record is None: - bucket[key] = { - **payload, - "occurrences": 1, - "first_line": line_number, - "last_line": line_number, - } - return - - record["occurrences"] = int(record["occurrences"]) + 1 - record["last_line"] = line_number - - -def sorted_records(bucket: dict[tuple[str, ...], dict[str, object]]) -> list[dict[str, object]]: - return sorted(bucket.values(), key=lambda item: (int(item["first_line"]), int(item["last_line"]))) - - -def detect_stage(line: str) -> str | None: - stage_match = STAGE_RE.search(line) - if stage_match is not None: - stage = stage_match.group(1).strip() - return stage if stage.startswith("Merkle") else None - - if any(marker in line for marker in MERKLE_LINE_MARKERS): - return "Merkle" - - return None - - -def parse_trace(trace_path: Path) -> dict[str, object]: - account_leaves: dict[tuple[str, ...], dict[str, object]] = {} - storage_leaves: dict[tuple[str, ...], dict[str, object]] = {} - state_trie_nodes: dict[tuple[str, ...], dict[str, object]] = {} - storage_trie_nodes: dict[tuple[str, ...], dict[str, object]] = {} - state_roots: list[dict[str, object]] = [] - storage_roots: list[dict[str, object]] = [] - stages_seen: set[str] = set() - - account_leaf_occurrences = 0 - storage_leaf_occurrences = 0 - state_trie_node_occurrences = 0 - storage_trie_node_occurrences = 0 - - with trace_path.open("r", encoding="utf-8", errors="replace") as handle: - for line_number, line in enumerate(handle, start=1): - stage = detect_stage(line) - if stage is None: - continue - - stages_seen.add(stage) - - storage_addr_match = STORAGE_TRIE_ADDR_RE.search(line) - storage_addr = storage_addr_match.group(1) if storage_addr_match else None - - if "trie::state_root: calculated state root" in line: - state_root_match = STATE_ROOT_RE.search(line) - if state_root_match: - state_roots.append( - { - "line": line_number, - "stage": stage, - "root": state_root_match.group(1), - "duration": state_root_match.group(2), - "branches_added": int(state_root_match.group(3)), - "leaves_added": int(state_root_match.group(4)), - } - ) - continue - - if "trie::storage_root: calculated storage root" in line: - storage_root_match = STORAGE_ROOT_RE.search(line) - if storage_root_match: - storage_roots.append( - { - "line": line_number, - "stage": stage, - "hashed_address": storage_root_match.group(2), - "root": storage_root_match.group(1), - "duration": storage_root_match.group(3), - "branches_added": int(storage_root_match.group(4)), - "leaves_added": int(storage_root_match.group(5)), - } - ) - continue - - if "trie::node_iter: return=Ok(Some(" not in line: - continue - - branch_match = BRANCH_RE.search(line) - if branch_match: - node_payload = { - "stage": stage, - "key": branch_match.group(1), - "node_hash": branch_match.group(2), - "children_are_in_trie": bool_from_string(branch_match.group(3)), - } - if "trie_type=Storage" in line and storage_addr is not None: - storage_trie_node_occurrences += 1 - add_unique( - storage_trie_nodes, - ( - stage, - storage_addr, - node_payload["key"], - node_payload["node_hash"], - str(node_payload["children_are_in_trie"]), - ), - {**node_payload, "hashed_address": storage_addr}, - line_number, - ) - elif "trie_type=State" in line: - state_trie_node_occurrences += 1 - add_unique( - state_trie_nodes, - ( - stage, - node_payload["key"], - node_payload["node_hash"], - str(node_payload["children_are_in_trie"]), - ), - node_payload, - line_number, - ) - continue - - account_leaf_match = ACCOUNT_LEAF_RE.search(line) - if account_leaf_match and "trie_type=State" in line: - account_leaf_occurrences += 1 - bytecode_hash = account_leaf_match.group(4) - add_unique( - account_leaves, - ( - stage, - account_leaf_match.group(1), - account_leaf_match.group(2), - account_leaf_match.group(3), - bytecode_hash or "None", - ), - { - "stage": stage, - "hashed_address": account_leaf_match.group(1), - "account": { - "nonce": account_leaf_match.group(2), - "balance": account_leaf_match.group(3), - "bytecode_hash": bytecode_hash, - }, - }, - line_number, - ) - continue - - storage_leaf_match = STORAGE_LEAF_RE.search(line) - if storage_leaf_match and "trie_type=Storage" in line and storage_addr is not None: - storage_leaf_occurrences += 1 - add_unique( - storage_leaves, - ( - stage, - storage_addr, - storage_leaf_match.group(1), - storage_leaf_match.group(2), - ), - { - "stage": stage, - "hashed_address": storage_addr, - "hashed_slot": storage_leaf_match.group(1), - "value": storage_leaf_match.group(2), - }, - line_number, - ) - - return { - "trace_file": str(trace_path), - "summary": { - "stages": sorted(stages_seen), - "account_leaves": { - "unique": len(account_leaves), - "occurrences": account_leaf_occurrences, - }, - "storage_leaves": { - "unique": len(storage_leaves), - "occurrences": storage_leaf_occurrences, - }, - "state_trie_nodes": { - "unique": len(state_trie_nodes), - "occurrences": state_trie_node_occurrences, - }, - "storage_trie_nodes": { - "unique": len(storage_trie_nodes), - "occurrences": storage_trie_node_occurrences, - }, - "state_roots": len(state_roots), - "storage_roots": len(storage_roots), - }, - "account_leaves": sorted_records(account_leaves), - "storage_leaves": sorted_records(storage_leaves), - "state_trie_nodes": sorted_records(state_trie_nodes), - "storage_trie_nodes": sorted_records(storage_trie_nodes), - "state_roots": state_roots, - "storage_roots": storage_roots, - } - - -def print_summary(result: dict[str, object]) -> None: - summary = result["summary"] - if not isinstance(summary, dict): - raise TypeError("summary must be a dictionary") - - print(f"trace_file: {result['trace_file']}") - print(f"stages: {', '.join(summary['stages'])}") - print( - "account_leaves: " - f"unique={summary['account_leaves']['unique']} " - f"occurrences={summary['account_leaves']['occurrences']}" - ) - print( - "storage_leaves: " - f"unique={summary['storage_leaves']['unique']} " - f"occurrences={summary['storage_leaves']['occurrences']}" - ) - print( - "state_trie_nodes: " - f"unique={summary['state_trie_nodes']['unique']} " - f"occurrences={summary['state_trie_nodes']['occurrences']}" - ) - print( - "storage_trie_nodes: " - f"unique={summary['storage_trie_nodes']['unique']} " - f"occurrences={summary['storage_trie_nodes']['occurrences']}" - ) - print(f"state_roots: {summary['state_roots']}") - print(f"storage_roots: {summary['storage_roots']}") - - -def build_arg_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Extract touched Merkle-stage account/storage leaves and trie nodes from a trace log.", - ) - parser.add_argument("trace_file", type=Path, help="Path to the trace log file") - parser.add_argument( - "--summary", - action="store_true", - help="Print only high-level counts instead of the full JSON payload", - ) - parser.add_argument( - "--output", - type=Path, - help="Write the extracted payload to a file instead of stdout", - ) - return parser - - -def main() -> int: - parser = build_arg_parser() - args = parser.parse_args() - - if not args.trace_file.is_file(): - parser.error(f"trace file does not exist: {args.trace_file}") - - result = parse_trace(args.trace_file) - - if args.summary: - if args.output is not None: - with args.output.open("w", encoding="utf-8") as handle: - original_stdout = sys.stdout - try: - sys.stdout = handle - print_summary(result) - finally: - sys.stdout = original_stdout - return 0 - - print_summary(result) - return 0 - - payload = json.dumps(result, indent=2, sort_keys=False) - if args.output is not None: - args.output.write_text(payload + "\n", encoding="utf-8") - return 0 - - print(payload) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/repro-hoodi-partial-persistence-reorg.sh b/scripts/repro-hoodi-partial-persistence-reorg.sh deleted file mode 100755 index 6853c9180df..00000000000 --- a/scripts/repro-hoodi-partial-persistence-reorg.sh +++ /dev/null @@ -1,560 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: repro-hoodi-partial-persistence-reorg.sh [options] - -Restores a hoodi datadir snapshot, starts reth with partial persistence, then -replays pre-generated reorg payload artifacts until a state-root mismatch is -observed, replay exits, or an optional timeout is reached. - -Unlike repro-hoodi-partial-persistence-unwind.sh, this script does not crash the -node and does not run restart, unwind, or Merkle-stage follow-up steps. - -Options: - --snapshot PATH Tar.zst snapshot to restore - (default: /mnt/data/hoodi.tar.zst) - --datadir PATH Restored reth datadir - (default: /mnt/data/hoodi) - --jwt-secret PATH JWT secret path - (default: /jwt.hex) - --payload-dir PATH Directory containing payload_block_*.json files - (default: /mnt/data/hoodi-bal-payload-artifacts-10k-reorg5/payloads) - --payload-count N Number of payload artifacts to replay - (default: 20000) - --expected-head N Expected local head after restore - (default: 2613962) - --start-block N First block expected to be replayed - (default: 2613963) - --artifacts-dir PATH Directory for logs and summary output - (default: /tmp/reth-hoodi-reorg-) - --start-timeout SECONDS Seconds to wait for node RPC startup - (default: 180) - --mismatch-timeout SECONDS Seconds to wait for a state-root mismatch after - reth-bench starts (default: 0, no timeout) - -h, --help Show this help - -Exit codes: - 0 Script ran to completion. See result.txt for whether a state-root mismatch - was observed. - 2 Setup/runtime failure prevented a conclusive run. -EOF -} - -log() { - printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*" >&2 -} - -regex_escape() { - printf '%s' "$1" | sed 's/[][(){}.^$+*?|\\/]/\\&/g' -} - -head_hex() { - local response - response=$(curl -fsS \ - -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \ - http://127.0.0.1:8545 2>/dev/null) || return 1 - response=${response//$'\n'/} - sed -n 's/.*"result"[[:space:]]*:[[:space:]]*"\(0x[0-9a-fA-F]\+\)".*/\1/p' <<<"$response" -} - -hex_to_dec() { - printf '%d\n' "$((16#${1#0x}))" -} - -pid_has_exited() { - local pid="$1" - local stat - - if ! kill -0 "$pid" 2>/dev/null; then - return 0 - fi - - stat=$(ps -o stat= -p "$pid" 2>/dev/null || true) - [[ "$stat" == *Z* ]] -} - -wait_for_pid_exit() { - local pid="$1" - local timeout="$2" - local elapsed=0 - - while (( elapsed < timeout )); do - if pid_has_exited "$pid"; then - return 0 - fi - sleep 1 - ((elapsed += 1)) - done - - return 1 -} - -record_node_exit() { - if [[ -z "${NODE_PID:-}" ]]; then - return 0 - fi - - if wait "$NODE_PID"; then - NODE_EXIT_CODE=0 - else - NODE_EXIT_CODE=$? - fi - log "reth node exited with code ${NODE_EXIT_CODE}" - NODE_PID="" -} - -stop_pid() { - local pid="$1" - local label="$2" - - if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then - log "Sending SIGTERM to ${label} (pid ${pid})" - kill -TERM "$pid" 2>/dev/null || true - fi -} - -stop_matching_processes() { - local pattern="$1" - local label="$2" - local -a pids=() - local pid - - while IFS= read -r pid; do - [[ -n "$pid" ]] && pids+=("$pid") - done < <(pgrep -f "$pattern" || true) - - if ((${#pids[@]} > 0)); then - log "Sending SIGTERM to stale ${label} processes: ${pids[*]}" - kill -TERM "${pids[@]}" 2>/dev/null || true - fi -} - -capture_command() { - local name="$1" - shift - { - printf '%s=' "$name" - printf '%q ' "$@" - printf '\n' - } >>"$COMMANDS_FILE" -} - -write_summary() { - { - printf 'result=%s\n' "${RESULT:-unknown}" - printf 'snapshot=%s\n' "$SNAPSHOT" - printf 'datadir=%s\n' "$DATADIR" - printf 'jwt_secret=%s\n' "$JWT_SECRET" - printf 'payload_dir=%s\n' "$PAYLOAD_DIR" - printf 'payload_count=%s\n' "$PAYLOAD_COUNT" - printf 'expected_head=%s\n' "$EXPECTED_HEAD" - printf 'start_block=%s\n' "$START_BLOCK" - printf 'head_before=%s\n' "${HEAD_BEFORE:-unknown}" - printf 'head_after=%s\n' "${HEAD_AFTER:-unknown}" - printf 'bench_exit_code=%s\n' "${BENCH_EXIT_CODE:-unknown}" - printf 'node_exit_code=%s\n' "${NODE_EXIT_CODE:-unknown}" - printf 'mismatch_source=%s\n' "${MISMATCH_SOURCE:-not_found}" - printf 'mismatch_line=%s\n' "${MISMATCH_LINE:-not_found}" - printf 'artifacts_dir=%s\n' "$ARTIFACTS_DIR" - printf 'node_log=%s\n' "$NODE_LOG" - printf 'bench_log=%s\n' "$BENCH_LOG" - } >"$SUMMARY_FILE" -} - -cleanup() { - if [[ -n "${BENCH_PID:-}" ]] && pid_has_exited "$BENCH_PID"; then - if wait "$BENCH_PID"; then - BENCH_EXIT_CODE=0 - else - BENCH_EXIT_CODE=$? - fi - BENCH_PID="" - fi - - stop_pid "${BENCH_PID:-}" "reth-bench" - if [[ -n "${BENCH_PID:-}" ]]; then - if wait "${BENCH_PID}" 2>/dev/null; then - BENCH_EXIT_CODE=0 - else - BENCH_EXIT_CODE=$? - fi - BENCH_PID="" - fi - - if [[ -n "${NODE_PID:-}" ]] && pid_has_exited "$NODE_PID"; then - record_node_exit - fi - - stop_pid "${NODE_PID:-}" "reth node" - if [[ -n "${NODE_PID:-}" ]]; then - wait "${NODE_PID}" 2>/dev/null || true - NODE_PID="" - fi - - write_summary -} - -SNAPSHOT="/mnt/data/hoodi.tar.zst" -DATADIR="/mnt/data/hoodi" -JWT_SECRET="" -PAYLOAD_DIR="/mnt/data/hoodi-bal-payload-artifacts-10k-reorg5/payloads" -PAYLOAD_COUNT=20000 -EXPECTED_HEAD=2613962 -START_BLOCK=2613963 -START_TIMEOUT=180 -MISMATCH_TIMEOUT=0 -RETH_BIN="/repos/reth/target/profiling/reth" -BENCH_BIN="/repos/reth/target/profiling/reth-bench" -CHAIN="hoodi" -RESULT="script_error" -HEAD_BEFORE="" -HEAD_AFTER="" -NODE_PID="" -BENCH_PID="" -BENCH_EXIT_CODE="" -NODE_EXIT_CODE="" -MISMATCH_SOURCE="" -MISMATCH_LINE="" -TIMESTAMP="$(date '+%Y%m%d-%H%M%S')" -ARTIFACTS_DIR="/tmp/reth-hoodi-reorg-${TIMESTAMP}" - -while (($# > 0)); do - case "$1" in - --snapshot) - SNAPSHOT="$2" - shift 2 - ;; - --datadir) - DATADIR="$2" - shift 2 - ;; - --jwt-secret) - JWT_SECRET="$2" - shift 2 - ;; - --payload-dir) - PAYLOAD_DIR="$2" - shift 2 - ;; - --payload-count) - PAYLOAD_COUNT="$2" - shift 2 - ;; - --expected-head) - EXPECTED_HEAD="$2" - shift 2 - ;; - --start-block) - START_BLOCK="$2" - shift 2 - ;; - --artifacts-dir) - ARTIFACTS_DIR="$2" - shift 2 - ;; - --start-timeout) - START_TIMEOUT="$2" - shift 2 - ;; - --mismatch-timeout) - MISMATCH_TIMEOUT="$2" - shift 2 - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "Unknown argument: $1" >&2 - usage - exit 2 - ;; - esac -done - -if [[ -z "$JWT_SECRET" ]]; then - JWT_SECRET="${DATADIR}/jwt.hex" -fi - -mkdir -p "$ARTIFACTS_DIR" -COMMANDS_FILE="${ARTIFACTS_DIR}/commands.txt" -SUMMARY_FILE="${ARTIFACTS_DIR}/result.txt" -NODE_LOG="${ARTIFACTS_DIR}/node.log" -BENCH_LOG="${ARTIFACTS_DIR}/bench.log" - -trap cleanup EXIT - -if [[ ! -x "$RETH_BIN" ]]; then - log "Missing executable reth binary: $RETH_BIN" - exit 2 -fi - -if [[ ! -x "$BENCH_BIN" ]]; then - log "Missing executable reth-bench binary: $BENCH_BIN" - exit 2 -fi - -if [[ ! -f "$SNAPSHOT" ]]; then - log "Missing snapshot archive: $SNAPSHOT" - exit 2 -fi - -if [[ ! -d "$PAYLOAD_DIR" ]]; then - log "Missing payload directory: $PAYLOAD_DIR" - exit 2 -fi - -if (( PAYLOAD_COUNT <= 0 )); then - log "--payload-count must be greater than 0" - exit 2 -fi - -NODE_PATTERN="^$(regex_escape "$RETH_BIN") node --datadir $(regex_escape "$DATADIR")( |$)" -stop_matching_processes "$NODE_PATTERN" "reth" -sleep 1 - -capture_command reth "$RETH_BIN" node \ - --datadir "$DATADIR" \ - --chain "$CHAIN" \ - --http --http.addr 127.0.0.1 --http.port 8545 --http.api eth,net,web3,reth,testing \ - --ws --ws.addr 127.0.0.1 --ws.port 8546 --ws.api eth,net,web3,reth \ - --authrpc.addr 127.0.0.1 --authrpc.port 8551 --authrpc.jwtsecret "$JWT_SECRET" \ - --disable-discovery \ - --engine.persistence-threshold 10 \ - --engine.deferred-trie-blocks 3 \ - --engine.accept-execution-requests-hash \ - --log.stdout.filter debug \ - --color never - -restore_snapshot() { - local parent_dir - local base_name - local extract_root - local candidate_datadir="" - local -a nested_candidates=() - local nested_dir - - parent_dir=$(dirname "$DATADIR") - base_name=$(basename "$DATADIR") - extract_root="${DATADIR}.extract.$$" - - log "Restoring snapshot ${SNAPSHOT} into ${DATADIR}" - rm -rf "$DATADIR" "$extract_root" - mkdir -p "$parent_dir" "$extract_root" - tar --zstd -xf "$SNAPSHOT" -C "$extract_root" - - if [[ -d "${extract_root}/${base_name}/db" && -d "${extract_root}/${base_name}/static_files" ]]; then - candidate_datadir="${extract_root}/${base_name}" - else - while IFS= read -r nested_dir; do - if [[ -d "${nested_dir}/db" && -d "${nested_dir}/static_files" ]]; then - nested_candidates+=("$nested_dir") - fi - done < <(find "$extract_root" -mindepth 1 -maxdepth 1 -type d | sort) - - if ((${#nested_candidates[@]} == 1)); then - candidate_datadir="${nested_candidates[0]}" - elif ((${#nested_candidates[@]} > 1)); then - log "Snapshot layout produced multiple nested datadir candidates under ${extract_root}: ${nested_candidates[*]}" - exit 2 - elif [[ -d "${extract_root}/db" && -d "${extract_root}/static_files" ]]; then - candidate_datadir="$extract_root" - fi - fi - - if [[ -z "$candidate_datadir" ]]; then - log "Snapshot layout did not produce an expected datadir under ${extract_root}" - exit 2 - fi - - if [[ "$candidate_datadir" == "$extract_root" ]]; then - mv "$extract_root" "$DATADIR" - else - mv "$candidate_datadir" "$DATADIR" - rm -rf "$extract_root" - fi - - if [[ ! -f "$JWT_SECRET" ]]; then - log "Restored datadir is missing jwt secret; generating ${JWT_SECRET}" - mkdir -p "$(dirname "$JWT_SECRET")" - umask 077 - head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' >"$JWT_SECRET" - printf '\n' >>"$JWT_SECRET" - fi -} - -start_node() { - ( - ulimit -c 0 - "$RETH_BIN" node \ - --datadir "$DATADIR" \ - --chain "$CHAIN" \ - --http --http.addr 127.0.0.1 --http.port 8545 --http.api eth,net,web3,reth,testing \ - --ws --ws.addr 127.0.0.1 --ws.port 8546 --ws.api eth,net,web3,reth \ - --authrpc.addr 127.0.0.1 --authrpc.port 8551 --authrpc.jwtsecret "$JWT_SECRET" \ - --disable-discovery \ - --engine.persistence-threshold 10 \ - --engine.deferred-trie-blocks 3 \ - --engine.accept-execution-requests-hash \ - --log.stdout.filter debug \ - --color never - ) >"$NODE_LOG" 2>&1 & - NODE_PID=$! -} - -wait_for_rpc_start() { - local pid="$1" - local timeout="$2" - local elapsed=0 - local block_hex - - while (( elapsed < timeout )); do - block_hex=$(head_hex || true) - if [[ -n "$block_hex" ]]; then - printf '%s\n' "$block_hex" - return 0 - fi - - if ! kill -0 "$pid" 2>/dev/null; then - log "Node exited before RPC became ready" - return 1 - fi - - sleep 1 - ((elapsed += 1)) - done - - log "Timed out waiting for node RPC readiness" - return 1 -} - -remove_stale_locks() { - rm -f "$DATADIR/db/lock" "$DATADIR/static_files/lock" "$DATADIR/rocksdb/LOCK" -} - -find_mismatch() { - local source="$1" - local log_file="$2" - local line - - line=$(grep -Ei -m1 \ - 'State root task returned incorrect state root|mismatched block state root|Failed to verify block state root' \ - "$log_file" 2>/dev/null || true) - if [[ -n "$line" ]]; then - MISMATCH_SOURCE="$source" - MISMATCH_LINE="$line" - RESULT="state_root_mismatch" - return 0 - fi - - return 1 -} - -monitor_for_mismatch() { - local start_epoch - local elapsed - local block_hex - - start_epoch=$(date +%s) - while true; do - if find_mismatch node "$NODE_LOG" || find_mismatch bench "$BENCH_LOG"; then - log "Observed state-root mismatch in ${MISMATCH_SOURCE} log" - if [[ -n "$NODE_PID" ]]; then - if wait_for_pid_exit "$NODE_PID" 5; then - record_node_exit - else - log "reth node is still running 5s after mismatch" - fi - fi - return 0 - fi - - block_hex=$(head_hex || true) - if [[ -n "$block_hex" ]]; then - HEAD_AFTER=$(hex_to_dec "$block_hex") - fi - - if [[ -n "$BENCH_PID" ]] && pid_has_exited "$BENCH_PID"; then - if wait "$BENCH_PID"; then - BENCH_EXIT_CODE=0 - RESULT="bench_completed_no_mismatch" - else - BENCH_EXIT_CODE=$? - if find_mismatch node "$NODE_LOG" || find_mismatch bench "$BENCH_LOG"; then - log "Observed state-root mismatch after reth-bench exit" - return 0 - fi - RESULT="bench_failed_no_mismatch" - fi - BENCH_PID="" - return 0 - fi - - if [[ -n "$NODE_PID" ]] && pid_has_exited "$NODE_PID"; then - if find_mismatch node "$NODE_LOG" || find_mismatch bench "$BENCH_LOG"; then - log "Observed state-root mismatch after node exit" - record_node_exit - return 0 - fi - RESULT="node_exited_no_mismatch" - record_node_exit - return 0 - fi - - if (( MISMATCH_TIMEOUT > 0 )); then - elapsed=$(($(date +%s) - start_epoch)) - if (( elapsed >= MISMATCH_TIMEOUT )); then - RESULT="timeout_no_mismatch" - return 0 - fi - fi - - sleep 1 - done -} - -restore_snapshot - -log "Starting reth for reorg replay run" -start_node - -HEAD_HEX=$(wait_for_rpc_start "$NODE_PID" "$START_TIMEOUT") || exit 2 -HEAD_BEFORE=$(hex_to_dec "$HEAD_HEX") -HEAD_AFTER="$HEAD_BEFORE" -printf '%s\n' "$HEAD_BEFORE" >"${ARTIFACTS_DIR}/current_head_before.txt" - -if (( HEAD_BEFORE != EXPECTED_HEAD )); then - log "Expected restored head ${EXPECTED_HEAD}, got ${HEAD_BEFORE}" - exit 2 -fi - -if (( HEAD_BEFORE + 1 != START_BLOCK )); then - log "Expected first replay block ${START_BLOCK}, but restored head implies ${HEAD_BEFORE} -> $((HEAD_BEFORE + 1))" - exit 2 -fi - -BENCH_ARGS=( - "$BENCH_BIN" -vvv replay-payloads - --reth-new-payload - --wait-for-persistence always - --jwt-secret "$JWT_SECRET" - --engine-rpc-url http://127.0.0.1:8551 - --payload-dir "$PAYLOAD_DIR" - --count "$PAYLOAD_COUNT" - --output "$ARTIFACTS_DIR/reth-bench" -) - -capture_command reth_bench "${BENCH_ARGS[@]}" - -log "Running reth-bench replay-payloads for ${PAYLOAD_COUNT} payloads from ${PAYLOAD_DIR}" - -"${BENCH_ARGS[@]}" >"$BENCH_LOG" 2>&1 & -BENCH_PID=$! - -monitor_for_mismatch - -log "Reorg repro result: ${RESULT}" diff --git a/scripts/repro-hoodi-partial-persistence-unwind.sh b/scripts/repro-hoodi-partial-persistence-unwind.sh deleted file mode 100755 index 0c7df9d9ddc..00000000000 --- a/scripts/repro-hoodi-partial-persistence-unwind.sh +++ /dev/null @@ -1,826 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: repro-hoodi-partial-persistence-unwind.sh [options] - -Restores a hoodi datadir snapshot, runs a partial-persistence sync replay with -reth-bench, kill -9s the node during the post-target persistence window, then -restarts the node and reports whether restart led to an unwind failure. - -Options: - --snapshot PATH Tar.zst snapshot to restore - (default: /mnt/data/hoodi.tar.zst) - --datadir PATH Restored reth datadir - (default: /mnt/data/hoodi) - --jwt-secret PATH JWT secret path - (default: /jwt.hex) - --rpc-url URL Remote hoodi RPC used by reth-bench - (default: https://rpc.hoodi.ethpandaops.io) - --expected-head N Expected local head after restore - (default: 2613962) - --start-block N First block expected to be replayed - (default: 2613963) - --target-block N Last block to replay before crashing - (default: 2614300) - --randomize-target-block Pick a random crash target between - --start-block and --target-block - --artifacts-dir PATH Directory for logs and summary output - (default: /tmp/reth-hoodi-unwind-) - --start-timeout SECONDS Seconds to wait for node RPC startup - (default: 180) - --target-timeout SECONDS Seconds to wait for local head to reach target - (default: 900) - --persistence-timeout SEC Seconds to wait for a persistence marker after - the target head is reached (default: 300) - --restart-timeout SECONDS Seconds to classify restart behavior - (default: 180) - -h, --help Show this help - -Exit codes: - 0 Script ran to completion. See result.txt for whether unwind succeeded, - failed, or was not triggered. - 2 Setup/runtime failure prevented a conclusive result. -EOF -} - -log() { - printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*" >&2 -} - -regex_escape() { - printf '%s' "$1" | sed 's/[][(){}.^$+*?|\\/]/\\&/g' -} - -head_hex() { - local response - response=$(curl -fsS \ - -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \ - http://127.0.0.1:8545 2>/dev/null) || return 1 - response=${response//$'\n'/} - sed -n 's/.*"result"[[:space:]]*:[[:space:]]*"\(0x[0-9a-fA-F]\+\)".*/\1/p' <<<"$response" -} - -hex_to_dec() { - printf '%d\n' "$((16#${1#0x}))" -} - -wait_for_pid_exit() { - local pid="$1" - local timeout="$2" - local elapsed=0 - - while (( elapsed < timeout )); do - if ! kill -0 "$pid" 2>/dev/null; then - return 0 - fi - sleep 1 - ((elapsed += 1)) - done - - return 1 -} - -stop_pid() { - local pid="$1" - local signal="$2" - local label="$3" - - if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then - log "Sending SIG${signal} to ${label} (pid ${pid})" - kill "-${signal}" "$pid" 2>/dev/null || true - fi -} - -kill_matching_processes() { - local signal="$1" - local pattern="$2" - local label="$3" - local -a pids=() - local pid - - while IFS= read -r pid; do - [[ -n "$pid" ]] && pids+=("$pid") - done < <(pgrep -f "$pattern" || true) - - if ((${#pids[@]} > 0)); then - log "Sending SIG${signal} to stale ${label} processes: ${pids[*]}" - kill "-${signal}" "${pids[@]}" 2>/dev/null || true - fi -} - -capture_command() { - local name="$1" - shift - { - printf '%s=' "$name" - printf '%q ' "$@" - printf '\n' - } >>"$COMMANDS_FILE" -} - -write_summary() { - { - printf 'result=%s\n' "${RESULT:-unknown}" - printf 'snapshot=%s\n' "$SNAPSHOT" - printf 'datadir=%s\n' "$DATADIR" - printf 'jwt_secret=%s\n' "$JWT_SECRET" - printf 'remote_rpc_url=%s\n' "$REMOTE_RPC_URL" - printf 'expected_head=%s\n' "$EXPECTED_HEAD" - printf 'start_block=%s\n' "$START_BLOCK" - printf 'target_block=%s\n' "$TARGET_BLOCK" - printf 'target_block_mode=%s\n' "$TARGET_BLOCK_MODE" - printf 'target_block_lower_bound=%s\n' "$TARGET_BLOCK_LOWER_BOUND" - printf 'target_block_upper_bound=%s\n' "$TARGET_BLOCK_UPPER_BOUND" - printf 'advance=%s\n' "${ADVANCE:-unknown}" - printf 'head_before=%s\n' "${HEAD_BEFORE:-unknown}" - printf 'head_after_crash=%s\n' "${HEAD_AT_CRASH:-unknown}" - printf 'head_after_restart=%s\n' "${HEAD_AFTER_RESTART:-unknown}" - printf 'artifacts_dir=%s\n' "$ARTIFACTS_DIR" - printf 'node1_log=%s\n' "$NODE1_LOG" - printf 'bench_log=%s\n' "$BENCH_LOG" - printf 'node2_log=%s\n' "$NODE2_LOG" - printf 'restart_trace_log=%s\n' "$RESTART_TRACE_LOG" - printf 'failed_unwind_target=%s\n' "${FAILED_UNWIND_TARGET:-unknown}" - printf 'drop_merkle_result=%s\n' "${DROP_MERKLE_RESULT:-not_run}" - printf 'drop_merkle_log=%s\n' "$DROP_MERKLE_LOG" - printf 'post_drop_unwind_result=%s\n' "${POST_DROP_UNWIND_RESULT:-not_run}" - printf 'post_drop_unwind_log=%s\n' "$POST_DROP_UNWIND_LOG" - printf 'post_drop_merkle_run_result=%s\n' "${POST_DROP_MERKLE_RUN_RESULT:-not_run}" - printf 'post_drop_merkle_run_log=%s\n' "$POST_DROP_MERKLE_RUN_LOG" - printf 'post_drop_merkle_run_trace_log=%s\n' "$POST_DROP_MERKLE_RUN_TRACE_LOG" - } >"$SUMMARY_FILE" -} - -cleanup() { - stop_pid "${BENCH_PID:-}" TERM "reth-bench" - if [[ -n "${BENCH_PID:-}" ]]; then - wait "${BENCH_PID}" 2>/dev/null || true - fi - - stop_pid "${NODE2_PID:-}" TERM "reth restart node" - if [[ -n "${NODE2_PID:-}" ]]; then - wait "${NODE2_PID}" 2>/dev/null || true - fi - - stop_pid "${NODE1_PID:-}" TERM "reth crash node" - if [[ -n "${NODE1_PID:-}" ]]; then - wait "${NODE1_PID}" 2>/dev/null || true - fi - - write_summary -} - -SNAPSHOT="/mnt/data/hoodi.tar.zst" -DATADIR="/mnt/data/hoodi" -JWT_SECRET="" -REMOTE_RPC_URL="https://rpc.hoodi.ethpandaops.io" -EXPECTED_HEAD=2613962 -START_BLOCK=2613963 -TARGET_BLOCK=2614300 -RANDOMIZE_TARGET_BLOCK=0 -START_TIMEOUT=180 -TARGET_TIMEOUT=900 -PERSISTENCE_TIMEOUT=300 -RESTART_TIMEOUT=180 -RETH_BIN="/repos/reth/target/profiling/reth" -BENCH_BIN="/repos/reth/target/profiling/reth-bench" -CHAIN="hoodi" -MERKLE_TRACE_FILTER='info' -RESULT="script_error" -ADVANCE="" -TARGET_BLOCK_MODE="fixed" -TARGET_BLOCK_LOWER_BOUND="$TARGET_BLOCK" -TARGET_BLOCK_UPPER_BOUND="$TARGET_BLOCK" -HEAD_BEFORE="" -HEAD_AT_CRASH="" -HEAD_AFTER_RESTART="" -NODE1_PID="" -NODE2_PID="" -BENCH_PID="" -FAILED_UNWIND_TARGET="" -DROP_MERKLE_RESULT="not_run" -POST_DROP_UNWIND_RESULT="not_run" -POST_DROP_MERKLE_RUN_RESULT="not_run" -TIMESTAMP="$(date '+%Y%m%d-%H%M%S')" -ARTIFACTS_DIR="/tmp/reth-hoodi-unwind-${TIMESTAMP}" - -while (($# > 0)); do - case "$1" in - --snapshot) - SNAPSHOT="$2" - shift 2 - ;; - --datadir) - DATADIR="$2" - shift 2 - ;; - --jwt-secret) - JWT_SECRET="$2" - shift 2 - ;; - --rpc-url) - REMOTE_RPC_URL="$2" - shift 2 - ;; - --expected-head) - EXPECTED_HEAD="$2" - shift 2 - ;; - --start-block) - START_BLOCK="$2" - shift 2 - ;; - --target-block) - TARGET_BLOCK="$2" - shift 2 - ;; - --randomize-target-block) - RANDOMIZE_TARGET_BLOCK=1 - shift - ;; - --artifacts-dir) - ARTIFACTS_DIR="$2" - shift 2 - ;; - --start-timeout) - START_TIMEOUT="$2" - shift 2 - ;; - --target-timeout) - TARGET_TIMEOUT="$2" - shift 2 - ;; - --persistence-timeout) - PERSISTENCE_TIMEOUT="$2" - shift 2 - ;; - --restart-timeout) - RESTART_TIMEOUT="$2" - shift 2 - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "Unknown argument: $1" >&2 - usage - exit 2 - ;; - esac -done - -if [[ -z "$JWT_SECRET" ]]; then - JWT_SECRET="${DATADIR}/jwt.hex" -fi - -mkdir -p "$ARTIFACTS_DIR" -COMMANDS_FILE="${ARTIFACTS_DIR}/commands.txt" -SUMMARY_FILE="${ARTIFACTS_DIR}/result.txt" -NODE1_LOG="${ARTIFACTS_DIR}/node1.log" -BENCH_LOG="${ARTIFACTS_DIR}/bench.log" -NODE2_LOG="${ARTIFACTS_DIR}/node2.log" -RESTART_TRACE_LOG="${ARTIFACTS_DIR}/restart-trace.log" -DROP_MERKLE_LOG="${ARTIFACTS_DIR}/drop-merkle.log" -POST_DROP_UNWIND_LOG="${ARTIFACTS_DIR}/post-drop-unwind.log" -POST_DROP_MERKLE_RUN_LOG="${ARTIFACTS_DIR}/post-drop-merkle-run.log" -POST_DROP_MERKLE_RUN_TRACE_LOG="not_captured" - -trap cleanup EXIT - -if [[ ! -x "$RETH_BIN" ]]; then - log "Missing executable reth binary: $RETH_BIN" - exit 2 -fi - -if [[ ! -x "$BENCH_BIN" ]]; then - log "Missing executable reth-bench binary: $BENCH_BIN" - exit 2 -fi - -if [[ ! -f "$SNAPSHOT" ]]; then - log "Missing snapshot archive: $SNAPSHOT" - exit 2 -fi - -NODE_PATTERN="^$(regex_escape "$RETH_BIN") node --datadir $(regex_escape "$DATADIR")( |$)" -kill_matching_processes TERM "$NODE_PATTERN" "reth" -sleep 1 -kill_matching_processes KILL "$NODE_PATTERN" "reth" - -capture_command reth "$RETH_BIN" node \ - --datadir "$DATADIR" \ - --chain "$CHAIN" \ - --http --http.addr 127.0.0.1 --http.port 8545 --http.api eth,net,web3,reth \ - --ws --ws.addr 127.0.0.1 --ws.port 8546 --ws.api eth,net,web3,reth \ - --authrpc.addr 127.0.0.1 --authrpc.port 8551 --authrpc.jwtsecret "$JWT_SECRET" \ - --disable-discovery \ - --engine.persistence-threshold 10 \ - --engine.deferred-trie-blocks 3 \ - --engine.accept-execution-requests-hash \ - --log.stdout.filter 'info,providers::db=debug,reth::providers::static_file=debug,reth::storage=debug,consensus::engine=debug' \ - --color never - -capture_command reth_restart "$RETH_BIN" node \ - --datadir "$DATADIR" \ - --chain "$CHAIN" \ - --http --http.addr 127.0.0.1 --http.port 8545 --http.api eth,net,web3,reth \ - --ws --ws.addr 127.0.0.1 --ws.port 8546 --ws.api eth,net,web3,reth \ - --authrpc.addr 127.0.0.1 --authrpc.port 8551 --authrpc.jwtsecret "$JWT_SECRET" \ - --disable-discovery \ - --engine.persistence-threshold 10 \ - --engine.deferred-trie-blocks 3 \ - --engine.accept-execution-requests-hash \ - --log.stdout.filter trace \ - --color never - -capture_command reth_stage_drop_merkle "$RETH_BIN" stage drop \ - --datadir "$DATADIR" \ - --chain "$CHAIN" \ - --log.stdout.filter info \ - --color never \ - merkle - -restore_snapshot() { - local parent_dir - local base_name - local extract_root - local candidate_datadir="" - local -a nested_candidates=() - local nested_dir - - parent_dir=$(dirname "$DATADIR") - base_name=$(basename "$DATADIR") - extract_root="${DATADIR}.extract.$$" - - log "Restoring snapshot ${SNAPSHOT} into ${DATADIR}" - rm -rf "$DATADIR" "$extract_root" - mkdir -p "$parent_dir" "$extract_root" - tar --zstd -xf "$SNAPSHOT" -C "$extract_root" - - if [[ -d "${extract_root}/${base_name}/db" && -d "${extract_root}/${base_name}/static_files" ]]; then - candidate_datadir="${extract_root}/${base_name}" - else - while IFS= read -r nested_dir; do - if [[ -d "${nested_dir}/db" && -d "${nested_dir}/static_files" ]]; then - nested_candidates+=("$nested_dir") - fi - done < <(find "$extract_root" -mindepth 1 -maxdepth 1 -type d | sort) - - if ((${#nested_candidates[@]} == 1)); then - candidate_datadir="${nested_candidates[0]}" - elif ((${#nested_candidates[@]} > 1)); then - log "Snapshot layout produced multiple nested datadir candidates under ${extract_root}: ${nested_candidates[*]}" - exit 2 - elif [[ -d "${extract_root}/db" && -d "${extract_root}/static_files" ]]; then - candidate_datadir="$extract_root" - fi - fi - - if [[ -z "$candidate_datadir" ]]; then - log "Snapshot layout did not produce an expected datadir under ${extract_root}" - exit 2 - fi - - if [[ "$candidate_datadir" == "$extract_root" ]]; then - mv "$extract_root" "$DATADIR" - else - mv "$candidate_datadir" "$DATADIR" - rm -rf "$extract_root" - fi - - if [[ ! -f "$JWT_SECRET" ]]; then - log "Restored datadir is missing jwt secret; generating ${JWT_SECRET}" - mkdir -p "$(dirname "$JWT_SECRET")" - umask 077 - head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' >"$JWT_SECRET" - printf '\n' >>"$JWT_SECRET" - fi -} - -start_node() { - local log_file="$1" - - "$RETH_BIN" node \ - --datadir "$DATADIR" \ - --chain "$CHAIN" \ - --http --http.addr 127.0.0.1 --http.port 8545 --http.api eth,net,web3,reth \ - --ws --ws.addr 127.0.0.1 --ws.port 8546 --ws.api eth,net,web3,reth \ - --authrpc.addr 127.0.0.1 --authrpc.port 8551 --authrpc.jwtsecret "$JWT_SECRET" \ - --disable-discovery \ - --engine.persistence-threshold 10 \ - --engine.deferred-trie-blocks 3 \ - --engine.accept-execution-requests-hash \ - --log.stdout.filter 'info,providers::db=debug,reth::providers::static_file=debug,reth::storage=debug,consensus::engine=debug' \ - --color never \ - >"$log_file" 2>&1 & - echo $! -} - -start_unwind_node() { - local log_file="$1" - local trace_log="$2" - - "$RETH_BIN" node \ - --datadir "$DATADIR" \ - --chain "$CHAIN" \ - --http --http.addr 127.0.0.1 --http.port 8545 --http.api eth,net,web3,reth \ - --ws --ws.addr 127.0.0.1 --ws.port 8546 --ws.api eth,net,web3,reth \ - --authrpc.addr 127.0.0.1 --authrpc.port 8551 --authrpc.jwtsecret "$JWT_SECRET" \ - --disable-discovery \ - --engine.persistence-threshold 10 \ - --engine.deferred-trie-blocks 3 \ - --engine.accept-execution-requests-hash \ - --log.stdout.filter trace \ - --color never \ - > >(tee "$trace_log" >"$log_file") 2>&1 & - echo $! -} - -wait_for_rpc_start() { - local pid="$1" - local timeout="$2" - local label="$3" - local elapsed=0 - local block_hex - - while (( elapsed < timeout )); do - block_hex=$(head_hex || true) - if [[ -n "$block_hex" ]]; then - printf '%s\n' "$block_hex" - return 0 - fi - - if ! kill -0 "$pid" 2>/dev/null; then - log "${label} exited before RPC became ready" - return 1 - fi - - sleep 1 - ((elapsed += 1)) - done - - log "Timed out waiting for ${label} RPC readiness" - return 1 -} - -wait_for_target_head() { - local pid="$1" - local target="$2" - local timeout="$3" - local elapsed=0 - local block_hex - local block_dec - - while (( elapsed < timeout )); do - block_hex=$(head_hex || true) - if [[ -n "$block_hex" ]]; then - block_dec=$(hex_to_dec "$block_hex") - if (( block_dec >= target )); then - printf '%s\n' "$block_dec" - return 0 - fi - fi - - if ! kill -0 "$pid" 2>/dev/null; then - log "Node exited before reaching target head ${target}" - return 1 - fi - - sleep 1 - ((elapsed += 1)) - done - - log "Timed out waiting for local head to reach ${target}" - return 1 -} - -wait_for_persistence_marker() { - local pid="$1" - local log_file="$2" - local start_line="$3" - local timeout="$4" - local elapsed=0 - - while (( elapsed <= timeout )); do - if grep -E -m1 \ - 'save_blocks step plan|save_blocks trie paths|write_trie_updates|Persisting canonical chain|Appended block data range' \ - < <(tail -n "+${start_line}" "$log_file") \ - >/dev/null 2>&1; then - return 0 - fi - - if ! kill -0 "$pid" 2>/dev/null; then - log "Node exited before emitting a post-target persistence marker" - return 1 - fi - - sleep 1 - ((elapsed += 1)) - done - - log "Timed out waiting for a post-target persistence marker" - return 1 -} - -stop_bench() { - if [[ -n "$BENCH_PID" ]] && kill -0 "$BENCH_PID" 2>/dev/null; then - stop_pid "$BENCH_PID" TERM "reth-bench" - if ! wait_for_pid_exit "$BENCH_PID" 10; then - stop_pid "$BENCH_PID" KILL "reth-bench" - wait "$BENCH_PID" 2>/dev/null || true - else - wait "$BENCH_PID" 2>/dev/null || true - fi - elif [[ -n "$BENCH_PID" ]]; then - wait "$BENCH_PID" 2>/dev/null || true - fi - - BENCH_PID="" -} - -remove_stale_locks() { - rm -f "$DATADIR/db/lock" "$DATADIR/static_files/lock" "$DATADIR/rocksdb/LOCK" -} - -stop_restart_node() { - if [[ -n "$NODE2_PID" ]] && kill -0 "$NODE2_PID" 2>/dev/null; then - stop_pid "$NODE2_PID" TERM "reth restart node" - if ! wait_for_pid_exit "$NODE2_PID" 30; then - stop_pid "$NODE2_PID" KILL "reth restart node" - fi - fi - - if [[ -n "$NODE2_PID" ]]; then - wait "$NODE2_PID" 2>/dev/null || true - fi - - NODE2_PID="" -} - -extract_unwind_target() { - local log_file="$1" - - sed -n 's/.*unwind_target=Unwind(\([0-9]\+\)).*/\1/p' "$log_file" | head -n1 -} - -run_drop_merkle() { - log "Dropping the Merkle stage before the targeted unwind rerun" - "$RETH_BIN" stage drop \ - --datadir "$DATADIR" \ - --chain "$CHAIN" \ - --log.stdout.filter info \ - --color never \ - merkle \ - >"$DROP_MERKLE_LOG" 2>&1 -} - -run_post_drop_unwind() { - local target="$1" - - log "Re-running unwind without trace capture to restore the pre-failure head" - "$RETH_BIN" stage unwind \ - --datadir "$DATADIR" \ - --chain "$CHAIN" \ - --log.stdout.filter info \ - --color never \ - to-block "$target" \ - >"$POST_DROP_UNWIND_LOG" 2>&1 -} - -run_post_drop_merkle() { - local target="$1" - local merkle_pid - - log "Rebuilding the Merkle stage without trace capture in ${POST_DROP_MERKLE_RUN_LOG}" - ( - "$RETH_BIN" stage run \ - --datadir "$DATADIR" \ - --chain "$CHAIN" \ - --from 0 \ - --to "$target" \ - --skip-unwind \ - --checkpoints \ - --commit \ - --disable-discovery \ - --log.stdout.filter "$MERKLE_TRACE_FILTER" \ - --color never \ - merkle \ - >"$POST_DROP_MERKLE_RUN_LOG" 2>&1 - ) & - merkle_pid=$! - - while kill -0 "$merkle_pid" 2>/dev/null; do - log "Waiting for the post-drop Merkle rebuild to finish" - sleep 300 - done - - wait "$merkle_pid" -} - -classify_restart() { - local pid="$1" - local log_file="$2" - local timeout="$3" - local elapsed=0 - local saw_unwind=0 - local rpc_ready_at=-1 - local block_hex - - while (( elapsed < timeout )); do - if grep -E -q 'Failed to verify block state root|failed to run unwind|mismatched block state root' "$log_file" 2>/dev/null; then - RESULT="unwind_failed" - return 0 - fi - - if grep -E -q 'Executing unwind after consistency check|inconsistency_source=partial state trie' "$log_file" 2>/dev/null; then - saw_unwind=1 - fi - - block_hex=$(head_hex || true) - if [[ -n "$block_hex" ]]; then - HEAD_AFTER_RESTART=$(hex_to_dec "$block_hex") - if (( rpc_ready_at < 0 )); then - rpc_ready_at=$elapsed - log "Restart RPC became ready at head ${HEAD_AFTER_RESTART}" - fi - fi - - if (( rpc_ready_at >= 0 && elapsed >= rpc_ready_at + 10 )); then - if (( saw_unwind == 1 )); then - RESULT="unwind_succeeded" - else - RESULT="no_unwind_detected" - fi - return 0 - fi - - if ! kill -0 "$pid" 2>/dev/null; then - if grep -E -q 'Failed to verify block state root|failed to run unwind|mismatched block state root' "$log_file" 2>/dev/null; then - RESULT="unwind_failed" - return 0 - fi - - RESULT="restart_exited_before_rpc_ready" - return 1 - fi - - sleep 1 - ((elapsed += 1)) - done - - RESULT="restart_timeout" - return 1 -} - -restore_snapshot - -log "Starting reth for replay run" -NODE1_PID=$(start_node "$NODE1_LOG") - -HEAD_HEX=$(wait_for_rpc_start "$NODE1_PID" "$START_TIMEOUT" "initial node") || exit 2 -HEAD_BEFORE=$(hex_to_dec "$HEAD_HEX") -printf '%s\n' "$HEAD_BEFORE" >"${ARTIFACTS_DIR}/current_head_before.txt" - -if (( HEAD_BEFORE != EXPECTED_HEAD )); then - log "Expected restored head ${EXPECTED_HEAD}, got ${HEAD_BEFORE}" - exit 2 -fi - -if (( HEAD_BEFORE + 1 != START_BLOCK )); then - log "Expected first replay block ${START_BLOCK}, but restored head implies ${HEAD_BEFORE} -> $((HEAD_BEFORE + 1))" - exit 2 -fi - -if (( RANDOMIZE_TARGET_BLOCK == 1 )); then - TARGET_BLOCK_MODE="randomized" - TARGET_BLOCK_LOWER_BOUND="$START_BLOCK" - TARGET_BLOCK_UPPER_BOUND="$TARGET_BLOCK" - - if (( TARGET_BLOCK_UPPER_BOUND < TARGET_BLOCK_LOWER_BOUND )); then - log "Randomized target range ${TARGET_BLOCK_LOWER_BOUND}-${TARGET_BLOCK_UPPER_BOUND} is invalid" - exit 2 - fi - - TARGET_BLOCK=$((TARGET_BLOCK_LOWER_BOUND + RANDOM % (TARGET_BLOCK_UPPER_BOUND - TARGET_BLOCK_LOWER_BOUND + 1))) - log "Randomized crash target block ${TARGET_BLOCK} (range ${TARGET_BLOCK_LOWER_BOUND}-${TARGET_BLOCK_UPPER_BOUND})" -else - TARGET_BLOCK_MODE="fixed" - TARGET_BLOCK_LOWER_BOUND="$TARGET_BLOCK" - TARGET_BLOCK_UPPER_BOUND="$TARGET_BLOCK" -fi - -ADVANCE=$((TARGET_BLOCK - HEAD_BEFORE)) -if (( ADVANCE <= 0 )); then - log "Target block ${TARGET_BLOCK} must be greater than restored head ${HEAD_BEFORE}" - exit 2 -fi - -capture_command reth_bench "$BENCH_BIN" -vvv new-payload-fcu \ - --rpc-url "$REMOTE_RPC_URL" \ - --advance "$ADVANCE" \ - --jwt-secret "$JWT_SECRET" \ - --engine-rpc-url http://127.0.0.1:8551 \ - --local-rpc-url http://127.0.0.1:8545 \ - --ws-rpc-url ws://127.0.0.1:8546 - -log "Running reth-bench with --advance ${ADVANCE} so replay begins at block ${START_BLOCK} and crashes at ${TARGET_BLOCK}" -"$BENCH_BIN" -vvv new-payload-fcu \ - --rpc-url "$REMOTE_RPC_URL" \ - --advance "$ADVANCE" \ - --jwt-secret "$JWT_SECRET" \ - --engine-rpc-url http://127.0.0.1:8551 \ - --local-rpc-url http://127.0.0.1:8545 \ - --ws-rpc-url ws://127.0.0.1:8546 \ - >"$BENCH_LOG" 2>&1 & -BENCH_PID=$! - -HEAD_AT_CRASH=$(wait_for_target_head "$NODE1_PID" "$TARGET_BLOCK" "$TARGET_TIMEOUT") || exit 2 -printf '%s\n' "$HEAD_AT_CRASH" >"${ARTIFACTS_DIR}/current_head_at_crash.txt" -# Allow for a small race where the target-head poll returns after the relevant -# persistence logs were already emitted. -POST_TARGET_LINE=$(( $(wc -l <"$NODE1_LOG") - 50 )) -(( POST_TARGET_LINE < 1 )) && POST_TARGET_LINE=1 - -log "Target head ${TARGET_BLOCK} reached; sending SIGTERM to trigger the final persistence flush" -stop_pid "$NODE1_PID" TERM "reth crash node" - -log "Waiting for the shutdown-triggered persistence marker before crashing" -wait_for_persistence_marker "$NODE1_PID" "$NODE1_LOG" "$POST_TARGET_LINE" "$PERSISTENCE_TIMEOUT" || exit 2 - -log "Crashing reth with SIGKILL" -stop_pid "$NODE1_PID" KILL "reth crash node" -wait "$NODE1_PID" 2>/dev/null || true -NODE1_PID="" - -stop_bench -remove_stale_locks - -log "Restarting reth to classify unwind behavior" -NODE2_PID=$(start_unwind_node "$NODE2_LOG" "$RESTART_TRACE_LOG") -classify_restart "$NODE2_PID" "$NODE2_LOG" "$RESTART_TIMEOUT" || exit 2 - -FAILED_UNWIND_TARGET=$(extract_unwind_target "$NODE2_LOG" || true) -if [[ -n "$FAILED_UNWIND_TARGET" ]]; then - printf '%s\n' "$FAILED_UNWIND_TARGET" >"${ARTIFACTS_DIR}/failed_unwind_target.txt" -fi - -stop_restart_node -remove_stale_locks - -if [[ "$RESULT" == "unwind_failed" || "$RESULT" == "unwind_succeeded" ]]; then - if [[ -z "$FAILED_UNWIND_TARGET" ]]; then - log "Failed to extract unwind_target from ${NODE2_LOG}" - exit 2 - fi - - capture_command reth_stage_unwind "$RETH_BIN" stage unwind \ - --datadir "$DATADIR" \ - --chain "$CHAIN" \ - --log.stdout.filter info \ - --color never \ - to-block "$FAILED_UNWIND_TARGET" - - capture_command reth_stage_run_merkle "$RETH_BIN" stage run \ - --datadir "$DATADIR" \ - --chain "$CHAIN" \ - --from 0 \ - --to "$FAILED_UNWIND_TARGET" \ - --skip-unwind \ - --checkpoints \ - --commit \ - --disable-discovery \ - --log.stdout.filter "$MERKLE_TRACE_FILTER" \ - --color never \ - merkle - - if ! run_drop_merkle; then - DROP_MERKLE_RESULT="failed" - exit 2 - fi - DROP_MERKLE_RESULT="ok" - - if ! run_post_drop_unwind "$FAILED_UNWIND_TARGET"; then - POST_DROP_UNWIND_RESULT="failed" - exit 2 - fi - POST_DROP_UNWIND_RESULT="ok" - - remove_stale_locks - - if ! run_post_drop_merkle "$FAILED_UNWIND_TARGET"; then - POST_DROP_MERKLE_RUN_RESULT="failed" - exit 2 - fi - POST_DROP_MERKLE_RUN_RESULT="ok" -else - DROP_MERKLE_RESULT="skipped_no_unwind_target" - POST_DROP_UNWIND_RESULT="skipped_no_unwind_target" - POST_DROP_MERKLE_RUN_RESULT="skipped_no_unwind_target" -fi - -log "Restart result: ${RESULT}" From c7b45826dec351600aa5e954b1a6805968df7127 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Sun, 10 May 2026 10:10:09 +0000 Subject: [PATCH 73/83] chore(engine): tidy origin main merge Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019e1154-168a-72ba-a57c-1d5831f60951 Co-authored-by: Amp --- crates/evm/evm/src/execute.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/evm/evm/src/execute.rs b/crates/evm/evm/src/execute.rs index 98619048905..19c148c31f6 100644 --- a/crates/evm/evm/src/execute.rs +++ b/crates/evm/evm/src/execute.rs @@ -503,6 +503,7 @@ where // merge all transitions into bundle state db.merge_transitions(BundleRetention::Reverts); + let block_access_list = db.take_built_alloy_bal(); let block_access_list_hash = block_access_list.as_ref().map(|bal| compute_block_access_list_hash(bal)); From 69a646939a99b3720c453ce0f02a5585f9893cca Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Sun, 10 May 2026 10:17:27 +0000 Subject: [PATCH 74/83] chore(engine): remove guarded debug tracing Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019e1154-168a-72ba-a57c-1d5831f60951 Co-authored-by: Amp --- crates/chain-state/src/lazy_overlay.rs | 60 -------- crates/engine/tree/src/tree/mod.rs | 10 -- .../engine/tree/src/tree/payload_validator.rs | 70 ---------- .../src/providers/database/provider.rs | 129 +----------------- .../provider/src/providers/state/overlay.rs | 41 ------ 5 files changed, 2 insertions(+), 308 deletions(-) diff --git a/crates/chain-state/src/lazy_overlay.rs b/crates/chain-state/src/lazy_overlay.rs index 3ebe3f426d3..d0a779613c7 100644 --- a/crates/chain-state/src/lazy_overlay.rs +++ b/crates/chain-state/src/lazy_overlay.rs @@ -71,18 +71,6 @@ impl LazyOverlay { "LazyOverlay blocks must be ordered newest to oldest along a single chain" ); - if tracing::enabled!(target: "chain_state::lazy_overlay", tracing::Level::DEBUG) { - debug!( - target: "chain_state::lazy_overlay", - num_blocks = blocks.len(), - tip = ?blocks.first().map(block_summary), - oldest = ?blocks.last().map(block_summary), - anchor_hash = ?blocks.last().map(|block| block.recovered_block().parent_hash()), - blocks = ?blocks.iter().map(block_summary).collect::>(), - "Creating lazy overlay" - ); - } - Self { inner: Default::default(), inputs: LazyOverlayInputs { blocks } } } @@ -91,11 +79,6 @@ impl LazyOverlay { self.inputs.blocks.len() } - /// Returns a compact summary of the blocks captured by this overlay. - pub fn block_summaries(&self) -> Vec { - self.inputs.blocks.iter().map(block_summary).collect() - } - /// Returns the oldest anchor hash this overlay can serve. /// /// This is the parent hash of the oldest block in the stored newest-to-oldest chain segment. @@ -158,30 +141,12 @@ impl LazyOverlay { let Some(last_index) = blocks.iter().position(|block| block.recovered_block().parent_hash() == anchor_hash) else { - if tracing::enabled!(target: "chain_state::lazy_overlay", tracing::Level::DEBUG) { - debug!( - target: "chain_state::lazy_overlay", - %anchor_hash, - available_blocks = ?blocks.iter().map(block_summary).collect::>(), - "Lazy overlay requested missing anchor" - ); - } panic!( "LazyOverlay does not contain a block whose parent hash matches requested anchor {anchor_hash}" ); }; let blocks = &blocks[..=last_index]; - if tracing::enabled!(target: "chain_state::lazy_overlay", tracing::Level::DEBUG) { - debug!( - target: "chain_state::lazy_overlay", - %anchor_hash, - num_selected_blocks = blocks.len(), - selected_blocks = ?blocks.iter().map(block_summary).collect::>(), - "Computing lazy overlay for anchor" - ); - } - // Fast path: Check if tip block's overlay is ready and anchor matches. // The tip block (first in list) has the cumulative overlay from all ancestors up to the // requested anchor. @@ -189,17 +154,6 @@ impl LazyOverlay { let data = tip.trie_data(); if let Some(anchored) = &data.anchored_trie_input { if anchored.anchor_hash == anchor_hash { - if tracing::enabled!(target: "chain_state::lazy_overlay", tracing::Level::DEBUG) - { - debug!( - target: "chain_state::lazy_overlay", - %anchor_hash, - tip = ?block_summary(tip), - trie_updates = anchored.trie_input.nodes.total_len(), - hashed_state = anchored.trie_input.state.total_len(), - "Reusing tip block's cached overlay (fast path)" - ); - } return Arc::clone(&anchored.trie_input); } debug!( @@ -212,15 +166,6 @@ impl LazyOverlay { } // Slow path: Merge the prefix of blocks from the tip back to the requested anchor. - if tracing::enabled!(target: "chain_state::lazy_overlay", tracing::Level::DEBUG) { - debug!( - target: "chain_state::lazy_overlay", - %anchor_hash, - num_blocks = blocks.len(), - blocks = ?blocks.iter().map(block_summary).collect::>(), - "Merging blocks (slow path)" - ); - } Arc::new(Self::merge_blocks(blocks)) } @@ -243,11 +188,6 @@ impl LazyOverlay { } } -fn block_summary(block: &ExecutedBlock) -> String { - let recovered = block.recovered_block(); - format!("#{} hash={} parent={}", recovered.number(), recovered.hash(), recovered.parent_hash()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/tree/src/tree/mod.rs b/crates/engine/tree/src/tree/mod.rs index 2b59824700b..f21170da689 100644 --- a/crates/engine/tree/src/tree/mod.rs +++ b/crates/engine/tree/src/tree/mod.rs @@ -1413,16 +1413,6 @@ where } let last_block = plan.last_block().expect("checked non-empty persisting blocks"); - - if tracing::enabled!(target: "engine::tree", tracing::Level::DEBUG) { - debug!( - target: "engine::tree", - count = plan.blocks.len(), - steps = ?plan.steps, - blocks = ?plan.blocks.iter().map(|block| block.recovered_block().num_hash()).collect::>(), - "Persisting blocks" - ); - } let (tx, rx) = crossbeam_channel::bounded(1); let _ = self.persistence.save_blocks(plan, tx); diff --git a/crates/engine/tree/src/tree/payload_validator.rs b/crates/engine/tree/src/tree/payload_validator.rs index b0b1690b97b..82bec583f34 100644 --- a/crates/engine/tree/src/tree/payload_validator.rs +++ b/crates/engine/tree/src/tree/payload_validator.rs @@ -91,7 +91,6 @@ use reth_provider::{ StorageChangeSetReader, StorageSettingsCache, }; use reth_revm::db::{states::bundle_state::BundleRetention, BundleAccount, State}; -use reth_stages_api::StageId; use reth_trie::{trie_cursor::TrieCursorFactory, updates::TrieUpdates, HashedPostState}; use reth_trie_db::ChangesetCache; use reth_trie_parallel::root::{ParallelStateRoot, ParallelStateRootError}; @@ -2218,75 +2217,6 @@ where state: &EngineApiTreeState, ) -> Option { let (lazy_overlay, anchor_hash) = Self::get_parent_lazy_overlay(parent_hash, state); - if tracing::enabled!(target: "engine::tree::payload_validator", tracing::Level::DEBUG) { - let lazy_anchor = lazy_overlay.as_ref().and_then(LazyOverlay::anchor_hash); - let lazy_blocks = lazy_overlay.as_ref().map(LazyOverlay::block_summaries); - match self.provider.database_provider_ro() { - Ok(provider) => match provider.get_stage_checkpoint(StageId::Finish) { - Ok(Some(checkpoint)) => { - let finish_tip_number = checkpoint.block_number; - let partial_state_trie_number = checkpoint - .finish_stage_checkpoint() - .and_then(|finish| finish.partial_state_trie) - .unwrap_or(finish_tip_number); - let partial_state_trie_hash = provider - .convert_number(partial_state_trie_number.into()) - .ok() - .flatten(); - let finish_tip_hash = - provider.convert_number(finish_tip_number.into()).ok().flatten(); - debug!( - target: "engine::tree::payload_validator", - %parent_hash, - %parent_state_root, - %anchor_hash, - ?lazy_anchor, - ?lazy_blocks, - partial_state_trie_number, - ?partial_state_trie_hash, - finish_tip_number, - ?finish_tip_hash, - "Preparing payload builder sparse trie overlay" - ); - } - Ok(None) => { - debug!( - target: "engine::tree::payload_validator", - %parent_hash, - %parent_state_root, - %anchor_hash, - ?lazy_anchor, - ?lazy_blocks, - "Preparing payload builder sparse trie overlay without finish checkpoint" - ); - } - Err(err) => { - debug!( - target: "engine::tree::payload_validator", - %parent_hash, - %parent_state_root, - %anchor_hash, - ?lazy_anchor, - ?lazy_blocks, - %err, - "Preparing payload builder sparse trie overlay without database frontiers" - ); - } - }, - Err(err) => { - debug!( - target: "engine::tree::payload_validator", - %parent_hash, - %parent_state_root, - %anchor_hash, - ?lazy_anchor, - ?lazy_blocks, - %err, - "Preparing payload builder sparse trie overlay without database frontiers" - ); - } - } - } let overlay_factory = OverlayStateProviderFactory::new( self.provider.clone(), OverlayBuilder::::new(anchor_hash, self.changeset_cache.clone()) diff --git a/crates/storage/provider/src/providers/database/provider.rs b/crates/storage/provider/src/providers/database/provider.rs index c8e4c22f144..447abee965d 100644 --- a/crates/storage/provider/src/providers/database/provider.rs +++ b/crates/storage/provider/src/providers/database/provider.rs @@ -68,7 +68,7 @@ use reth_storage_api::{ use reth_storage_errors::provider::{ProviderResult, StaticFileWriterError}; use reth_trie::{ updates::{StorageTrieUpdatesSorted, TrieUpdatesSorted}, - HashedPostStateSorted, Nibbles, + HashedPostStateSorted, }; use reth_trie_db::{ChangesetCache, DatabaseStorageTrieCursor, TrieTableAdapter}; use revm_database::states::{ @@ -102,60 +102,6 @@ impl CommitOrder { } } -fn format_trie_node_path(path: &Nibbles) -> String { - let mut formatted = String::from("0x"); - for nibble in path.iter() { - formatted.push(char::from_digit(nibble as u32, 16).expect("nibbles are always hex")); - } - formatted -} - -fn format_branch_node_compact(node: &reth_trie::BranchNodeCompact) -> String { - format!( - "state_mask={:?} tree_mask={:?} hash_mask={:?} hashes={:?} root_hash={:?}", - node.state_mask, node.tree_mask, node.hash_mask, node.hashes, node.root_hash - ) -} - -fn format_trie_node_update(node: Option<&reth_trie::BranchNodeCompact>) -> String { - match node { - Some(node) => format!("upsert {}", format_branch_node_compact(node)), - None => "remove".to_string(), - } -} - -fn collect_all_trie_nodes(trie_updates: &TrieUpdatesSorted) -> Vec { - let mut nodes = trie_updates - .account_nodes_ref() - .iter() - .map(|(path, node)| { - format!( - "account {} {}", - format_trie_node_path(path), - format_trie_node_update(node.as_ref()) - ) - }) - .collect::>(); - - for (hashed_address, storage_trie) in - trie_updates.storage_tries_ref().iter().sorted_by_key(|(hashed_address, _)| *hashed_address) - { - if storage_trie.is_deleted() { - nodes.push(format!("storage {hashed_address:#x} delete trie")); - } - - nodes.extend(storage_trie.storage_nodes_ref().iter().map(|(path, node)| { - format!( - "storage {hashed_address:#x}@{} {}", - format_trie_node_path(path), - format_trie_node_update(node.as_ref()) - ) - })); - } - - nodes -} - /// A [`DatabaseProvider`] that holds a read-only database transaction. pub type DatabaseProviderRO = DatabaseProvider<::TX, N>; @@ -663,60 +609,6 @@ impl DatabaseProvider>(); - let masking_blocks = step - .state_trie_masking_range - .as_ref() - .map(|range| { - blocks[range.clone()] - .iter() - .map(|block| block.recovered_block().num_hash()) - .collect::>() - }) - .unwrap_or_default(); - - ( - step_index, - step.block_range.clone(), - step.persist_rest, - step.state_trie_masking_range.clone(), - step_blocks, - masking_blocks, - ) - }) - .collect::>(); - - debug!(target: "providers::db", ?step_plan, "save_blocks step plan"); - - if save_mode.with_state() { - let per_block_trie_updates = blocks - .iter() - .map(|block| { - ( - block.recovered_block().number(), - collect_all_trie_nodes(block.trie_data().trie_updates.as_ref()), - ) - }) - .collect::>(); - - debug!( - target: "providers::db", - range = ?first_number..=last_block_number, - per_block_trie_updates = ?per_block_trie_updates, - "save_blocks per-block trie updates" - ); - } - } - let tx_nums: Vec = if persist_rest_blocks.is_empty() { Vec::new() } else { @@ -860,7 +752,7 @@ impl DatabaseProvider DatabaseProvider>(); - let masking_trie_updates = masking_trie_data - .iter() - .map(|data| data.trie_updates.as_ref()) - .collect::>(); - let merged_masking_trie = TrieUpdatesSorted::merge_slice(&masking_trie_updates); - let start = Instant::now(); let merged_hashed_state = HashedPostStateSorted::disjointed_merge_batch( step_trie_data.iter().map(|data| data.hashed_state.as_ref()).collect(), @@ -929,17 +815,6 @@ impl DatabaseProvider OverlayBuilder { hashed_state_overlay: Option>, ) -> Self { if let Some(state) = hashed_state_overlay { - if tracing::enabled!(target: "providers::state::overlay", tracing::Level::DEBUG) { - debug!( - target: "providers::state::overlay", - anchor_hash = ?self.anchor_hash, - hashed_state_updates = state.total_len(), - "Configuring immediate hashed-state overlay" - ); - } self.overlay_source = Some(OverlaySource::Immediate { trie: Arc::new(TrieUpdatesSorted::default()), state, @@ -191,15 +183,6 @@ impl OverlayBuilder { /// If no overlay exists, creates a new immediate overlay with the given state. /// If a lazy overlay exists, it is resolved first then extended. pub fn with_extended_hashed_state_overlay(mut self, other: HashedPostStateSorted) -> Self { - if tracing::enabled!(target: "providers::state::overlay", tracing::Level::DEBUG) { - debug!( - target: "providers::state::overlay", - anchor_hash = ?self.anchor_hash, - existing_source = overlay_source_kind(self.overlay_source.as_ref()), - added_hashed_state_updates = other.total_len(), - "Extending hashed-state overlay" - ); - } match &mut self.overlay_source { Some(OverlaySource::Immediate { state, .. }) => { Arc::make_mut(state).extend_ref_and_sort(&other); @@ -244,20 +227,6 @@ impl OverlayBuilder { } }; - if tracing::enabled!(target: "providers::state::overlay", tracing::Level::DEBUG) { - debug!( - target: "providers::state::overlay", - requested_anchor_hash = ?anchor_hash, - builder_anchor_hash = ?self.anchor_hash, - source = overlay_source_kind(self.overlay_source.as_ref()), - source_anchor = ?self.overlay_source.as_ref().and_then(overlay_source_anchor), - source_blocks = ?self.overlay_source.as_ref().and_then(overlay_source_num_blocks), - resolved_trie_updates = result.0.total_len(), - resolved_hashed_state = result.1.total_len(), - "Resolved overlay source" - ); - } - Ok(result) } @@ -760,16 +729,6 @@ where let Overlay { trie_updates, hashed_post_state } = self.get_overlay(&provider)?; let is_v2 = provider.cached_storage_settings().is_v2(); - if tracing::enabled!(target: "providers::state::overlay", tracing::Level::DEBUG) { - debug!( - target: "providers::state::overlay", - anchor_hash = ?self.overlay_builder.anchor_hash, - trie_updates = trie_updates.total_len(), - hashed_state = hashed_post_state.total_len(), - is_v2, - "Created overlay state provider" - ); - } self.overlay_builder.metrics.database_provider_ro_duration.record(overall_start.elapsed()); Ok(OverlayStateProvider::new(provider, trie_updates, hashed_post_state, is_v2)) } From e55fc9a2e93a14435671e9011b8aa6baedefb1f7 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Mon, 11 May 2026 07:52:57 +0000 Subject: [PATCH 75/83] fix(engine): rename state masking CLI flag Co-Authored-By: Brian Picciano <933154+mediocregopher@users.noreply.github.com> --- crates/engine/primitives/src/config.rs | 12 +++---- crates/engine/tree/src/tree/tests.rs | 2 +- crates/node/core/src/args/engine.rs | 47 +++++++++++++------------- docs/vocs/docs/pages/cli/reth/node.mdx | 2 +- 4 files changed, 32 insertions(+), 31 deletions(-) diff --git a/crates/engine/primitives/src/config.rs b/crates/engine/primitives/src/config.rs index 0f7614cf841..c2bf942b2ef 100644 --- a/crates/engine/primitives/src/config.rs +++ b/crates/engine/primitives/src/config.rs @@ -8,7 +8,7 @@ pub const DEFAULT_PERSISTENCE_THRESHOLD: u64 = 2; /// Maximum number of consecutive canonical blocks whose non-trie outputs may be persisted ahead /// of trie persistence. -pub const DEFAULT_DEFERRED_TRIE_BLOCKS: u64 = 0; +pub const DEFAULT_NUM_STATE_MASKING_BLOCKS: u64 = 0; /// How close to the canonical head we persist blocks. pub const DEFAULT_MEMORY_BLOCK_BUFFER_TARGET: u64 = 0; @@ -248,12 +248,12 @@ impl Default for TreeConfig { ); assert_state_masking_invariant( DEFAULT_PERSISTENCE_THRESHOLD, - DEFAULT_DEFERRED_TRIE_BLOCKS, + DEFAULT_NUM_STATE_MASKING_BLOCKS, DEFAULT_MEMORY_BLOCK_BUFFER_TARGET, ); Self { persistence_threshold: DEFAULT_PERSISTENCE_THRESHOLD, - num_state_masking_blocks: DEFAULT_DEFERRED_TRIE_BLOCKS, + num_state_masking_blocks: DEFAULT_NUM_STATE_MASKING_BLOCKS, memory_block_buffer_target: DEFAULT_MEMORY_BLOCK_BUFFER_TARGET, persistence_backpressure_threshold, block_buffer_limit: DEFAULT_BLOCK_BUFFER_LIMIT, @@ -843,8 +843,8 @@ impl TreeConfig { #[cfg(test)] mod tests { use super::{ - default_persistence_backpressure_threshold, TreeConfig, DEFAULT_DEFERRED_TRIE_BLOCKS, - DEFAULT_MEMORY_BLOCK_BUFFER_TARGET, DEFAULT_PERSISTENCE_THRESHOLD, + default_persistence_backpressure_threshold, TreeConfig, DEFAULT_MEMORY_BLOCK_BUFFER_TARGET, + DEFAULT_NUM_STATE_MASKING_BLOCKS, DEFAULT_PERSISTENCE_THRESHOLD, }; #[test] @@ -852,7 +852,7 @@ mod tests { let config = TreeConfig::default(); assert_eq!(config.persistence_threshold(), DEFAULT_PERSISTENCE_THRESHOLD); - assert_eq!(config.num_state_masking_blocks(), DEFAULT_DEFERRED_TRIE_BLOCKS); + assert_eq!(config.num_state_masking_blocks(), DEFAULT_NUM_STATE_MASKING_BLOCKS); assert_eq!(config.memory_block_buffer_target(), DEFAULT_MEMORY_BLOCK_BUFFER_TARGET); assert_eq!( config.persistence_backpressure_threshold(), diff --git a/crates/engine/tree/src/tree/tests.rs b/crates/engine/tree/src/tree/tests.rs index 147da2bc68b..5a3d15b5743 100644 --- a/crates/engine/tree/src/tree/tests.rs +++ b/crates/engine/tree/src/tree/tests.rs @@ -1040,7 +1040,7 @@ async fn test_get_canonical_blocks_to_persist() { } #[test] -fn test_get_save_blocks_plan_with_deferred_trie_blocks() { +fn test_get_save_blocks_plan_with_state_masking_blocks() { let chain_spec = MAINNET.clone(); let mut test_harness = TestHarness::new(chain_spec); let mut test_block_builder = TestBlockBuilder::eth(); diff --git a/crates/node/core/src/args/engine.rs b/crates/node/core/src/args/engine.rs index 51de5997f93..08c4b827365 100644 --- a/crates/node/core/src/args/engine.rs +++ b/crates/node/core/src/args/engine.rs @@ -4,9 +4,10 @@ use clap::{builder::Resettable, Args}; use eyre::ensure; use reth_cli_util::{parse_duration_from_secs_or_ms, parsers::format_duration_as_secs_or_ms}; use reth_engine_primitives::{ - default_persistence_backpressure_threshold, TreeConfig, DEFAULT_DEFERRED_TRIE_BLOCKS, + default_persistence_backpressure_threshold, TreeConfig, DEFAULT_INVALID_HEADER_HIT_EVICTION_THRESHOLD, DEFAULT_MULTIPROOF_TASK_CHUNK_SIZE, - DEFAULT_SPARSE_TRIE_MAX_HOT_ACCOUNTS, DEFAULT_SPARSE_TRIE_MAX_HOT_SLOTS, + DEFAULT_NUM_STATE_MASKING_BLOCKS, DEFAULT_SPARSE_TRIE_MAX_HOT_ACCOUNTS, + DEFAULT_SPARSE_TRIE_MAX_HOT_SLOTS, }; use std::{sync::OnceLock, time::Duration}; @@ -25,7 +26,7 @@ static ENGINE_DEFAULTS: OnceLock = OnceLock::new(); pub struct DefaultEngineValues { persistence_threshold: u64, persistence_backpressure_threshold: Option, - deferred_trie_blocks: u64, + num_state_masking_blocks: u64, memory_block_buffer_target: u64, invalid_header_hit_eviction_threshold: u8, legacy_state_root_task_enabled: bool, @@ -91,9 +92,9 @@ impl DefaultEngineValues { self } - /// Set the default deferred trie block target - pub const fn with_deferred_trie_blocks(mut self, v: u64) -> Self { - self.deferred_trie_blocks = v; + /// Set the default number of state masking blocks. + pub const fn with_num_state_masking_blocks(mut self, v: u64) -> Self { + self.num_state_masking_blocks = v; self } @@ -280,7 +281,7 @@ impl Default for DefaultEngineValues { Self { persistence_threshold: DEFAULT_PERSISTENCE_THRESHOLD, persistence_backpressure_threshold: None, - deferred_trie_blocks: DEFAULT_DEFERRED_TRIE_BLOCKS, + num_state_masking_blocks: DEFAULT_NUM_STATE_MASKING_BLOCKS, memory_block_buffer_target: DEFAULT_MEMORY_BLOCK_BUFFER_TARGET, invalid_header_hit_eviction_threshold: DEFAULT_INVALID_HEADER_HIT_EVICTION_THRESHOLD, legacy_state_root_task_enabled: false, @@ -335,8 +336,8 @@ pub struct EngineArgs { /// Configure how many of the blocks being persisted should only mask state/trie writes instead /// of durably persisting their state/trie updates in the current cycle. - #[arg(long = "engine.deferred-trie-blocks", default_value_t = DefaultEngineValues::get_global().deferred_trie_blocks)] - pub deferred_trie_blocks: u64, + #[arg(long = "engine.num-state-masking-blocks", default_value_t = DefaultEngineValues::get_global().num_state_masking_blocks)] + pub num_state_masking_blocks: u64, /// Configure the target number of blocks to keep in memory. #[arg(long = "engine.memory-block-buffer-target", default_value_t = DefaultEngineValues::get_global().memory_block_buffer_target)] @@ -569,7 +570,7 @@ impl Default for EngineArgs { let DefaultEngineValues { persistence_threshold, persistence_backpressure_threshold, - deferred_trie_blocks, + num_state_masking_blocks, memory_block_buffer_target, invalid_header_hit_eviction_threshold, legacy_state_root_task_enabled, @@ -610,7 +611,7 @@ impl Default for EngineArgs { ) }, ), - deferred_trie_blocks, + num_state_masking_blocks, memory_block_buffer_target, invalid_header_hit_eviction_threshold, legacy_state_root_task_enabled, @@ -663,9 +664,9 @@ impl EngineArgs { self.persistence_threshold ); ensure!( - self.deferred_trie_blocks + self.memory_block_buffer_target < self.persistence_threshold, - "--engine.deferred-trie-blocks ({}) + --engine.memory-block-buffer-target ({}) must be less than --engine.persistence-threshold ({})", - self.deferred_trie_blocks, + self.num_state_masking_blocks + self.memory_block_buffer_target < self.persistence_threshold, + "--engine.num-state-masking-blocks ({}) + --engine.memory-block-buffer-target ({}) must be less than --engine.persistence-threshold ({})", + self.num_state_masking_blocks, self.memory_block_buffer_target, self.persistence_threshold, ); @@ -681,7 +682,7 @@ impl EngineArgs { let config = TreeConfig::default() .with_persistence_threshold(self.persistence_threshold) .with_persistence_backpressure_threshold(self.persistence_backpressure_threshold) - .with_num_state_masking_blocks(self.deferred_trie_blocks) + .with_num_state_masking_blocks(self.num_state_masking_blocks) .with_memory_block_buffer_target(self.memory_block_buffer_target) .with_invalid_header_hit_eviction_threshold(self.invalid_header_hit_eviction_threshold) .with_legacy_state_root(self.legacy_state_root_task_enabled) @@ -763,7 +764,7 @@ mod tests { let args = EngineArgs::default(); assert_eq!(args.persistence_threshold, DEFAULT_PERSISTENCE_THRESHOLD); - assert_eq!(args.deferred_trie_blocks, DEFAULT_DEFERRED_TRIE_BLOCKS); + assert_eq!(args.num_state_masking_blocks, DEFAULT_NUM_STATE_MASKING_BLOCKS); assert_eq!(args.memory_block_buffer_target, DEFAULT_MEMORY_BLOCK_BUFFER_TARGET); assert_eq!( args.persistence_backpressure_threshold, @@ -780,7 +781,7 @@ mod tests { let args = EngineArgs { persistence_threshold: 100, persistence_backpressure_threshold: 101, - deferred_trie_blocks: 25, + num_state_masking_blocks: 25, memory_block_buffer_target: 50, invalid_header_hit_eviction_threshold: 7, legacy_state_root_task_enabled: true, @@ -825,7 +826,7 @@ mod tests { "100", "--engine.persistence-backpressure-threshold", "101", - "--engine.deferred-trie-blocks", + "--engine.num-state-masking-blocks", "25", "--engine.memory-block-buffer-target", "50", @@ -871,17 +872,17 @@ mod tests { } #[test] - fn test_parse_deferred_trie_blocks() { + fn test_parse_num_state_masking_blocks() { let args = CommandParser::::parse_from([ "reth", "--engine.persistence-threshold", "8", - "--engine.deferred-trie-blocks", + "--engine.num-state-masking-blocks", "7", ]) .args; - assert_eq!(args.deferred_trie_blocks, 7); + assert_eq!(args.num_state_masking_blocks, 7); assert_eq!(args.tree_config().num_state_masking_blocks(), 7); } @@ -902,13 +903,13 @@ mod tests { fn validate_rejects_state_masking_window_at_or_above_threshold() { let args = EngineArgs { persistence_threshold: 4, - deferred_trie_blocks: 2, + num_state_masking_blocks: 2, memory_block_buffer_target: 2, ..EngineArgs::default() }; let err = args.validate().unwrap_err().to_string(); - assert!(err.contains("engine.deferred-trie-blocks")); + assert!(err.contains("engine.num-state-masking-blocks")); assert!(err.contains("engine.memory-block-buffer-target")); assert!(err.contains("engine.persistence-threshold")); } diff --git a/docs/vocs/docs/pages/cli/reth/node.mdx b/docs/vocs/docs/pages/cli/reth/node.mdx index df19cca2e8a..83e39f1b696 100644 --- a/docs/vocs/docs/pages/cli/reth/node.mdx +++ b/docs/vocs/docs/pages/cli/reth/node.mdx @@ -973,7 +973,7 @@ Engine: [default: 16] - --engine.deferred-trie-blocks + --engine.num-state-masking-blocks Configure how many of the blocks being persisted should only mask state/trie writes instead of durably persisting their state/trie updates in the current cycle [default: 0] From 0644ee6f002673c8389a5052f701650773b1bc85 Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Mon, 11 May 2026 15:04:28 +0000 Subject: [PATCH 76/83] fix(engine): simplify disk reorg truncation Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019e1759-2890-7038-8151-a9a23a973f0b Co-authored-by: Amp --- crates/engine/tree/src/persistence.rs | 140 ++------------------------ crates/engine/tree/src/tree/mod.rs | 44 +------- 2 files changed, 7 insertions(+), 177 deletions(-) diff --git a/crates/engine/tree/src/persistence.rs b/crates/engine/tree/src/persistence.rs index fce548c7949..ec77418a3c8 100644 --- a/crates/engine/tree/src/persistence.rs +++ b/crates/engine/tree/src/persistence.rs @@ -1,20 +1,16 @@ use crate::metrics::PersistenceMetrics; -use alloy_consensus::BlockHeader; use alloy_eips::BlockNumHash; use crossbeam_channel::Sender as CrossbeamSender; -use reth_chain_state::ExecutedBlock; use reth_errors::ProviderError; use reth_ethereum_primitives::EthPrimitives; use reth_primitives_traits::{FastInstant as Instant, NodePrimitives}; use reth_provider::{ providers::ProviderNodeTypes, BalProvider, BlockExecutionWriter, BlockHashReader, ChainStateBlockWriter, DBProvider, DatabaseProviderFactory, ProviderFactory, SaveBlocksMode, - SaveBlocksPlan, SaveBlocksPlanStep, StageCheckpointReader, StageCheckpointWriter, + SaveBlocksPlan, StageCheckpointReader, }; use reth_prune::{PrunerError, PrunerWithFactory}; -use reth_stages_api::{ - FinishCheckpoint, MetricEvent, MetricEventsSender, StageCheckpoint, StageId, -}; +use reth_stages_api::{MetricEvent, MetricEventsSender, StageId}; use reth_tasks::spawn_os_thread; use std::{ sync::{ @@ -104,8 +100,8 @@ where // If the receiver errors then senders have disconnected, so the loop should then end. while let Ok(action) = self.incoming.recv() { match action { - PersistenceAction::RemoveBlocksAbove(new_tip_num, trie_state_blocks, sender) => { - let result = self.on_remove_blocks_above(new_tip_num, trie_state_blocks)?; + PersistenceAction::RemoveBlocksAbove(new_tip_num, sender) => { + let result = self.on_remove_blocks_above(new_tip_num)?; // send new sync metrics based on removed blocks let _ = self.sync_metrics_tx.send(MetricEvent::SyncHeight { height: new_tip_num }); @@ -140,7 +136,6 @@ where fn on_remove_blocks_above( &self, new_tip_num: u64, - trie_state_blocks: Vec>, ) -> Result { debug!(target: "engine::persistence", ?new_tip_num, "Removing blocks"); let start_time = Instant::now(); @@ -148,79 +143,6 @@ where let new_tip_hash = provider_rw.block_hash(new_tip_num)?; - let finish_checkpoint = provider_rw.get_stage_checkpoint(StageId::Finish)?; - if let Some(checkpoint) = finish_checkpoint.as_ref() { - let partial_state_trie = checkpoint - .finish_stage_checkpoint() - .and_then(|finish| finish.partial_state_trie) - .unwrap_or(checkpoint.block_number); - - if new_tip_num > partial_state_trie { - let expected_start = partial_state_trie + 1; - let expected_len = (new_tip_num - partial_state_trie) as usize; - if trie_state_blocks.len() != expected_len { - return Err(ProviderError::HeaderNotFound(expected_start.into()).into()) - } - - for (index, block) in trie_state_blocks.iter().enumerate() { - let expected_number = expected_start + index as u64; - let num_hash = block.recovered_block().num_hash(); - if num_hash.number != expected_number { - return Err(ProviderError::HeaderNotFound(expected_number.into()).into()) - } - - let expected_hash = provider_rw - .block_hash(expected_number)? - .ok_or_else(|| ProviderError::HeaderNotFound(expected_number.into()))?; - if num_hash.hash != expected_hash { - return Err(ProviderError::BlockHashNotFound(expected_hash).into()) - } - - if index == 0 { - let expected_parent = - provider_rw.block_hash(partial_state_trie)?.ok_or_else(|| { - ProviderError::HeaderNotFound(partial_state_trie.into()) - })?; - if block.recovered_block().parent_num_hash().hash != expected_parent { - return Err(ProviderError::BlockHashNotFound(expected_parent).into()) - } - } else if block.recovered_block().parent_num_hash().hash != - trie_state_blocks[index - 1].recovered_block().num_hash().hash - { - return Err(ProviderError::HeaderNotFound(expected_number.into()).into()) - } - } - - let new_tip_hash = new_tip_hash - .ok_or_else(|| ProviderError::HeaderNotFound(new_tip_num.into()))?; - if trie_state_blocks - .last() - .is_none_or(|block| block.recovered_block().hash() != new_tip_hash) - { - return Err(ProviderError::BlockHashNotFound(new_tip_hash).into()) - } - - let catchup_len = trie_state_blocks.len(); - provider_rw.save_blocks( - &SaveBlocksPlan::new( - trie_state_blocks, - vec![SaveBlocksPlanStep::new( - 0..catchup_len, - Some(catchup_len..catchup_len), - false, - )], - ), - SaveBlocksMode::Full, - )?; - provider_rw.save_stage_checkpoint( - StageId::Finish, - StageCheckpoint::new(checkpoint.block_number).with_finish_stage_checkpoint( - FinishCheckpoint { partial_state_trie: Some(new_tip_num) }, - ), - )?; - } - } - provider_rw.remove_block_and_execution_above(new_tip_num)?; let last_state_trie_block = provider_rw.get_stage_checkpoint(StageId::Finish)?.map(|checkpoint| { @@ -343,12 +265,9 @@ pub enum PersistenceAction { /// Removes block data above the given block number from the database. /// - /// If the durable trie frontier is below the new tip, the supplied blocks are first used to - /// catch trie/state persistence up to the new tip before the unwind removes the old suffix. - /// /// This will first update checkpoints from the database, then remove actual block data from /// static files. - RemoveBlocksAbove(u64, Vec>, CrossbeamSender), + RemoveBlocksAbove(u64, CrossbeamSender), /// Update the persisted finalized block on disk SaveFinalizedBlock(u64), @@ -457,18 +376,14 @@ impl PersistenceHandle { /// Tells the persistence service to remove blocks above a certain block number. The removed /// blocks are returned by the service. /// - /// `trie_state_blocks` must contain canonical in-memory blocks from the current trie frontier + - /// 1 through `block_num`, if that frontier is below `block_num`. - /// /// When the operation completes, the new tip hash is returned in the receiver end of the sender /// argument. pub fn remove_blocks_above( &self, block_num: u64, - trie_state_blocks: Vec>, tx: CrossbeamSender, ) -> Result<(), SendError>> { - self.send_action(PersistenceAction::RemoveBlocksAbove(block_num, trie_state_blocks, tx)) + self.send_action(PersistenceAction::RemoveBlocksAbove(block_num, tx)) } } @@ -702,49 +617,6 @@ mod tests { } } - #[test] - fn test_remove_blocks_above_catches_up_partial_state_trie() { - reth_tracing::init_test_tracing(); - - let provider = create_test_provider_factory(); - let mut test_block_builder = TestBlockBuilder::eth().with_state(); - let blocks = test_block_builder.get_executed_blocks(0..4).collect::>(); - let trie_state_blocks = vec![blocks[2].clone()]; - - let provider_rw = provider.database_provider_rw().unwrap(); - provider_rw - .save_blocks( - &SaveBlocksPlan::new( - blocks, - vec![ - SaveBlocksPlanStep::new(0..2, Some(2..4), true), - SaveBlocksPlanStep::new(2..4, None, true), - ], - ), - SaveBlocksMode::Full, - ) - .unwrap(); - provider_rw.commit().unwrap(); - - let handle = persistence_handle(provider.clone()); - let (tx, rx) = crossbeam_channel::bounded(1); - - handle.remove_blocks_above(2, trie_state_blocks, tx).unwrap(); - - let result = rx.recv_timeout(std::time::Duration::from_secs(10)).expect("test timed out"); - let last_block = result.last_block.unwrap(); - assert_eq!(last_block.number, 2); - assert_eq!(result.last_state_trie_block, Some(2)); - - let finish_checkpoint = - provider.provider().unwrap().get_stage_checkpoint(StageId::Finish).unwrap().unwrap(); - assert_eq!(finish_checkpoint.block_number, 2); - assert_eq!( - finish_checkpoint.finish_stage_checkpoint().unwrap().partial_state_trie, - Some(2) - ); - } - /// Verifies that committing `save_blocks` history before running the pruner /// prevents the pruner from overwriting new entries. /// diff --git a/crates/engine/tree/src/tree/mod.rs b/crates/engine/tree/src/tree/mod.rs index f21170da689..d733626979f 100644 --- a/crates/engine/tree/src/tree/mod.rs +++ b/crates/engine/tree/src/tree/mod.rs @@ -1356,54 +1356,12 @@ where debug!(target: "engine::tree", ?new_tip_num, last_persisted_block=?self.persistence_state.last_persisted_block.number, "Removing blocks using persistence task"); if new_tip_num < self.persistence_state.last_persisted_block.number { debug!(target: "engine::tree", ?new_tip_num, "Starting remove blocks job"); - let Some(trie_state_blocks) = self.remove_blocks_trie_state_catchup_blocks(new_tip_num) - else { - warn!( - target: "engine::tree", - ?new_tip_num, - last_state_trie_persisted_block = ?self.persistence_state.last_state_trie_persisted_block.number, - "Cannot remove blocks: missing in-memory block needed for trie catchup" - ); - return - }; let (tx, rx) = crossbeam_channel::bounded(1); - let _ = self.persistence.remove_blocks_above(new_tip_num, trie_state_blocks, tx); + let _ = self.persistence.remove_blocks_above(new_tip_num, tx); self.persistence_state.start_remove(new_tip_num, rx); } } - /// Returns canonical in-memory blocks whose state/trie data must be materialized before an - /// on-disk removal can unwind from the persisted block-data tip down to `new_tip_num`. - fn remove_blocks_trie_state_catchup_blocks( - &self, - new_tip_num: u64, - ) -> Option>> { - let last_state_trie_persisted_block_number = - self.persistence_state.last_state_trie_persisted_block.number; - if new_tip_num <= last_state_trie_persisted_block_number { - return Some(Vec::new()) - } - - let mut blocks = - Vec::with_capacity((new_tip_num - last_state_trie_persisted_block_number) as usize); - for block_number in last_state_trie_persisted_block_number + 1..=new_tip_num { - let Some(block_state) = self.canonical_in_memory_state.state_by_number(block_number) - else { - debug!( - target: "engine::tree", - block_number, - ?new_tip_num, - ?last_state_trie_persisted_block_number, - "missing in-memory block needed for remove-blocks trie catchup" - ); - return None - }; - blocks.push(block_state.block()); - } - - Some(blocks) - } - /// Helper method to save blocks and set the persistence state. This ensures we keep track of /// the current persistence action while we're saving blocks. fn persist_blocks(&mut self, plan: SaveBlocksPlan) { From 00d526e24e752fb55e52aea63ae69e54dab4d67a Mon Sep 17 00:00:00 2001 From: Brian Picciano <933154+mediocregopher@users.noreply.github.com> Date: Tue, 12 May 2026 13:20:05 +0000 Subject: [PATCH 77/83] fix(engine): cap partial persistence plan by threshold Co-Authored-By: Brian Picciano <933154+mediocregopher@users.noreply.github.com> --- crates/engine/tree/src/tree/mod.rs | 5 +++- crates/engine/tree/src/tree/tests.rs | 37 ++++++++++++++++++++++------ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/crates/engine/tree/src/tree/mod.rs b/crates/engine/tree/src/tree/mod.rs index d733626979f..fb120463577 100644 --- a/crates/engine/tree/src/tree/mod.rs +++ b/crates/engine/tree/src/tree/mod.rs @@ -2116,7 +2116,10 @@ where let canonical_head_number = self.state.tree_state.canonical_block_number(); let last_block_target_number = match target { PersistTarget::Threshold => { - canonical_head_number.saturating_sub(self.config.memory_block_buffer_target()) + canonical_head_number.saturating_sub(self.config.memory_block_buffer_target()).min( + last_state_trie_persisted_block_number + .saturating_add(self.config.persistence_threshold()), + ) } PersistTarget::Head => canonical_head_number, }; diff --git a/crates/engine/tree/src/tree/tests.rs b/crates/engine/tree/src/tree/tests.rs index 5a3d15b5743..73d011a81c4 100644 --- a/crates/engine/tree/src/tree/tests.rs +++ b/crates/engine/tree/src/tree/tests.rs @@ -1068,7 +1068,7 @@ fn test_get_save_blocks_plan_with_state_masking_blocks() { } #[test] -fn test_get_save_blocks_plan_persists_full_region_before_deferred_tail() { +fn test_get_save_blocks_plan_limits_partial_persistence_to_threshold() { let chain_spec = MAINNET.clone(); let mut test_harness = TestHarness::new(chain_spec); let mut test_block_builder = TestBlockBuilder::eth(); @@ -1086,16 +1086,39 @@ fn test_get_save_blocks_plan_persists_full_region_before_deferred_tail() { let plan = test_harness.tree.get_save_blocks_plan(PersistTarget::Threshold).unwrap(); - assert_plan_steps( - &plan, - &[(0..3, Some(14..16), false), (3..14, Some(14..16), true), (14..16, None, true)], + assert_plan_steps(&plan, &[(0..3, Some(3..5), false), (3..5, None, true)]); + assert_eq!(plan.blocks.len(), 5); + assert_eq!( + plan.blocks.iter().map(|block| block.recovered_block().number()).collect::>(), + (13..=17).collect::>() ); - assert_eq!(plan.blocks.len(), 16); + assert_eq!(plan.last_block(), Some(blocks[17].recovered_block().num_hash())); +} + +#[test] +fn test_get_save_blocks_plan_state_masking_counts_towards_threshold() { + let chain_spec = MAINNET.clone(); + let mut test_harness = TestHarness::new(chain_spec); + let mut test_block_builder = TestBlockBuilder::eth(); + + let blocks: Vec<_> = test_block_builder.get_executed_blocks(0..20).collect(); + test_harness = test_harness.with_blocks(blocks.clone()); + test_harness.tree.persistence_state.last_state_trie_persisted_block = + blocks[0].recovered_block().num_hash(); + test_harness.tree.persistence_state.last_persisted_block = + blocks[3].recovered_block().num_hash(); + test_harness.tree.config = + TreeConfig::default().with_persistence_threshold(13).with_num_state_masking_blocks(10); + + let plan = test_harness.tree.get_save_blocks_plan(PersistTarget::Threshold).unwrap(); + + assert_plan_steps(&plan, &[(0..3, Some(3..13), false), (3..13, None, true)]); + assert_eq!(plan.blocks.len(), 13); assert_eq!( plan.blocks.iter().map(|block| block.recovered_block().number()).collect::>(), - (13..=28).collect::>() + (1..=13).collect::>() ); - assert_eq!(plan.last_block(), Some(blocks[28].recovered_block().num_hash())); + assert_eq!(plan.last_block(), Some(blocks[13].recovered_block().num_hash())); } #[test] From 1d285fe80457eb0d44200666c43ac6f28a3aeb87 Mon Sep 17 00:00:00 2001 From: Brian Date: Mon, 18 May 2026 18:42:37 +0200 Subject: [PATCH 78/83] fix(engine): restore partial persistence merge logic --- crates/engine/tree/src/tree/mod.rs | 184 +++++++++++++++++++---------- 1 file changed, 121 insertions(+), 63 deletions(-) diff --git a/crates/engine/tree/src/tree/mod.rs b/crates/engine/tree/src/tree/mod.rs index e0bf3934b8c..f8f24dd4bd4 100644 --- a/crates/engine/tree/src/tree/mod.rs +++ b/crates/engine/tree/src/tree/mod.rs @@ -30,9 +30,9 @@ use reth_primitives_traits::{ }; use reth_provider::{ BlockExecutionOutput, BlockExecutionResult, BlockReader, ChangeSetReader, - DatabaseProviderFactory, HashedPostStateProvider, ProviderError, StageCheckpointReader, - StateProviderBox, StateProviderFactory, StateReader, StorageChangeSetReader, - StorageSettingsCache, TransactionVariant, + DatabaseProviderFactory, HashedPostStateProvider, ProviderError, SaveBlocksPlan, + SaveBlocksPlanStep, StageCheckpointReader, StateProviderBox, StateProviderFactory, StateReader, + StorageChangeSetReader, StorageSettingsCache, TransactionVariant, }; use reth_revm::database::StateProviderDatabase; use reth_stages_api::ControlFlow; @@ -440,6 +440,7 @@ where let persistence_state = PersistenceState { last_persisted_block: BlockNumHash::new(best_block_number, header.hash()), + last_state_trie_persisted_block: BlockNumHash::new(best_block_number, header.hash()), rx: None, }; @@ -1369,24 +1370,17 @@ where /// Helper method to save blocks and set the persistence state. This ensures we keep track of /// the current persistence action while we're saving blocks. - fn persist_blocks(&mut self, blocks_to_persist: Vec>) { - if blocks_to_persist.is_empty() { + fn persist_blocks(&mut self, plan: SaveBlocksPlan) { + if plan.is_empty() { debug!(target: "engine::tree", "Returned empty set of blocks to persist"); return } - // NOTE: checked non-empty above - let highest_num_hash = blocks_to_persist - .iter() - .max_by_key(|block| block.recovered_block().number()) - .map(|b| b.recovered_block().num_hash()) - .expect("Checked non-empty persisting blocks"); - - debug!(target: "engine::tree", count=blocks_to_persist.len(), blocks = ?blocks_to_persist.iter().map(|block| block.recovered_block().num_hash()).collect::>(), "Persisting blocks"); + let last_block = plan.last_block().expect("checked non-empty persisting blocks"); let (tx, rx) = crossbeam_channel::bounded(1); - let _ = self.persistence.save_blocks(blocks_to_persist, tx); + let _ = self.persistence.save_blocks(plan, tx); - self.persistence_state.start_save(highest_num_hash, rx); + self.persistence_state.start_save(last_block, rx); } /// Triggers new persistence actions if no persistence task is currently in progress. @@ -1398,9 +1392,8 @@ where if let Some(new_tip_num) = self.find_disk_reorg()? { self.remove_blocks(new_tip_num) } else if self.should_persist() { - let blocks_to_persist = - self.get_canonical_blocks_to_persist(PersistTarget::Threshold)?; - self.persist_blocks(blocks_to_persist); + let plan = self.get_save_blocks_plan(PersistTarget::Threshold)?; + self.persist_blocks(plan); } } @@ -1431,15 +1424,15 @@ where self.on_persistence_complete(result, start_time)?; } - let blocks_to_persist = self.get_canonical_blocks_to_persist(PersistTarget::Head)?; + let plan = self.get_save_blocks_plan(PersistTarget::Head)?; - if blocks_to_persist.is_empty() { + if plan.is_empty() { debug!(target: "engine::tree", "persistence complete, signaling termination"); return Ok(()) } - debug!(target: "engine::tree", count = blocks_to_persist.len(), "persisting remaining blocks before shutdown"); - self.persist_blocks(blocks_to_persist); + debug!(target: "engine::tree", count = plan.blocks.len(), "persisting remaining blocks before shutdown"); + self.persist_blocks(plan); } } @@ -1475,25 +1468,25 @@ where ) -> Result<(), AdvancePersistenceError> { self.metrics.engine.persistence_duration.record(start_time.elapsed()); - let commit_duration = result.commit_duration; - let Some(BlockNumHash { - hash: last_persisted_block_hash, - number: last_persisted_block_number, - }) = result.last_block + let PersistenceResult { last_block, last_state_trie_block, commit_duration } = result; + let Some(BlockNumHash { hash: last_block_hash, number: last_block_number }) = last_block else { // if this happened, then we persisted no blocks because we sent an empty vec of blocks warn!(target: "engine::tree", "Persistence task completed but did not persist any blocks"); return Ok(()) }; - debug!(target: "engine::tree", ?last_persisted_block_hash, ?last_persisted_block_number, elapsed=?start_time.elapsed(), "Finished persisting, calling finish"); - self.persistence_state.finish(last_persisted_block_hash, last_persisted_block_number); + let last_block = BlockNumHash::new(last_block_number, last_block_hash); + let last_state_trie_persisted_block = + self.last_state_trie_persisted_block(last_block, last_state_trie_block)?; + + debug!(target: "engine::tree", ?last_block_hash, ?last_block_number, last_state_trie_persisted_block = last_state_trie_persisted_block.number, elapsed=?start_time.elapsed(), "Finished persisting, calling finish"); + self.persistence_state.finish(last_block, last_state_trie_persisted_block); // Evict trie changesets for blocks below the eviction threshold. // Keep at least CHANGESET_CACHE_RETENTION_BLOCKS from the persisted tip, and also respect // the finalized block if set. - let min_threshold = - last_persisted_block_number.saturating_sub(CHANGESET_CACHE_RETENTION_BLOCKS); + let min_threshold = last_block_number.saturating_sub(CHANGESET_CACHE_RETENTION_BLOCKS); let eviction_threshold = if let Some(finalized) = self.canonical_in_memory_state.get_finalized_num_hash() { // Use the minimum of finalized block and retention threshold to be conservative @@ -1504,20 +1497,48 @@ where }; debug!( target: "engine::tree", - last_persisted = last_persisted_block_number, + last_persisted_block = last_block_number, finalized_number = ?self.canonical_in_memory_state.get_finalized_num_hash().map(|f| f.number), eviction_threshold, "Evicting changesets below threshold" ); self.changeset_cache.evict(eviction_threshold); - self.on_new_persisted_block()?; + self.on_new_persisted_block(last_state_trie_persisted_block)?; - self.purge_timing_stats(last_persisted_block_number, commit_duration); + self.purge_timing_stats(last_block_number, commit_duration); Ok(()) } + /// Returns the highest block that can be dropped from memory after persistence completes. + fn last_state_trie_persisted_block( + &self, + last_block: BlockNumHash, + last_state_trie_block: Option, + ) -> ProviderResult { + let Some(last_state_trie_block) = last_state_trie_block else { return Ok(last_block) }; + debug_assert!( + last_state_trie_block <= last_block.number, + "state/trie frontier cannot exceed the last persisted block" + ); + if last_state_trie_block >= last_block.number { + return Ok(last_block) + } + + let hash = self + .canonical_in_memory_state + .hash_by_number(last_state_trie_block) + .map(Ok) + .unwrap_or_else(|| { + self.provider + .block_hash(last_state_trie_block)? + .ok_or_else(|| ProviderError::HeaderNotFound(last_state_trie_block.into())) + })?; + + Ok(BlockNumHash::new(last_state_trie_block, hash)) + } + /// Handles a message from the engine. /// /// Returns `ControlFlow::Break(())` if the engine should terminate. @@ -1836,7 +1857,7 @@ where // update the tracked chain height, after backfill sync both the canonical height and // persisted height are the same self.state.tree_state.set_canonical_head(new_head.num_hash()); - self.persistence_state.finish(new_head.hash(), new_head.number()); + self.persistence_state.finish(new_head.num_hash(), new_head.num_hash()); // update the tracked canonical head self.canonical_in_memory_state.set_canonical_head(new_head); @@ -2072,62 +2093,99 @@ where self.config.persistence_threshold() } - /// Returns a batch of consecutive canonical blocks to persist in the range - /// `(last_persisted_number .. target]`. The expected order is oldest -> newest. - fn get_canonical_blocks_to_persist( + /// Returns the save plan for the next persistence cycle. + fn get_save_blocks_plan( &self, target: PersistTarget, - ) -> Result>, AdvancePersistenceError> { + ) -> Result, AdvancePersistenceError> { // We will calculate the state root using the database, so we need to be sure there are no // changes debug_assert!(!self.persistence_state.in_progress()); - let mut blocks_to_persist = Vec::new(); + let mut blocks = Vec::new(); let mut current_hash = self.state.tree_state.canonical_block_hash(); - let last_persisted_number = self.persistence_state.last_persisted_block.number; + let last_state_trie_persisted_block_number = + self.persistence_state.last_state_trie_persisted_block.number; + let last_persisted_block_number = self.persistence_state.last_persisted_block.number; let canonical_head_number = self.state.tree_state.canonical_block_number(); - - let target_number = match target { - PersistTarget::Head => canonical_head_number, + let last_block_target_number = match target { PersistTarget::Threshold => { - canonical_head_number.saturating_sub(self.config.memory_block_buffer_target()) + canonical_head_number.saturating_sub(self.config.memory_block_buffer_target()).min( + last_state_trie_persisted_block_number + .saturating_add(self.config.persistence_threshold()), + ) } + PersistTarget::Head => canonical_head_number, }; debug!( target: "engine::tree", ?current_hash, - ?last_persisted_number, + ?last_state_trie_persisted_block_number, + ?last_persisted_block_number, ?canonical_head_number, - ?target_number, - "Returning canonical blocks to persist" + target = ?target, + "Returning save plan" ); while let Some(block) = self.state.tree_state.blocks_by_hash.get(¤t_hash) { - if block.recovered_block().number() <= last_persisted_number { + if block.recovered_block().number() <= last_state_trie_persisted_block_number { break; } - if block.recovered_block().number() <= target_number { - blocks_to_persist.push(block.clone()); + if block.recovered_block().number() <= last_block_target_number { + blocks.push(block.clone()); } current_hash = block.recovered_block().parent_hash(); } // Reverse the order so that the oldest block comes first - blocks_to_persist.reverse(); + blocks.reverse(); + + let trie_catchup_block_count = last_persisted_block_number + .saturating_sub(last_state_trie_persisted_block_number) + .min(blocks.len() as u64) as usize; + let persist_rest_block_count = blocks.len().saturating_sub(trie_catchup_block_count); + let state_masking_block_count = + persist_rest_block_count.min(self.config.num_state_masking_blocks() as usize); + let full_persist_block_count = persist_rest_block_count - state_masking_block_count; + let full_persist_start = trie_catchup_block_count; + let state_masking_start = full_persist_start + full_persist_block_count; + let state_masking_range = state_masking_start..blocks.len(); + let mut steps = Vec::new(); - Ok(blocks_to_persist) + if trie_catchup_block_count > 0 { + steps.push(SaveBlocksPlanStep::new( + 0..trie_catchup_block_count, + Some(state_masking_range.clone()), + false, + )); + } + if full_persist_block_count > 0 { + steps.push(SaveBlocksPlanStep::new( + full_persist_start..state_masking_start, + Some(state_masking_range.clone()), + true, + )); + } + if state_masking_block_count > 0 { + steps.push(SaveBlocksPlanStep::new(state_masking_range, None, true)); + } + + Ok(SaveBlocksPlan::new(blocks, steps)) } - /// This clears the blocks from the in-memory tree state that have been persisted to the - /// database. + /// This clears the blocks from the in-memory tree state that no longer need to stay resident + /// after persistence completes. /// - /// This also updates the canonical in-memory state to reflect the newest persisted block - /// height. + /// This also updates the canonical in-memory state to reflect the newest persisted block tip, + /// even if trie persistence only advanced through an earlier block. /// /// Assumes that `finish` has been called on the `persistence_state` at least once - fn on_new_persisted_block(&mut self) -> ProviderResult<()> { + fn on_new_persisted_block( + &mut self, + in_memory_persisted_block: BlockNumHash, + ) -> ProviderResult<()> { // If we have an on-disk reorg, we need to handle it first before touching the in-memory // state. if let Some(remove_above) = self.find_disk_reorg()? { @@ -2136,11 +2194,11 @@ where } let finalized = self.state.forkchoice_state_tracker.last_valid_finalized(); - self.remove_before(self.persistence_state.last_persisted_block, finalized)?; - self.canonical_in_memory_state.remove_persisted_blocks(BlockNumHash { - number: self.persistence_state.last_persisted_block.number, - hash: self.persistence_state.last_persisted_block.hash, - }); + self.remove_before(in_memory_persisted_block, finalized)?; + self.canonical_in_memory_state.remove_persisted_blocks_until( + self.persistence_state.last_persisted_block, + in_memory_persisted_block.number, + ); Ok(()) } From f36848f1cd090d0ad3ceb7bf915dafdc6310e31a Mon Sep 17 00:00:00 2001 From: Brian Date: Mon, 18 May 2026 21:46:09 +0200 Subject: [PATCH 79/83] fix(engine): restore partial persistence overlays --- Cargo.lock | 1 - crates/chain-state/Cargo.toml | 4 +- crates/chain-state/src/deferred_trie.rs | 848 ++++++++++++++++-- crates/chain-state/src/in_memory.rs | 26 +- crates/chain-state/src/lazy_overlay.rs | 328 +++++++ crates/chain-state/src/lib.rs | 4 +- crates/chain-state/src/state_trie_overlay.rs | 682 -------------- crates/engine/tree/src/tree/metrics.rs | 4 + crates/engine/tree/src/tree/mod.rs | 28 +- .../engine/tree/src/tree/payload_validator.rs | 107 ++- crates/engine/tree/src/tree/state.rs | 188 ++-- crates/engine/tree/src/tree/tests.rs | 17 +- .../provider/src/providers/state/overlay.rs | 724 +++++++++++---- 13 files changed, 1916 insertions(+), 1045 deletions(-) create mode 100644 crates/chain-state/src/lazy_overlay.rs delete mode 100644 crates/chain-state/src/state_trie_overlay.rs diff --git a/Cargo.lock b/Cargo.lock index 48f26852d9f..92de64299a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7730,7 +7730,6 @@ dependencies = [ "reth-metrics", "reth-primitives-traits", "reth-storage-api", - "reth-tasks", "reth-testing-utils", "reth-trie", "revm-database", diff --git a/crates/chain-state/Cargo.toml b/crates/chain-state/Cargo.toml index ab85cc8fce9..a4ce20fd434 100644 --- a/crates/chain-state/Cargo.toml +++ b/crates/chain-state/Cargo.toml @@ -20,7 +20,6 @@ reth-metrics.workspace = true reth-ethereum-primitives.workspace = true reth-primitives-traits = { workspace = true, features = ["dashmap"] } reth-storage-api.workspace = true -reth-tasks = { workspace = true, features = ["rayon"], optional = true } reth-trie.workspace = true # ethereum @@ -84,6 +83,5 @@ test-utils = [ "reth-primitives-traits/test-utils", "reth-trie/test-utils", "reth-ethereum-primitives/test-utils", - "reth-tasks?/test-utils", ] -rayon = ["dep:rayon", "dep:reth-tasks"] +rayon = ["dep:rayon"] diff --git a/crates/chain-state/src/deferred_trie.rs b/crates/chain-state/src/deferred_trie.rs index ea71b92c15d..5dea55b7be3 100644 --- a/crates/chain-state/src/deferred_trie.rs +++ b/crates/chain-state/src/deferred_trie.rs @@ -1,8 +1,9 @@ +use alloy_primitives::B256; use parking_lot::Mutex; use reth_metrics::{metrics::Counter, Metrics}; use reth_trie::{ updates::{TrieUpdates, TrieUpdatesSorted}, - HashedPostState, HashedPostStateSorted, + HashedPostState, HashedPostStateSorted, TrieInputSorted, }; use std::{ fmt, @@ -10,26 +11,46 @@ use std::{ }; use tracing::{debug_span, instrument}; -/// Shared handle to asynchronously populated per-block trie data. +/// Shared handle to asynchronously populated trie data. /// -/// If the background task has not completed by the time trie data is needed, the caller computes -/// the sorted data synchronously from the retained unsorted inputs and caches the result. +/// Uses a try-lock + fallback computation approach for deadlock-free access. +/// If the deferred task hasn't completed, computes trie data synchronously +/// from stored unsorted inputs rather than blocking. #[derive(Clone)] pub struct DeferredTrieData { /// Shared deferred state holding either raw inputs (pending) or computed result (ready). - state: Arc>, + state: Arc>, } -/// Sorted trie data computed for one executed block. -/// -/// Cumulative overlays are intentionally managed by -/// [`StateTrieOverlayManager`](crate::StateTrieOverlayManager), not by each block. +/// Sorted trie data computed for an executed block. +/// These represent the complete set of sorted trie data required to persist +/// block state for, and generate proofs on top of, a block. #[derive(Clone, Debug, Default)] pub struct ComputedTrieData { /// Sorted hashed post-state produced by execution. pub hashed_state: Arc, /// Sorted trie updates produced by state root computation. pub trie_updates: Arc, + /// Trie input bundled with its anchor hash, if available. + pub anchored_trie_input: Option, +} + +/// Trie input bundled with its anchor hash. +/// +/// The `trie_input` contains the **cumulative** overlay of all in-memory ancestor blocks, +/// not just this block's changes. Child blocks reuse the parent's overlay in O(1) by +/// cloning the Arc-wrapped data. +/// +/// The `anchor_hash` is metadata indicating which persisted base state this overlay +/// sits on top of. It is CRITICAL for overlay reuse decisions: an overlay built on top +/// of Anchor A cannot be reused for a block anchored to Anchor B, as it would result +/// in an incorrect state. +#[derive(Clone, Debug)] +pub struct AnchoredTrieInput { + /// The persisted ancestor hash this trie input is anchored to. + pub anchor_hash: B256, + /// Cumulative trie input overlay from all in-memory ancestors. + pub trie_input: Arc, } /// Metrics for deferred trie computation. @@ -46,9 +67,8 @@ static DEFERRED_TRIE_METRICS: LazyLock = LazyLock::new(DeferredTrieMetrics::default); /// Internal state for deferred trie data. -enum DeferredTrieDataInner { +enum DeferredState { /// Data is not yet available; raw inputs stored for fallback computation. - /// /// Wrapped in `Option` to allow taking ownership during computation. Pending(Option), /// Data has been computed and is ready. @@ -62,16 +82,20 @@ struct PendingInputs { hashed_state: Arc, /// Unsorted trie updates from state root computation. trie_updates: Arc, + /// The persisted ancestor hash this trie input is anchored to. + anchor_hash: B256, + /// Deferred trie data from ancestor blocks for merging. + ancestors: Vec, } impl fmt::Debug for DeferredTrieData { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let state = self.state.lock(); match &*state { - DeferredTrieDataInner::Pending(_) => { + DeferredState::Pending(_) => { f.debug_struct("DeferredTrieData").field("state", &"pending").finish() } - DeferredTrieDataInner::Ready(_) => { + DeferredState::Ready(_) => { f.debug_struct("DeferredTrieData").field("state", &"ready").finish() } } @@ -80,24 +104,64 @@ impl fmt::Debug for DeferredTrieData { impl DeferredTrieData { /// Create a new pending handle with fallback inputs for synchronous computation. - pub fn pending(hashed_state: Arc, trie_updates: Arc) -> Self { + /// + /// If the async task hasn't completed when `wait_cloned` is called, the trie data + /// will be computed synchronously from these inputs. This eliminates deadlock risk. + /// + /// # Arguments + /// * `hashed_state` - Unsorted hashed post-state from execution + /// * `trie_updates` - Unsorted trie updates from state root computation + /// * `anchor_hash` - The persisted ancestor hash this trie input is anchored to + /// * `ancestors` - Deferred trie data from ancestor blocks for merging + pub fn pending( + hashed_state: Arc, + trie_updates: Arc, + anchor_hash: B256, + ancestors: Vec, + ) -> Self { Self { - state: Arc::new(Mutex::new(DeferredTrieDataInner::Pending(Some(PendingInputs { + state: Arc::new(Mutex::new(DeferredState::Pending(Some(PendingInputs { hashed_state, trie_updates, + anchor_hash, + ancestors, })))), } } /// Create a handle that is already populated with the given [`ComputedTrieData`]. + /// + /// Useful when trie data is available immediately. + /// [`Self::wait_cloned`] will return without any computation. pub fn ready(bundle: ComputedTrieData) -> Self { - Self { state: Arc::new(Mutex::new(DeferredTrieDataInner::Ready(bundle))) } + Self { state: Arc::new(Mutex::new(DeferredState::Ready(bundle))) } } - /// Sorts block execution outputs. - pub fn sort( + /// Sort block execution outputs and build a [`TrieInputSorted`] overlay. + /// + /// The trie input overlay accumulates sorted hashed state (account/storage changes) and + /// trie node updates from all in-memory ancestor blocks. This overlay is required for: + /// - Computing state roots on top of in-memory blocks + /// - Generating storage/account proofs for unpersisted state + /// + /// # Process + /// 1. Sort the current block's hashed state and trie updates + /// 2. Reuse parent's cached overlay if available (O(1) - the common case) + /// 3. Otherwise, rebuild overlay from ancestors (rare fallback) + /// 4. Extend the overlay with this block's sorted data + /// + /// Used by both the async background task and the synchronous fallback path. + /// + /// # Arguments + /// * `hashed_state` - Unsorted hashed post-state (account/storage changes) from execution + /// * `trie_updates` - Unsorted trie node updates from state root computation + /// * `anchor_hash` - The persisted ancestor hash this trie input is anchored to + /// * `ancestors` - Deferred trie data from ancestor blocks for merging (oldest -> newest) + pub fn sort_and_build_trie_input( hashed_state: Arc, trie_updates: Arc, + anchor_hash: B256, + ancestors: &[Self], ) -> ComputedTrieData { let _span = debug_span!(target: "engine::tree::deferred_trie", "sort_inputs").entered(); @@ -125,24 +189,200 @@ impl DeferredTrieData { }, ); - ComputedTrieData::new(Arc::new(sorted_hashed_state), Arc::new(sorted_trie_updates)) + drop(_span); + + let _span = debug_span!(target: "engine::tree::deferred_trie", "build_overlay").entered(); + + // Reuse parent's overlay if available and anchors match. + // We can only reuse the parent's overlay if it was built on top of the same + // persisted anchor. If the anchor has changed (e.g., due to persistence), + // the parent's overlay is relative to an old state and cannot be used. + let overlay = if let Some(parent) = ancestors.last() { + let parent_data = parent.wait_cloned(); + + match &parent_data.anchored_trie_input { + // Case 1: Parent has cached overlay AND anchors match. + Some(AnchoredTrieInput { anchor_hash: parent_anchor, trie_input }) + if *parent_anchor == anchor_hash => + { + // O(1): Reuse parent's overlay, extend with current block's data. + let mut overlay = TrieInputSorted::new( + Arc::clone(&trie_input.nodes), + Arc::clone(&trie_input.state), + Default::default(), // prefix_sets are per-block, not cumulative + ); + let _span = + debug_span!(target: "engine::tree::deferred_trie", "extend_overlay") + .entered(); + // Only trigger COW clone if there's actually data to add. + #[cfg(feature = "rayon")] + { + rayon::join( + || { + if !sorted_hashed_state.is_empty() { + Arc::make_mut(&mut overlay.state) + .extend_ref_and_sort(&sorted_hashed_state); + } + }, + || { + if !sorted_trie_updates.is_empty() { + Arc::make_mut(&mut overlay.nodes) + .extend_ref_and_sort(&sorted_trie_updates); + } + }, + ); + } + #[cfg(not(feature = "rayon"))] + { + if !sorted_hashed_state.is_empty() { + Arc::make_mut(&mut overlay.state) + .extend_ref_and_sort(&sorted_hashed_state); + } + if !sorted_trie_updates.is_empty() { + Arc::make_mut(&mut overlay.nodes) + .extend_ref_and_sort(&sorted_trie_updates); + } + } + overlay + } + // Case 2: Parent exists but anchor mismatch or no cached overlay. + // We must rebuild from the ancestors list (which only contains unpersisted blocks). + _ => Self::merge_ancestors_into_overlay( + ancestors, + &sorted_hashed_state, + &sorted_trie_updates, + ), + } + } else { + // Case 3: No in-memory ancestors (first block after persisted anchor). + // Build overlay with just this block's data. + Self::merge_ancestors_into_overlay(&[], &sorted_hashed_state, &sorted_trie_updates) + }; + + ComputedTrieData::with_trie_input( + Arc::new(sorted_hashed_state), + Arc::new(sorted_trie_updates), + anchor_hash, + Arc::new(overlay), + ) + } + + /// Merge all ancestors and current block's data into a single overlay. + /// + /// This is a rare fallback path, only used when no ancestor has a cached + /// `anchored_trie_input` (e.g., blocks created via alternative constructors). + /// In normal operation, the parent always has a cached overlay and this + /// function is never called. + /// + /// When the `rayon` feature is enabled: + /// 1. Collects ancestor data (states and updates) + /// 2. Merges states and trie updates in parallel using k-way merge + #[cfg(feature = "rayon")] + fn merge_ancestors_into_overlay( + ancestors: &[Self], + sorted_hashed_state: &HashedPostStateSorted, + sorted_trie_updates: &TrieUpdatesSorted, + ) -> TrieInputSorted { + // Early exit: no ancestors means just wrap current block's data + if ancestors.is_empty() { + return TrieInputSorted::new( + Arc::new(sorted_trie_updates.clone()), + Arc::new(sorted_hashed_state.clone()), + Default::default(), + ); + } + + // Collect ancestor data in reverse (newest to oldest) for merge_slice + let (states, updates): (Vec<_>, Vec<_>) = ancestors + .iter() + .rev() + .map(|a| { + // Note: we can assume that this trie data has already been computed + let data = a.wait_cloned(); + (data.hashed_state, data.trie_updates) + }) + .unzip(); + + // Merge state and nodes in parallel using k-way merge + let (state, nodes) = rayon::join( + || { + let mut merged = HashedPostStateSorted::merge_slice(&states); + merged.extend_ref_and_sort(sorted_hashed_state); + merged + }, + || { + let mut merged = TrieUpdatesSorted::merge_slice(&updates); + merged.extend_ref_and_sort(sorted_trie_updates); + merged + }, + ); + + TrieInputSorted::new(Arc::new(nodes), Arc::new(state), Default::default()) + } + + /// Sequential fallback when rayon is not available. + #[cfg(not(feature = "rayon"))] + fn merge_ancestors_into_overlay( + ancestors: &[Self], + sorted_hashed_state: &HashedPostStateSorted, + sorted_trie_updates: &TrieUpdatesSorted, + ) -> TrieInputSorted { + let _span = debug_span!(target: "engine::tree::deferred_trie", "merge_ancestors", num_ancestors = ancestors.len()).entered(); + let mut overlay = TrieInputSorted::default(); + + let state_mut = Arc::make_mut(&mut overlay.state); + let nodes_mut = Arc::make_mut(&mut overlay.nodes); + + for ancestor in ancestors { + let ancestor_data = ancestor.wait_cloned(); + state_mut.extend_ref_and_sort(ancestor_data.hashed_state.as_ref()); + nodes_mut.extend_ref_and_sort(ancestor_data.trie_updates.as_ref()); + } + + state_mut.extend_ref_and_sort(sorted_hashed_state); + nodes_mut.extend_ref_and_sort(sorted_trie_updates); + + overlay } /// Returns trie data, computing synchronously if the async task hasn't completed. + /// + /// - If the async task has completed (`Ready`), returns the cached result. + /// - If pending, computes synchronously from stored inputs. + /// + /// Deadlock is avoided as long as the provided ancestors form a true ancestor chain (a DAG): + /// - Each block only waits on its ancestors (blocks on the path to the persisted root) + /// - Sibling blocks (forks) are never in each other's ancestor lists + /// - A block never waits on its descendants + /// + /// Given that invariant, circular wait dependencies are impossible. #[instrument(level = "debug", target = "engine::tree::deferred_trie", skip_all)] pub fn wait_cloned(&self) -> ComputedTrieData { let mut state = self.state.lock(); match &mut *state { - DeferredTrieDataInner::Ready(bundle) => { + // If the deferred trie data is ready, return the cached result. + DeferredState::Ready(bundle) => { DEFERRED_TRIE_METRICS.deferred_trie_async_ready.increment(1); bundle.clone() } - DeferredTrieDataInner::Pending(maybe_inputs) => { + // If the deferred trie data is pending, compute the trie data synchronously and return + // the result. This is the fallback path if the async task hasn't completed. + DeferredState::Pending(maybe_inputs) => { DEFERRED_TRIE_METRICS.deferred_trie_sync_fallback.increment(1); let inputs = maybe_inputs.take().expect("inputs must be present in Pending state"); - let computed = Self::sort(inputs.hashed_state, inputs.trie_updates); - *state = DeferredTrieDataInner::Ready(computed.clone()); + + let computed = Self::sort_and_build_trie_input( + inputs.hashed_state, + inputs.trie_updates, + inputs.anchor_hash, + &inputs.ancestors, + ); + *state = DeferredState::Ready(computed.clone()); + + // Release lock before inputs (and its ancestors) drop to avoid holding it + // while their potential last Arc refs drop (which could trigger recursive locking) + drop(state); computed } @@ -151,102 +391,586 @@ impl DeferredTrieData { } impl ComputedTrieData { - /// Construct sorted trie data for one block. + /// Construct sorted trie data without an accumulated trie input overlay. pub const fn new( hashed_state: Arc, trie_updates: Arc, ) -> Self { - Self { hashed_state, trie_updates } + Self::without_trie_input(hashed_state, trie_updates) + } + + /// Construct a bundle that includes trie input anchored to a persisted ancestor. + pub const fn with_trie_input( + hashed_state: Arc, + trie_updates: Arc, + anchor_hash: B256, + trie_input: Arc, + ) -> Self { + Self { + hashed_state, + trie_updates, + anchored_trie_input: Some(AnchoredTrieInput { anchor_hash, trie_input }), + } + } + + /// Construct a bundle without trie input or anchor information. + /// + /// Unlike [`Self::with_trie_input`], this constructor omits the accumulated trie input overlay + /// and its anchor hash. Use this when the trie input is not needed, such as in block builders + /// or sequencers that don't require proof generation on top of in-memory state. + /// + /// The trie input anchor identifies the persisted block hash from which the in-memory overlay + /// was built. Without it, consumers cannot determine which on-disk state to combine with. + pub const fn without_trie_input( + hashed_state: Arc, + trie_updates: Arc, + ) -> Self { + Self { hashed_state, trie_updates, anchored_trie_input: None } + } + + /// Returns the anchor hash, if present. + pub fn anchor_hash(&self) -> Option { + self.anchored_trie_input.as_ref().map(|anchored| anchored.anchor_hash) + } + + /// Returns the trie input, if present. + pub fn trie_input(&self) -> Option<&Arc> { + self.anchored_trie_input.as_ref().map(|anchored| &anchored.trie_input) } } #[cfg(test)] mod tests { use super::*; - use alloy_primitives::{map::B256Map, B256, U256}; + use alloy_primitives::{map::B256Map, U256}; use reth_primitives_traits::Account; - use reth_trie::{updates::TrieUpdates, HashedStorage}; + use reth_trie::updates::TrieUpdates; use std::{ + sync::Arc, thread, time::{Duration, Instant}, }; + fn empty_bundle() -> ComputedTrieData { + ComputedTrieData { + hashed_state: Arc::default(), + trie_updates: Arc::default(), + anchored_trie_input: None, + } + } + fn empty_pending() -> DeferredTrieData { + empty_pending_with_anchor(B256::ZERO) + } + + fn empty_pending_with_anchor(anchor: B256) -> DeferredTrieData { DeferredTrieData::pending( Arc::new(HashedPostState::default()), Arc::new(TrieUpdates::default()), + anchor, + Vec::new(), ) } + /// Verifies that a ready handle returns immediately without computation. #[test] fn ready_returns_immediately() { - let bundle = ComputedTrieData::default(); + let bundle = empty_bundle(); let deferred = DeferredTrieData::ready(bundle.clone()); + let start = Instant::now(); + let result = deferred.wait_cloned(); + let elapsed = start.elapsed(); + + assert_eq!(result.hashed_state, bundle.hashed_state); + assert_eq!(result.trie_updates, bundle.trie_updates); + assert_eq!(result.anchor_hash(), bundle.anchor_hash()); + assert!(elapsed < Duration::from_millis(20)); + } + + /// Verifies that a pending handle computes trie data synchronously via fallback. + #[test] + fn pending_computes_fallback() { + let deferred = empty_pending(); + + // wait_cloned should compute from inputs without blocking + let start = Instant::now(); let result = deferred.wait_cloned(); + let elapsed = start.elapsed(); - assert_eq!(result.hashed_state.total_len(), bundle.hashed_state.total_len()); - assert_eq!(result.trie_updates.total_len(), bundle.trie_updates.total_len()); + // Should return quickly (fallback computation) + assert!(elapsed < Duration::from_millis(100)); + assert!(result.hashed_state.is_empty()); } + /// Verifies that fallback computation result is cached for subsequent calls. #[test] - fn pending_computes_and_caches_result() { + fn fallback_result_is_cached() { let deferred = empty_pending(); + // First call computes and should stash the result let first = deferred.wait_cloned(); + // Second call should reuse the cached result (same Arc pointer) let second = deferred.wait_cloned(); assert!(Arc::ptr_eq(&first.hashed_state, &second.hashed_state)); assert!(Arc::ptr_eq(&first.trie_updates, &second.trie_updates)); + assert_eq!(first.anchor_hash(), second.anchor_hash()); } + /// Verifies that concurrent `wait_cloned` calls result in only one computation, + /// with all callers receiving the same cached result. #[test] - fn concurrent_waits_share_computed_result() { + fn concurrent_wait_cloned_computes_once() { let deferred = empty_pending(); - let deferred2 = deferred.clone(); - let handle = thread::spawn(move || deferred2.wait_cloned()); - let result1 = deferred.wait_cloned(); - let result2 = handle.join().unwrap(); + // Spawn multiple threads that all call wait_cloned concurrently + let handles: Vec<_> = (0..10) + .map(|_| { + let d = deferred.clone(); + thread::spawn(move || d.wait_cloned()) + }) + .collect(); + + // Collect all results + let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect(); + + // All results should share the same Arc pointers (same computed result) + let first = &results[0]; + for result in &results[1..] { + assert!(Arc::ptr_eq(&first.hashed_state, &result.hashed_state)); + assert!(Arc::ptr_eq(&first.trie_updates, &result.trie_updates)); + } + } + + /// Tests that ancestor trie data is merged during fallback computation and that the + /// resulting `ComputedTrieData` uses the current block's anchor hash, not the ancestor's. + #[test] + fn ancestors_are_merged() { + // Create ancestor with some data + let ancestor_bundle = ComputedTrieData { + hashed_state: Arc::default(), + trie_updates: Arc::default(), + anchored_trie_input: Some(AnchoredTrieInput { + anchor_hash: B256::with_last_byte(1), + trie_input: Arc::new(TrieInputSorted::default()), + }), + }; + let ancestor = DeferredTrieData::ready(ancestor_bundle); + + // Create pending with ancestor + let deferred = DeferredTrieData::pending( + Arc::new(HashedPostState::default()), + Arc::new(TrieUpdates::default()), + B256::with_last_byte(2), + vec![ancestor], + ); - assert!(Arc::ptr_eq(&result1.hashed_state, &result2.hashed_state)); - assert!(Arc::ptr_eq(&result1.trie_updates, &result2.trie_updates)); + let result = deferred.wait_cloned(); + // Should have the current block's anchor, not the ancestor's + assert_eq!(result.anchor_hash(), Some(B256::with_last_byte(2))); } + /// Ensures ancestor overlays are merged oldest -> newest so latest state wins (no overwrite by + /// older ancestors). #[test] - fn sorts_non_empty_inputs() { - let hashed_address = B256::with_last_byte(1); - let hashed_slot = B256::with_last_byte(2); - let hashed_state = HashedPostState::default() - .with_accounts([(hashed_address, Some(Account::default()))]) - .with_storages([( - hashed_address, - HashedStorage::from_iter(false, [(hashed_slot, U256::from(1))]), - )]); - - let deferred = - DeferredTrieData::pending(Arc::new(hashed_state), Arc::new(TrieUpdates::default())); + fn ancestors_merge_in_chronological_order() { + let key = B256::with_last_byte(1); + // Oldest ancestor sets nonce to 1 + let oldest_state = HashedPostStateSorted::new( + vec![(key, Some(Account { nonce: 1, balance: U256::ZERO, bytecode_hash: None }))], + B256Map::default(), + ); + // Newest ancestor overwrites nonce to 2 + let newest_state = HashedPostStateSorted::new( + vec![(key, Some(Account { nonce: 2, balance: U256::ZERO, bytecode_hash: None }))], + B256Map::default(), + ); + + let oldest = ComputedTrieData { + hashed_state: Arc::new(oldest_state), + trie_updates: Arc::default(), + anchored_trie_input: None, + }; + let newest = ComputedTrieData { + hashed_state: Arc::new(newest_state), + trie_updates: Arc::default(), + anchored_trie_input: None, + }; + + // Pass ancestors oldest -> newest; newest should take precedence + let deferred = DeferredTrieData::pending( + Arc::new(HashedPostState::default()), + Arc::new(TrieUpdates::default()), + B256::ZERO, + vec![DeferredTrieData::ready(oldest), DeferredTrieData::ready(newest)], + ); + let result = deferred.wait_cloned(); + let overlay_state = &result.anchored_trie_input.as_ref().unwrap().trie_input.state.accounts; + assert_eq!(overlay_state.len(), 1); + let (_, account) = &overlay_state[0]; + assert_eq!(account.unwrap().nonce, 2); + } - assert_eq!(result.hashed_state.total_len(), 2); - assert_eq!(result.trie_updates.total_len(), 0); + /// Helper to create a ready block with anchored trie input containing specific state. + fn ready_block_with_state( + anchor_hash: B256, + accounts: Vec<(B256, Option)>, + ) -> DeferredTrieData { + let hashed_state = Arc::new(HashedPostStateSorted::new(accounts, B256Map::default())); + let trie_updates = Arc::default(); + let mut overlay = TrieInputSorted::default(); + Arc::make_mut(&mut overlay.state).extend_ref_and_sort(hashed_state.as_ref()); + + DeferredTrieData::ready(ComputedTrieData { + hashed_state, + trie_updates, + anchored_trie_input: Some(AnchoredTrieInput { + anchor_hash, + trie_input: Arc::new(overlay), + }), + }) } + /// Verifies that first block after anchor (no ancestors) creates empty base overlay. #[test] - fn wait_does_not_block_after_first_compute() { - let mut accounts = B256Map::default(); - for i in 0..100 { - accounts.insert(B256::with_last_byte(i), Some(Account::default())); - } - let deferred = DeferredTrieData::pending( - Arc::new(HashedPostState { accounts, storages: Default::default() }), + fn first_block_after_anchor_creates_empty_base() { + let anchor = B256::with_last_byte(1); + let key = B256::with_last_byte(42); + let account = Account { nonce: 1, balance: U256::ZERO, bytecode_hash: None }; + + // First block after anchor - no ancestors + let first_block = DeferredTrieData::pending( + Arc::new(HashedPostState::default().with_accounts([(key, Some(account))])), Arc::new(TrieUpdates::default()), + anchor, + vec![], // No ancestors ); - let _ = deferred.wait_cloned(); + let result = first_block.wait_cloned(); + + // Should have overlay with just this block's data + let overlay = result.anchored_trie_input.as_ref().unwrap(); + assert_eq!(overlay.anchor_hash, anchor); + assert_eq!(overlay.trie_input.state.accounts.len(), 1); + let (found_key, found_account) = &overlay.trie_input.state.accounts[0]; + assert_eq!(*found_key, key); + assert_eq!(found_account.unwrap().nonce, 1); + } + + /// Verifies that parent's overlay is reused regardless of anchor. + #[test] + fn reuses_parent_overlay() { + let anchor = B256::with_last_byte(1); + let key = B256::with_last_byte(42); + let account = Account { nonce: 100, balance: U256::ZERO, bytecode_hash: None }; + + // Create parent with anchored trie input + let parent = ready_block_with_state(anchor, vec![(key, Some(account))]); + + // Create child - should reuse parent's overlay + let child = DeferredTrieData::pending( + Arc::new(HashedPostState::default()), + Arc::new(TrieUpdates::default()), + anchor, + vec![parent], + ); + + let result = child.wait_cloned(); + + // Verify parent's account is in the overlay + let overlay = result.anchored_trie_input.as_ref().unwrap(); + assert_eq!(overlay.anchor_hash, anchor); + assert_eq!(overlay.trie_input.state.accounts.len(), 1); + let (found_key, found_account) = &overlay.trie_input.state.accounts[0]; + assert_eq!(*found_key, key); + assert_eq!(found_account.unwrap().nonce, 100); + } + + /// Verifies that parent's overlay is NOT reused when anchor changes (after persist). + /// The overlay data is dependent on the anchor, so it must be rebuilt from the + /// remaining ancestors. + #[test] + fn rebuilds_overlay_when_anchor_changes() { + let old_anchor = B256::with_last_byte(1); + let new_anchor = B256::with_last_byte(2); + let key = B256::with_last_byte(42); + let account = Account { nonce: 50, balance: U256::ZERO, bytecode_hash: None }; + + // Create parent with OLD anchor + let parent = ready_block_with_state(old_anchor, vec![(key, Some(account))]); + + // Create child with NEW anchor (simulates after persist) + // Should NOT reuse parent's overlay because anchor changed + let child = DeferredTrieData::pending( + Arc::new(HashedPostState::default()), + Arc::new(TrieUpdates::default()), + new_anchor, + vec![parent], + ); + + let result = child.wait_cloned(); + + // Verify result uses new anchor + let overlay = result.anchored_trie_input.as_ref().unwrap(); + assert_eq!(overlay.anchor_hash, new_anchor); + + // Crucially, since we provided `parent` in ancestors but it has a different anchor, + // the code falls back to `merge_ancestors_into_overlay`. + // `merge_ancestors_into_overlay` reads `parent.hashed_state` (which has the account). + // So the account IS present, but it was obtained via REBUILD, not REUSE. + // We can check `DEFERRED_TRIE_METRICS` if we want to be sure, but functionally: + assert_eq!(overlay.trie_input.state.accounts.len(), 1); + let (found_key, found_account) = &overlay.trie_input.state.accounts[0]; + assert_eq!(*found_key, key); + assert_eq!(found_account.unwrap().nonce, 50); + } + + /// Verifies that parent without `anchored_trie_input` triggers rebuild path. + #[test] + fn rebuilds_when_parent_has_no_anchored_input() { + let anchor = B256::with_last_byte(1); + let key = B256::with_last_byte(42); + let account = Account { nonce: 25, balance: U256::ZERO, bytecode_hash: None }; + + // Create parent WITHOUT anchored trie input (e.g., from without_trie_input constructor) + let parent_state = + HashedPostStateSorted::new(vec![(key, Some(account))], B256Map::default()); + let parent = DeferredTrieData::ready(ComputedTrieData { + hashed_state: Arc::new(parent_state), + trie_updates: Arc::default(), + anchored_trie_input: None, // No anchored input + }); + + // Create child - should rebuild from parent's hashed_state + let child = DeferredTrieData::pending( + Arc::new(HashedPostState::default()), + Arc::new(TrieUpdates::default()), + anchor, + vec![parent], + ); + + let result = child.wait_cloned(); + + // Verify overlay is built and contains parent's data + let overlay = result.anchored_trie_input.as_ref().unwrap(); + assert_eq!(overlay.anchor_hash, anchor); + assert_eq!(overlay.trie_input.state.accounts.len(), 1); + } + + /// Verifies that a chain of blocks with matching anchors builds correct cumulative overlay. + #[test] + fn chain_of_blocks_builds_cumulative_overlay() { + let anchor = B256::with_last_byte(1); + let key1 = B256::with_last_byte(1); + let key2 = B256::with_last_byte(2); + let key3 = B256::with_last_byte(3); + + // Block 1: sets account at key1 + let block1 = ready_block_with_state( + anchor, + vec![(key1, Some(Account { nonce: 1, balance: U256::ZERO, bytecode_hash: None }))], + ); + + // Block 2: adds account at key2, ancestor is block1 + let block2_hashed = HashedPostState::default().with_accounts([( + key2, + Some(Account { nonce: 2, balance: U256::ZERO, bytecode_hash: None }), + )]); + let block2 = DeferredTrieData::pending( + Arc::new(block2_hashed), + Arc::new(TrieUpdates::default()), + anchor, + vec![block1.clone()], + ); + // Compute block2's trie data + let block2_computed = block2.wait_cloned(); + let block2_ready = DeferredTrieData::ready(block2_computed); + + // Block 3: adds account at key3, ancestor is block2 (which includes block1) + let block3_hashed = HashedPostState::default().with_accounts([( + key3, + Some(Account { nonce: 3, balance: U256::ZERO, bytecode_hash: None }), + )]); + let block3 = DeferredTrieData::pending( + Arc::new(block3_hashed), + Arc::new(TrieUpdates::default()), + anchor, + vec![block1, block2_ready], + ); + + let result = block3.wait_cloned(); + + // Verify all three accounts are in the cumulative overlay + let overlay = result.anchored_trie_input.as_ref().unwrap(); + assert_eq!(overlay.trie_input.state.accounts.len(), 3); + + // Accounts should be sorted by key (B256 ordering) + let accounts = &overlay.trie_input.state.accounts; + assert!(accounts.iter().any(|(k, a)| *k == key1 && a.unwrap().nonce == 1)); + assert!(accounts.iter().any(|(k, a)| *k == key2 && a.unwrap().nonce == 2)); + assert!(accounts.iter().any(|(k, a)| *k == key3 && a.unwrap().nonce == 3)); + } + + /// Verifies that child block's state overwrites parent's state for the same key. + #[test] + fn child_state_overwrites_parent() { + let anchor = B256::with_last_byte(1); + let key = B256::with_last_byte(42); + + // Parent sets nonce to 10 + let parent = ready_block_with_state( + anchor, + vec![(key, Some(Account { nonce: 10, balance: U256::ZERO, bytecode_hash: None }))], + ); + + // Child overwrites nonce to 99 + let child_hashed = HashedPostState::default().with_accounts([( + key, + Some(Account { nonce: 99, balance: U256::ZERO, bytecode_hash: None }), + )]); + let child = DeferredTrieData::pending( + Arc::new(child_hashed), + Arc::new(TrieUpdates::default()), + anchor, + vec![parent], + ); + + let result = child.wait_cloned(); + + // Verify child's value wins (extend_ref uses later value) + let overlay = result.anchored_trie_input.as_ref().unwrap(); + // Note: extend_ref may result in duplicate keys; check the last occurrence + let accounts = &overlay.trie_input.state.accounts; + let last_account = accounts.iter().rfind(|(k, _)| *k == key).unwrap(); + assert_eq!(last_account.1.unwrap().nonce, 99); + } + + /// Stress test: verify O(N) behavior by building a chain of many blocks. + /// This test ensures the fix doesn't regress - previously this would be O(N²). + #[test] + fn long_chain_builds_in_linear_time() { + let anchor = B256::with_last_byte(1); + let num_blocks = 50; // Enough to notice O(N²) vs O(N) difference + + let mut ancestors: Vec = Vec::new(); + let start = Instant::now(); - let _ = deferred.wait_cloned(); - assert!(start.elapsed() < Duration::from_millis(10)); + for i in 0..num_blocks { + let key = B256::with_last_byte(i as u8); + let account = Account { nonce: i as u64, balance: U256::ZERO, bytecode_hash: None }; + let hashed = HashedPostState::default().with_accounts([(key, Some(account))]); + + let block = DeferredTrieData::pending( + Arc::new(hashed), + Arc::new(TrieUpdates::default()), + anchor, + ancestors.clone(), + ); + + // Compute and add to ancestors for next iteration + let computed = block.wait_cloned(); + ancestors.push(DeferredTrieData::ready(computed)); + } + + let elapsed = start.elapsed(); + + // With O(N) fix, 50 blocks should complete quickly (< 1 second) + // With O(N²), this would take significantly longer + assert!( + elapsed < Duration::from_secs(2), + "Chain of {num_blocks} blocks took {:?}, possible O(N²) regression", + elapsed + ); + + // Verify final overlay has all accounts + let final_result = ancestors.last().unwrap().wait_cloned(); + let overlay = final_result.anchored_trie_input.as_ref().unwrap(); + assert_eq!(overlay.trie_input.state.accounts.len(), num_blocks); + } + + /// Verifies that a multi-ancestor overlay is rebuilt when anchor changes. + /// This simulates the "persist prefix then keep building" scenario where: + /// 1. A chain of blocks is built with anchor A + /// 2. Some blocks are persisted, changing anchor to B + /// 3. New blocks must rebuild the overlay from the remaining ancestors + #[test] + fn multi_ancestor_overlay_rebuilt_after_anchor_change() { + let old_anchor = B256::with_last_byte(1); + let new_anchor = B256::with_last_byte(2); + let key1 = B256::with_last_byte(1); + let key2 = B256::with_last_byte(2); + let key3 = B256::with_last_byte(3); + let key4 = B256::with_last_byte(4); + + // Build a chain of 3 blocks with old_anchor + let block1 = ready_block_with_state( + old_anchor, + vec![(key1, Some(Account { nonce: 1, balance: U256::ZERO, bytecode_hash: None }))], + ); + + let block2_hashed = HashedPostState::default().with_accounts([( + key2, + Some(Account { nonce: 2, balance: U256::ZERO, bytecode_hash: None }), + )]); + let block2 = DeferredTrieData::pending( + Arc::new(block2_hashed), + Arc::new(TrieUpdates::default()), + old_anchor, + vec![block1.clone()], + ); + let block2_ready = DeferredTrieData::ready(block2.wait_cloned()); + + let block3_hashed = HashedPostState::default().with_accounts([( + key3, + Some(Account { nonce: 3, balance: U256::ZERO, bytecode_hash: None }), + )]); + let block3 = DeferredTrieData::pending( + Arc::new(block3_hashed), + Arc::new(TrieUpdates::default()), + old_anchor, + vec![block1.clone(), block2_ready.clone()], + ); + let block3_ready = DeferredTrieData::ready(block3.wait_cloned()); + + // Verify block3's overlay has all 3 accounts with old_anchor + let block3_overlay = block3_ready.wait_cloned().anchored_trie_input.unwrap(); + assert_eq!(block3_overlay.anchor_hash, old_anchor); + assert_eq!(block3_overlay.trie_input.state.accounts.len(), 3); + + // Now simulate persist: create block4 with NEW anchor but same ancestors. + // To verify correct rebuilding, we must provide ALL unpersisted ancestors. + // If we only provided block3, the rebuild would only see block3's state. + // We pass block1, block2, block3 to simulate that they are all still in memory + // but the anchor check forces a rebuild (e.g. artificial anchor change). + let block4_hashed = HashedPostState::default().with_accounts([( + key4, + Some(Account { nonce: 4, balance: U256::ZERO, bytecode_hash: None }), + )]); + let block4 = DeferredTrieData::pending( + Arc::new(block4_hashed), + Arc::new(TrieUpdates::default()), + new_anchor, // Different anchor - simulates post-persist + vec![block1, block2_ready, block3_ready], + ); + + let result = block4.wait_cloned(); + + // Verify: + // 1. New anchor is used in result + assert_eq!(result.anchor_hash(), Some(new_anchor)); + + // 2. All 4 accounts are in the overlay (rebuilt from ancestors + extended) + let overlay = result.anchored_trie_input.as_ref().unwrap(); + assert_eq!(overlay.trie_input.state.accounts.len(), 4); + + // 3. All accounts have correct values + let accounts = &overlay.trie_input.state.accounts; + assert!(accounts.iter().any(|(k, a)| *k == key1 && a.unwrap().nonce == 1)); + assert!(accounts.iter().any(|(k, a)| *k == key2 && a.unwrap().nonce == 2)); + assert!(accounts.iter().any(|(k, a)| *k == key3 && a.unwrap().nonce == 3)); + assert!(accounts.iter().any(|(k, a)| *k == key4 && a.unwrap().nonce == 4)); } } diff --git a/crates/chain-state/src/in_memory.rs b/crates/chain-state/src/in_memory.rs index b684982257a..702031adfbb 100644 --- a/crates/chain-state/src/in_memory.rs +++ b/crates/chain-state/src/in_memory.rs @@ -17,7 +17,10 @@ use reth_primitives_traits::{ SignedTransaction, }; use reth_storage_api::StateProviderBox; -use reth_trie::{updates::TrieUpdatesSorted, HashedPostStateSorted, LazyTrieData, SortedTrieData}; +use reth_trie::{ + updates::TrieUpdatesSorted, HashedPostStateSorted, LazyTrieData, SortedTrieData, + TrieInputSorted, +}; use std::{collections::BTreeMap, sync::Arc, time::Instant}; use tokio::sync::{broadcast, watch}; @@ -815,9 +818,10 @@ impl ExecutedBlock { /// This is useful if the trie data is populated somewhere else, e.g. asynchronously /// after the block was validated. /// - /// The [`DeferredTrieData`] handle allows expensive trie operations (sorting hashed state and - /// trie updates) to be performed outside the critical validation path. This can improve latency - /// for time-sensitive operations like block validation. + /// The [`DeferredTrieData`] handle allows expensive trie operations (sorting hashed state, + /// sorting trie updates, and building the accumulated trie input overlay) to be performed + /// outside the critical validation path. This can improve latency for time-sensitive + /// operations like block validation. /// /// If the data hasn't been populated when [`Self::trie_data()`] is called, computation /// occurs synchronously from stored inputs, so there is no blocking or deadlock risk. @@ -886,6 +890,20 @@ impl ExecutedBlock { self.trie_data().trie_updates } + /// Returns the trie input anchored to the persisted ancestor. + /// + /// May compute trie data synchronously if the deferred task hasn't completed. + #[inline] + pub fn trie_input(&self) -> Option> { + self.trie_data().trie_input().cloned() + } + + /// Returns the anchor hash of the trie input, if present. + #[inline] + pub fn anchor_hash(&self) -> Option { + self.trie_data().anchor_hash() + } + /// Returns a [`BlockNumber`] of the block. #[inline] pub fn block_number(&self) -> BlockNumber { diff --git a/crates/chain-state/src/lazy_overlay.rs b/crates/chain-state/src/lazy_overlay.rs new file mode 100644 index 00000000000..d0a779613c7 --- /dev/null +++ b/crates/chain-state/src/lazy_overlay.rs @@ -0,0 +1,328 @@ +//! Lazy overlay computation for trie input. +//! +//! This module provides [`LazyOverlay`], a type that computes the [`TrieInputSorted`] +//! lazily on first access. This allows execution to start before the trie overlay +//! is fully computed. + +use crate::{EthPrimitives, ExecutedBlock}; +use alloy_primitives::B256; +use reth_primitives_traits::{ + dashmap::{self, DashMap}, + AlloyBlockHeader, NodePrimitives, +}; +use reth_trie::{updates::TrieUpdatesSorted, HashedPostStateSorted, TrieInputSorted}; +use std::sync::Arc; +use tracing::debug; + +/// Inputs captured for lazy overlay computation. +#[derive(Clone)] +struct LazyOverlayInputs { + /// In-memory blocks from tip to anchor child. + /// + /// Blocks must be provided in reverse chain order (newest to oldest). + blocks: Vec>, +} + +/// Lazily computed trie overlay. +/// +/// Captures the inputs needed to compute a [`TrieInputSorted`] and defers the actual +/// computation until first access. +/// +/// Blocks must be provided in reverse chain order (newest to oldest), so the first block is the +/// chain tip and the last block is the oldest in-memory block in the chain segment. +/// +/// # Fast Path vs Slow Path +/// +/// - **Fast path**: If the tip block's cached `anchored_trie_input` is ready and its `anchor_hash` +/// matches our expected anchor, we can reuse it directly (O(1)). +/// - **Slow path**: Otherwise, we merge all ancestor blocks' trie data into a new overlay. +#[derive(Clone)] +pub struct LazyOverlay { + /// Computed results, cached by requested anchor hash. + inner: Arc>>, + /// Inputs for lazy computation. + inputs: LazyOverlayInputs, +} + +impl std::fmt::Debug for LazyOverlay { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LazyOverlay") + .field( + "oldest_block_parent_hash", + &self.inputs.blocks.last().map(|block| block.recovered_block().parent_hash()), + ) + .field("num_blocks", &self.inputs.blocks.len()) + .field("cached_anchors", &self.inner.len()) + .finish() + } +} + +impl LazyOverlay { + /// Create a new lazy overlay from in-memory blocks. + /// + /// # Arguments + /// + /// * `blocks` - Executed blocks in reverse chain order (newest to oldest) + pub fn new(blocks: Vec>) -> Self { + debug_assert!( + blocks.windows(2).all(|window| { + window[0].recovered_block().parent_hash() == window[1].recovered_block().hash() + }), + "LazyOverlay blocks must be ordered newest to oldest along a single chain" + ); + + Self { inner: Default::default(), inputs: LazyOverlayInputs { blocks } } + } + + /// Returns the number of in-memory blocks this overlay covers. + pub const fn num_blocks(&self) -> usize { + self.inputs.blocks.len() + } + + /// Returns the oldest anchor hash this overlay can serve. + /// + /// This is the parent hash of the oldest block in the stored newest-to-oldest chain segment. + pub fn anchor_hash(&self) -> Option { + self.inputs.blocks.last().map(|block| block.recovered_block().parent_hash()) + } + + /// Returns true if there are no blocks in the overlay, or if one of the blocks has the given + /// hash as a parent hash. + pub fn has_anchor_hash(&self, hash: B256) -> bool { + self.inputs.blocks.is_empty() || + self.inputs.blocks.iter().any(|b| b.recovered_block().parent_hash() == hash) + } + + #[cfg(test)] + /// Returns true if the overlay has already been computed for the requested anchor. + pub fn is_computed(&self, anchor_hash: B256) -> bool { + self.inner.contains_key(&anchor_hash) + } + + /// Returns the computed trie input for the requested anchor, computing it if necessary. + /// + /// The first call triggers computation (which may block waiting for deferred data). + /// Subsequent calls for the same anchor return the cached result immediately. + pub fn get(&self, anchor_hash: B256) -> Arc { + match self.inner.entry(anchor_hash) { + dashmap::Entry::Occupied(entry) => { + debug!( + target: "chain_state::lazy_overlay", + %anchor_hash, + num_blocks = self.inputs.blocks.len(), + "Using cached lazy overlay result" + ); + Arc::clone(entry.get()) + } + dashmap::Entry::Vacant(entry) => { + let input = self.compute(anchor_hash); + entry.insert(Arc::clone(&input)); + input + } + } + } + + /// Returns the overlay as (nodes, state) tuple for use with `OverlayStateProviderFactory`. + pub fn as_overlay( + &self, + anchor_hash: B256, + ) -> (Arc, Arc) { + let input = self.get(anchor_hash); + (Arc::clone(&input.nodes), Arc::clone(&input.state)) + } + + /// Compute the trie input overlay. + fn compute(&self, anchor_hash: B256) -> Arc { + let blocks = &self.inputs.blocks; + if blocks.is_empty() { + return Default::default() + } + + let Some(last_index) = + blocks.iter().position(|block| block.recovered_block().parent_hash() == anchor_hash) + else { + panic!( + "LazyOverlay does not contain a block whose parent hash matches requested anchor {anchor_hash}" + ); + }; + let blocks = &blocks[..=last_index]; + + // Fast path: Check if tip block's overlay is ready and anchor matches. + // The tip block (first in list) has the cumulative overlay from all ancestors up to the + // requested anchor. + if let Some(tip) = blocks.first() { + let data = tip.trie_data(); + if let Some(anchored) = &data.anchored_trie_input { + if anchored.anchor_hash == anchor_hash { + return Arc::clone(&anchored.trie_input); + } + debug!( + target: "chain_state::lazy_overlay", + computed_anchor = %anchored.anchor_hash, + %anchor_hash, + "Anchor mismatch, falling back to merge" + ); + } + } + + // Slow path: Merge the prefix of blocks from the tip back to the requested anchor. + Arc::new(Self::merge_blocks(blocks)) + } + + /// Merge all blocks' trie data into a single [`TrieInputSorted`]. + /// + /// Blocks are ordered newest to oldest. + fn merge_blocks(blocks: &[ExecutedBlock]) -> TrieInputSorted { + if blocks.is_empty() { + return TrieInputSorted::default(); + } + + let state = HashedPostStateSorted::merge_batch( + blocks.iter().map(|block| block.trie_data().hashed_state), + ); + let nodes = TrieUpdatesSorted::merge_batch( + blocks.iter().map(|block| block.trie_data().trie_updates), + ); + + TrieInputSorted { state, nodes, prefix_sets: Default::default() } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{test_utils::TestBlockBuilder, ComputedTrieData, EthPrimitives, ExecutedBlock}; + use alloy_primitives::U256; + use reth_primitives_traits::Account; + use reth_trie::{updates::TrieUpdatesSorted, HashedPostState, HashedStorage}; + use std::sync::Arc; + + fn with_unique_state( + block: &ExecutedBlock, + id: u8, + ) -> ExecutedBlock { + let hashed_address = B256::with_last_byte(id); + let hashed_slot = B256::with_last_byte(id.saturating_add(32)); + let hashed_state = HashedPostState::default() + .with_accounts([(hashed_address, Some(Account::default()))]) + .with_storages([( + hashed_address, + HashedStorage::from_iter(false, [(hashed_slot, U256::from(id))]), + )]) + .into_sorted(); + + ExecutedBlock::new( + Arc::clone(&block.recovered_block), + Arc::clone(&block.execution_output), + ComputedTrieData::without_trie_input( + Arc::new(hashed_state), + Arc::new(TrieUpdatesSorted::default()), + ), + ) + } + + fn test_blocks() -> Vec> { + TestBlockBuilder::eth() + .get_executed_blocks(1..4) + .collect::>() + .into_iter() + .rev() + .enumerate() + .map(|(index, block)| with_unique_state(&block, index as u8 + 1)) + .collect() + } + + #[test] + fn single_block_uses_data_directly() { + let block = TestBlockBuilder::eth().get_executed_block_with_number(1, B256::random()); + let anchor_hash = block.recovered_block().parent_hash(); + let overlay = LazyOverlay::new(vec![block]); + + assert!(!overlay.is_computed(anchor_hash)); + let _ = overlay.get(anchor_hash); + assert!(overlay.is_computed(anchor_hash)); + } + + #[test] + fn caches_results_per_anchor() { + let blocks = test_blocks(); + let prefix_anchor = blocks[2].recovered_block().hash(); + let full_anchor = blocks[2].recovered_block().parent_hash(); + let overlay = LazyOverlay::new(blocks); + + let prefix = overlay.get(prefix_anchor); + let full = overlay.get(full_anchor); + + assert!(overlay.is_computed(prefix_anchor)); + assert!(overlay.is_computed(full_anchor)); + assert!(!Arc::ptr_eq(&prefix, &full)); + assert!(Arc::ptr_eq(&prefix, &overlay.get(prefix_anchor))); + assert!(Arc::ptr_eq(&full, &overlay.get(full_anchor))); + } + + #[test] + fn requested_anchor_limits_the_merged_prefix() { + let blocks = test_blocks(); + let prefix_anchor = blocks[2].recovered_block().hash(); + let expected = LazyOverlay::merge_blocks(&blocks[..2]); + let overlay = LazyOverlay::new(blocks); + let actual = overlay.get(prefix_anchor); + + assert_eq!(actual.nodes.as_ref(), expected.nodes.as_ref()); + assert_eq!(actual.state.as_ref(), expected.state.as_ref()); + } + + #[test] + fn anchor_hash_returns_oldest_served_anchor() { + let blocks = test_blocks(); + let expected_anchor = blocks.last().unwrap().recovered_block().parent_hash(); + let overlay = LazyOverlay::new(blocks); + + assert_eq!(overlay.anchor_hash(), Some(expected_anchor)); + } + + #[test] + fn reuses_tip_overlay_when_anchor_matches() { + let mut blocks = test_blocks(); + let prefix_anchor = blocks[2].recovered_block().hash(); + let tip_overlay = Arc::new(LazyOverlay::merge_blocks(&blocks[..2])); + let tip_data = blocks[0].trie_data(); + + blocks[0] = ExecutedBlock::new( + Arc::clone(&blocks[0].recovered_block), + Arc::clone(&blocks[0].execution_output), + ComputedTrieData::with_trie_input( + tip_data.hashed_state, + tip_data.trie_updates, + prefix_anchor, + Arc::clone(&tip_overlay), + ), + ); + + let overlay = LazyOverlay::new(blocks); + let actual = overlay.get(prefix_anchor); + + assert!(Arc::ptr_eq(&actual, &tip_overlay)); + } + + #[test] + #[should_panic( + expected = "LazyOverlay does not contain a block whose parent hash matches requested anchor" + )] + fn missing_anchor_panics() { + let blocks = test_blocks(); + let missing_anchor = blocks[0].recovered_block().hash(); + let overlay = LazyOverlay::new(blocks); + + let _ = overlay.get(missing_anchor); + } + + #[test] + #[should_panic( + expected = "LazyOverlay blocks must be ordered newest to oldest along a single chain" + )] + fn misordered_blocks_panic() { + let blocks: Vec<_> = TestBlockBuilder::eth().get_executed_blocks(1..3).collect(); + let _ = LazyOverlay::new(blocks); + } +} diff --git a/crates/chain-state/src/lib.rs b/crates/chain-state/src/lib.rs index 6912fee626b..4bea662dfcd 100644 --- a/crates/chain-state/src/lib.rs +++ b/crates/chain-state/src/lib.rs @@ -17,8 +17,8 @@ pub use in_memory::*; mod deferred_trie; pub use deferred_trie::*; -mod state_trie_overlay; -pub use state_trie_overlay::*; +mod lazy_overlay; +pub use lazy_overlay::*; mod noop; diff --git a/crates/chain-state/src/state_trie_overlay.rs b/crates/chain-state/src/state_trie_overlay.rs deleted file mode 100644 index d7baef361fd..00000000000 --- a/crates/chain-state/src/state_trie_overlay.rs +++ /dev/null @@ -1,682 +0,0 @@ -//! Flattened state trie overlays for in-memory blocks. -//! -//! Payload validation needs a view of the state trie as of an in-memory parent block even when that -//! parent has not been persisted yet. [`StateTrieOverlayManager`] tracks those in-memory blocks and -//! builds reusable flattened state trie overlays on demand. - -use crate::{EthPrimitives, ExecutedBlock}; -use alloy_primitives::B256; -use reth_metrics::{ - metrics::{Counter, Histogram}, - Metrics, -}; -use reth_primitives_traits::{ - dashmap::{mapref::entry::Entry, DashMap}, - AlloyBlockHeader, NodePrimitives, -}; -#[cfg(feature = "rayon")] -use reth_tasks::WorkerPool; -use reth_trie::{updates::TrieUpdatesSorted, HashedPostStateSorted, TrieInputSorted}; -use std::{fmt, sync::Arc, time::Instant}; -use tracing::{debug, trace}; - -/// Manages flattened state trie overlays for in-memory blocks. -/// -/// The manager owns the in-memory block graph and a cache of flattened state trie overlays keyed by -/// `(anchor_hash, tip_hash)`. -#[derive(Clone)] -pub struct StateTrieOverlayManager { - blocks: Arc>>, - overlays: Arc>>, - #[cfg(feature = "rayon")] - worker_pool: Option>, - metrics: StateTrieOverlayMetrics, -} - -/// Metrics for state trie overlay management. -#[derive(Clone, Metrics)] -#[metrics(scope = "sync.block_validation.state_trie_overlay")] -struct StateTrieOverlayMetrics { - /// Duration of overlay computation in seconds. - overlay_computation_duration_seconds: Histogram, - /// Number of requests satisfied by an existing overlay cache entry. - overlay_cache_reuses: Counter, - /// Number of overlay cache entries populated by computing an overlay. - overlay_cache_fills: Counter, -} - -impl Default for StateTrieOverlayManager { - fn default() -> Self { - Self { - blocks: Default::default(), - overlays: Default::default(), - #[cfg(feature = "rayon")] - worker_pool: None, - metrics: Default::default(), - } - } -} - -impl std::fmt::Debug for StateTrieOverlayManager { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("StateTrieOverlayManager") - .field("blocks", &self.blocks.len()) - .field("overlays", &self.overlays.len()) - .finish() - } -} - -impl StateTrieOverlayManager { - /// Create a new [`StateTrieOverlayManager`] backed by the given worker pool. - #[cfg(feature = "rayon")] - pub fn new(worker_pool: Arc) -> Self { - Self { - blocks: Default::default(), - overlays: Default::default(), - worker_pool: Some(worker_pool), - metrics: Default::default(), - } - } - - /// Inserts an executed in-memory block into the state trie overlay manager. - #[tracing::instrument( - level = "trace", - target = "chain_state::state_trie_overlay", - skip_all, - fields( - block_hash = %block.recovered_block().hash(), - parent_hash = %block.recovered_block().parent_hash(), - duplicate = false, - ) - )] - pub fn insert_block(&self, block: ExecutedBlock) { - let hash = block.recovered_block().hash(); - let parent_hash = block.recovered_block().parent_hash(); - let span = tracing::Span::current(); - - // First add the block to the live graph; duplicate inserts do not need cache work. - match self.blocks.entry(hash) { - Entry::Occupied(_) => { - span.record("duplicate", true); - debug!( - target: "chain_state::state_trie_overlay", - %hash, - %parent_hash, - "state trie overlay block already inserted" - ); - return - } - Entry::Vacant(entry) => { - entry.insert(block); - } - } - - // Snapshot matching parent overlays before spawning so DashMap iteration guards are - // dropped. - let cached_parent_overlays = self - .overlays - .iter() - .filter_map(|entry| { - let key = *entry.key(); - (key.tip_hash == parent_hash).then_some(key.anchor_hash) - }) - .collect::>(); - - debug!( - target: "chain_state::state_trie_overlay", - %hash, - %parent_hash, - "inserted block into state trie overlay manager" - ); - if cached_parent_overlays.is_empty() { - return - } - - #[cfg(feature = "rayon")] - let Some(worker_pool) = self.worker_pool.clone() else { - return - }; - - #[cfg(not(feature = "rayon"))] - let _ = cached_parent_overlays; - - #[cfg(feature = "rayon")] - { - let parent_span = span; - for anchor_hash in cached_parent_overlays { - let manager = ::clone(self); - let parent_span = parent_span.clone(); - worker_pool.spawn(move || { - let _span = tracing::trace_span!( - target: "chain_state::state_trie_overlay", - parent: parent_span, - "precompute_state_trie_overlay", - tip_hash = %hash, - anchor_hash = %anchor_hash, - ) - .entered(); - let _ = manager.get_overlay(hash, anchor_hash); - }); - } - } - } - - /// Removes blocks from the live block graph and prunes cached overlays that can no longer be - /// built from the remaining blocks. - #[tracing::instrument( - level = "trace", - target = "chain_state::state_trie_overlay", - skip_all, - fields( - block_count = tracing::field::Empty, - removed_blocks = tracing::field::Empty, - pruned_overlays = tracing::field::Empty, - ) - )] - pub fn remove_blocks(&self, hashes: impl IntoIterator) { - let span = tracing::Span::current(); - - // Remove blocks first, then prune overlays against the remaining block graph. - let mut block_count = 0usize; - let mut removed_blocks = 0usize; - let mut pruned_overlays = 0usize; - for hash in hashes { - block_count += 1; - removed_blocks += self.blocks.remove(&hash).is_some() as usize; - } - span.record("block_count", block_count); - span.record("removed_blocks", removed_blocks); - - if removed_blocks > 0 { - let overlays_before = self.overlays.len(); - let blocks = Arc::clone(&self.blocks); - self.overlays.retain(|key, _| { - key.tip_hash != key.anchor_hash && - Self::anchor_for_parent_in(blocks.as_ref(), key.tip_hash, key.anchor_hash) == - Some(key.anchor_hash) - }); - pruned_overlays = overlays_before.saturating_sub(self.overlays.len()); - span.record("pruned_overlays", pruned_overlays); - } - debug!( - target: "chain_state::state_trie_overlay", - block_count, - removed_blocks, - pruned_overlays, - "removed blocks from state trie overlay manager" - ); - } - - /// Returns the flattened overlay from `anchor_hash` to `parent_hash`. - #[tracing::instrument( - level = "trace", - target = "chain_state::state_trie_overlay", - skip_all, - fields(tip_hash = %parent_hash, anchor_hash = %anchor_hash) - )] - pub fn overlay_for_parent( - &self, - parent_hash: B256, - anchor_hash: B256, - ) -> Result<(Arc, Arc), StateTrieOverlayError> { - debug!( - target: "chain_state::state_trie_overlay", - tip_hash = %parent_hash, - %anchor_hash, - "loading state trie overlay for parent" - ); - let input = self.get_overlay(parent_hash, anchor_hash)?; - Ok((Arc::clone(&input.nodes), Arc::clone(&input.state))) - } - - #[tracing::instrument( - level = "trace", - target = "chain_state::state_trie_overlay", - skip_all, - fields( - tip_hash = %tip_hash, - anchor_hash = %anchor_hash, - cache_reused = tracing::field::Empty, - block_count = tracing::field::Empty, - parent_overlay_reused = tracing::field::Empty, - ) - )] - fn get_overlay( - &self, - tip_hash: B256, - anchor_hash: B256, - ) -> Result, StateTrieOverlayError> { - let key = OverlayCacheKey { anchor_hash, tip_hash }; - let span = tracing::Span::current(); - - if let Some(input) = self.overlays.get(&key).map(|entry| Arc::clone(entry.value())) { - self.metrics.overlay_cache_reuses.increment(1); - span.record("cache_reused", true); - return Ok(input) - } - span.record("cache_reused", false); - - // Resolve the block path and any cached parent overlay before locking the child entry. - let mut hash = tip_hash; - let mut blocks = Vec::new(); - loop { - let block = - self.blocks.get(&hash).ok_or(StateTrieOverlayError { tip_hash, anchor_hash })?; - let parent_hash = block.recovered_block().parent_hash(); - blocks.push(block.clone()); - - if parent_hash == anchor_hash { - break - } - hash = parent_hash; - } - span.record("block_count", blocks.len()); - let parent_input = blocks.first().and_then(|block| { - let parent_hash = block.recovered_block().parent_hash(); - (parent_hash != anchor_hash) - .then(|| { - self.overlays - .get(&OverlayCacheKey { anchor_hash, tip_hash: parent_hash }) - .map(|entry| Arc::clone(entry.value())) - }) - .flatten() - }); - span.record("parent_overlay_reused", parent_input.is_some()); - let compute_input = match parent_input { - Some(parent_input) => { - ComputeOverlayInput::ExtendCached { block: blocks.swap_remove(0), parent_input } - } - None => ComputeOverlayInput::MergeBlocks(blocks), - }; - - // The vacant entry is the cache-fill gate: racing callers block instead of recomputing. - let input = match self.overlays.entry(key) { - Entry::Occupied(entry) => { - self.metrics.overlay_cache_reuses.increment(1); - span.record("cache_reused", true); - return Ok(Arc::clone(entry.get())) - } - Entry::Vacant(entry) => { - self.metrics.overlay_cache_fills.increment(1); - let input = { - #[cfg(feature = "rayon")] - { - if let Some(worker_pool) = &self.worker_pool { - let compute_span = span; - let metrics = self.metrics.clone(); - Arc::new(worker_pool.install_fn(move || { - let _guard = compute_span.enter(); - compute_overlay(compute_input, anchor_hash, &metrics) - })) - } else { - Arc::new(compute_overlay(compute_input, anchor_hash, &self.metrics)) - } - } - - #[cfg(not(feature = "rayon"))] - { - Arc::new(compute_overlay(compute_input, anchor_hash, &self.metrics)) - } - }; - - entry.insert(Arc::clone(&input)); - input - } - }; - - Ok(input) - } - - /// Returns `preferred_anchor` if it is on the parent chain, otherwise the first missing parent. - /// - /// Returns `None` if `parent_hash` is not `preferred_anchor` and the manager does not contain a - /// block for `parent_hash`, meaning there is no in-memory parent chain to inspect. - pub fn anchor_for_parent(&self, parent_hash: B256, preferred_anchor: B256) -> Option { - Self::anchor_for_parent_in(self.blocks.as_ref(), parent_hash, preferred_anchor) - } - - fn anchor_for_parent_in( - blocks: &DashMap>, - parent_hash: B256, - preferred_anchor: B256, - ) -> Option { - if parent_hash == preferred_anchor { - return Some(preferred_anchor) - } - - let mut hash = parent_hash; - - loop { - let block_parent_hash = blocks.get(&hash)?.recovered_block().parent_hash(); - if block_parent_hash == preferred_anchor { - return Some(block_parent_hash) - } - if !blocks.contains_key(&block_parent_hash) { - return Some(block_parent_hash) - } - hash = block_parent_hash; - } - } -} - -/// Error returned when a state trie overlay cannot be built from the manager's current block set. -#[derive(Debug)] -pub struct StateTrieOverlayError { - /// Requested in-memory tip hash. - tip_hash: B256, - /// Requested anchor hash. - anchor_hash: B256, -} - -impl fmt::Display for StateTrieOverlayError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "state trie overlay for tip {} cannot be anchored to {} with current blocks", - self.tip_hash, self.anchor_hash - ) - } -} - -impl std::error::Error for StateTrieOverlayError {} - -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -struct OverlayCacheKey { - anchor_hash: B256, - tip_hash: B256, -} - -enum ComputeOverlayInput { - ExtendCached { block: ExecutedBlock, parent_input: Arc }, - MergeBlocks(Vec>), -} - -#[tracing::instrument( - level = "trace", - target = "chain_state::state_trie_overlay", - skip_all, - fields( - anchor_hash = %anchor_hash, - block_count = tracing::field::Empty, - parent_overlay = tracing::field::Empty, - elapsed_us = tracing::field::Empty, - ) -)] -fn compute_overlay( - input: ComputeOverlayInput, - anchor_hash: B256, - metrics: &StateTrieOverlayMetrics, -) -> TrieInputSorted { - let started_at = Instant::now(); - let block_count = match &input { - ComputeOverlayInput::ExtendCached { .. } => 1, - ComputeOverlayInput::MergeBlocks(blocks) => blocks.len(), - }; - let parent_overlay = matches!(&input, ComputeOverlayInput::ExtendCached { .. }); - tracing::Span::current().record("block_count", block_count); - tracing::Span::current().record("parent_overlay", parent_overlay); - - let overlay = match input { - ComputeOverlayInput::ExtendCached { block, parent_input } => { - let trie_data = block.trie_data(); - - trace!( - target: "chain_state::state_trie_overlay", - %anchor_hash, - head = %block.recovered_block().hash(), - "extending cached parent state trie overlay" - ); - - let mut overlay = parent_input.as_ref().clone(); - extend_overlay(&mut overlay, &trie_data.hashed_state, &trie_data.trie_updates); - overlay - } - ComputeOverlayInput::MergeBlocks(blocks) => merge_blocks(blocks), - }; - - let elapsed = started_at.elapsed(); - metrics.overlay_computation_duration_seconds.record(elapsed.as_secs_f64()); - tracing::Span::current().record("elapsed_us", elapsed.as_micros() as u64); - debug!( - target: "chain_state::state_trie_overlay", - %anchor_hash, - block_count, - parent_overlay, - ?elapsed, - "computed state trie overlay" - ); - - overlay -} - -fn merge_blocks(blocks: Vec>) -> TrieInputSorted { - let trie_data = blocks.iter().map(ExecutedBlock::trie_data).collect::>(); - - #[cfg(feature = "rayon")] - let (nodes, state) = rayon::join( - || { - TrieUpdatesSorted::merge_batch( - trie_data.iter().map(|data| Arc::clone(&data.trie_updates)), - ) - }, - || { - HashedPostStateSorted::merge_batch( - trie_data.iter().map(|data| Arc::clone(&data.hashed_state)), - ) - }, - ); - - #[cfg(not(feature = "rayon"))] - let (nodes, state) = ( - TrieUpdatesSorted::merge_batch(trie_data.iter().map(|data| Arc::clone(&data.trie_updates))), - HashedPostStateSorted::merge_batch( - trie_data.iter().map(|data| Arc::clone(&data.hashed_state)), - ), - ); - - TrieInputSorted::new(nodes, state, Default::default()) -} - -fn extend_overlay( - overlay: &mut TrieInputSorted, - hashed_state: &HashedPostStateSorted, - trie_updates: &TrieUpdatesSorted, -) { - #[cfg(feature = "rayon")] - { - rayon::join( - || { - if !hashed_state.is_empty() { - Arc::make_mut(&mut overlay.state).extend_ref_and_sort(hashed_state); - } - }, - || { - if !trie_updates.is_empty() { - Arc::make_mut(&mut overlay.nodes).extend_ref_and_sort(trie_updates); - } - }, - ); - } - - #[cfg(not(feature = "rayon"))] - { - if !hashed_state.is_empty() { - Arc::make_mut(&mut overlay.state).extend_ref_and_sort(hashed_state); - } - if !trie_updates.is_empty() { - Arc::make_mut(&mut overlay.nodes).extend_ref_and_sort(trie_updates); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{test_utils::TestBlockBuilder, ComputedTrieData, EthPrimitives, ExecutedBlock}; - use alloy_primitives::U256; - use reth_primitives_traits::Account; - use reth_trie::{updates::TrieUpdatesSorted, HashedPostState, HashedStorage}; - use std::sync::Arc; - #[cfg(feature = "rayon")] - use std::{ - thread, - time::{Duration, Instant}, - }; - - fn with_unique_state( - block: &ExecutedBlock, - id: u8, - ) -> ExecutedBlock { - let hashed_address = B256::with_last_byte(id); - let hashed_slot = B256::with_last_byte(id.saturating_add(32)); - let hashed_state = HashedPostState::default() - .with_accounts([(hashed_address, Some(Account::default()))]) - .with_storages([( - hashed_address, - HashedStorage::from_iter(false, [(hashed_slot, U256::from(id))]), - )]) - .into_sorted(); - - ExecutedBlock::new( - Arc::clone(&block.recovered_block), - Arc::clone(&block.execution_output), - ComputedTrieData::new(Arc::new(hashed_state), Arc::new(TrieUpdatesSorted::default())), - ) - } - - fn test_blocks() -> Vec> { - TestBlockBuilder::eth() - .get_executed_blocks(1..4) - .enumerate() - .map(|(index, block)| with_unique_state(&block, index as u8 + 1)) - .collect() - } - - #[test] - fn errors_for_unknown_parent() { - let manager = StateTrieOverlayManager::::default(); - let parent = B256::random(); - let anchor = B256::random(); - - let err = manager.overlay_for_parent(parent, anchor).unwrap_err(); - - assert_eq!(err.tip_hash, parent); - assert_eq!(err.anchor_hash, anchor); - } - - #[test] - fn builds_managed_overlay_for_inserted_blocks() { - let manager = StateTrieOverlayManager::default(); - let blocks = test_blocks(); - for block in &blocks { - manager.insert_block(block.clone()); - } - - let anchor_hash = blocks[0].recovered_block().parent_hash(); - - let (_, state) = - manager.overlay_for_parent(blocks[2].recovered_block().hash(), anchor_hash).unwrap(); - assert_eq!(state.accounts.len(), 3); - - let short_anchor = blocks[1].recovered_block().hash(); - let (_, short) = - manager.overlay_for_parent(blocks[2].recovered_block().hash(), short_anchor).unwrap(); - assert_eq!(short.accounts.len(), 1); - let (_, cached_short) = - manager.overlay_for_parent(blocks[2].recovered_block().hash(), short_anchor).unwrap(); - assert!(Arc::ptr_eq(&short, &cached_short)); - } - - #[test] - fn returns_anchor_for_in_memory_parent() { - let manager = StateTrieOverlayManager::default(); - let blocks = test_blocks(); - for block in &blocks { - manager.insert_block(block.clone()); - } - - assert_eq!( - manager.anchor_for_parent(blocks[2].recovered_block().hash(), B256::random()), - Some(blocks[0].recovered_block().parent_hash()) - ); - - manager.remove_blocks([blocks[0].recovered_block().hash()]); - assert_eq!( - manager.anchor_for_parent( - blocks[2].recovered_block().hash(), - blocks[0].recovered_block().hash() - ), - Some(blocks[0].recovered_block().hash()) - ); - } - - #[test] - fn prefers_anchor_in_parent_chain() { - let manager = StateTrieOverlayManager::default(); - let blocks = test_blocks(); - for block in &blocks { - manager.insert_block(block.clone()); - } - - let db_tip_hash = blocks[1].recovered_block().hash(); - assert_eq!( - manager.anchor_for_parent(blocks[2].recovered_block().hash(), db_tip_hash), - Some(db_tip_hash) - ); - } - - #[cfg(feature = "rayon")] - #[test] - fn insert_block_prepares_child_overlay_from_cached_parent() { - let manager = StateTrieOverlayManager::new(Arc::new(WorkerPool::new(2, "test-ovly"))); - let blocks = test_blocks(); - - manager.insert_block(blocks[0].clone()); - - let anchor_hash = blocks[0].recovered_block().parent_hash(); - let parent_hash = blocks[0].recovered_block().hash(); - manager.overlay_for_parent(parent_hash, anchor_hash).unwrap(); - - let child_hash = blocks[1].recovered_block().hash(); - manager.insert_block(blocks[1].clone()); - - let child_key = OverlayCacheKey { anchor_hash, tip_hash: child_hash }; - let deadline = Instant::now() + Duration::from_secs(5); - while !manager.overlays.contains_key(&child_key) { - assert!( - Instant::now() < deadline, - "timed out waiting for optimistically prepared child overlay" - ); - thread::sleep(Duration::from_millis(10)); - } - - let (_, state) = manager.overlay_for_parent(child_hash, anchor_hash).unwrap(); - assert_eq!(state.accounts.len(), 2); - } - - #[test] - fn prunes_cached_overlays_after_removing_blocks() { - let manager = StateTrieOverlayManager::default(); - let blocks = test_blocks(); - for block in &blocks { - manager.insert_block(block.clone()); - } - - let original_anchor = blocks[0].recovered_block().parent_hash(); - manager.overlay_for_parent(blocks[2].recovered_block().hash(), original_anchor).unwrap(); - - manager.remove_blocks([ - blocks[0].recovered_block().hash(), - blocks[1].recovered_block().hash(), - ]); - - let anchor_hash = blocks[1].recovered_block().hash(); - assert!(manager - .overlay_for_parent(blocks[2].recovered_block().hash(), original_anchor) - .is_err()); - - let (_, state) = - manager.overlay_for_parent(blocks[2].recovered_block().hash(), anchor_hash).unwrap(); - assert_eq!(state.accounts.len(), 1); - } -} diff --git a/crates/engine/tree/src/tree/metrics.rs b/crates/engine/tree/src/tree/metrics.rs index 018f422e22e..f8a96e3c6bb 100644 --- a/crates/engine/tree/src/tree/metrics.rs +++ b/crates/engine/tree/src/tree/metrics.rs @@ -537,6 +537,10 @@ pub struct BlockValidationMetrics { pub hashed_post_state_size: Histogram, /// Size of `TrieUpdatesSorted` (`total_len`) pub trie_updates_sorted_size: Histogram, + /// Size of `AnchoredTrieInput` overlay `TrieUpdatesSorted` (`total_len`) + pub anchored_overlay_trie_updates_size: Histogram, + /// Size of `AnchoredTrieInput` overlay `HashedPostStateSorted` (`total_len`) + pub anchored_overlay_hashed_state_size: Histogram, } impl BlockValidationMetrics { diff --git a/crates/engine/tree/src/tree/mod.rs b/crates/engine/tree/src/tree/mod.rs index f8f24dd4bd4..9dbfb5dcd67 100644 --- a/crates/engine/tree/src/tree/mod.rs +++ b/crates/engine/tree/src/tree/mod.rs @@ -14,7 +14,7 @@ use alloy_rpc_types_engine::{ use error::{InsertBlockError, InsertBlockFatalError, InsertBlockValidationError}; use reth_chain_state::{ CanonicalInMemoryState, ComputedTrieData, ExecutedBlock, ExecutionTimingStats, - MemoryOverlayStateProvider, NewCanonicalChain, StateTrieOverlayManager, + MemoryOverlayStateProvider, NewCanonicalChain, }; use reth_consensus::{Consensus, FullConsensus}; use reth_engine_primitives::{ @@ -36,7 +36,7 @@ use reth_provider::{ }; use reth_revm::database::StateProviderDatabase; use reth_stages_api::ControlFlow; -use reth_tasks::{spawn_os_thread, utils::increase_thread_priority, WorkerPool}; +use reth_tasks::{spawn_os_thread, utils::increase_thread_priority}; use reth_trie_db::ChangesetCache; use revm::interpreter::debug_unreachable; use state::TreeState; @@ -156,7 +156,6 @@ impl EngineApiTreeState { invalid_header_hit_eviction_threshold: u8, canonical_block: BlockNumHash, engine_kind: EngineApiKind, - state_trie_overlay_worker_pool: Arc, ) -> Self { Self { invalid_headers: InvalidHeaderCache::new( @@ -164,11 +163,7 @@ impl EngineApiTreeState { invalid_header_hit_eviction_threshold, ), buffer: BlockBuffer::new(block_buffer_limit), - tree_state: TreeState::new( - canonical_block, - engine_kind, - StateTrieOverlayManager::new(state_trie_overlay_worker_pool), - ), + tree_state: TreeState::new(canonical_block, engine_kind), forkchoice_state_tracker: ForkchoiceStateTracker::default(), } } @@ -451,7 +446,6 @@ where config.invalid_header_hit_eviction_threshold(), header.num_hash(), kind, - runtime.state_trie_overlay_worker_pool(), ); let task = Self::new( @@ -1504,8 +1498,20 @@ where ); self.changeset_cache.evict(eviction_threshold); + // Invalidate cached overlay since the anchor has changed. + self.state.tree_state.invalidate_cached_overlay(); + self.on_new_persisted_block(last_state_trie_persisted_block)?; + // Re-prepare overlay for the current canonical head with the new anchor. + // Spawn a background task to trigger computation so it's ready when the next payload + // arrives. + if let Some(prepared) = self.state.tree_state.prepare_canonical_overlay() { + self.runtime.spawn_blocking_named("prepare-overlay", move || { + let _ = prepared.overlay.get(prepared.anchor_hash); + }); + } + self.purge_timing_stats(last_block_number, commit_duration); Ok(()) @@ -2241,7 +2247,9 @@ where let sorted_hashed_state = Arc::new(hashed_state.into_sorted()); let sorted_trie_updates = Arc::new(trie_updates); - let trie_data = ComputedTrieData::new(sorted_hashed_state, sorted_trie_updates); + // Skip building trie input and anchor for DB-loaded blocks. + let trie_data = + ComputedTrieData::without_trie_input(sorted_hashed_state, sorted_trie_updates); let execution_output = Arc::new(BlockExecutionOutput { state: execution_output.bundle, diff --git a/crates/engine/tree/src/tree/payload_validator.rs b/crates/engine/tree/src/tree/payload_validator.rs index d6fcf373574..f0fd4e5fed3 100644 --- a/crates/engine/tree/src/tree/payload_validator.rs +++ b/crates/engine/tree/src/tree/payload_validator.rs @@ -59,7 +59,7 @@ use reth_trie_sparse::debug_recorder::TrieDebugRecorder; use crate::tree::payload_processor::receipt_root_task::{IndexedReceipt, ReceiptRootTaskHandle}; use reth_chain_state::{ - CanonicalInMemoryState, DeferredTrieData, ExecutedBlock, ExecutionTimingStats, + CanonicalInMemoryState, DeferredTrieData, ExecutedBlock, ExecutionTimingStats, LazyOverlay, }; use reth_consensus::{ConsensusError, FullConsensus, ReceiptRootBloom}; use reth_engine_primitives::{ @@ -478,14 +478,15 @@ where // Get an iterator over the transactions in the payload let txs = self.tx_iterator_for(&input)?; + // Create lazy overlay from ancestors. This doesn't block, allowing execution to start + // before the trie data is ready. The overlay will be computed on first access. + let (lazy_overlay, anchor_hash) = Self::get_parent_lazy_overlay(parent_hash, ctx.state()); + // Create overlay factory for payload processor (StateRootTask path needs it for - // multiproofs) + // multiproofs). let provider_factory = self.provider.clone(); - let overlay_builder = Self::overlay_builder_for_parent( - parent_hash, - ctx.state(), - self.changeset_cache.clone(), - ); + let overlay_builder = OverlayBuilder::::new(anchor_hash, self.changeset_cache.clone()) + .with_lazy_overlay(lazy_overlay); let overlay_factory = OverlayStateProviderFactory::new(provider_factory.clone(), overlay_builder.clone()); let changeset_provider = self.spawn_changeset_provider_task(overlay_factory.clone()); @@ -809,6 +810,7 @@ where let executed_block = self.spawn_deferred_trie_task( Arc::new(block), output, + ctx.state(), hashed_state, trie_output, changeset_provider, @@ -1709,14 +1711,45 @@ where self.invalid_block_hook.on_invalid_block(parent_header, block, output, trie_updates); } - /// Returns an overlay builder configured for a payload parent. - fn overlay_builder_for_parent( + /// Creates a [`LazyOverlay`] for the parent block without blocking. + /// + /// Returns a lazy overlay that will compute the trie input on first access, and the anchor + /// block hash (the highest persisted ancestor). This allows execution to start immediately + /// while the trie input computation is deferred until the overlay is actually needed. + /// + /// If parent is on disk (no in-memory blocks), returns `None` for the lazy overlay. + /// + /// Uses a cached overlay if available for the canonical head (the common case). + fn get_parent_lazy_overlay( parent_hash: B256, state: &EngineApiTreeState, - changeset_cache: ChangesetCache, - ) -> OverlayBuilder { - OverlayBuilder::new(parent_hash, changeset_cache) - .with_state_trie_overlay_manager(state.tree_state.state_trie_overlays.clone()) + ) -> (Option>, B256) { + let (anchor_hash, blocks) = + state.tree_state.blocks_by_hash(parent_hash).unwrap_or_else(|| (parent_hash, vec![])); + + if blocks.is_empty() { + debug!(target: "engine::tree::payload_validator", "Parent found on disk, no lazy overlay needed"); + return (None, anchor_hash) + } + + if let Some(cached) = state.tree_state.get_cached_overlay(parent_hash, anchor_hash) { + debug!( + target: "engine::tree::payload_validator", + %parent_hash, + %anchor_hash, + "Using cached canonical overlay" + ); + return (Some(cached.overlay.clone()), cached.anchor_hash) + } + + debug!( + target: "engine::tree::payload_validator", + %anchor_hash, + num_blocks = blocks.len(), + "Creating lazy overlay for in-memory blocks" + ); + + (Some(LazyOverlay::new(blocks)), anchor_hash) } /// Spawns a background task to compute and sort trie data for the executed block. @@ -1736,10 +1769,22 @@ where &self, block: Arc>, execution_outcome: Arc>, + state: &EngineApiTreeState, hashed_state: LazyHashedPostState, trie_output: Arc, changeset_provider: impl TrieCursorFactory + Send + 'static, ) -> ExecutedBlock { + // Capture parent hash and ancestor overlays for deferred trie input construction. + let (anchor_hash, overlay_blocks) = state + .tree_state + .blocks_by_hash(block.parent_hash()) + .unwrap_or_else(|| (block.parent_hash(), Vec::new())); + + // Collect lightweight ancestor trie data handles. We don't call trie_data() here; + // the merge and any fallback sorting happens in the compute_trie_input_task. + let ancestors: Vec = + overlay_blocks.iter().rev().map(|b| b.trie_data_handle()).collect(); + // Create deferred handle with fallback inputs in case the background task hasn't completed. // Resolve the lazy handle into Arc. By this point the hashed state has // already been computed and used for state root verification, so .get() returns instantly. @@ -1747,7 +1792,8 @@ where Ok(state) => state, Err(handle) => handle.get().clone(), }; - let deferred_trie_data = DeferredTrieData::pending(hashed_state, trie_output); + let deferred_trie_data = + DeferredTrieData::pending(hashed_state, trie_output, anchor_hash, ancestors); let deferred_handle_task = deferred_trie_data.clone(); let block_validation_metrics = self.metrics.block_validation.clone(); @@ -1784,6 +1830,15 @@ where block_validation_metrics .trie_updates_sorted_size .record(computed.trie_updates.total_len() as f64); + if let Some(anchored) = &computed.anchored_trie_input { + block_validation_metrics + .anchored_overlay_trie_updates_size + .record(anchored.trie_input.nodes.total_len() as f64); + block_validation_metrics + .anchored_overlay_hashed_state_size + .record(anchored.trie_input.state.total_len() as f64); + } + // Compute and cache changesets using the computed trie_updates. // Use the pre-created provider to avoid races with changeset cache // eviction that can happen between task spawn and execution. @@ -2116,19 +2171,18 @@ where &block.execution_output.state, ); - let overlay_factory = OverlayStateProviderFactory::new( - self.provider.clone(), - Self::overlay_builder_for_parent( - block.recovered_block.parent_hash(), - state, - self.changeset_cache.clone(), - ), - ); + let (lazy_overlay, anchor_hash) = + Self::get_parent_lazy_overlay(block.recovered_block.parent_hash(), state); + let overlay_builder = OverlayBuilder::::new(anchor_hash, self.changeset_cache.clone()) + .with_lazy_overlay(lazy_overlay); + let overlay_factory = + OverlayStateProviderFactory::new(self.provider.clone(), overlay_builder); let changeset_provider = overlay_factory.database_provider_ro()?; Ok(self.spawn_deferred_trie_task( block.recovered_block, block.execution_output, + state, LazyHashedPostState::ready(block.hashed_state), block.trie_updates, changeset_provider, @@ -2145,10 +2199,11 @@ where parent_state_root: B256, state: &EngineApiTreeState, ) -> Option { - let overlay_factory = OverlayStateProviderFactory::new( - self.provider.clone(), - Self::overlay_builder_for_parent(parent_hash, state, self.changeset_cache.clone()), - ); + let (lazy_overlay, anchor_hash) = Self::get_parent_lazy_overlay(parent_hash, state); + let overlay_builder = OverlayBuilder::::new(anchor_hash, self.changeset_cache.clone()) + .with_lazy_overlay(lazy_overlay); + let overlay_factory = + OverlayStateProviderFactory::new(self.provider.clone(), overlay_builder); Some(self.payload_processor.spawn_state_root( overlay_factory, diff --git a/crates/engine/tree/src/tree/state.rs b/crates/engine/tree/src/tree/state.rs index b8a33adb8b0..a5b3e40d0b0 100644 --- a/crates/engine/tree/src/tree/state.rs +++ b/crates/engine/tree/src/tree/state.rs @@ -6,7 +6,7 @@ use alloy_primitives::{ map::{B256Map, B256Set}, BlockNumber, B256, }; -use reth_chain_state::{EthPrimitives, ExecutedBlock, StateTrieOverlayManager}; +use reth_chain_state::{EthPrimitives, ExecutedBlock, LazyOverlay}; use reth_primitives_traits::{AlloyBlockHeader, NodePrimitives, SealedHeader}; use std::{ collections::{btree_map, hash_map, BTreeMap, VecDeque}, @@ -38,39 +38,30 @@ pub struct TreeState { pub(crate) current_canonical_head: BlockNumHash, /// The engine API variant of this handler pub(crate) engine_kind: EngineApiKind, - /// Flattened state trie overlays for in-memory blocks. - pub(crate) state_trie_overlays: StateTrieOverlayManager, + /// Pre-computed lazy overlay for the canonical head. + /// + /// This is optimistically prepared after the canonical head changes, so that + /// the next payload building on the canonical head can use it immediately + /// without recomputing. + pub(crate) cached_canonical_overlay: Option>, } impl TreeState { /// Returns a new, empty tree state that points to the given canonical head. - pub fn new( - current_canonical_head: BlockNumHash, - engine_kind: EngineApiKind, - state_trie_overlays: StateTrieOverlayManager, - ) -> Self { + pub fn new(current_canonical_head: BlockNumHash, engine_kind: EngineApiKind) -> Self { Self { blocks_by_hash: B256Map::default(), blocks_by_number: BTreeMap::new(), current_canonical_head, parent_to_child: B256Map::default(), engine_kind, - state_trie_overlays, + cached_canonical_overlay: None, } } /// Resets the state and points to the given canonical head. pub fn reset(&mut self, current_canonical_head: BlockNumHash) { - let engine_kind = self.engine_kind; - let removed_hashes = self.blocks_by_hash.keys().copied().collect::>(); - if !removed_hashes.is_empty() { - self.state_trie_overlays.remove_blocks(removed_hashes); - } - self.blocks_by_hash.clear(); - self.blocks_by_number.clear(); - self.parent_to_child.clear(); - self.current_canonical_head = current_canonical_head; - self.engine_kind = engine_kind; + *self = Self::new(current_canonical_head, self.engine_kind); } /// Returns the number of executed blocks stored. @@ -110,6 +101,64 @@ impl TreeState { Some((parent_hash, blocks)) } + /// Prepares a cached lazy overlay for the current canonical head. + /// + /// This should be called after the canonical head changes to optimistically + /// prepare the overlay for the next payload that will likely build on it. + /// + /// Returns a clone of the prepared overlay so the caller can spawn a background + /// task to trigger computation via [`LazyOverlay::get`] for the cached anchor. + /// This ensures the overlay is actually computed before the next payload arrives. + pub(crate) fn prepare_canonical_overlay(&mut self) -> Option> { + let canonical_hash = self.current_canonical_head.hash; + + // Get blocks leading to the canonical head + let Some((anchor_hash, blocks)) = self.blocks_by_hash(canonical_hash) else { + // Canonical head not in memory (persisted), no overlay needed + self.cached_canonical_overlay = None; + return None; + }; + + let num_blocks = blocks.len(); + let prepared = PreparedCanonicalOverlay { + parent_hash: canonical_hash, + overlay: LazyOverlay::new(blocks), + anchor_hash, + }; + self.cached_canonical_overlay = Some(prepared.clone()); + + debug!( + target: "engine::tree", + %canonical_hash, + %anchor_hash, + num_blocks, + "Prepared cached canonical overlay" + ); + + Some(prepared) + } + + /// Returns the cached overlay if it matches the requested parent hash and anchor. + /// + /// Both parent hash and anchor hash must match to ensure the overlay is valid. + /// This prevents using a stale overlay after persistence has advanced the anchor. + pub fn get_cached_overlay( + &self, + parent_hash: B256, + expected_anchor: B256, + ) -> Option<&PreparedCanonicalOverlay> { + self.cached_canonical_overlay.as_ref().filter(|cached| { + cached.parent_hash == parent_hash && cached.anchor_hash == expected_anchor + }) + } + + /// Invalidates the cached overlay. + /// + /// Should be called when the anchor changes (e.g., after persistence). + pub(crate) fn invalidate_cached_overlay(&mut self) { + self.cached_canonical_overlay = None; + } + /// Insert executed block into the state. pub fn insert_executed(&mut self, executed: ExecutedBlock) { let hash = executed.recovered_block().hash(); @@ -120,13 +169,11 @@ impl TreeState { return; } - let overlay_block = executed.clone(); self.blocks_by_hash.insert(hash, executed.clone()); self.blocks_by_number.entry(block_number).or_default().push(executed); self.parent_to_child.entry(parent_hash).or_default().insert(hash); - self.state_trie_overlays.insert_block(overlay_block); } /// Remove single executed block by its hash. @@ -187,12 +234,7 @@ impl TreeState { /// Removes canonical blocks below the upper bound, only if the last persisted hash is /// part of the canonical chain. - fn remove_canonical_until( - &mut self, - upper_bound: BlockNumber, - last_persisted_hash: B256, - removed_hashes: &mut Vec, - ) { + pub fn remove_canonical_until(&mut self, upper_bound: BlockNumber, last_persisted_hash: B256) { debug!(target: "engine::tree", ?upper_bound, ?last_persisted_hash, "Removing canonical blocks from the tree"); // If the last persisted hash is not canonical, then we don't want to remove any canonical @@ -207,12 +249,9 @@ impl TreeState { while let Some(executed) = self.blocks_by_hash.get(¤t_block) { current_block = executed.recovered_block().parent_hash(); if executed.recovered_block().number() <= upper_bound { - let hash = executed.recovered_block().hash(); let num_hash = executed.recovered_block().num_hash(); debug!(target: "engine::tree", ?num_hash, "Attempting to remove block walking back from the head"); - if self.remove_by_hash(hash).is_some() { - removed_hashes.push(hash); - } + self.remove_by_hash(executed.recovered_block().hash()); } } debug!(target: "engine::tree", ?upper_bound, ?last_persisted_hash, "Removed canonical blocks from the tree"); @@ -220,11 +259,7 @@ impl TreeState { /// Removes all blocks that are below the finalized block, as well as removing non-canonical /// sidechains that fork from below the finalized block. - fn prune_finalized_sidechains( - &mut self, - finalized_num_hash: BlockNumHash, - removed_hashes: &mut Vec, - ) { + pub fn prune_finalized_sidechains(&mut self, finalized_num_hash: BlockNumHash) { let BlockNumHash { number: finalized_num, hash: finalized_hash } = finalized_num_hash; // We remove disconnected sidechains in three steps: @@ -243,7 +278,6 @@ impl TreeState { for hash in blocks_to_remove { if let Some((removed, _)) = self.remove_by_hash(hash) { debug!(target: "engine::tree", num_hash=?removed.recovered_block().num_hash(), "Removed finalized sidechain block"); - removed_hashes.push(hash); } } @@ -270,7 +304,6 @@ impl TreeState { while let Some(block) = blocks_to_remove.pop_front() { if let Some((removed, children)) = self.remove_by_hash(block) { debug!(target: "engine::tree", num_hash=?removed.recovered_block().num_hash(), "Removed finalized sidechain child block"); - removed_hashes.push(block); blocks_to_remove.extend(children); } } @@ -311,18 +344,16 @@ impl TreeState { // * remove all canonical blocks below the upper bound // * fetch the number of the finalized hash, removing any sidechains that are __below__ the // finalized block - let mut removed_hashes = Vec::new(); - self.remove_canonical_until(upper_bound.number, last_persisted_hash, &mut removed_hashes); + self.remove_canonical_until(upper_bound.number, last_persisted_hash); // Now, we have removed canonical blocks (assuming the upper bound is above the finalized // block) and only have sidechains below the finalized block. if let Some(finalized_num_hash) = finalized_num_hash { - self.prune_finalized_sidechains(finalized_num_hash, &mut removed_hashes); + self.prune_finalized_sidechains(finalized_num_hash); } - if !removed_hashes.is_empty() { - self.state_trie_overlays.remove_blocks(removed_hashes); - } + // Invalidate the cached overlay since blocks were removed and the anchor may have changed + self.invalidate_cached_overlay(); } /// Updates the canonical head to the given block. @@ -390,6 +421,39 @@ impl TreeState { } } +/// Pre-computed lazy overlay for the canonical head block. +/// +/// This is prepared **optimistically** when the canonical head changes, allowing +/// the next payload (which typically builds on the canonical head) to reuse +/// the pre-computed overlay immediately without re-traversing in-memory blocks. +/// +/// The overlay captures executed blocks from all in-memory blocks +/// between the canonical head and the persisted anchor. When a new payload +/// arrives building on the canonical head, this cached overlay can be used +/// directly instead of calling `blocks_by_hash` again. +/// +/// # Invalidation +/// +/// The cached overlay is invalidated when: +/// - Persistence completes (anchor changes) +/// - The canonical head changes to a different block +#[derive(Debug, Clone)] +pub struct PreparedCanonicalOverlay { + /// The block hash for which this overlay is prepared as a parent. + /// + /// When a payload arrives with this parent hash, the overlay can be reused. + pub parent_hash: B256, + /// The pre-computed lazy overlay containing executed blocks for the canonical segment. + /// + /// This is computed optimistically after `set_canonical_head` so subsequent payloads don't + /// need to walk the in-memory chain again. + pub overlay: LazyOverlay, + /// The anchor hash (persisted ancestor) this overlay is based on. + /// + /// Used to verify the overlay is still valid (anchor hasn't changed due to persistence). + pub anchor_hash: B256, +} + #[cfg(test)] mod tests { use super::*; @@ -397,11 +461,7 @@ mod tests { #[test] fn test_tree_state_normal_descendant() { - let mut tree_state = TreeState::new( - BlockNumHash::default(), - EngineApiKind::Ethereum, - StateTrieOverlayManager::default(), - ); + let mut tree_state = TreeState::new(BlockNumHash::default(), EngineApiKind::Ethereum); let blocks: Vec<_> = TestBlockBuilder::eth().get_executed_blocks(1..4).collect(); tree_state.insert_executed(blocks[0].clone()); @@ -424,11 +484,7 @@ mod tests { #[tokio::test] async fn test_tree_state_insert_executed() { - let mut tree_state = TreeState::new( - BlockNumHash::default(), - EngineApiKind::Ethereum, - StateTrieOverlayManager::default(), - ); + let mut tree_state = TreeState::new(BlockNumHash::default(), EngineApiKind::Ethereum); let blocks: Vec<_> = TestBlockBuilder::eth().get_executed_blocks(1..4).collect(); tree_state.insert_executed(blocks[0].clone()); @@ -454,11 +510,7 @@ mod tests { #[tokio::test] async fn test_tree_state_insert_executed_with_reorg() { - let mut tree_state = TreeState::new( - BlockNumHash::default(), - EngineApiKind::Ethereum, - StateTrieOverlayManager::default(), - ); + let mut tree_state = TreeState::new(BlockNumHash::default(), EngineApiKind::Ethereum); let mut test_block_builder = TestBlockBuilder::eth(); let blocks: Vec<_> = test_block_builder.get_executed_blocks(1..6).collect(); @@ -498,11 +550,7 @@ mod tests { #[tokio::test] async fn test_tree_state_remove_before() { let start_num_hash = BlockNumHash::default(); - let mut tree_state = TreeState::new( - start_num_hash, - EngineApiKind::Ethereum, - StateTrieOverlayManager::default(), - ); + let mut tree_state = TreeState::new(start_num_hash, EngineApiKind::Ethereum); let blocks: Vec<_> = TestBlockBuilder::eth().get_executed_blocks(1..6).collect(); for block in &blocks { @@ -552,11 +600,7 @@ mod tests { #[tokio::test] async fn test_tree_state_remove_before_finalized() { let start_num_hash = BlockNumHash::default(); - let mut tree_state = TreeState::new( - start_num_hash, - EngineApiKind::Ethereum, - StateTrieOverlayManager::default(), - ); + let mut tree_state = TreeState::new(start_num_hash, EngineApiKind::Ethereum); let blocks: Vec<_> = TestBlockBuilder::eth().get_executed_blocks(1..6).collect(); for block in &blocks { @@ -606,11 +650,7 @@ mod tests { #[tokio::test] async fn test_tree_state_remove_before_lower_finalized() { let start_num_hash = BlockNumHash::default(); - let mut tree_state = TreeState::new( - start_num_hash, - EngineApiKind::Ethereum, - StateTrieOverlayManager::default(), - ); + let mut tree_state = TreeState::new(start_num_hash, EngineApiKind::Ethereum); let blocks: Vec<_> = TestBlockBuilder::eth().get_executed_blocks(1..6).collect(); for block in &blocks { diff --git a/crates/engine/tree/src/tree/tests.rs b/crates/engine/tree/src/tree/tests.rs index 6831c96370b..73d011a81c4 100644 --- a/crates/engine/tree/src/tree/tests.rs +++ b/crates/engine/tree/src/tree/tests.rs @@ -19,9 +19,7 @@ use alloy_rpc_types_engine::{ ExecutionData, ExecutionPayloadSidecar, ExecutionPayloadV1, ForkchoiceState, }; use assert_matches::assert_matches; -use reth_chain_state::{ - test_utils::TestBlockBuilder, BlockState, ComputedTrieData, StateTrieOverlayManager, -}; +use reth_chain_state::{test_utils::TestBlockBuilder, BlockState, ComputedTrieData}; use reth_chainspec::{ChainSpec, HOLESKY, MAINNET}; use reth_engine_primitives::{EngineApiValidator, ForkchoiceStatus, NoopInvalidBlockHook}; use reth_ethereum_consensus::EthBeaconConsensus; @@ -188,7 +186,6 @@ impl TestHarness { let (from_tree_tx, from_tree_rx) = unbounded_channel(); let tree_config = TreeConfig::default().with_legacy_state_root(false).with_has_enough_parallelism(true); - let runtime = reth_tasks::Runtime::test(); let header = chain_spec.genesis_header().clone(); let header = SealedHeader::seal_slow(header); @@ -198,7 +195,6 @@ impl TestHarness { tree_config.invalid_header_hit_eviction_threshold(), header.num_hash(), EngineApiKind::Ethereum, - runtime.state_trie_overlay_worker_pool(), ); let canonical_in_memory_state = CanonicalInMemoryState::with_head(header, None, None); @@ -215,7 +211,7 @@ impl TestHarness { TreeConfig::default(), Box::new(NoopInvalidBlockHook::default()), changeset_cache.clone(), - runtime.clone(), + reth_tasks::Runtime::test(), ); let tree = EngineApiTreeHandler::new( @@ -236,7 +232,7 @@ impl TestHarness { EngineApiKind::Ethereum, evm_config, changeset_cache, - runtime, + reth_tasks::Runtime::test(), ); let block_builder = TestBlockBuilder::default().with_chain_spec((*chain_spec).clone()); @@ -271,18 +267,13 @@ impl TestHarness { parent_hash = hash; } - let state_trie_overlays = StateTrieOverlayManager::default(); - for block in &blocks { - state_trie_overlays.insert_block(block.clone()); - } - self.tree.state.tree_state = TreeState { blocks_by_hash, blocks_by_number, current_canonical_head: blocks.last().unwrap().recovered_block().num_hash(), parent_to_child, engine_kind: EngineApiKind::Ethereum, - state_trie_overlays, + cached_canonical_overlay: None, }; let last_executed_block = blocks.last().unwrap().clone(); diff --git a/crates/storage/provider/src/providers/state/overlay.rs b/crates/storage/provider/src/providers/state/overlay.rs index 1d45351f0a8..e278a46e187 100644 --- a/crates/storage/provider/src/providers/state/overlay.rs +++ b/crates/storage/provider/src/providers/state/overlay.rs @@ -1,7 +1,7 @@ use alloy_eips::BlockNumHash; use alloy_primitives::{BlockHash, BlockNumber, B256}; use metrics::{Counter, Histogram}; -use reth_chain_state::{EthPrimitives, StateTrieOverlayManager}; +use reth_chain_state::{EthPrimitives, LazyOverlay}; use reth_db_api::{tables, transaction::DbTx, DatabaseError}; use reth_errors::{ProviderError, ProviderResult}; use reth_metrics::Metrics; @@ -61,39 +61,38 @@ pub(super) struct Overlay { pub(super) hashed_post_state: Arc, } +#[derive(Debug)] +struct OverlayRevertPlan { + revert_blocks: Option>, + overlay_anchor_hash: BlockHash, +} + /// Source of overlay data for [`OverlayStateProviderFactory`]. +/// +/// Either provides immediate pre-computed overlay data, or a lazy overlay that computes +/// on first access. #[derive(Debug, Clone)] pub(super) enum OverlaySource { /// Immediate overlay with already-computed data. Immediate { /// Trie updates overlay. - /// - /// This can be non-empty when a caller starts with an explicit `TrieInputSorted`, such - /// as historical providers. trie: Arc, /// Hashed state overlay. state: Arc, }, - /// Manager-backed overlay for in-memory state, with optional immediate overlay data. - Managed { - /// Manager used to resolve in-memory parent state if the parent is not persisted. - manager: StateTrieOverlayManager, - /// Immediate hashed state overlay applied on top of any manager-produced overlay. - /// - /// This is populated by the `with_hashed_state_overlay` methods. - state: Arc, - }, + /// Lazy overlay computed on first access. + Lazy(LazyOverlay), } /// Builder for calculating trie and hashed-state overlays. /// -/// This stores the overlay configuration and the logic for resolving overlays and collecting -/// reverts. It is intentionally independent from any provider factory or overlay cache. +/// This stores the overlay configuration and the logic for resolving immediate/lazy overlays and +/// collecting reverts. It is intentionally independent from any provider factory or overlay cache. #[derive(Debug, Clone)] pub struct OverlayBuilder { - /// Parent hash requested by the caller. - parent_hash: B256, - /// Optional overlay source. + /// Anchor hash to revert the DB state to before applying overlays. + anchor_hash: B256, + /// Optional overlay source (lazy or immediate). overlay_source: Option>, /// Changeset cache handle for retrieving trie changesets changeset_cache: ChangesetCache, @@ -103,32 +102,59 @@ pub struct OverlayBuilder { impl OverlayBuilder { /// Create a new overlay builder. - pub fn new(parent_hash: B256, changeset_cache: ChangesetCache) -> Self { + pub fn new(anchor_hash: B256, changeset_cache: ChangesetCache) -> Self { Self { - parent_hash, + anchor_hash, overlay_source: None, changeset_cache, metrics: OverlayStateProviderMetrics::default(), } } - /// Set the overlay source. + /// Set the overlay source (lazy or immediate). /// - /// This overlay will be applied on top of any reverts. + /// This overlay will be applied on top of any reverts applied via `anchor_hash`. pub(super) fn with_overlay_source(mut self, source: Option>) -> Self { + if let Some(OverlaySource::Lazy(lazy_overlay)) = source.as_ref() { + self.assert_lazy_overlay_anchor(lazy_overlay); + } + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.anchor_hash, + source = overlay_source_kind(source.as_ref()), + source_anchor = ?source.as_ref().and_then(overlay_source_anchor), + source_blocks = ?source.as_ref().and_then(overlay_source_num_blocks), + "Configuring overlay source" + ); self.overlay_source = source; self } - /// Set the state trie overlay manager used to resolve in-memory parent state. - pub fn with_state_trie_overlay_manager( - mut self, - state_trie_overlay_manager: StateTrieOverlayManager, - ) -> Self { - self.overlay_source = Some(OverlaySource::Managed { - manager: state_trie_overlay_manager, - state: Arc::new(HashedPostStateSorted::default()), - }); + fn assert_lazy_overlay_anchor(&self, lazy_overlay: &LazyOverlay) { + let Some(lazy_overlay_anchor) = lazy_overlay.anchor_hash() else { return }; + assert!( + lazy_overlay_anchor == self.anchor_hash, + "LazyOverlay's anchor ({}) != OverlayBuilder's anchor ({})", + lazy_overlay_anchor, + self.anchor_hash, + ); + } + + /// Set a lazy overlay that will be computed on first access. + /// + /// Panics if the [`LazyOverlay`]'s anchor hash does not match [`Self`]'s `anchor_hash`. + pub fn with_lazy_overlay(mut self, lazy_overlay: Option>) -> Self { + if let Some(lazy_overlay) = lazy_overlay.as_ref() { + self.assert_lazy_overlay_anchor(lazy_overlay); + } + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.anchor_hash, + lazy_anchor = ?lazy_overlay.as_ref().and_then(LazyOverlay::anchor_hash), + lazy_blocks = ?lazy_overlay.as_ref().map(LazyOverlay::num_blocks), + "Configuring lazy overlay" + ); + self.overlay_source = lazy_overlay.map(OverlaySource::Lazy); self } @@ -138,29 +164,35 @@ impl OverlayBuilder { hashed_state_overlay: Option>, ) -> Self { if let Some(state) = hashed_state_overlay { - match &mut self.overlay_source { - Some(OverlaySource::Managed { state: managed_state, .. }) => { - *managed_state = state; - } - _ => { - self.overlay_source = Some(OverlaySource::Immediate { - trie: Arc::new(TrieUpdatesSorted::default()), - state, - }); - } - } + self.overlay_source = Some(OverlaySource::Immediate { + trie: Arc::new(TrieUpdatesSorted::default()), + state, + }); + } else { + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.anchor_hash, + "Clearing hashed-state overlay" + ); } self } /// Extends the existing hashed state overlay with the given [`HashedPostStateSorted`]. /// - /// If no overlay exists, creates an immediate overlay with the given state. + /// If no overlay exists, creates a new immediate overlay with the given state. + /// If a lazy overlay exists, it is resolved first then extended. pub fn with_extended_hashed_state_overlay(mut self, other: HashedPostStateSorted) -> Self { match &mut self.overlay_source { - Some(OverlaySource::Immediate { state, .. } | OverlaySource::Managed { state, .. }) => { + Some(OverlaySource::Immediate { state, .. }) => { Arc::make_mut(state).extend_ref_and_sort(&other); } + Some(OverlaySource::Lazy(overlay)) => { + // Resolve lazy overlay and convert to immediate with extension + let (trie, mut state) = overlay.as_overlay(self.anchor_hash); + Arc::make_mut(&mut state).extend_ref_and_sort(&other); + self.overlay_source = Some(OverlaySource::Immediate { trie, state }); + } None => { self.overlay_source = Some(OverlaySource::Immediate { trie: Arc::new(TrieUpdatesSorted::default()), @@ -172,89 +204,159 @@ impl OverlayBuilder { } /// Resolves the effective overlay (trie updates, hashed state). + /// + /// If an overlay source is set, it is resolved (blocking if lazy). + /// Otherwise, returns empty defaults. fn resolve_overlays( &self, anchor_hash: BlockHash, ) -> ProviderResult<(Arc, Arc)> { - match &self.overlay_source { - Some(OverlaySource::Managed { manager, state }) => { - let (trie, mut overlay_state) = if anchor_hash == self.parent_hash { - ( - Arc::new(TrieUpdatesSorted::default()), - Arc::new(HashedPostStateSorted::default()), - ) - } else { - manager - .overlay_for_parent(self.parent_hash, anchor_hash) - .map_err(ProviderError::other)? - }; - - if overlay_state.is_empty() { - overlay_state = Arc::clone(state); - } else if !state.is_empty() { - Arc::make_mut(&mut overlay_state).extend_ref_and_sort(state); - } - - Ok((trie, overlay_state)) - } + let result = match &self.overlay_source { + Some(OverlaySource::Lazy(lazy_overlay)) => lazy_overlay.as_overlay(anchor_hash), Some(OverlaySource::Immediate { trie, state }) => { - if anchor_hash != self.parent_hash { + if anchor_hash != self.anchor_hash { return Err(ProviderError::other(std::io::Error::other(format!( - "anchor_hash {anchor_hash} doesn't match OverlayBuilder's configured parent ({})", - self.parent_hash - )))) + "anchor_hash {anchor_hash} doesn't match OverlayBuilder's configured anchor ({})", + self.anchor_hash + )))); } - Ok((Arc::clone(trie), Arc::clone(state))) + (Arc::clone(trie), Arc::clone(state)) } - None => Ok(( - Arc::new(TrieUpdatesSorted::default()), - Arc::new(HashedPostStateSorted::default()), - )), - } + None => { + (Arc::new(TrieUpdatesSorted::default()), Arc::new(HashedPostStateSorted::default())) + } + }; + + Ok(result) } - /// Returns the block which is at the tip of the DB, i.e. the block which the state tables of - /// the DB are currently synced to. - fn get_db_tip_block(&self, provider: &Provider) -> ProviderResult + /// Returns the block number for [`Self`]'s `anchor_hash` field. + fn get_block_number(&self, provider: &Provider) -> ProviderResult + where + Provider: BlockNumReader, + { + provider + .convert_hash_or_number(self.anchor_hash.into())? + .ok_or(ProviderError::BlockHashNotFound(self.anchor_hash)) + } + + /// Returns the highest blocks whose state/trie data and non-state/trie data are durably + /// available in the database. + fn get_db_tip_blocks( + &self, + provider: &Provider, + ) -> ProviderResult<(BlockNumHash, BlockNumHash)> where Provider: StageCheckpointReader + BlockNumReader, { - let block_number = provider - .get_stage_checkpoint(StageId::Finish)? - .as_ref() - .map(|chk| chk.block_number) - .ok_or_else(|| ProviderError::InsufficientChangesets { - requested: 0, - available: 0..=0, - })?; - let hash = provider + let checkpoint = provider.get_stage_checkpoint(StageId::Finish)?.ok_or_else(|| { + ProviderError::InsufficientChangesets { requested: 0, available: 0..=0 } + })?; + let block_number = checkpoint + .finish_stage_checkpoint() + .and_then(|finish| finish.partial_state_trie) + .unwrap_or(checkpoint.block_number); + let state_trie_tip_hash = provider .convert_number(block_number.into())? .ok_or_else(|| ProviderError::HeaderNotFound(block_number.into()))?; - Ok(BlockNumHash::new(block_number, hash)) + let finish_tip_number = checkpoint.block_number; + let finish_tip_hash = provider + .convert_number(finish_tip_number.into())? + .ok_or_else(|| ProviderError::HeaderNotFound(finish_tip_number.into()))?; + debug!( + target: "providers::state::overlay", + state_trie_tip_number = block_number, + state_trie_tip_hash = ?state_trie_tip_hash, + finish_tip_number, + finish_tip_hash = ?finish_tip_hash, + anchor_hash = ?self.anchor_hash, + "Loaded database overlay frontiers" + ); + Ok(( + BlockNumHash::new(block_number, state_trie_tip_hash), + BlockNumHash::new(finish_tip_number, finish_tip_hash), + )) } - /// Returns whether or not it is required to collect reverts, and validates that there are - /// sufficient changesets to revert to the requested block number if so. + /// Returns the revert plan required to expose the requested overlay base state, and validates + /// that there are sufficient changesets to revert to the requested block number if so. /// /// Takes into account both the stage checkpoint and the prune checkpoint to determine the /// available data range. - fn reverts_required( + fn revert_plan( &self, provider: &Provider, - db_tip_block: BlockNumHash, - anchor_hash: B256, - ) -> ProviderResult>> + state_trie_tip_block: BlockNumHash, + finish_tip_block: BlockNumHash, + ) -> ProviderResult where Provider: BlockNumReader + PruneCheckpointReader, { - // If the anchor is the DB tip then there won't be any reverts necessary. - if db_tip_block.hash == anchor_hash { - return Ok(None) + let anchor_number = self.get_block_number(provider)?; + let anchor_hash_at_number = provider + .convert_number(anchor_number.into())? + .ok_or_else(|| ProviderError::HeaderNotFound(anchor_number.into()))?; + if anchor_hash_at_number != self.anchor_hash { + return Err(ProviderError::other(std::io::Error::other(format!( + "anchor hash {} is not on the durable finish chain at block {} (found {})", + self.anchor_hash, anchor_number, anchor_hash_at_number, + )))); } - let anchor_number = provider - .convert_hash_or_number(anchor_hash.into())? - .ok_or(ProviderError::BlockHashNotFound(anchor_hash))?; + // If the requested anchor is the current durable Finish frontier, the database already + // exposes a consistent logical state for the overlay base. + if state_trie_tip_block.hash == finish_tip_block.hash && + finish_tip_block.hash == self.anchor_hash + { + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.anchor_hash, + ?state_trie_tip_block, + ?finish_tip_block, + overlay_anchor_hash = ?finish_tip_block.hash, + "Overlay anchor matches durable finish frontier; no reverts required" + ); + return Ok(OverlayRevertPlan { + revert_blocks: None, + overlay_anchor_hash: finish_tip_block.hash, + }); + } + + if let Some(OverlaySource::Lazy(lazy)) = self.overlay_source.as_ref() { + let lazy_covers_state_trie_tip = lazy.has_anchor_hash(state_trie_tip_block.hash); + let lazy_covers_finish_gap = state_trie_tip_block.hash == finish_tip_block.hash || + lazy.has_anchor_hash(finish_tip_block.hash); + + if lazy_covers_state_trie_tip && lazy_covers_finish_gap { + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.anchor_hash, + ?state_trie_tip_block, + ?finish_tip_block, + overlay_anchor_hash = ?state_trie_tip_block.hash, + source = overlay_source_kind(self.overlay_source.as_ref()), + source_anchor = ?self.overlay_source.as_ref().and_then(overlay_source_anchor), + source_blocks = ?self.overlay_source.as_ref().and_then(overlay_source_num_blocks), + "Lazy overlay covers partial state trie frontier; no reverts required" + ); + return Ok(OverlayRevertPlan { + revert_blocks: None, + overlay_anchor_hash: state_trie_tip_block.hash, + }) + } + } + + if anchor_number > state_trie_tip_block.number { + return Err(ProviderError::other(std::io::Error::other(format!( + "overlay anchor #{} ({}) is after partial state trie frontier #{} ({}); missing trie updates for blocks #{}..=#{}", + anchor_number, + self.anchor_hash, + state_trie_tip_block.number, + state_trie_tip_block.hash, + state_trie_tip_block.number + 1, + anchor_number, + )))); + } // Check account history prune checkpoint to determine the lower bound of available data. // The prune checkpoint's block_number is the highest pruned block, so data is available @@ -265,7 +367,19 @@ impl OverlayBuilder { .map(|block_number| block_number + 1) .unwrap_or_default(); - let available_range = lower_bound..=db_tip_block.number; + let available_range = lower_bound..=finish_tip_block.number; + + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.anchor_hash, + anchor_number, + ?state_trie_tip_block, + ?finish_tip_block, + prune_lower_bound = lower_bound, + available_start = *available_range.start(), + available_end = *available_range.end(), + "Checking overlay revert requirements" + ); // Check if the requested block is within the available range if !available_range.contains(&anchor_number) { @@ -275,20 +389,36 @@ impl OverlayBuilder { }); } - Ok(Some(anchor_number + 1..=db_tip_block.number)) + let revert_range = anchor_number + 1..=finish_tip_block.number; + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.anchor_hash, + anchor_number, + revert_start = *revert_range.start(), + revert_end = *revert_range.end(), + overlay_anchor_hash = ?self.anchor_hash, + "Overlay reverts required" + ); + + Ok(OverlayRevertPlan { + revert_blocks: Some(revert_range), + overlay_anchor_hash: self.anchor_hash, + }) } - /// Calculates a new [`Overlay`] given a transaction and the current db tip. + /// Calculates a new [`Overlay`] given a transaction and the current durable state/trie + /// frontier. #[instrument( level = "debug", target = "providers::state::overlay", skip_all, - fields(?db_tip_block, parent_hash = ?self.parent_hash) + fields(?state_trie_tip_block, ?finish_tip_block, anchor_hash = ?self.anchor_hash) )] fn calculate_overlay( &self, provider: &Provider, - db_tip_block: BlockNumHash, + state_trie_tip_block: BlockNumHash, + finish_tip_block: BlockNumHash, ) -> ProviderResult where Provider: ChangeSetReader @@ -306,29 +436,20 @@ impl OverlayBuilder { let retrieve_hashed_state_reverts_duration; let trie_updates_total_len; let hashed_state_updates_total_len; - let anchor_hash = match &self.overlay_source { - Some(OverlaySource::Managed { manager, .. }) => { - let parent_is_persisted = provider - .convert_hash_or_number(self.parent_hash.into())? - .is_some_and(|parent_number| parent_number <= db_tip_block.number); - if parent_is_persisted { - self.parent_hash - } else { - manager - .anchor_for_parent(self.parent_hash, db_tip_block.hash) - .ok_or(ProviderError::BlockHashNotFound(self.parent_hash))? - } - } - _ => self.parent_hash, - }; - // Collect any reverts which are required to bring the DB view back to the anchor hash. - let (trie_updates, hashed_post_state) = if let Some(revert_blocks) = - self.reverts_required(provider, db_tip_block, anchor_hash)? - { + let OverlayRevertPlan { revert_blocks, overlay_anchor_hash } = + self.revert_plan(provider, state_trie_tip_block, finish_tip_block)?; + + // Collect any reverts which are required to bring the DB view back to the overlay anchor + // hash. + let (trie_updates, hashed_post_state) = if let Some(revert_blocks) = revert_blocks { debug!( target: "providers::state::overlay", ?revert_blocks, + overlay_anchor_hash = ?overlay_anchor_hash, + source = overlay_source_kind(self.overlay_source.as_ref()), + source_anchor = ?self.overlay_source.as_ref().and_then(overlay_source_anchor), + source_blocks = ?self.overlay_source.as_ref().and_then(overlay_source_num_blocks), "Collecting trie reverts for overlay state provider" ); @@ -359,9 +480,9 @@ impl OverlayBuilder { res }; - // Resolve overlays and extend reverts with them. + // Resolve overlays (lazy or immediate) and extend reverts with them. // If reverts are empty, use overlays directly to avoid cloning. - let (overlay_trie, overlay_state) = self.resolve_overlays(anchor_hash)?; + let (overlay_trie, overlay_state) = self.resolve_overlays(overlay_anchor_hash)?; let trie_updates = if trie_reverts.is_empty() { overlay_trie @@ -388,19 +509,31 @@ impl OverlayBuilder { target: "providers::state::overlay", num_trie_updates = ?trie_updates_total_len, num_state_updates = ?hashed_state_updates_total_len, - "Reverted to anchor block", + overlay_anchor_hash = ?overlay_anchor_hash, + source = overlay_source_kind(self.overlay_source.as_ref()), + "Built overlay after reverting to anchor", ); (trie_updates, hashed_state_updates) } else { - // If no reverts are needed then the db tip is the anchor hash. Use overlays directly. - let (trie_updates, hashed_state) = self.resolve_overlays(db_tip_block.hash)?; + // If no reverts are needed then the overlay can be resolved directly from the durable + // logical frontier selected by the revert plan. + let (trie_updates, hashed_state) = self.resolve_overlays(overlay_anchor_hash)?; retrieve_trie_reverts_duration = Duration::ZERO; retrieve_hashed_state_reverts_duration = Duration::ZERO; trie_updates_total_len = trie_updates.total_len(); hashed_state_updates_total_len = hashed_state.total_len(); + debug!( + target: "providers::state::overlay", + num_trie_updates = trie_updates_total_len, + num_state_updates = hashed_state_updates_total_len, + overlay_anchor_hash = ?overlay_anchor_hash, + source = overlay_source_kind(self.overlay_source.as_ref()), + "Built overlay directly from durable frontier" + ); + (trie_updates, hashed_state) }; @@ -429,8 +562,40 @@ impl OverlayBuilder { + BlockNumReader + StorageSettingsCache, { - let db_tip_block = self.get_db_tip_block(provider)?; - self.calculate_overlay(provider, db_tip_block) + let (state_trie_tip_block, finish_tip_block) = self.get_db_tip_blocks(provider)?; + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.anchor_hash, + ?state_trie_tip_block, + ?finish_tip_block, + source = overlay_source_kind(self.overlay_source.as_ref()), + source_anchor = ?self.overlay_source.as_ref().and_then(overlay_source_anchor), + source_blocks = ?self.overlay_source.as_ref().and_then(overlay_source_num_blocks), + "Building overlay" + ); + self.calculate_overlay(provider, state_trie_tip_block, finish_tip_block) + } +} + +fn overlay_source_kind(source: Option<&OverlaySource>) -> &'static str { + match source { + Some(OverlaySource::Immediate { .. }) => "immediate", + Some(OverlaySource::Lazy(_)) => "lazy", + None => "none", + } +} + +fn overlay_source_anchor(source: &OverlaySource) -> Option { + match source { + OverlaySource::Immediate { .. } => None, + OverlaySource::Lazy(lazy) => lazy.anchor_hash(), + } +} + +fn overlay_source_num_blocks(source: &OverlaySource) -> Option { + match source { + OverlaySource::Immediate { .. } => None, + OverlaySource::Lazy(lazy) => Some(lazy.num_blocks()), } } @@ -444,9 +609,11 @@ pub struct OverlayStateProviderFactory { factory: F, /// Overlay builder containing the configuration and overlay calculation logic. overlay_builder: OverlayBuilder, - /// A cache which maps `db_tip -> Overlay`. If the db tip changes during usage of the factory - /// then a new entry will get added to this, but in most cases only one entry is present. - overlay_cache: Arc>, + /// A cache which maps `(state_trie_tip_hash, finish_tip_hash) -> Overlay`. + /// + /// Under partial persistence the overlay depends on both the durable trie frontier and the + /// fully durable Finish frontier, so both hashes are part of the cache key. + overlay_cache: Arc>, } impl OverlayStateProviderFactory { @@ -455,6 +622,13 @@ impl OverlayStateProviderFactory { Self { factory, overlay_builder, overlay_cache: Default::default() } } + /// Set a lazy overlay that will be computed on first access. + pub fn with_lazy_overlay(mut self, lazy_overlay: Option>) -> Self { + self.overlay_builder = self.overlay_builder.with_lazy_overlay(lazy_overlay); + self.overlay_cache = Default::default(); + self + } + /// Set the hashed state overlay. pub fn with_hashed_state_overlay( mut self, @@ -472,8 +646,8 @@ impl OverlayStateProviderFactory { self } - /// Fetches an [`Overlay`] from the cache based on the current db tip block. If there is no - /// cached value then this calculates the [`Overlay`] and populates the cache. + /// Fetches an [`Overlay`] from the cache based on the current durable frontiers. If there is + /// no cached value then this calculates the [`Overlay`] and populates the cache. #[instrument(level = "debug", target = "providers::state::overlay", skip_all)] fn get_overlay(&self, provider: &Provider) -> ProviderResult where @@ -485,12 +659,36 @@ impl OverlayStateProviderFactory { + BlockNumReader + StorageSettingsCache, { - let db_tip_block = self.overlay_builder.get_db_tip_block(provider)?; + let (state_trie_tip_block, finish_tip_block) = + self.overlay_builder.get_db_tip_blocks(provider)?; - let overlay = match self.overlay_cache.entry(db_tip_block.hash) { - dashmap::Entry::Occupied(entry) => entry.get().clone(), + let overlay = match self + .overlay_cache + .entry((state_trie_tip_block.hash, finish_tip_block.hash)) + { + dashmap::Entry::Occupied(entry) => { + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.overlay_builder.anchor_hash, + ?state_trie_tip_block, + ?finish_tip_block, + source = overlay_source_kind(self.overlay_builder.overlay_source.as_ref()), + "Using cached overlay" + ); + entry.get().clone() + } dashmap::Entry::Vacant(entry) => { self.overlay_builder.metrics.overlay_cache_misses.increment(1); + debug!( + target: "providers::state::overlay", + anchor_hash = ?self.overlay_builder.anchor_hash, + ?state_trie_tip_block, + ?finish_tip_block, + source = overlay_source_kind(self.overlay_builder.overlay_source.as_ref()), + source_anchor = ?self.overlay_builder.overlay_source.as_ref().and_then(overlay_source_anchor), + source_blocks = ?self.overlay_builder.overlay_source.as_ref().and_then(overlay_source_num_blocks), + "Overlay cache miss" + ); let overlay = self.overlay_builder.build_overlay(provider)?; entry.insert(overlay.clone()); overlay @@ -657,45 +855,235 @@ where #[cfg(test)] mod tests { use super::*; + use crate::{ + test_utils::create_test_provider_factory, BlockWriter, SaveBlocksMode, SaveBlocksPlan, + SaveBlocksPlanStep, + }; + use alloy_primitives::{B256, U256}; + use reth_chain_state::{test_utils::TestBlockBuilder, ComputedTrieData, ExecutedBlock}; use reth_primitives_traits::Account; - use reth_trie::HashedPostState; + use reth_stages_types::{FinishCheckpoint, StageCheckpoint}; + use reth_storage_api::StageCheckpointWriter; + use reth_trie::{updates::TrieUpdatesSorted, HashedPostState, HashedStorage}; + use std::sync::Arc; + + fn full_save_plan( + blocks: impl IntoIterator>, + ) -> SaveBlocksPlan { + let blocks = blocks.into_iter().collect::>(); + let full_range = 0..blocks.len(); + SaveBlocksPlan::new( + blocks, + vec![SaveBlocksPlanStep::new( + full_range.clone(), + Some(full_range.end..full_range.end), + true, + )], + ) + } - #[test] - fn managed_overlay_skips_manager_for_persisted_parent() { - let parent_hash = B256::with_last_byte(1); - let builder = OverlayBuilder::::new(parent_hash, ChangesetCache::default()) - .with_state_trie_overlay_manager(StateTrieOverlayManager::default()); + fn partial_save_plan( + blocks: impl IntoIterator>, + steps: Vec, + ) -> SaveBlocksPlan { + SaveBlocksPlan::new(blocks.into_iter().collect(), steps) + } + + fn with_unique_state( + block: &ExecutedBlock, + id: u8, + ) -> ExecutedBlock { + let hashed_address = B256::with_last_byte(id); + let hashed_slot = B256::with_last_byte(id.saturating_add(32)); + let hashed_state = HashedPostState::default() + .with_accounts([(hashed_address, Some(Account::default()))]) + .with_storages([( + hashed_address, + HashedStorage::from_iter(false, [(hashed_slot, U256::from(id))]), + )]) + .into_sorted(); - let (trie, state) = builder.resolve_overlays(parent_hash).unwrap(); - assert!(trie.is_empty()); - assert!(state.is_empty()); + ExecutedBlock::new( + Arc::clone(&block.recovered_block), + Arc::clone(&block.execution_output), + ComputedTrieData::without_trie_input( + Arc::new(hashed_state), + Arc::new(TrieUpdatesSorted::default()), + ), + ) } #[test] - fn managed_overlay_errors_if_parent_is_not_persisted_or_managed() { - let parent_hash = B256::with_last_byte(1); - let anchor_hash = B256::with_last_byte(2); - let builder = OverlayBuilder::::new(parent_hash, ChangesetCache::default()) - .with_state_trie_overlay_manager(StateTrieOverlayManager::default()); - - let err = builder.resolve_overlays(anchor_hash).unwrap_err(); + fn build_overlay_reverts_when_finish_frontier_is_after_state_trie_frontier() { + let factory = create_test_provider_factory(); + let mut block_builder = TestBlockBuilder::eth(); + let blocks = block_builder + .get_executed_blocks(0..5) + .enumerate() + .map(|(index, block)| with_unique_state(&block, index as u8 + 1)) + .collect::>(); + + let state_trie_tip = &blocks[1]; + let finish_tip = &blocks[3]; + let lazy_overlay_blocks = vec![blocks[4].clone(), blocks[3].clone(), blocks[2].clone()]; + + let provider_rw = factory.provider_rw().unwrap(); + provider_rw.insert_block(blocks[0].recovered_block()).unwrap(); + provider_rw.insert_block(state_trie_tip.recovered_block()).unwrap(); + provider_rw.insert_block(blocks[2].recovered_block()).unwrap(); + provider_rw.insert_block(finish_tip.recovered_block()).unwrap(); + provider_rw + .save_stage_checkpoint( + StageId::Finish, + StageCheckpoint::new(finish_tip.block_number()).with_finish_stage_checkpoint( + FinishCheckpoint { partial_state_trie: Some(state_trie_tip.block_number()) }, + ), + ) + .unwrap(); + provider_rw.commit().unwrap(); + + let provider = factory.provider().unwrap(); + let overlay = OverlayBuilder::::new( + state_trie_tip.recovered_block().hash(), + ChangesetCache::new(), + ) + .with_lazy_overlay(Some(LazyOverlay::new(lazy_overlay_blocks))) + .build_overlay(&provider) + .unwrap(); + + assert_eq!(overlay.hashed_post_state.accounts.len(), 3); + } - assert!(err.to_string().contains("cannot be anchored")); + #[test] + fn build_overlay_errors_for_anchor_after_state_trie_frontier() { + let factory = create_test_provider_factory(); + let mut block_builder = TestBlockBuilder::eth().with_state(); + + let genesis = block_builder.get_executed_blocks(0..1).next().unwrap(); + let blocks = block_builder.get_executed_blocks(1..4).collect::>(); + + let provider_rw = factory.provider_rw().unwrap(); + provider_rw + .save_blocks( + &full_save_plan(std::slice::from_ref(&genesis).to_vec()), + SaveBlocksMode::Full, + ) + .unwrap(); + provider_rw.commit().unwrap(); + + let provider_rw = factory.provider_rw().unwrap(); + provider_rw + .save_blocks( + &partial_save_plan( + blocks.clone(), + vec![ + SaveBlocksPlanStep::new(0..1, Some(1..3), true), + SaveBlocksPlanStep::new(1..3, None, true), + ], + ), + SaveBlocksMode::Full, + ) + .unwrap(); + provider_rw.commit().unwrap(); + + let provider = factory.provider().unwrap(); + let anchor = blocks[1].recovered_block().hash(); + let error = OverlayBuilder::::new(anchor, ChangesetCache::new()) + .with_lazy_overlay(Some(LazyOverlay::new(vec![blocks[2].clone()]))) + .build_overlay(&provider) + .unwrap_err(); + + assert!( + error.to_string().contains("is after partial state trie frontier"), + "unexpected error: {error}" + ); } #[test] - fn extending_hashed_state_keeps_managed_overlay_source() { - let parent_hash = B256::with_last_byte(1); - let hashed_state = HashedPostState::default() - .with_accounts([(B256::with_last_byte(2), Some(Account::default()))]) - .into_sorted(); - let builder = OverlayBuilder::::new(parent_hash, ChangesetCache::default()) - .with_state_trie_overlay_manager(StateTrieOverlayManager::default()) - .with_extended_hashed_state_overlay(hashed_state); + fn build_overlay_uses_lazy_superset_for_anchor_after_state_trie_frontier() { + let factory = create_test_provider_factory(); + let mut block_builder = TestBlockBuilder::eth(); + let blocks = block_builder + .get_executed_blocks(0..5) + .enumerate() + .map(|(index, block)| with_unique_state(&block, index as u8 + 1)) + .collect::>(); + + let state_trie_tip = &blocks[1]; + let finish_tip = &blocks[3]; + let lazy_overlay_blocks = + vec![blocks[4].clone(), blocks[3].clone(), blocks[2].clone(), blocks[1].clone()]; + + let provider_rw = factory.provider_rw().unwrap(); + provider_rw.insert_block(blocks[0].recovered_block()).unwrap(); + provider_rw.insert_block(state_trie_tip.recovered_block()).unwrap(); + provider_rw.insert_block(blocks[2].recovered_block()).unwrap(); + provider_rw.insert_block(finish_tip.recovered_block()).unwrap(); + provider_rw + .save_stage_checkpoint( + StageId::Finish, + StageCheckpoint::new(finish_tip.block_number()).with_finish_stage_checkpoint( + FinishCheckpoint { partial_state_trie: Some(state_trie_tip.block_number()) }, + ), + ) + .unwrap(); + provider_rw.commit().unwrap(); + + let provider = factory.provider().unwrap(); + let overlay = OverlayBuilder::::new( + blocks[0].recovered_block().hash(), + ChangesetCache::new(), + ) + .with_lazy_overlay(Some(LazyOverlay::new(lazy_overlay_blocks))) + .build_overlay(&provider) + .unwrap(); + + assert_eq!(overlay.hashed_post_state.accounts.len(), 3); + } - let Some(OverlaySource::Managed { state, .. }) = builder.overlay_source else { - panic!("expected managed overlay source") - }; - assert_eq!(state.total_len(), 1); + #[test] + fn build_overlay_errors_for_finish_anchor_after_state_trie_frontier() { + let factory = create_test_provider_factory(); + let mut block_builder = TestBlockBuilder::eth().with_state(); + + let genesis = block_builder.get_executed_blocks(0..1).next().unwrap(); + let blocks = block_builder.get_executed_blocks(1..4).collect::>(); + + let provider_rw = factory.provider_rw().unwrap(); + provider_rw + .save_blocks( + &full_save_plan(std::slice::from_ref(&genesis).to_vec()), + SaveBlocksMode::Full, + ) + .unwrap(); + provider_rw.commit().unwrap(); + + let provider_rw = factory.provider_rw().unwrap(); + provider_rw + .save_blocks( + &partial_save_plan( + blocks.clone(), + vec![ + SaveBlocksPlanStep::new(0..1, Some(1..3), true), + SaveBlocksPlanStep::new(1..3, None, true), + ], + ), + SaveBlocksMode::Full, + ) + .unwrap(); + provider_rw.commit().unwrap(); + + let provider = factory.provider().unwrap(); + let finish_anchor = blocks[2].recovered_block().hash(); + + let error = OverlayBuilder::::new(finish_anchor, ChangesetCache::new()) + .with_lazy_overlay(None) + .build_overlay(&provider) + .unwrap_err(); + + assert!( + error.to_string().contains("is after partial state trie frontier"), + "unexpected error: {error}" + ); } } From dbf7656dbd08f6824f3a0b149812b8c698ed5c90 Mon Sep 17 00:00:00 2001 From: Brian Date: Tue, 19 May 2026 10:00:10 +0200 Subject: [PATCH 80/83] fix(engine): keep partial persistence rest frontier moving --- crates/engine/tree/src/tree/mod.rs | 3 +- crates/engine/tree/src/tree/tests.rs | 54 +++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/crates/engine/tree/src/tree/mod.rs b/crates/engine/tree/src/tree/mod.rs index 9dbfb5dcd67..73e463f8e0c 100644 --- a/crates/engine/tree/src/tree/mod.rs +++ b/crates/engine/tree/src/tree/mod.rs @@ -2117,8 +2117,7 @@ where let last_block_target_number = match target { PersistTarget::Threshold => { canonical_head_number.saturating_sub(self.config.memory_block_buffer_target()).min( - last_state_trie_persisted_block_number - .saturating_add(self.config.persistence_threshold()), + last_persisted_block_number.saturating_add(self.config.persistence_threshold()), ) } PersistTarget::Head => canonical_head_number, diff --git a/crates/engine/tree/src/tree/tests.rs b/crates/engine/tree/src/tree/tests.rs index 73d011a81c4..2d75eacb4c9 100644 --- a/crates/engine/tree/src/tree/tests.rs +++ b/crates/engine/tree/src/tree/tests.rs @@ -1086,17 +1086,20 @@ fn test_get_save_blocks_plan_limits_partial_persistence_to_threshold() { let plan = test_harness.tree.get_save_blocks_plan(PersistTarget::Threshold).unwrap(); - assert_plan_steps(&plan, &[(0..3, Some(3..5), false), (3..5, None, true)]); - assert_eq!(plan.blocks.len(), 5); + assert_plan_steps( + &plan, + &[(0..3, Some(6..8), false), (3..6, Some(6..8), true), (6..8, None, true)], + ); + assert_eq!(plan.blocks.len(), 8); assert_eq!( plan.blocks.iter().map(|block| block.recovered_block().number()).collect::>(), - (13..=17).collect::>() + (13..=20).collect::>() ); - assert_eq!(plan.last_block(), Some(blocks[17].recovered_block().num_hash())); + assert_eq!(plan.last_block(), Some(blocks[20].recovered_block().num_hash())); } #[test] -fn test_get_save_blocks_plan_state_masking_counts_towards_threshold() { +fn test_get_save_blocks_plan_state_masking_does_not_reduce_persist_rest_threshold() { let chain_spec = MAINNET.clone(); let mut test_harness = TestHarness::new(chain_spec); let mut test_block_builder = TestBlockBuilder::eth(); @@ -1112,13 +1115,46 @@ fn test_get_save_blocks_plan_state_masking_counts_towards_threshold() { let plan = test_harness.tree.get_save_blocks_plan(PersistTarget::Threshold).unwrap(); - assert_plan_steps(&plan, &[(0..3, Some(3..13), false), (3..13, None, true)]); - assert_eq!(plan.blocks.len(), 13); + assert_plan_steps( + &plan, + &[(0..3, Some(6..16), false), (3..6, Some(6..16), true), (6..16, None, true)], + ); + assert_eq!(plan.blocks.len(), 16); + assert_eq!( + plan.blocks.iter().map(|block| block.recovered_block().number()).collect::>(), + (1..=16).collect::>() + ); + assert_eq!(plan.last_block(), Some(blocks[16].recovered_block().num_hash())); +} + +#[test] +fn test_get_save_blocks_plan_steady_state_masking_has_catchup_overlap_and_masked_tail() { + let chain_spec = MAINNET.clone(); + let mut test_harness = TestHarness::new(chain_spec); + let mut test_block_builder = TestBlockBuilder::eth(); + + let blocks: Vec<_> = test_block_builder.get_executed_blocks(0..30).collect(); + test_harness = test_harness.with_blocks(blocks.clone()); + test_harness.tree.persistence_state.last_state_trie_persisted_block = + blocks[5].recovered_block().num_hash(); + test_harness.tree.persistence_state.last_persisted_block = + blocks[11].recovered_block().num_hash(); + test_harness.tree.config = + TreeConfig::default().with_persistence_threshold(11).with_num_state_masking_blocks(6); + + let plan = test_harness.tree.get_save_blocks_plan(PersistTarget::Threshold).unwrap(); + + assert_plan_steps( + &plan, + &[(0..6, Some(11..17), false), (6..11, Some(11..17), true), (11..17, None, true)], + ); + assert_eq!(plan.blocks.len(), 17); assert_eq!( plan.blocks.iter().map(|block| block.recovered_block().number()).collect::>(), - (1..=13).collect::>() + (6..=22).collect::>() ); - assert_eq!(plan.last_block(), Some(blocks[13].recovered_block().num_hash())); + assert_eq!(plan.last_block(), Some(blocks[22].recovered_block().num_hash())); + assert_eq!(plan.last_state_trie_block(), Some(blocks[16].recovered_block().num_hash())); } #[test] From 519750e4bda2bcee04168a923591c9315ab813ef Mon Sep 17 00:00:00 2001 From: Derek <256792747+decofe@users.noreply.github.com> Date: Tue, 19 May 2026 08:52:52 +0000 Subject: [PATCH 81/83] fix engine backpressure default parsing --- crates/node/core/src/args/engine.rs | 83 +++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 9 deletions(-) diff --git a/crates/node/core/src/args/engine.rs b/crates/node/core/src/args/engine.rs index ab6536486b3..7ce747c9698 100644 --- a/crates/node/core/src/args/engine.rs +++ b/crates/node/core/src/args/engine.rs @@ -295,6 +295,21 @@ impl Default for DefaultEngineValues { } } +fn default_persistence_backpressure_threshold( + persistence_threshold: u64, + memory_block_buffer_target: u64, +) -> u64 { + let min_backpressure_threshold = + persistence_threshold.saturating_add(memory_block_buffer_target).saturating_add(1); + if DefaultEngineValues::get_global().persistence_backpressure_threshold + > min_backpressure_threshold + { + DefaultEngineValues::get_global().persistence_backpressure_threshold + } else { + min_backpressure_threshold + } +} + /// Parameters for configuring the engine driver. #[derive(Debug, Clone, Args, PartialEq, Eq)] #[command(next_help_heading = "Engine")] @@ -311,8 +326,8 @@ pub struct EngineArgs { /// Configure the maximum canonical-minus-persisted gap before engine API processing stalls. /// /// This value must be greater than `--engine.persistence-threshold`. - #[arg(long = "engine.persistence-backpressure-threshold", default_value_t = DefaultEngineValues::get_global().persistence_backpressure_threshold)] - pub persistence_backpressure_threshold: u64, + #[arg(long = "engine.persistence-backpressure-threshold")] + pub persistence_backpressure_threshold: Option, /// Configure the target number of blocks to keep in memory. #[arg(long = "engine.memory-block-buffer-target", default_value_t = DefaultEngineValues::get_global().memory_block_buffer_target)] @@ -544,7 +559,7 @@ impl Default for EngineArgs { fn default() -> Self { let DefaultEngineValues { persistence_threshold, - persistence_backpressure_threshold, + persistence_backpressure_threshold: _, memory_block_buffer_target, invalid_header_hit_eviction_threshold, legacy_state_root_task_enabled, @@ -577,7 +592,7 @@ impl Default for EngineArgs { } = DefaultEngineValues::get_global().clone(); Self { persistence_threshold, - persistence_backpressure_threshold, + persistence_backpressure_threshold: None, memory_block_buffer_target, invalid_header_hit_eviction_threshold, legacy_state_root_task_enabled, @@ -621,12 +636,23 @@ impl Default for EngineArgs { } impl EngineArgs { + /// Returns the effective persistence backpressure threshold. + pub fn persistence_backpressure_threshold(&self) -> u64 { + self.persistence_backpressure_threshold.unwrap_or_else(|| { + default_persistence_backpressure_threshold( + self.persistence_threshold, + self.memory_block_buffer_target, + ) + }) + } + /// Validates cross-field engine arguments. pub fn validate(&self) -> eyre::Result<()> { + let persistence_backpressure_threshold = self.persistence_backpressure_threshold(); ensure!( - self.persistence_backpressure_threshold > self.persistence_threshold, + persistence_backpressure_threshold > self.persistence_threshold, "--engine.persistence-backpressure-threshold ({}) must be greater than --engine.persistence-threshold ({})", - self.persistence_backpressure_threshold, + persistence_backpressure_threshold, self.persistence_threshold ); ensure!( @@ -639,8 +665,8 @@ impl EngineArgs { /// Creates a [`TreeConfig`] from the engine arguments. pub fn tree_config(&self) -> TreeConfig { let config = TreeConfig::default() + .with_persistence_backpressure_threshold(self.persistence_backpressure_threshold()) .with_persistence_threshold(self.persistence_threshold) - .with_persistence_backpressure_threshold(self.persistence_backpressure_threshold) .with_memory_block_buffer_target(self.memory_block_buffer_target) .with_invalid_header_hit_eviction_threshold(self.invalid_header_hit_eviction_threshold) .with_legacy_state_root(self.legacy_state_root_task_enabled) @@ -696,6 +722,45 @@ mod tests { let default_args = EngineArgs::default(); let args = CommandParser::::parse_from(["reth"]).args; assert_eq!(args, default_args); + assert_eq!( + args.persistence_backpressure_threshold(), + DefaultEngineValues::get_global().persistence_backpressure_threshold + ); + } + + #[test] + fn default_backpressure_threshold_uses_parsed_persistence_args() { + let args = CommandParser::::parse_from([ + "reth", + "--engine.persistence-threshold", + "100", + "--engine.memory-block-buffer-target", + "50", + ]) + .args; + + assert_eq!(args.persistence_backpressure_threshold(), 151); + + let tree_config = args.tree_config(); + assert_eq!(tree_config.persistence_threshold(), 100); + assert_eq!(tree_config.memory_block_buffer_target(), 50); + assert_eq!(tree_config.persistence_backpressure_threshold(), 151); + } + + #[test] + fn explicit_backpressure_threshold_overrides_calculated_default() { + let args = CommandParser::::parse_from([ + "reth", + "--engine.persistence-threshold", + "100", + "--engine.memory-block-buffer-target", + "50", + "--engine.persistence-backpressure-threshold", + "101", + ]) + .args; + + assert_eq!(args.persistence_backpressure_threshold(), 101); } #[test] @@ -703,7 +768,7 @@ mod tests { fn engine_args() { let args = EngineArgs { persistence_threshold: 100, - persistence_backpressure_threshold: 101, + persistence_backpressure_threshold: Some(101), memory_block_buffer_target: 50, invalid_header_hit_eviction_threshold: 7, legacy_state_root_task_enabled: true, @@ -795,7 +860,7 @@ mod tests { fn validate_rejects_invalid_backpressure_threshold() { let args = EngineArgs { persistence_threshold: 4, - persistence_backpressure_threshold: 4, + persistence_backpressure_threshold: Some(4), ..EngineArgs::default() }; From c9faab45013c5223f0ab89ea64b7b0d658fb4ead Mon Sep 17 00:00:00 2001 From: Brian Date: Tue, 19 May 2026 11:10:14 +0200 Subject: [PATCH 82/83] fix(engine): adjust backpressure default parsing --- crates/node/core/src/args/engine.rs | 41 ++++++++++++++++------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/crates/node/core/src/args/engine.rs b/crates/node/core/src/args/engine.rs index 7ce747c9698..14b927ed494 100644 --- a/crates/node/core/src/args/engine.rs +++ b/crates/node/core/src/args/engine.rs @@ -295,19 +295,10 @@ impl Default for DefaultEngineValues { } } -fn default_persistence_backpressure_threshold( - persistence_threshold: u64, - memory_block_buffer_target: u64, -) -> u64 { - let min_backpressure_threshold = - persistence_threshold.saturating_add(memory_block_buffer_target).saturating_add(1); - if DefaultEngineValues::get_global().persistence_backpressure_threshold - > min_backpressure_threshold - { - DefaultEngineValues::get_global().persistence_backpressure_threshold - } else { - min_backpressure_threshold - } +fn default_persistence_backpressure_threshold(persistence_threshold: u64) -> u64 { + DefaultEngineValues::get_global() + .persistence_backpressure_threshold + .max(persistence_threshold.saturating_mul(2)) } /// Parameters for configuring the engine driver. @@ -639,10 +630,7 @@ impl EngineArgs { /// Returns the effective persistence backpressure threshold. pub fn persistence_backpressure_threshold(&self) -> u64 { self.persistence_backpressure_threshold.unwrap_or_else(|| { - default_persistence_backpressure_threshold( - self.persistence_threshold, - self.memory_block_buffer_target, - ) + default_persistence_backpressure_threshold(self.persistence_threshold) }) } @@ -739,12 +727,27 @@ mod tests { ]) .args; - assert_eq!(args.persistence_backpressure_threshold(), 151); + assert_eq!(args.persistence_backpressure_threshold(), 200); let tree_config = args.tree_config(); assert_eq!(tree_config.persistence_threshold(), 100); assert_eq!(tree_config.memory_block_buffer_target(), 50); - assert_eq!(tree_config.persistence_backpressure_threshold(), 151); + assert_eq!(tree_config.persistence_backpressure_threshold(), 200); + } + + #[test] + fn default_backpressure_threshold_uses_global_default_when_larger() { + let args = CommandParser::::parse_from([ + "reth", + "--engine.persistence-threshold", + "4", + ]) + .args; + + assert_eq!( + args.persistence_backpressure_threshold(), + DefaultEngineValues::get_global().persistence_backpressure_threshold + ); } #[test] From d04eef4dd14ce159aa17e5bdf30a11085d2cb59d Mon Sep 17 00:00:00 2001 From: Brian Date: Tue, 19 May 2026 11:39:08 +0200 Subject: [PATCH 83/83] docs: update generated cli reference --- docs/vocs/docs/pages/cli/reth/node.mdx | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/vocs/docs/pages/cli/reth/node.mdx b/docs/vocs/docs/pages/cli/reth/node.mdx index b9bb3c46db7..f0d37a01f78 100644 --- a/docs/vocs/docs/pages/cli/reth/node.mdx +++ b/docs/vocs/docs/pages/cli/reth/node.mdx @@ -973,8 +973,6 @@ Engine: This value must be greater than `--engine.persistence-threshold`. - [default: 16] - --engine.memory-block-buffer-target Configure the target number of blocks to keep in memory