From bf732da287aa25fd2ea81813f8c9c724698aa58d Mon Sep 17 00:00:00 2001 From: Alexey Shekhirin Date: Mon, 13 Jul 2026 09:44:19 +0000 Subject: [PATCH 01/32] feat(engine): txpool prewarming --- crates/chain-state/src/execution_stats.rs | 24 +- .../execution-cache/src/cached_state.rs | 277 +++++- crates/engine/execution-cache/src/lib.rs | 3 + crates/engine/execution-cache/src/txpool.rs | 149 +++ crates/engine/primitives/src/config.rs | 83 ++ crates/engine/tree/src/tree/mod.rs | 37 +- .../payload_processor/bal_prewarm_pool.rs | 29 +- .../src/tree/payload_processor/prewarm.rs | 37 +- .../engine/tree/src/tree/payload_validator.rs | 124 ++- crates/engine/tree/src/tree/txpool_prewarm.rs | 856 ++++++++++++++++++ crates/engine/tree/src/tree/types.rs | 4 + crates/ethereum/evm/src/lib.rs | 19 + crates/evm/evm/src/lib.rs | 12 + crates/node/builder/src/lib.rs | 2 + crates/node/builder/src/rpc.rs | 22 +- crates/node/builder/src/txpool_prewarm.rs | 64 ++ crates/node/core/src/args/engine.rs | 85 +- crates/node/events/src/node.rs | 9 + crates/transaction-pool/src/pool/best.rs | 21 +- crates/transaction-pool/src/pool/pending.rs | 1 + crates/transaction-pool/src/traits.rs | 12 + docs/vocs/docs/pages/cli/reth/node.mdx | 18 + 22 files changed, 1821 insertions(+), 67 deletions(-) create mode 100644 crates/engine/execution-cache/src/txpool.rs create mode 100644 crates/engine/tree/src/tree/txpool_prewarm.rs create mode 100644 crates/node/builder/src/txpool_prewarm.rs diff --git a/crates/chain-state/src/execution_stats.rs b/crates/chain-state/src/execution_stats.rs index 4b87df1ac62..ee5255dcbc6 100644 --- a/crates/chain-state/src/execution_stats.rs +++ b/crates/chain-state/src/execution_stats.rs @@ -54,16 +54,28 @@ pub struct ExecutionTimingStats { pub eip7702_delegations_set: usize, /// Number of EIP-7702 delegations cleared pub eip7702_delegations_cleared: usize, - /// Account cache hits + /// Execution-cache account hits pub account_cache_hits: usize, - /// Account cache misses + /// Execution-cache account misses pub account_cache_misses: usize, - /// Storage cache hits + /// Execution-cache storage hits pub storage_cache_hits: usize, - /// Storage cache misses + /// Execution-cache storage misses pub storage_cache_misses: usize, - /// Code cache hits + /// Execution-cache code hits pub code_cache_hits: usize, - /// Code cache misses + /// Execution-cache code misses pub code_cache_misses: usize, + /// Txpool-prewarm snapshot account hits + pub txpool_snapshot_account_hits: usize, + /// Txpool-prewarm snapshot account misses + pub txpool_snapshot_account_misses: usize, + /// Txpool-prewarm snapshot storage hits + pub txpool_snapshot_storage_hits: usize, + /// Txpool-prewarm snapshot storage misses + pub txpool_snapshot_storage_misses: usize, + /// Txpool-prewarm snapshot code hits + pub txpool_snapshot_code_hits: usize, + /// Txpool-prewarm snapshot code misses + pub txpool_snapshot_code_misses: usize, } diff --git a/crates/engine/execution-cache/src/cached_state.rs b/crates/engine/execution-cache/src/cached_state.rs index e6963da71f7..93bf8e943bc 100644 --- a/crates/engine/execution-cache/src/cached_state.rs +++ b/crates/engine/execution-cache/src/cached_state.rs @@ -1,4 +1,5 @@ //! Execution cache implementation for block processing. +use crate::TxPoolPrewarmCacheSnapshot; use alloy_primitives::{ map::{DefaultHashBuilder, FbBuildHasher}, Address, StorageKey, StorageValue, B256, @@ -92,8 +93,9 @@ type FixedCache = fixed_cache::Cache { /// The state provider @@ -102,11 +104,17 @@ pub struct CachedStateProvider { /// The caches used for the provider caches: ExecutionCache, + /// Optional immutable txpool-prewarm snapshot consulted before the regular execution cache. + txpool_snapshot: Option, + /// Metrics for the cached state provider. metrics: Option, - /// Provider-local hit/miss counters flushed when the provider is dropped. - metric_counts: CacheMetricCounts, + /// Provider-local execution-cache hit/miss counters flushed when the provider is dropped. + execution_metric_counts: CacheMetricCounts, + + /// Provider-local txpool-cache hit/miss counters flushed when the provider is dropped. + txpool_metric_counts: CacheMetricCounts, /// Whether cache misses should populate the shared execution cache. fill_mode: CacheFillMode, @@ -146,13 +154,21 @@ impl CachedStateProvider { Self { state_provider, caches, + txpool_snapshot: None, metrics, - metric_counts: CacheMetricCounts::new(), + execution_metric_counts: CacheMetricCounts::new(), + txpool_metric_counts: CacheMetricCounts::new(), fill_mode, cache_stats, } } + /// Adds an immutable txpool-prewarm snapshot as the first cache lookup tier. + pub fn with_txpool_snapshot(mut self, snapshot: Option) -> Self { + self.txpool_snapshot = snapshot; + self + } + fn record_account_hit(&self) { self.record_metric(CacheMetricKind::AccountHit); if let Some(stats) = &self.cache_stats { @@ -195,21 +211,72 @@ impl CachedStateProvider { } } + fn record_txpool_account_hit(&self) { + self.record_txpool_metric(CacheMetricKind::AccountHit); + if let Some(stats) = &self.cache_stats { + stats.record_txpool_snapshot_account_hit(); + } + } + + fn record_txpool_account_miss(&self) { + self.record_txpool_metric(CacheMetricKind::AccountMiss); + if let Some(stats) = &self.cache_stats { + stats.record_txpool_snapshot_account_miss(); + } + } + + fn record_txpool_storage_hit(&self) { + self.record_txpool_metric(CacheMetricKind::StorageHit); + if let Some(stats) = &self.cache_stats { + stats.record_txpool_snapshot_storage_hit(); + } + } + + fn record_txpool_storage_miss(&self) { + self.record_txpool_metric(CacheMetricKind::StorageMiss); + if let Some(stats) = &self.cache_stats { + stats.record_txpool_snapshot_storage_miss(); + } + } + + fn record_txpool_code_hit(&self) { + self.record_txpool_metric(CacheMetricKind::CodeHit); + if let Some(stats) = &self.cache_stats { + stats.record_txpool_snapshot_code_hit(); + } + } + + fn record_txpool_code_miss(&self) { + self.record_txpool_metric(CacheMetricKind::CodeMiss); + if let Some(stats) = &self.cache_stats { + stats.record_txpool_snapshot_code_miss(); + } + } + #[inline] fn record_metric(&self, kind: CacheMetricKind) { if self.metrics.is_some() { - self.metric_counts.record(kind); + self.execution_metric_counts.record(kind); + } + } + + #[inline] + fn record_txpool_metric(&self, kind: CacheMetricKind) { + if self.metrics.is_some() { + self.txpool_metric_counts.record(kind); } } fn flush_buffered_metrics(&self) { - let counts = self.metric_counts.take(); - if counts.is_empty() { + let execution_counts = self.execution_metric_counts.take(); + let txpool_counts = self.txpool_metric_counts.take(); + if execution_counts.is_empty() && txpool_counts.is_empty() { return; } if let Some(metrics) = &self.metrics { - metrics.record_access_counts(counts); + metrics.record_access_counts(execution_counts); + metrics.record_txpool_access_counts(txpool_counts); } } @@ -343,7 +410,7 @@ impl fmt::Display for CachedStateMetricsSource { } } -/// Metrics for the cached state provider, showing hits / misses for each cache +/// Metrics for the cached state provider, showing hits and misses for each cache tier. #[derive(Metrics, Clone)] #[metrics(scope = "sync.caching")] pub struct CachedStateMetrics { @@ -353,23 +420,41 @@ pub struct CachedStateMetrics { /// Duration of execution cache creation in seconds execution_cache_creation_duration_seconds: Histogram, - /// Code cache hits + /// Execution-cache code hits code_cache_hits: Gauge, - /// Code cache misses + /// Execution-cache code misses code_cache_misses: Gauge, - /// Storage cache hits + /// Execution-cache storage hits storage_cache_hits: Gauge, - /// Storage cache misses + /// Execution-cache storage misses storage_cache_misses: Gauge, - /// Account cache hits + /// Execution-cache account hits account_cache_hits: Gauge, - /// Account cache misses + /// Execution-cache account misses account_cache_misses: Gauge, + + /// Txpool-prewarm snapshot code hits + txpool_snapshot_code_hits: Gauge, + + /// Txpool-prewarm snapshot code misses + txpool_snapshot_code_misses: Gauge, + + /// Txpool-prewarm snapshot storage hits + txpool_snapshot_storage_hits: Gauge, + + /// Txpool-prewarm snapshot storage misses + txpool_snapshot_storage_misses: Gauge, + + /// Txpool-prewarm snapshot account hits + txpool_snapshot_account_hits: Gauge, + + /// Txpool-prewarm snapshot account misses + txpool_snapshot_account_misses: Gauge, } /// Metrics for shared execution cache state. @@ -418,6 +503,18 @@ impl CachedStateMetrics { // account cache self.account_cache_hits.set(0); self.account_cache_misses.set(0); + + // txpool-prewarm code cache + self.txpool_snapshot_code_hits.set(0); + self.txpool_snapshot_code_misses.set(0); + + // txpool-prewarm storage cache + self.txpool_snapshot_storage_hits.set(0); + self.txpool_snapshot_storage_misses.set(0); + + // txpool-prewarm account cache + self.txpool_snapshot_account_hits.set(0); + self.txpool_snapshot_account_misses.set(0); } /// Returns a new zeroed-out instance of [`CachedStateMetrics`] with a `source` label @@ -460,6 +557,46 @@ impl CachedStateMetrics { } } + fn record_txpool_access(&self, kind: CacheMetricKind, count: u64) { + match kind { + CacheMetricKind::AccountHit => { + self.txpool_snapshot_account_hits.increment(count as f64) + } + CacheMetricKind::AccountMiss => { + self.txpool_snapshot_account_misses.increment(count as f64) + } + CacheMetricKind::StorageHit => { + self.txpool_snapshot_storage_hits.increment(count as f64) + } + CacheMetricKind::StorageMiss => { + self.txpool_snapshot_storage_misses.increment(count as f64) + } + CacheMetricKind::CodeHit => self.txpool_snapshot_code_hits.increment(count as f64), + CacheMetricKind::CodeMiss => self.txpool_snapshot_code_misses.increment(count as f64), + } + } + + fn record_txpool_access_counts(&self, counts: CacheMetricSnapshot) { + if counts.account_hits != 0 { + self.record_txpool_access(CacheMetricKind::AccountHit, counts.account_hits); + } + if counts.account_misses != 0 { + self.record_txpool_access(CacheMetricKind::AccountMiss, counts.account_misses); + } + if counts.storage_hits != 0 { + self.record_txpool_access(CacheMetricKind::StorageHit, counts.storage_hits); + } + if counts.storage_misses != 0 { + self.record_txpool_access(CacheMetricKind::StorageMiss, counts.storage_misses); + } + if counts.code_hits != 0 { + self.record_txpool_access(CacheMetricKind::CodeHit, counts.code_hits); + } + if counts.code_misses != 0 { + self.record_txpool_access(CacheMetricKind::CodeMiss, counts.code_misses); + } + } + /// Records a new execution cache creation with its duration. pub fn record_cache_creation(&self, duration: Duration) { self.execution_cache_created_total.increment(1); @@ -470,18 +607,30 @@ impl CachedStateMetrics { /// Cache hit/miss statistics for detailed block logging. #[derive(Debug, Default)] pub struct CacheStats { - /// Account cache hits + /// Execution-cache account hits account_hits: AtomicUsize, - /// Account cache misses + /// Execution-cache account misses account_misses: AtomicUsize, - /// Storage cache hits + /// Execution-cache storage hits storage_hits: AtomicUsize, - /// Storage cache misses + /// Execution-cache storage misses storage_misses: AtomicUsize, - /// Code cache hits + /// Execution-cache code hits code_hits: AtomicUsize, - /// Code cache misses + /// Execution-cache code misses code_misses: AtomicUsize, + /// Txpool-prewarm snapshot account hits + txpool_snapshot_account_hits: AtomicUsize, + /// Txpool-prewarm snapshot account misses + txpool_snapshot_account_misses: AtomicUsize, + /// Txpool-prewarm snapshot storage hits + txpool_snapshot_storage_hits: AtomicUsize, + /// Txpool-prewarm snapshot storage misses + txpool_snapshot_storage_misses: AtomicUsize, + /// Txpool-prewarm snapshot code hits + txpool_snapshot_code_hits: AtomicUsize, + /// Txpool-prewarm snapshot code misses + txpool_snapshot_code_misses: AtomicUsize, } impl CacheStats { @@ -544,6 +693,66 @@ impl CacheStats { pub fn code_misses(&self) -> usize { self.code_misses.load(Ordering::Relaxed) } + + /// Records a txpool-prewarm snapshot account hit. + pub fn record_txpool_snapshot_account_hit(&self) { + self.txpool_snapshot_account_hits.fetch_add(1, Ordering::Relaxed); + } + + /// Records a txpool-prewarm snapshot account miss. + pub fn record_txpool_snapshot_account_miss(&self) { + self.txpool_snapshot_account_misses.fetch_add(1, Ordering::Relaxed); + } + + /// Returns the number of txpool-prewarm snapshot account hits. + pub fn txpool_snapshot_account_hits(&self) -> usize { + self.txpool_snapshot_account_hits.load(Ordering::Relaxed) + } + + /// Returns the number of txpool-prewarm snapshot account misses. + pub fn txpool_snapshot_account_misses(&self) -> usize { + self.txpool_snapshot_account_misses.load(Ordering::Relaxed) + } + + /// Records a txpool-prewarm snapshot storage hit. + pub fn record_txpool_snapshot_storage_hit(&self) { + self.txpool_snapshot_storage_hits.fetch_add(1, Ordering::Relaxed); + } + + /// Records a txpool-prewarm snapshot storage miss. + pub fn record_txpool_snapshot_storage_miss(&self) { + self.txpool_snapshot_storage_misses.fetch_add(1, Ordering::Relaxed); + } + + /// Returns the number of txpool-prewarm snapshot storage hits. + pub fn txpool_snapshot_storage_hits(&self) -> usize { + self.txpool_snapshot_storage_hits.load(Ordering::Relaxed) + } + + /// Returns the number of txpool-prewarm snapshot storage misses. + pub fn txpool_snapshot_storage_misses(&self) -> usize { + self.txpool_snapshot_storage_misses.load(Ordering::Relaxed) + } + + /// Records a txpool-prewarm snapshot code hit. + pub fn record_txpool_snapshot_code_hit(&self) { + self.txpool_snapshot_code_hits.fetch_add(1, Ordering::Relaxed); + } + + /// Records a txpool-prewarm snapshot code miss. + pub fn record_txpool_snapshot_code_miss(&self) { + self.txpool_snapshot_code_misses.fetch_add(1, Ordering::Relaxed); + } + + /// Returns the number of txpool-prewarm snapshot code hits. + pub fn txpool_snapshot_code_hits(&self) -> usize { + self.txpool_snapshot_code_hits.load(Ordering::Relaxed) + } + + /// Returns the number of txpool-prewarm snapshot code misses. + pub fn txpool_snapshot_code_misses(&self) -> usize { + self.txpool_snapshot_code_misses.load(Ordering::Relaxed) + } } /// A stats handler for fixed-cache that tracks collisions and size. @@ -637,6 +846,14 @@ impl StatsHandler for CacheStatsHandler { impl AccountReader for CachedStateProvider { fn basic_account(&self, address: &Address) -> ProviderResult> { + if let Some(snapshot) = &self.txpool_snapshot { + if let Some(account) = snapshot.account(address) { + self.record_txpool_account_hit(); + return Ok(account) + } + self.record_txpool_account_miss(); + } + if self.should_fill_on_miss() { match self.caches.get_or_try_insert_account_with(*address, || { self.state_provider.basic_account(address) @@ -675,6 +892,14 @@ impl StateProvider for CachedStateProvider { account: Address, storage_key: StorageKey, ) -> ProviderResult> { + if let Some(snapshot) = &self.txpool_snapshot { + if let Some(value) = snapshot.storage(account, storage_key) { + self.record_txpool_storage_hit(); + return Ok(nonzero_storage_value(value)) + } + self.record_txpool_storage_miss(); + } + if self.should_fill_on_miss() { match self.caches.get_or_try_insert_storage_with(account, storage_key, || { self.state_provider.storage(account, storage_key).map(Option::unwrap_or_default) @@ -700,6 +925,14 @@ impl StateProvider for CachedStateProvider { impl BytecodeReader for CachedStateProvider { fn bytecode_by_hash(&self, code_hash: &B256) -> ProviderResult> { + if let Some(snapshot) = &self.txpool_snapshot { + if let Some(code) = snapshot.bytecode(code_hash) { + self.record_txpool_code_hit(); + return Ok(code) + } + self.record_txpool_code_miss(); + } + if self.should_fill_on_miss() { match self.caches.get_or_try_insert_code_with(*code_hash, || { self.state_provider.bytecode_by_hash(code_hash) diff --git a/crates/engine/execution-cache/src/lib.rs b/crates/engine/execution-cache/src/lib.rs index b276d19b66e..f9cd0cf5f1b 100644 --- a/crates/engine/execution-cache/src/lib.rs +++ b/crates/engine/execution-cache/src/lib.rs @@ -17,6 +17,9 @@ mod cached_state; pub use cached_state::*; +mod txpool; +pub use txpool::*; + use alloy_primitives::B256; use metrics::{Counter, Histogram}; use parking_lot::Mutex; diff --git a/crates/engine/execution-cache/src/txpool.rs b/crates/engine/execution-cache/src/txpool.rs new file mode 100644 index 00000000000..cdac61c665b --- /dev/null +++ b/crates/engine/execution-cache/src/txpool.rs @@ -0,0 +1,149 @@ +//! Reusable cache and immutable snapshots for txpool-driven state prewarming. + +use crate::CachedStatus; +use alloy_primitives::{map::HashMap, Address, StorageKey, StorageValue, B256}; +use parking_lot::RwLock; +use reth_primitives_traits::{Account, Bytecode}; +use std::sync::Arc; + +/// A mutable state-read cache reused by the txpool prewarmer between canonical heads. +/// +/// The prewarmer is the only logical writer, although its transaction workers can fill the cache +/// concurrently. [`Self::clear`] retains the maps' allocations for the next head. +#[derive(Debug, Default)] +pub struct TxPoolPrewarmCache { + accounts: RwLock>>, + storage: RwLock>, + bytecodes: RwLock>>, +} + +impl TxPoolPrewarmCache { + /// Creates an empty reusable cache. + pub fn new() -> Self { + Self::default() + } + + /// Clears all cached state while retaining the backing allocations. + pub fn clear(&self) { + self.accounts.write().clear(); + self.storage.write().clear(); + self.bytecodes.write().clear(); + } + + /// Creates a deep, immutable snapshot tagged with the state hash it was warmed against. + /// + /// Callers must only snapshot at a completed prewarming-wave boundary, when no workers are + /// writing to the cache. + pub fn snapshot(&self, parent_hash: B256) -> TxPoolPrewarmCacheSnapshot { + TxPoolPrewarmCacheSnapshot { + inner: Arc::new(TxPoolPrewarmCacheSnapshotInner { + parent_hash, + accounts: self.accounts.read().clone(), + storage: self.storage.read().clone(), + bytecodes: self.bytecodes.read().clone(), + }), + } + } + + /// Gets an account or fills it using `f`. + pub fn get_or_try_insert_account_with( + &self, + address: Address, + f: impl FnOnce() -> Result, E>, + ) -> Result>, E> { + if let Some(account) = self.accounts.read().get(&address).copied() { + return Ok(CachedStatus::Cached(account)) + } + + let account = f()?; + let mut accounts = self.accounts.write(); + if let Some(cached) = accounts.get(&address).copied() { + Ok(CachedStatus::Cached(cached)) + } else { + accounts.insert(address, account); + Ok(CachedStatus::NotCached(account)) + } + } + + /// Gets a storage value or fills it using `f`. + pub fn get_or_try_insert_storage_with( + &self, + address: Address, + key: StorageKey, + f: impl FnOnce() -> Result, + ) -> Result, E> { + if let Some(value) = self.storage.read().get(&(address, key)).copied() { + return Ok(CachedStatus::Cached(value)) + } + + let value = f()?; + let mut storage = self.storage.write(); + if let Some(cached) = storage.get(&(address, key)).copied() { + Ok(CachedStatus::Cached(cached)) + } else { + storage.insert((address, key), value); + Ok(CachedStatus::NotCached(value)) + } + } + + /// Gets bytecode or fills it using `f`. + pub fn get_or_try_insert_code_with( + &self, + code_hash: B256, + f: impl FnOnce() -> Result, E>, + ) -> Result>, E> { + if let Some(code) = self.bytecodes.read().get(&code_hash).cloned() { + return Ok(CachedStatus::Cached(code)) + } + + let code = f()?; + let mut bytecodes = self.bytecodes.write(); + if let Some(cached) = bytecodes.get(&code_hash).cloned() { + Ok(CachedStatus::Cached(cached)) + } else { + bytecodes.insert(code_hash, code.clone()); + Ok(CachedStatus::NotCached(code)) + } + } +} + +/// A deep, immutable txpool-prewarm cache snapshot for one parent state. +#[derive(Clone, Debug)] +pub struct TxPoolPrewarmCacheSnapshot { + inner: Arc, +} + +#[derive(Debug)] +struct TxPoolPrewarmCacheSnapshotInner { + parent_hash: B256, + accounts: HashMap>, + storage: HashMap<(Address, StorageKey), StorageValue>, + bytecodes: HashMap>, +} + +impl TxPoolPrewarmCacheSnapshot { + /// Returns the hash of the state this snapshot was warmed against. + pub fn parent_hash(&self) -> B256 { + self.inner.parent_hash + } + + /// Returns a cached account, preserving cached non-existence. + pub fn account(&self, address: &Address) -> Option> { + self.inner.accounts.get(address).copied() + } + + /// Returns a cached storage value, preserving cached zero values. + pub fn storage(&self, address: Address, key: StorageKey) -> Option { + self.inner.storage.get(&(address, key)).copied() + } + + /// Returns cached bytecode, preserving cached non-existence. + pub fn bytecode(&self, code_hash: &B256) -> Option> { + self.inner.bytecodes.get(code_hash).cloned() + } + + /// Returns `(accounts, storage slots, bytecodes)` in the snapshot. + pub fn entry_counts(&self) -> (usize, usize, usize) { + (self.inner.accounts.len(), self.inner.storage.len(), self.inner.bytecodes.len()) + } +} diff --git a/crates/engine/primitives/src/config.rs b/crates/engine/primitives/src/config.rs index 2d5e7abc844..7edfe982d74 100644 --- a/crates/engine/primitives/src/config.rs +++ b/crates/engine/primitives/src/config.rs @@ -87,6 +87,74 @@ pub fn has_enough_parallelism() -> bool { false } +/// Configuration for best-effort txpool transaction prewarming. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TxPoolPrewarmingConfig { + /// Whether txpool transaction prewarming is enabled. + pub enabled: bool, + /// Maximum transactions to prewarm for one sender while warming one parent head. + pub max_transactions_per_sender: usize, + /// Maximum fresh candidates considered from the txpool for one refresh. + pub max_candidate_scan: usize, + /// Multiplier applied to the parent block gas limit for one refresh's fresh-transaction gas + /// budget. + pub gas_limit_multiplier: u64, +} + +impl TxPoolPrewarmingConfig { + /// Default per-sender transaction cap. + pub const DEFAULT_MAX_TRANSACTIONS_PER_SENDER: usize = 16; + /// Default candidate scan cap. + pub const DEFAULT_MAX_CANDIDATE_SCAN: usize = 4096; + /// Default gas-budget multiplier. + pub const DEFAULT_GAS_LIMIT_MULTIPLIER: u64 = 6; + /// Default configuration. The feature is opt-in. + pub const DEFAULT: Self = Self { + enabled: false, + max_transactions_per_sender: Self::DEFAULT_MAX_TRANSACTIONS_PER_SENDER, + max_candidate_scan: Self::DEFAULT_MAX_CANDIDATE_SCAN, + gas_limit_multiplier: Self::DEFAULT_GAS_LIMIT_MULTIPLIER, + }; + + /// Returns whether this configuration can launch work. + pub const fn should_prewarm(self) -> bool { + self.enabled && + self.max_transactions_per_sender > 0 && + self.max_candidate_scan > 0 && + self.gas_limit_multiplier > 0 + } + + /// Enables or disables txpool prewarming. + pub const fn with_enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + /// Sets the per-sender transaction cap. + pub const fn with_max_transactions_per_sender(mut self, max: usize) -> Self { + self.max_transactions_per_sender = max; + self + } + + /// Sets the candidate scan cap. + pub const fn with_max_candidate_scan(mut self, max: usize) -> Self { + self.max_candidate_scan = max; + self + } + + /// Sets the gas-budget multiplier. + pub const fn with_gas_limit_multiplier(mut self, multiplier: u64) -> Self { + self.gas_limit_multiplier = multiplier; + self + } +} + +impl Default for TxPoolPrewarmingConfig { + fn default() -> Self { + Self::DEFAULT + } +} + /// The configuration of the engine tree. #[derive(Debug, Clone)] pub struct TreeConfig { @@ -122,6 +190,8 @@ pub struct TreeConfig { disable_state_cache: bool, /// Whether to disable parallel prewarming. disable_prewarming: bool, + /// Configuration for txpool-driven prewarming between payloads. + txpool_prewarming: TxPoolPrewarmingConfig, /// Whether to enable state provider metrics. state_provider_metrics: bool, /// Cross-block cache size in bytes. @@ -228,6 +298,7 @@ impl Default for TreeConfig { always_compare_trie_updates: false, disable_state_cache: false, disable_prewarming: false, + txpool_prewarming: TxPoolPrewarmingConfig::DEFAULT, state_provider_metrics: false, cross_block_cache_size: DEFAULT_CROSS_BLOCK_CACHE_SIZE, has_enough_parallelism: has_enough_parallelism(), @@ -304,6 +375,7 @@ impl TreeConfig { always_compare_trie_updates, disable_state_cache, disable_prewarming, + txpool_prewarming: TxPoolPrewarmingConfig::DEFAULT, state_provider_metrics, cross_block_cache_size, has_enough_parallelism, @@ -400,6 +472,11 @@ impl TreeConfig { self.disable_prewarming } + /// Returns txpool prewarming configuration. + pub const fn txpool_prewarming(&self) -> TxPoolPrewarmingConfig { + self.txpool_prewarming + } + /// Returns whether to always compare trie updates from the state root task to the trie updates /// from the regular state root calculation. pub const fn always_compare_trie_updates(&self) -> bool { @@ -519,6 +596,12 @@ impl TreeConfig { self } + /// Sets txpool transaction prewarming configuration. + pub const fn with_txpool_prewarming(mut self, config: TxPoolPrewarmingConfig) -> Self { + self.txpool_prewarming = config; + self + } + /// Setter for whether to always compare trie updates from the state root task to the trie /// updates from the regular state root calculation. pub const fn with_always_compare_trie_updates( diff --git a/crates/engine/tree/src/tree/mod.rs b/crates/engine/tree/src/tree/mod.rs index 91038f61d22..96a0ec9502e 100644 --- a/crates/engine/tree/src/tree/mod.rs +++ b/crates/engine/tree/src/tree/mod.rs @@ -63,6 +63,7 @@ pub mod state_root_strategy; #[cfg(test)] mod tests; mod trie_updates; +mod txpool_prewarm; pub mod types; use crate::{persistence::PersistenceResult, tree::error::AdvancePersistenceError}; @@ -75,7 +76,12 @@ pub use persistence_state::PersistenceState; pub use reth_engine_primitives::TreeConfig; pub use reth_execution_cache::{ CachedStateCacheMetrics, CachedStateMetrics, CachedStateMetricsSource, CachedStateProvider, - ExecutionCache, PayloadExecutionCache, SavedCache, + ExecutionCache, PayloadExecutionCache, SavedCache, TxPoolPrewarmCache, + TxPoolPrewarmCacheSnapshot, +}; +pub use txpool_prewarm::{ + TxPoolPrewarmSelection, TxPoolPrewarmSource, TxPoolPrewarmTransaction, + TxPoolPrewarmTransactions, }; pub use types::{ExecutionEnv, ValidationOutcome, ValidationOutput}; @@ -1262,15 +1268,23 @@ where return Ok(Some(TreeOutcome::new(outcome))); } + let notify_canonical_head = self.payload_validator.canonical_head_notifications_enabled(); + let tip = if attrs.is_some() || notify_canonical_head { + self.sealed_header_by_hash(self.state.tree_state.canonical_block_hash())? + } else { + None + }; + if notify_canonical_head && let Some(tip) = tip.as_ref() { + self.payload_validator.on_canonical_head_changed(tip, &self.state); + } + // Process payload attributes if the head is already canonical if let Some(attr) = attrs { - let tip = self - .sealed_header_by_hash(self.state.tree_state.canonical_block_hash())? - .ok_or_else(|| { - // If we can't find the canonical block, then something is wrong and we need - // to return an error - ProviderError::HeaderNotFound(state.head_block_hash.into()) - })?; + let tip = tip.ok_or_else(|| { + // If we can't find the canonical block, then something is wrong and we need + // to return an error + ProviderError::HeaderNotFound(state.head_block_hash.into()) + })?; // Clone only when we actually need to process the attributes let updated = self.process_payload_attributes(attr.clone(), &tip, state); return Ok(Some(TreeOutcome::new(updated))); @@ -1752,6 +1766,9 @@ where let gas_used = payload.gas_used(); let num_hash = payload.num_hash(); let mut output = self.on_new_payload(payload); + if cache_wait.is_some() { + self.payload_validator.resume_caches(); + } let latency = start.elapsed(); self.metrics.engine.new_payload.update_response_metrics( start, @@ -2721,6 +2738,7 @@ where // update the tracked in-memory state with the new chain self.canonical_in_memory_state.update_chain(chain_update); self.canonical_in_memory_state.set_canonical_head(tip.clone()); + self.payload_validator.on_canonical_head_changed(&tip, &self.state); // Update metrics based on new tip self.metrics.tree.canonical_chain_height.set(tip.number() as f64); @@ -3477,4 +3495,7 @@ pub trait WaitForCaches { /// /// Returns the time spent waiting for each cache separately. fn wait_for_caches(&self) -> CacheWaitDurations; + + /// Resumes cache work suspended by [`Self::wait_for_caches`]. + fn resume_caches(&self) {} } diff --git a/crates/engine/tree/src/tree/payload_processor/bal_prewarm_pool.rs b/crates/engine/tree/src/tree/payload_processor/bal_prewarm_pool.rs index 4c39b38b49b..734fa57c229 100644 --- a/crates/engine/tree/src/tree/payload_processor/bal_prewarm_pool.rs +++ b/crates/engine/tree/src/tree/payload_processor/bal_prewarm_pool.rs @@ -1,5 +1,5 @@ use alloy_primitives::{Address, StorageKey}; -use reth_execution_cache::{CachedStateProvider, ExecutionCache}; +use reth_execution_cache::{CachedStateProvider, ExecutionCache, TxPoolPrewarmCacheSnapshot}; use reth_provider::{ AccountReader, BytecodeReader, ProviderResult, StateProvider, StateProviderBox, }; @@ -27,7 +27,11 @@ enum PrewarmTarget { /// FIFO): one `BeginBlock`, then the worker's share of `Warm`s, then one `EndBlock`. enum PrewarmMsg { /// Open a read txn for the new block: build a provider over the parent state and hold it. - BeginBlock { build: Arc, caches: ExecutionCache }, + BeginBlock { + build: Arc, + caches: ExecutionCache, + txpool_snapshot: Option, + }, /// Warm one target into the held provider's cache. Ignored if no provider is held. Warm(PrewarmTarget), /// Drop the held provider (and its read txn). @@ -66,10 +70,18 @@ impl BalPrewarmPool { /// Begins a block: hands every worker the provider builder and shared cache so each opens its /// own read txn over the parent state. Pair with [`end_block`](Self::end_block). - pub(crate) fn begin_block(&self, build: Arc, caches: ExecutionCache) { + pub(crate) fn begin_block( + &self, + build: Arc, + caches: ExecutionCache, + txpool_snapshot: Option, + ) { for worker in &self.workers { - let _ = worker - .send(PrewarmMsg::BeginBlock { build: build.clone(), caches: caches.clone() }); + let _ = worker.send(PrewarmMsg::BeginBlock { + build: build.clone(), + caches: caches.clone(), + txpool_snapshot: txpool_snapshot.clone(), + }); } } @@ -135,9 +147,12 @@ fn prewarm_loop(rx: crossbeam_channel::Receiver) { // Blocks when idle; the channel disconnects (and the loop ends) when the pool is dropped. while let Ok(msg) = rx.recv() { match msg { - PrewarmMsg::BeginBlock { build, caches } => { + PrewarmMsg::BeginBlock { build, caches, txpool_snapshot } => { provider = match (build)() { - Ok(inner) => Some(CachedStateProvider::new_prewarm(inner, caches)), + Ok(inner) => Some( + CachedStateProvider::new_prewarm(inner, caches) + .with_txpool_snapshot(txpool_snapshot), + ), Err(err) => { trace!(target: "engine::tree::bal_prewarm_pool", %err, "failed to build provider"); None diff --git a/crates/engine/tree/src/tree/payload_processor/prewarm.rs b/crates/engine/tree/src/tree/payload_processor/prewarm.rs index 8feda8e1bec..58f90c58e01 100644 --- a/crates/engine/tree/src/tree/payload_processor/prewarm.rs +++ b/crates/engine/tree/src/tree/payload_processor/prewarm.rs @@ -24,6 +24,7 @@ use alloy_primitives::{keccak256, B256, U256}; use metrics::{Counter, Gauge, Histogram}; use rayon::prelude::*; use reth_evm::{execute::ExecutableTxFor, ConfigureEvm, Evm, EvmFor, RecoveredTx, SpecFor}; +use reth_execution_cache::CacheFillMode; use reth_metrics::Metrics; use reth_primitives_traits::{Account, FastInstant as Instant, NodePrimitives}; use reth_provider::{ @@ -413,7 +414,7 @@ where let provider_builder = ctx.provider.clone(); let build = Arc::new(move || provider_builder.build()); - pool.begin_block(build, caches); + pool.begin_block(build, caches, ctx.env.txpool_snapshot.clone()); for account in prefetch_bal.as_bal() { pool.warm_account(account.address); for change in &account.storage_changes { @@ -582,7 +583,10 @@ where // Use the caches to create a new provider with caching if let Some(saved_cache) = &self.saved_cache { let caches = saved_cache.cache().clone(); - state_provider = Box::new(CachedStateProvider::new_prewarm(state_provider, caches)); + state_provider = Box::new( + CachedStateProvider::new_prewarm(state_provider, caches) + .with_txpool_snapshot(self.env.txpool_snapshot.clone()), + ); } let state_provider = StateProviderDatabase::new(state_provider); @@ -695,14 +699,29 @@ where return; } }; - let boxed: Box = - match (self.disable_bal_batch_io, &self.saved_cache) { - (false, Some(saved)) => { - let caches = saved.cache().clone(); - Box::new(CachedStateProvider::new_prewarm(inner, caches)) + let boxed: Box = match &self.saved_cache { + Some(saved) => { + let caches = saved.cache().clone(); + if self.disable_bal_batch_io { + Box::new( + CachedStateProvider::new_with_mode( + inner, + caches, + CacheFillMode::LookupOnly, + None, + None, + ) + .with_txpool_snapshot(self.env.txpool_snapshot.clone()), + ) + } else { + Box::new( + CachedStateProvider::new_prewarm(inner, caches) + .with_txpool_snapshot(self.env.txpool_snapshot.clone()), + ) } - _ => Box::new(inner), - }; + } + None => Box::new(inner), + }; *provider = Some(boxed); } let account_reader = provider.as_ref().expect("provider just initialized"); diff --git a/crates/engine/tree/src/tree/payload_validator.rs b/crates/engine/tree/src/tree/payload_validator.rs index 01c5d4c4282..014977da4ef 100644 --- a/crates/engine/tree/src/tree/payload_validator.rs +++ b/crates/engine/tree/src/tree/payload_validator.rs @@ -100,6 +100,7 @@ use crate::tree::{ instrumented_state::{InstrumentedStateProvider, StateProviderMetrics, StateProviderStats}, payload_processor::PayloadProcessor, precompile_cache::{CachedPrecompile, CachedPrecompileMetrics, PrecompileCacheMap}, + txpool_prewarm::TxPoolPrewarmHandle, types::{InsertPayloadResult, ValidationOutput}, CacheWaitDurations, CachedStateProvider, EngineApiMetrics, EngineApiTreeState, ExecutionEnv, PayloadHandle, StateProviderBuilder, StateProviderDatabase, TreeConfig, WaitForCaches, @@ -295,6 +296,9 @@ where /// State-root strategy used to prepare per-block commitment tasks. #[debug(skip)] state_root_strategy: Arc>, + /// Persistent txpool prewarming worker and its latest immutable snapshot. + #[debug(skip)] + txpool_prewarm: Option>, } impl BasicEngineValidator @@ -315,6 +319,8 @@ where + StateReader + HashedPostStateProvider + Clone + + Send + + Sync + 'static, OverlayStateProviderFactory: DatabaseProviderROFactory + Clone @@ -358,6 +364,7 @@ where runtime, state_trie_overlays, state_root_strategy: Arc::new(DefaultStateRootStrategy::default()), + txpool_prewarm: None, } } @@ -370,6 +377,27 @@ where self } + /// Installs the txpool source and starts the persistent cache-prewarming worker. + pub fn with_txpool_prewarm_source( + mut self, + source: impl crate::tree::TxPoolPrewarmSource + 'static, + ) -> Self { + let config = self.config.txpool_prewarming(); + if !config.should_prewarm() || + self.config.disable_state_cache() || + self.config.disable_prewarming() + { + return self + } + self.txpool_prewarm = Some(TxPoolPrewarmHandle::spawn( + &self.runtime, + Arc::new(source), + self.evm_config.clone(), + config, + )); + self + } + /// Converts a [`BlockOrPayload`] to a recovered block. #[instrument(level = "debug", target = "engine::tree::payload_validator", skip_all)] pub fn convert_to_block>>( @@ -466,6 +494,13 @@ where Evm: ConfigureEngineEvm, { let parent_hash = input.parent_hash(); + let (txpool_snapshot, _txpool_prewarm_guard) = + if let Some(prewarmer) = self.txpool_prewarm.as_ref() { + let (snapshot, guard) = prewarmer.pause_and_snapshot(parent_hash); + (snapshot, Some(guard)) + } else { + (None, None) + }; let _jit_pause = JitPauseGuard::new(&self.evm_config); // Fetch parent block. This goes to memory most of the time unless the parent block is @@ -571,7 +606,9 @@ where gas_used: input.gas_used(), withdrawals: input.withdrawals().map(|w| w.to_vec()), decoded_bal: decoded_bal.as_ref().map(Arc::clone), + txpool_snapshot, }; + let validation_txpool_snapshot = env.txpool_snapshot.clone(); // Get an iterator over the transactions in the payload let txs = self.tx_iterator_for(&input)?; @@ -662,13 +699,16 @@ where } else { CacheFillMode::LookupOnly }; - Box::new(CachedStateProvider::new_with_mode( - provider, - caches.clone(), - fill_mode, - cache_metrics.clone(), - cache_stats.clone(), - )) as StateProviderBox + Box::new( + CachedStateProvider::new_with_mode( + provider, + caches.clone(), + fill_mode, + cache_metrics.clone(), + cache_stats.clone(), + ) + .with_txpool_snapshot(validation_txpool_snapshot.clone()), + ) as StateProviderBox } else { provider }; @@ -1650,6 +1690,18 @@ where .unwrap_or_default(); let (code_cache_hits, code_cache_misses) = cache_stats.as_ref().map(|s| (s.code_hits(), s.code_misses())).unwrap_or_default(); + let (txpool_snapshot_account_hits, txpool_snapshot_account_misses) = cache_stats + .as_ref() + .map(|s| (s.txpool_snapshot_account_hits(), s.txpool_snapshot_account_misses())) + .unwrap_or_default(); + let (txpool_snapshot_storage_hits, txpool_snapshot_storage_misses) = cache_stats + .as_ref() + .map(|s| (s.txpool_snapshot_storage_hits(), s.txpool_snapshot_storage_misses())) + .unwrap_or_default(); + let (txpool_snapshot_code_hits, txpool_snapshot_code_misses) = cache_stats + .as_ref() + .map(|s| (s.txpool_snapshot_code_hits(), s.txpool_snapshot_code_misses())) + .unwrap_or_default(); // Build execution timing stats for detailed block logging Box::new(ExecutionTimingStats { @@ -1678,6 +1730,12 @@ where storage_cache_misses, code_cache_hits, code_cache_misses, + txpool_snapshot_account_hits, + txpool_snapshot_account_misses, + txpool_snapshot_storage_hits, + txpool_snapshot_storage_misses, + txpool_snapshot_code_hits, + txpool_snapshot_code_misses, }) } } @@ -1741,6 +1799,19 @@ pub trait EngineValidator< block: BuiltPayloadExecutedBlock, ) -> ProviderResult>; + /// Registers the exact state for a new canonical head with background cache prewarming. + fn on_canonical_head_changed( + &self, + _header: &SealedHeader, + _state: &EngineApiTreeState, + ) { + } + + /// Returns whether canonical-head notifications require otherwise-unneeded provider work. + fn canonical_head_notifications_enabled(&self) -> bool { + false + } + /// Prepares the resources loaned to a payload builder job. /// /// `timestamp` is taken from the payload attributes. @@ -1830,6 +1901,45 @@ where )) } + fn on_canonical_head_changed( + &self, + header: &SealedHeader, + state: &EngineApiTreeState, + ) { + let Some(txpool_prewarm) = self.txpool_prewarm.as_ref() else { return }; + let provider_builder = match self.state_provider_builder(header.hash(), state) { + Ok(Some(provider_builder)) => provider_builder, + Ok(None) => return, + Err(err) => { + trace!( + target: "engine::tree::txpool_prewarm", + %err, + block_hash = ?header.hash(), + "failed to derive canonical txpool prewarming provider" + ); + return + } + }; + match self.evm_config.txpool_prewarm_env(header.header()) { + Ok(Some(evm_env)) => { + txpool_prewarm.start(header.hash(), evm_env, provider_builder, header.gas_limit()) + } + Ok(None) => {} + Err(err) => { + trace!( + target: "engine::tree::txpool_prewarm", + %err, + block_hash = ?header.hash(), + "failed to derive canonical txpool prewarming environment" + ); + } + } + } + + fn canonical_head_notifications_enabled(&self) -> bool { + self.txpool_prewarm.is_some() + } + fn payload_builder_resources( &self, parent_hash: B256, diff --git a/crates/engine/tree/src/tree/txpool_prewarm.rs b/crates/engine/tree/src/tree/txpool_prewarm.rs new file mode 100644 index 00000000000..8c08c816941 --- /dev/null +++ b/crates/engine/tree/src/tree/txpool_prewarm.rs @@ -0,0 +1,856 @@ +//! Txpool-driven state prewarming and immutable snapshot publication. + +use crate::tree::{ + StateProviderBuilder, StateProviderDatabase, TxPoolPrewarmCache, TxPoolPrewarmCacheSnapshot, +}; +use alloy_consensus::{transaction::Recovered, Transaction as _}; +use alloy_evm::Evm; +use alloy_primitives::{ + map::{AddressMap, HashSet}, + Address, BlockNumber, StorageKey, StorageValue, B256, +}; +use reth_engine_primitives::TxPoolPrewarmingConfig; +use reth_evm::{ConfigureEvm, EvmEnvFor}; +use reth_primitives_traits::{NodePrimitives, TxTy}; +use reth_provider::{BlockReader, StateProviderBox, StateProviderFactory, StateReader}; +use reth_revm::{database::EvmStateProvider, db::State}; +use std::{ + fmt::Debug, + marker::PhantomData, + panic::{catch_unwind, AssertUnwindSafe}, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Condvar, Mutex, RwLock, + }, + time::{Duration, Instant}, +}; +use tracing::{debug, trace, warn}; + +/// Delay between txpool refreshes after a completed or empty selection. +const TXPOOL_REFRESH_INTERVAL: Duration = Duration::from_millis(100); + +/// Delay while waiting for pool maintenance to advance to the state being warmed. +const TXPOOL_HEAD_POLL_INTERVAL: Duration = Duration::from_millis(10); + +type TxPoolPrewarmMarker = PhantomData (N, P, Evm)>; + +/// A transaction selected from the txpool for cache-only prewarming. +#[derive(Debug, Clone)] +pub struct TxPoolPrewarmTransaction { + /// Transaction hash. + pub hash: B256, + /// Recovered sender. + pub sender: Address, + /// Recovered consensus transaction. + pub transaction: Recovered>, +} + +/// Transactions selected for one refresh of the reusable txpool cache. +#[derive(Debug, Clone)] +pub struct TxPoolPrewarmSelection { + /// Fresh selected transactions in block-builder order. + pub transactions: Vec>, + /// Fresh candidates scanned while selecting. + pub scanned: usize, + /// Sum of selected transaction gas limits. + pub selected_gas: u64, + /// Whether selection observed cancellation. + pub canceled: bool, +} + +impl Default for TxPoolPrewarmSelection { + fn default() -> Self { + Self { transactions: Vec::new(), scanned: 0, selected_gas: 0, canceled: false } + } +} + +impl TxPoolPrewarmSelection { + /// Returns whether no transactions were selected. + pub const fn is_empty(&self) -> bool { + self.transactions.is_empty() + } + + /// Returns the selected transaction count. + pub const fn len(&self) -> usize { + self.transactions.len() + } +} + +/// A live, forward-only view of the pool's best transactions for one canonical parent. +/// +/// Returning [`None`](Iterator::next) only means no transaction is currently ready. The same +/// iterator can yield transactions that become pending later. +pub type TxPoolPrewarmTransactions = + Box> + Send>; + +/// Source of txpool transactions for best-effort cache prewarming. +pub trait TxPoolPrewarmSource: Send + Sync + Debug { + /// Returns the canonical block hash currently tracked by the pool. + fn tracked_block_hash(&self) -> B256; + + /// Opens a live best-transactions iterator for `parent_hash`. + /// + /// The worker opens this once per canonical parent and retains it across empty polls, snapshot + /// publications, and validation pauses. Sources should return [`None`] if they are not yet + /// tracking `parent_hash`. + fn best_transactions(&self, parent_hash: B256) -> Option>; +} + +/// A request to warm txpool transactions against one fully validated parent state. +struct TxPoolPrewarmJob> { + parent_hash: B256, + evm_env: EvmEnvFor, + provider_builder: StateProviderBuilder, + block_gas_limit: u64, +} + +/// Latest-wins canonical-head request slot shared with the worker. +struct TxPoolPrewarmRequests { + latest: Mutex>, + available: Condvar, + closed: AtomicBool, +} + +impl TxPoolPrewarmRequests { + const fn new() -> Self { + Self { latest: Mutex::new(None), available: Condvar::new(), closed: AtomicBool::new(false) } + } + + fn replace(&self, request: J) { + *self.latest.lock().expect("txpool prewarm request lock poisoned") = Some(request); + self.available.notify_one(); + } + + fn take(&self) -> Option { + self.latest.lock().expect("txpool prewarm request lock poisoned").take() + } + + fn wait(&self, timeout: Option) -> Option { + let mut latest = self.latest.lock().expect("txpool prewarm request lock poisoned"); + while latest.is_none() && !self.closed.load(Ordering::Acquire) { + if let Some(timeout) = timeout { + let (guard, result) = self + .available + .wait_timeout(latest, timeout) + .expect("txpool prewarm request lock poisoned"); + latest = guard; + if result.timed_out() { + break + } + } else { + latest = self.available.wait(latest).expect("txpool prewarm request lock poisoned"); + } + } + latest.take() + } + + fn close(&self) { + let _latest = self.latest.lock().expect("txpool prewarm request lock poisoned"); + self.closed.store(true, Ordering::Release); + self.available.notify_all(); + } + + fn is_closed(&self) -> bool { + self.closed.load(Ordering::Acquire) + } +} + +/// Linearization point shared by worker publication and payload snapshot acquisition. +#[derive(Debug, Default)] +struct TxPoolSnapshotPublication { + generation: AtomicU64, + paused: AtomicBool, + snapshot: RwLock>, +} + +impl TxPoolSnapshotPublication { + fn begin_head(&self) -> Option { + let mut snapshot = + self.snapshot.write().expect("txpool snapshot publication lock poisoned"); + if self.paused.load(Ordering::Acquire) { + return None + } + let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1; + *snapshot = None; + Some(generation) + } + + fn resume_head(&self) -> Option { + let _snapshot = self.snapshot.write().expect("txpool snapshot publication lock poisoned"); + if self.paused.load(Ordering::Acquire) { + return None + } + Some(self.generation.fetch_add(1, Ordering::SeqCst) + 1) + } + + fn cancel(&self) -> u64 { + let _snapshot = self.snapshot.write().expect("txpool snapshot publication lock poisoned"); + self.paused.store(true, Ordering::Release); + self.generation.fetch_add(1, Ordering::SeqCst) + 1 + } + + fn cancel_and_snapshot(&self, parent_hash: B256) -> (Option, u64) { + let snapshot = self.snapshot.write().expect("txpool snapshot publication lock poisoned"); + self.paused.store(true, Ordering::Release); + let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1; + ( + snapshot.as_ref().filter(|snapshot| snapshot.parent_hash() == parent_hash).cloned(), + generation, + ) + } + + fn publish(&self, generation: u64, snapshot: TxPoolPrewarmCacheSnapshot) -> bool { + let mut published = + self.snapshot.write().expect("txpool snapshot publication lock poisoned"); + if self.generation.load(Ordering::Acquire) != generation { + return false + } + *published = Some(snapshot); + true + } + + fn is_current(&self, generation: u64) -> bool { + self.generation.load(Ordering::Acquire) == generation + } + + fn is_paused(&self) -> bool { + self.paused.load(Ordering::Acquire) + } + + fn resume(&self, generation: u64) { + let _snapshot = self.snapshot.write().expect("txpool snapshot publication lock poisoned"); + if self.generation.load(Ordering::Acquire) == generation { + self.paused.store(false, Ordering::Release); + } + } +} + +/// Resumes head tracking after payload validation, including every early-return path. +pub(crate) struct TxPoolPrewarmPauseGuard { + publication: Arc, + generation: u64, +} + +#[derive(Debug, Default)] +struct TxPoolWorkerActivity { + active: Mutex, + idle: Condvar, +} + +impl TxPoolWorkerActivity { + fn try_enter( + self: &Arc, + publication: &TxPoolSnapshotPublication, + generation: u64, + ) -> Option { + let mut active = self.active.lock().expect("txpool worker activity lock poisoned"); + if publication.is_paused() || !publication.is_current(generation) { + return None + } + *active = true; + Some(TxPoolWorkerActivityGuard { activity: Arc::clone(self) }) + } + + fn wait_until_idle(&self) -> Duration { + let start = Instant::now(); + let mut active = self.active.lock().expect("txpool worker activity lock poisoned"); + while *active { + active = self.idle.wait(active).expect("txpool worker activity lock poisoned"); + } + start.elapsed() + } +} + +struct TxPoolWorkerActivityGuard { + activity: Arc, +} + +impl Drop for TxPoolWorkerActivityGuard { + fn drop(&mut self) { + *self.activity.active.lock().expect("txpool worker activity lock poisoned") = false; + self.activity.idle.notify_all(); + } +} + +impl Drop for TxPoolPrewarmPauseGuard { + fn drop(&mut self) { + self.publication.resume(self.generation); + } +} + +/// Coordinates a long-lived worker and the latest completed immutable snapshot. +pub(crate) struct TxPoolPrewarmHandle +where + N: NodePrimitives, + Evm: ConfigureEvm, +{ + requests: Arc>>, + publication: Arc, + activity: Arc, + wait_generation: AtomicU64, + _marker: TxPoolPrewarmMarker, +} + +impl Debug for TxPoolPrewarmHandle +where + N: NodePrimitives, + Evm: ConfigureEvm, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TxPoolPrewarmHandle") + .field("generation", &self.publication.generation.load(Ordering::Relaxed)) + .field( + "published", + &self + .publication + .snapshot + .read() + .expect("txpool snapshot publication lock poisoned") + .as_ref() + .map(|s| s.parent_hash()), + ) + .finish_non_exhaustive() + } +} + +impl TxPoolPrewarmHandle +where + N: NodePrimitives, + Evm: ConfigureEvm, +{ + /// Cancels speculative work and waits for any provider-backed EVM call to release its state. + pub(crate) fn cancel_and_wait(&self) -> Duration { + let generation = self.publication.cancel(); + self.wait_generation.store(generation, Ordering::Release); + self.activity.wait_until_idle() + } + + /// Resumes work after an explicit cache wait, including early-return payload paths. + pub(crate) fn resume_after_wait(&self) { + let generation = self.wait_generation.swap(0, Ordering::AcqRel); + if generation > 0 { + self.publication.resume(generation); + } + } +} + +impl TxPoolPrewarmHandle +where + N: NodePrimitives, + P: BlockReader + StateProviderFactory + StateReader + Clone + Send + Sync + 'static, + Evm: ConfigureEvm + 'static, +{ + /// Spawns the long-lived worker. Its mutable cache remains owned by this worker and is cleared + /// between heads without releasing map capacity. + pub(crate) fn spawn( + runtime: &reth_tasks::Runtime, + source: Arc>, + evm_config: Evm, + config: TxPoolPrewarmingConfig, + ) -> Self { + let requests = Arc::new(TxPoolPrewarmRequests::new()); + let worker_requests = Arc::clone(&requests); + let publication = Arc::new(TxPoolSnapshotPublication::default()); + let worker_publication = Arc::clone(&publication); + let activity = Arc::new(TxPoolWorkerActivity::default()); + let worker_activity = Arc::clone(&activity); + + runtime.spawn_blocking_named("txpool-prewarm", move || { + txpool_prewarm_loop( + worker_requests, + source, + evm_config, + config, + worker_publication, + worker_activity, + ) + }); + + Self { + requests, + publication, + activity, + wait_generation: AtomicU64::new(0), + _marker: PhantomData, + } + } + + /// Cancels the active wave and returns the latest fully published snapshot for `parent_hash`. + /// + /// Taking the publication write lock linearizes cancellation with publication. The worker + /// either published a completed snapshot before this method and it is returned, or observes the + /// new generation and discards its unfinished wave. + pub(crate) fn pause_and_snapshot( + &self, + parent_hash: B256, + ) -> (Option, TxPoolPrewarmPauseGuard) { + let (snapshot, generation) = self.publication.cancel_and_snapshot(parent_hash); + ( + snapshot, + TxPoolPrewarmPauseGuard { publication: Arc::clone(&self.publication), generation }, + ) + } + + /// Starts continuous warming for the latest canonical head. + pub(crate) fn start( + &self, + parent_hash: B256, + evm_env: EvmEnvFor, + provider_builder: StateProviderBuilder, + block_gas_limit: u64, + ) { + self.requests.replace(TxPoolPrewarmJob { + parent_hash, + evm_env, + provider_builder, + block_gas_limit, + }); + } +} + +impl Drop for TxPoolPrewarmHandle +where + N: NodePrimitives, + Evm: ConfigureEvm, +{ + fn drop(&mut self) { + self.publication.cancel(); + self.requests.close(); + } +} + +fn txpool_prewarm_loop( + requests: Arc>>, + source: Arc>, + evm_config: Evm, + config: TxPoolPrewarmingConfig, + publication: Arc, + activity: Arc, +) where + N: NodePrimitives, + P: BlockReader + StateProviderFactory + StateReader + Clone + Send + Sync + 'static, + Evm: ConfigureEvm + 'static, +{ + let cache = TxPoolPrewarmCache::new(); + let mut warmed = HashSet::default(); + let mut warmed_per_sender = AddressMap::::default(); + let mut sender_prefixes = AddressMap::>>::default(); + let mut best_txs: Option> = None; + let mut best_txs_parent_hash = None; + let mut best_txs_failed = false; + let mut deferred = None; + let mut inflight: Option> = None; + let mut pending = None; + let mut cache_parent_hash = None; + + while !requests.is_closed() { + if let Some(request) = requests.take() { + pending = Some(request); + } + if pending.is_none() { + pending = requests.wait(None); + continue + } + if publication.is_paused() { + if let Some(request) = requests.wait(Some(TXPOOL_HEAD_POLL_INTERVAL)) { + pending = Some(request); + } + continue + } + + let tracked_hash = source.tracked_block_hash(); + if pending.as_ref().is_none_or(|job| job.parent_hash != tracked_hash) { + if let Some(request) = requests.wait(Some(TXPOOL_HEAD_POLL_INTERVAL)) { + pending = Some(request); + } + continue + } + let job = pending.take().expect("matching canonical request exists"); + + if best_txs_parent_hash != Some(job.parent_hash) { + let opened = + catch_unwind(AssertUnwindSafe(|| source.best_transactions(job.parent_hash))); + let opened = match opened { + Ok(Some(opened)) => opened, + Ok(None) => { + pending = Some(job); + std::thread::sleep(TXPOOL_HEAD_POLL_INTERVAL); + continue + } + Err(_) => { + warn!( + target: "engine::tree::txpool_prewarm", + parent_hash = ?job.parent_hash, + "opening txpool best-transactions iterator panicked" + ); + pending = Some(job); + std::thread::sleep(TXPOOL_REFRESH_INTERVAL); + continue + } + }; + best_txs = Some(opened); + best_txs_parent_hash = Some(job.parent_hash); + best_txs_failed = false; + deferred = None; + inflight = None; + } + + if source.tracked_block_hash() != job.parent_hash { + pending = Some(job); + continue + } + + // Wait for pool maintenance to catch up to the authoritative canonical-head request + // before invalidating the previous publication. + let new_parent = cache_parent_hash != Some(job.parent_hash); + let active_generation = + if new_parent { publication.begin_head() } else { publication.resume_head() }; + let Some(active_generation) = active_generation else { + if pending.is_none() { + pending = Some(job); + } + continue + }; + if new_parent { + cache.clear(); + warmed.clear(); + warmed_per_sender.clear(); + sender_prefixes.clear(); + cache_parent_hash = Some(job.parent_hash); + } + debug!( + target: "engine::tree::txpool_prewarm", + parent_hash = ?job.parent_hash, + "started txpool prewarming" + ); + + while publication.is_current(active_generation) && !publication.is_paused() { + if let Some(request) = requests.take() { + pending = Some(request); + } + if requests.is_closed() || publication.is_paused() { + break + } + + if source.tracked_block_hash() != job.parent_hash { + break + } + + if inflight.is_none() { + if best_txs_failed { + std::thread::sleep(TXPOOL_REFRESH_INTERVAL); + continue + } + let (selection, cursor_panicked) = select_best_transactions( + best_txs.as_mut().expect("best transactions opened for active parent"), + &mut deferred, + job.block_gas_limit, + config, + &warmed, + &warmed_per_sender, + &publication.paused, + ); + if cursor_panicked { + best_txs_failed = true; + warn!( + target: "engine::tree::txpool_prewarm", + parent_hash = ?job.parent_hash, + "txpool best-transactions iterator panicked; retiring it for this parent" + ); + } + let canceled = selection.canceled; + if !selection.is_empty() { + inflight = Some(selection); + } + if canceled || !publication.is_current(active_generation) || publication.is_paused() + { + break + } + if inflight.is_none() { + std::thread::sleep(TXPOOL_REFRESH_INTERVAL); + continue + } + } + + let sender_transactions = transactions_with_prefixes( + &sender_prefixes, + &inflight.as_ref().expect("non-empty selection is in flight").transactions, + ); + let Some(activity_guard) = activity.try_enter(&publication, active_generation) else { + break + }; + let completed = catch_unwind(AssertUnwindSafe(|| { + prewarm_transactions( + &evm_config, + &job, + &cache, + &sender_transactions, + active_generation, + &publication, + ) + })) + .unwrap_or_else(|_| { + warn!( + target: "engine::tree::txpool_prewarm", + parent_hash = ?job.parent_hash, + "txpool prewarming batch panicked" + ); + false + }); + drop(activity_guard); + + if !completed { + if publication.is_current(active_generation) && !publication.is_paused() { + std::thread::sleep(TXPOOL_REFRESH_INTERVAL); + } + break + } + if !publication.is_current(active_generation) { + break + } + + // The deep clone happens privately. Only the short pointer swap below is serialized + // with `newPayload` snapshot acquisition. + let snapshot = cache.snapshot(job.parent_hash); + let entries = snapshot.entry_counts(); + if publication.publish(active_generation, snapshot) { + let selection = inflight.take().expect("published selection is in flight"); + for transaction in &selection.transactions { + warmed.insert(transaction.hash); + *warmed_per_sender.entry(transaction.sender).or_default() += 1; + } + for (sender, transactions) in sender_transactions { + sender_prefixes.insert(sender, transactions); + } + debug!( + target: "engine::tree::txpool_prewarm", + parent_hash = ?job.parent_hash, + transactions = selection.len(), + scanned = selection.scanned, + selected_gas = selection.selected_gas, + accounts = entries.0, + storage = entries.1, + bytecodes = entries.2, + "published txpool prewarming snapshot" + ); + } else { + break + } + + std::thread::sleep(TXPOOL_REFRESH_INTERVAL); + } + + if !requests.is_closed() && + pending.is_none() && + source.tracked_block_hash() == job.parent_hash + { + pending = Some(job); + } + } +} + +/// Selects the next bounded batch from a live best-transactions iterator. +/// +/// `best_txs` is forward-only and retained for the entire canonical parent. A transaction that +/// would overflow a non-empty batch is deferred rather than discarded, so the next batch resumes +/// at that transaction without reopening the iterator. +fn select_best_transactions( + best_txs: &mut TxPoolPrewarmTransactions, + deferred: &mut Option>, + block_gas_limit: u64, + config: TxPoolPrewarmingConfig, + warmed: &HashSet, + warmed_per_sender: &AddressMap, + stop: &AtomicBool, +) -> (TxPoolPrewarmSelection, bool) { + let gas_limit = block_gas_limit.saturating_mul(config.gas_limit_multiplier); + let mut selection = TxPoolPrewarmSelection::default(); + let mut selected_per_sender = AddressMap::::default(); + + while selection.scanned < config.max_candidate_scan { + if stop.load(Ordering::Relaxed) { + selection.canceled = true; + break + } + + let transaction = if let Some(transaction) = deferred.take() { + Some(transaction) + } else { + match catch_unwind(AssertUnwindSafe(|| best_txs.next())) { + Ok(transaction) => transaction, + Err(_) => return (selection, true), + } + }; + let Some(transaction) = transaction else { break }; + if warmed.contains(&transaction.hash) { + selection.scanned += 1; + continue + } + + let selected_for_sender = selected_per_sender.entry(transaction.sender).or_default(); + let already_warmed = + warmed_per_sender.get(&transaction.sender).copied().unwrap_or_default(); + if already_warmed.saturating_add(*selected_for_sender) >= config.max_transactions_per_sender + { + selection.scanned += 1; + continue + } + + let tx_gas_limit = transaction.transaction.gas_limit(); + if tx_gas_limit > gas_limit { + selection.scanned += 1; + continue + } + if selection.selected_gas.saturating_add(tx_gas_limit) > gas_limit { + *deferred = Some(transaction); + break + } + + selection.scanned += 1; + *selected_for_sender += 1; + selection.selected_gas = selection.selected_gas.saturating_add(tx_gas_limit); + selection.transactions.push(transaction); + } + + (selection, false) +} + +/// Merges fresh transactions into the published nonce-ordered prefixes for affected senders. +/// +/// Replaying the bounded prefix lets a later delta observe speculative state produced by earlier +/// transactions from the same sender. A replacement overwrites the transaction at its nonce. +fn transactions_with_prefixes( + prefixes: &AddressMap>>, + fresh: &[TxPoolPrewarmTransaction], +) -> AddressMap>> { + let mut merged = AddressMap::>>::default(); + for transaction in fresh { + let transactions = merged + .entry(transaction.sender) + .or_insert_with(|| prefixes.get(&transaction.sender).cloned().unwrap_or_default()); + if let Some(existing) = transactions + .iter_mut() + .find(|existing| existing.transaction.nonce() == transaction.transaction.nonce()) + { + *existing = transaction.clone(); + } else { + transactions.push(transaction.clone()); + } + } + for transactions in merged.values_mut() { + transactions.sort_unstable_by_key(|transaction| transaction.transaction.nonce()); + } + merged +} + +fn prewarm_transactions( + evm_config: &Evm, + job: &TxPoolPrewarmJob, + cache: &TxPoolPrewarmCache, + transactions: &AddressMap>>, + generation: u64, + publication: &TxPoolSnapshotPublication, +) -> bool +where + N: NodePrimitives, + P: BlockReader + StateProviderFactory + StateReader + Clone, + Evm: ConfigureEvm, +{ + for sender_transactions in transactions.values() { + if !publication.is_current(generation) { + return false + } + + let state_provider = match job.provider_builder.build() { + Ok(provider) => provider, + Err(err) => { + trace!( + target: "engine::tree::txpool_prewarm", + %err, + parent_hash = ?job.parent_hash, + "failed to build txpool prewarming state provider" + ); + return false + } + }; + let state_provider = TxPoolPrewarmStateProvider { inner: state_provider, cache }; + let state_provider = StateProviderDatabase::new(state_provider); + let mut state = State::builder().with_database(state_provider).build(); + let mut evm_env = job.evm_env.clone(); + evm_env.cfg_env.disable_nonce_check = true; + evm_env.cfg_env.disable_balance_check = true; + let mut evm = evm_config.evm_with_env(&mut state, evm_env); + + for transaction in sender_transactions { + if !publication.is_current(generation) { + return false + } + + if let Err(err) = evm.transact_commit(transaction.transaction.clone()) { + trace!( + target: "engine::tree::txpool_prewarm", + %err, + tx_hash = ?transaction.hash, + sender = %transaction.sender, + "speculative txpool transaction execution failed" + ); + } + } + } + + true +} + +/// Provider that fills only the reusable txpool-prewarm cache. +struct TxPoolPrewarmStateProvider<'a> { + inner: StateProviderBox, + cache: &'a TxPoolPrewarmCache, +} + +impl EvmStateProvider for TxPoolPrewarmStateProvider<'_> { + fn basic_account( + &self, + address: &Address, + ) -> reth_errors::ProviderResult> { + self.cache + .get_or_try_insert_account_with(*address, || self.inner.basic_account(address)) + .map(cached_value) + } + + fn block_hash(&self, number: BlockNumber) -> reth_errors::ProviderResult> { + EvmStateProvider::block_hash(&self.inner, number) + } + + fn bytecode_by_hash( + &self, + code_hash: &B256, + ) -> reth_errors::ProviderResult> { + self.cache + .get_or_try_insert_code_with(*code_hash, || self.inner.bytecode_by_hash(code_hash)) + .map(cached_value) + } + + fn storage( + &self, + account: Address, + storage_key: StorageKey, + ) -> reth_errors::ProviderResult> { + self.cache + .get_or_try_insert_storage_with(account, storage_key, || { + self.inner.storage(account, storage_key).map(Option::unwrap_or_default) + }) + .map(cached_value) + .map(nonzero_storage_value) + } +} + +fn cached_value(status: reth_execution_cache::CachedStatus) -> T { + match status { + reth_execution_cache::CachedStatus::Cached(value) | + reth_execution_cache::CachedStatus::NotCached(value) => value, + } +} + +fn nonzero_storage_value(value: StorageValue) -> Option { + (!value.is_zero()).then_some(value) +} diff --git a/crates/engine/tree/src/tree/types.rs b/crates/engine/tree/src/tree/types.rs index 2030c13c4d6..680d2501501 100644 --- a/crates/engine/tree/src/tree/types.rs +++ b/crates/engine/tree/src/tree/types.rs @@ -6,6 +6,7 @@ use alloy_eips::eip4895::Withdrawal; use alloy_primitives::B256; use reth_chain_state::{ExecutedBlock, ExecutionTimingStats}; use reth_evm::{ConfigureEvm, EvmEnvFor}; +use reth_execution_cache::TxPoolPrewarmCacheSnapshot; use reth_primitives_traits::{BlockTy, NodePrimitives}; use std::sync::Arc; @@ -34,6 +35,8 @@ pub struct ExecutionEnv { /// Optional decoded BAL for the block. /// Used to validate and optimize execution. pub decoded_bal: Option>, + /// Latest completed txpool-prewarm snapshot for this block's parent state. + pub txpool_snapshot: Option, } impl ExecutionEnv @@ -52,6 +55,7 @@ where gas_used: 0, withdrawals: None, decoded_bal: None, + txpool_snapshot: None, } } } diff --git a/crates/ethereum/evm/src/lib.rs b/crates/ethereum/evm/src/lib.rs index 5fb989c3ea8..86dcea7a802 100644 --- a/crates/ethereum/evm/src/lib.rs +++ b/crates/ethereum/evm/src/lib.rs @@ -227,6 +227,25 @@ where )) } + fn txpool_prewarm_env(&self, parent: &Header) -> Result, Self::Error> { + self.next_evm_env( + parent, + &NextBlockEnvAttributes { + timestamp: parent.timestamp.saturating_add(12), + suggested_fee_recipient: parent.beneficiary, + prev_randao: alloy_primitives::B256::ZERO, + gas_limit: parent.gas_limit, + parent_beacon_block_root: parent + .parent_beacon_block_root + .map(|_| Default::default()), + withdrawals: parent.withdrawals_root.map(|_| Default::default()), + extra_data: parent.extra_data.clone(), + slot_number: parent.slot_number.map(|slot| slot.saturating_add(1)), + }, + ) + .map(Some) + } + fn context_for_block<'a>( &self, block: &'a SealedBlock, diff --git a/crates/evm/evm/src/lib.rs b/crates/evm/evm/src/lib.rs index e9ef87ad4e3..64ff75521c9 100644 --- a/crates/evm/evm/src/lib.rs +++ b/crates/evm/evm/src/lib.rs @@ -240,6 +240,18 @@ pub trait ConfigureEvm: Clone + Debug + Send + Sync + Unpin { attributes: &Self::NextBlockEnvCtx, ) -> Result, Self::Error>; + /// Returns a predicted next-block environment for txpool cache prewarming. + /// + /// Implementations should return `None` when they cannot derive a safe prediction without + /// consensus-layer payload attributes. The prediction only controls which parent-state reads + /// are warmed; speculative writes are never published. + fn txpool_prewarm_env( + &self, + _parent: &HeaderTy, + ) -> Result>, Self::Error> { + Ok(None) + } + /// Returns the configured [`BlockExecutorFactory::ExecutionCtx`] for a given block. fn context_for_block<'a>( &self, diff --git a/crates/node/builder/src/lib.rs b/crates/node/builder/src/lib.rs index 7f1e71d5c17..a8722004437 100644 --- a/crates/node/builder/src/lib.rs +++ b/crates/node/builder/src/lib.rs @@ -36,6 +36,8 @@ pub use launch::{ *, }; +mod txpool_prewarm; + mod handle; pub use handle::NodeHandle; diff --git a/crates/node/builder/src/rpc.rs b/crates/node/builder/src/rpc.rs index 491db06fc6b..4f4222d3c84 100644 --- a/crates/node/builder/src/rpc.rs +++ b/crates/node/builder/src/rpc.rs @@ -13,8 +13,8 @@ pub use reth_rpc_builder::{ pub use reth_trie_db::ChangesetCache; use crate::{ - invalid_block_hook::InvalidBlockHookExt, ConfigureEngineEvm, ConsensusEngineEvent, - ConsensusEngineHandle, + invalid_block_hook::InvalidBlockHookExt, txpool_prewarm::PoolTxPoolPrewarmSource, + ConfigureEngineEvm, ConsensusEngineEvent, ConsensusEngineHandle, }; use alloy_rpc_types::engine::ClientVersionV1; use alloy_rpc_types_engine::ExecutionData; @@ -1468,7 +1468,10 @@ where let data_dir = ctx.config.datadir.clone().resolve_datadir(ctx.config.chain.chain()); let invalid_block_hook = ctx.create_invalid_block_hook(&data_dir).await?; - Ok(BasicEngineValidator::new( + let txpool_prewarming = tree_config.txpool_prewarming(); + let txpool_prewarming_available = + !tree_config.disable_state_cache() && !tree_config.disable_prewarming(); + let mut validator = BasicEngineValidator::new( ctx.node.provider().clone(), std::sync::Arc::new(ctx.node.consensus().clone()), ctx.node.evm_config().clone(), @@ -1478,7 +1481,18 @@ where changeset_cache, state_trie_overlays, ctx.node.task_executor().clone(), - )) + ); + + if txpool_prewarming.should_prewarm() && txpool_prewarming_available { + validator = validator.with_txpool_prewarm_source(PoolTxPoolPrewarmSource::< + PrimitivesTy, + _, + >::new( + ctx.node.pool().clone() + )); + } + + Ok(validator) } } diff --git a/crates/node/builder/src/txpool_prewarm.rs b/crates/node/builder/src/txpool_prewarm.rs new file mode 100644 index 00000000000..a6eae6c4fa2 --- /dev/null +++ b/crates/node/builder/src/txpool_prewarm.rs @@ -0,0 +1,64 @@ +//! Transaction-pool backed candidate source for engine cache prewarming. + +use alloy_primitives::B256; +use reth_engine_tree::tree::{ + TxPoolPrewarmSource, TxPoolPrewarmTransaction, TxPoolPrewarmTransactions, +}; +use reth_primitives_traits::{NodePrimitives, TxTy}; +use reth_transaction_pool::{ + BestTransactions, BestTransactionsAttributes, PoolTransaction, TransactionPool, +}; +use std::{fmt::Debug, marker::PhantomData}; + +/// [`TransactionPool`]-backed [`TxPoolPrewarmSource`]. +#[derive(Debug)] +pub(crate) struct PoolTxPoolPrewarmSource { + pool: P, + _marker: PhantomData, +} + +impl PoolTxPoolPrewarmSource { + /// Creates a new txpool prewarm source. + pub(crate) const fn new(pool: P) -> Self { + Self { pool, _marker: PhantomData } + } +} + +impl TxPoolPrewarmSource for PoolTxPoolPrewarmSource +where + N: NodePrimitives, + P: TransactionPool>> + + Clone + + Send + + Sync + + Debug + + 'static, +{ + fn tracked_block_hash(&self) -> B256 { + self.pool.block_info().last_seen_block_hash + } + + fn best_transactions(&self, parent_hash: B256) -> Option> { + let block_info = self.pool.block_info(); + if block_info.last_seen_block_hash != parent_hash { + return None + } + + let mut best = + self.pool.best_transactions_with_attributes(BestTransactionsAttributes::new( + block_info.pending_basefee, + block_info.pending_blob_fee.map(|fee| u64::try_from(fee).unwrap_or(u64::MAX)), + )); + best.allow_updates_out_of_order(); + + if self.pool.block_info().last_seen_block_hash != parent_hash { + return None + } + + Some(Box::new(best.map(|transaction| TxPoolPrewarmTransaction { + hash: *transaction.hash(), + sender: transaction.sender(), + transaction: transaction.transaction.clone_into_consensus(), + }))) + } +} diff --git a/crates/node/core/src/args/engine.rs b/crates/node/core/src/args/engine.rs index 20dcdf4dc0f..bf175b1d7d1 100644 --- a/crates/node/core/src/args/engine.rs +++ b/crates/node/core/src/args/engine.rs @@ -7,9 +7,9 @@ use clap::{ 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_INVALID_HEADER_HIT_EVICTION_THRESHOLD, DEFAULT_MULTIPROOF_TASK_CHUNK_SIZE, - DEFAULT_PERSISTENCE_BACKPRESSURE_THRESHOLD, DEFAULT_SPARSE_TRIE_MAX_HOT_ACCOUNTS, - DEFAULT_SPARSE_TRIE_MAX_HOT_SLOTS, + TreeConfig, TxPoolPrewarmingConfig, DEFAULT_INVALID_HEADER_HIT_EVICTION_THRESHOLD, + DEFAULT_MULTIPROOF_TASK_CHUNK_SIZE, DEFAULT_PERSISTENCE_BACKPRESSURE_THRESHOLD, + DEFAULT_SPARSE_TRIE_MAX_HOT_ACCOUNTS, DEFAULT_SPARSE_TRIE_MAX_HOT_SLOTS, }; use std::{sync::OnceLock, time::Duration}; @@ -32,6 +32,10 @@ pub struct DefaultEngineValues { invalid_header_hit_eviction_threshold: u8, state_cache_disabled: bool, prewarming_disabled: bool, + txpool_prewarming_enabled: bool, + txpool_prewarming_max_transactions_per_sender: usize, + txpool_prewarming_max_candidate_scan: usize, + txpool_prewarming_gas_limit_multiplier: u64, state_provider_metrics: bool, cross_block_cache_size: usize, state_root_task_compare_updates: bool, @@ -105,6 +109,30 @@ impl DefaultEngineValues { self } + /// Set whether to enable txpool prewarming by default + pub const fn with_txpool_prewarming_enabled(mut self, v: bool) -> Self { + self.txpool_prewarming_enabled = v; + self + } + + /// Set the default maximum transactions prewarmed per sender and parent head + pub const fn with_txpool_prewarming_max_transactions_per_sender(mut self, v: usize) -> Self { + self.txpool_prewarming_max_transactions_per_sender = v; + self + } + + /// Set the default maximum fresh txpool candidates considered per refresh + pub const fn with_txpool_prewarming_max_candidate_scan(mut self, v: usize) -> Self { + self.txpool_prewarming_max_candidate_scan = v; + self + } + + /// Set the default txpool prewarming gas-limit multiplier + pub const fn with_txpool_prewarming_gas_limit_multiplier(mut self, v: u64) -> Self { + self.txpool_prewarming_gas_limit_multiplier = v; + self + } + /// Set whether to enable state provider metrics by default pub const fn with_state_provider_metrics(mut self, v: bool) -> Self { self.state_provider_metrics = v; @@ -262,6 +290,13 @@ impl Default for DefaultEngineValues { invalid_header_hit_eviction_threshold: DEFAULT_INVALID_HEADER_HIT_EVICTION_THRESHOLD, state_cache_disabled: false, prewarming_disabled: false, + txpool_prewarming_enabled: TxPoolPrewarmingConfig::DEFAULT.enabled, + txpool_prewarming_max_transactions_per_sender: + TxPoolPrewarmingConfig::DEFAULT_MAX_TRANSACTIONS_PER_SENDER, + txpool_prewarming_max_candidate_scan: + TxPoolPrewarmingConfig::DEFAULT_MAX_CANDIDATE_SCAN, + txpool_prewarming_gas_limit_multiplier: + TxPoolPrewarmingConfig::DEFAULT_GAS_LIMIT_MULTIPLIER, state_provider_metrics: false, cross_block_cache_size: DEFAULT_CROSS_BLOCK_CACHE_SIZE_MB, state_root_task_compare_updates: false, @@ -350,6 +385,22 @@ pub struct EngineArgs { #[arg(long = "engine.disable-prewarming", alias = "engine.disable-caching-and-prewarming", default_value_t = DefaultEngineValues::get_global().prewarming_disabled)] pub prewarming_disabled: bool, + /// Enable best-effort txpool transaction prewarming between payloads. + #[arg(long = "engine.txpool-prewarming", default_value_t = DefaultEngineValues::get_global().txpool_prewarming_enabled)] + pub txpool_prewarming_enabled: bool, + + /// Configure the maximum transactions prewarmed per sender for one parent head. + #[arg(long = "engine.txpool-prewarming-max-transactions-per-sender", default_value_t = DefaultEngineValues::get_global().txpool_prewarming_max_transactions_per_sender, value_parser = RangedU64ValueParser::::new().range(1..))] + pub txpool_prewarming_max_transactions_per_sender: usize, + + /// Configure the maximum fresh txpool candidates considered per prewarming refresh. + #[arg(long = "engine.txpool-prewarming-max-candidate-scan", default_value_t = DefaultEngineValues::get_global().txpool_prewarming_max_candidate_scan, value_parser = RangedU64ValueParser::::new().range(1..))] + pub txpool_prewarming_max_candidate_scan: usize, + + /// Configure the parent gas-limit multiplier used as each refresh's fresh-transaction budget. + #[arg(long = "engine.txpool-prewarming-gas-limit-multiplier", default_value_t = DefaultEngineValues::get_global().txpool_prewarming_gas_limit_multiplier, value_parser = RangedU64ValueParser::::new().range(1..))] + pub txpool_prewarming_gas_limit_multiplier: u64, + /// CAUTION: This CLI flag has no effect anymore. The parallel sparse trie is always enabled. #[deprecated] #[arg(long = "engine.parallel-sparse-trie", default_value = "true", hide = true)] @@ -555,6 +606,10 @@ impl Default for EngineArgs { invalid_header_hit_eviction_threshold, state_cache_disabled, prewarming_disabled, + txpool_prewarming_enabled, + txpool_prewarming_max_transactions_per_sender, + txpool_prewarming_max_candidate_scan, + txpool_prewarming_gas_limit_multiplier, state_provider_metrics, cross_block_cache_size, state_root_task_compare_updates, @@ -590,6 +645,10 @@ impl Default for EngineArgs { caching_and_prewarming_enabled: true, state_cache_disabled, prewarming_disabled, + txpool_prewarming_enabled, + txpool_prewarming_max_transactions_per_sender, + txpool_prewarming_max_candidate_scan, + txpool_prewarming_gas_limit_multiplier, parallel_sparse_trie_enabled: true, parallel_sparse_trie_disabled: false, state_provider_metrics, @@ -662,6 +721,15 @@ impl EngineArgs { .with_invalid_header_hit_eviction_threshold(self.invalid_header_hit_eviction_threshold) .without_state_cache(self.state_cache_disabled) .without_prewarming(self.prewarming_disabled) + .with_txpool_prewarming( + TxPoolPrewarmingConfig::DEFAULT + .with_enabled(self.txpool_prewarming_enabled) + .with_max_transactions_per_sender( + self.txpool_prewarming_max_transactions_per_sender, + ) + .with_max_candidate_scan(self.txpool_prewarming_max_candidate_scan) + .with_gas_limit_multiplier(self.txpool_prewarming_gas_limit_multiplier), + ) .with_state_provider_metrics(self.state_provider_metrics) .with_always_compare_trie_updates(self.state_root_task_compare_updates) .with_cross_block_cache_size(self.cross_block_cache_size * 1024 * 1024) @@ -780,6 +848,10 @@ mod tests { caching_and_prewarming_enabled: true, state_cache_disabled: true, prewarming_disabled: true, + txpool_prewarming_enabled: true, + txpool_prewarming_max_transactions_per_sender: 8, + txpool_prewarming_max_candidate_scan: 1024, + txpool_prewarming_gas_limit_multiplier: 3, parallel_sparse_trie_enabled: true, parallel_sparse_trie_disabled: false, state_provider_metrics: true, @@ -825,6 +897,13 @@ mod tests { "--engine.legacy-state-root", "--engine.disable-state-cache", "--engine.disable-prewarming", + "--engine.txpool-prewarming", + "--engine.txpool-prewarming-max-transactions-per-sender", + "8", + "--engine.txpool-prewarming-max-candidate-scan", + "1024", + "--engine.txpool-prewarming-gas-limit-multiplier", + "3", "--engine.state-provider-metrics", "--engine.cross-block-cache-size", "256", diff --git a/crates/node/events/src/node.rs b/crates/node/events/src/node.rs index 7223026273f..7ee9ef5660f 100644 --- a/crates/node/events/src/node.rs +++ b/crates/node/events/src/node.rs @@ -337,6 +337,15 @@ impl NodeState { cache.code.hits = stats.code_cache_hits, cache.code.misses = stats.code_cache_misses, cache.code.hit_rate = format!("{:.2}", hit_rate(stats.code_cache_hits, stats.code_cache_misses)), + cache.txpool_snapshot.account.hits = stats.txpool_snapshot_account_hits, + cache.txpool_snapshot.account.misses = stats.txpool_snapshot_account_misses, + cache.txpool_snapshot.account.hit_rate = format!("{:.2}", hit_rate(stats.txpool_snapshot_account_hits, stats.txpool_snapshot_account_misses)), + cache.txpool_snapshot.storage.hits = stats.txpool_snapshot_storage_hits, + cache.txpool_snapshot.storage.misses = stats.txpool_snapshot_storage_misses, + cache.txpool_snapshot.storage.hit_rate = format!("{:.2}", hit_rate(stats.txpool_snapshot_storage_hits, stats.txpool_snapshot_storage_misses)), + cache.txpool_snapshot.code.hits = stats.txpool_snapshot_code_hits, + cache.txpool_snapshot.code.misses = stats.txpool_snapshot_code_misses, + cache.txpool_snapshot.code.hit_rate = format!("{:.2}", hit_rate(stats.txpool_snapshot_code_hits, stats.txpool_snapshot_code_misses)), ); } } diff --git a/crates/transaction-pool/src/pool/best.rs b/crates/transaction-pool/src/pool/best.rs index 6d6b46cdf8e..69f91124502 100644 --- a/crates/transaction-pool/src/pool/best.rs +++ b/crates/transaction-pool/src/pool/best.rs @@ -41,6 +41,10 @@ impl crate::traits::BestTransactions for BestTransaction self.best.no_updates() } + fn allow_updates_out_of_order(&mut self) { + self.best.allow_updates_out_of_order() + } + fn skip_blobs(&mut self) { self.set_skip_blobs(true) } @@ -111,6 +115,8 @@ pub struct BestTransactions { pub(crate) last_priority: Option>, /// Flag to control whether to skip blob transactions (EIP4844). pub(crate) skip_blobs: bool, + /// Whether live updates can be yielded after a lower-priority transaction. + pub(crate) allow_updates_out_of_order: bool, } impl BestTransactions { @@ -136,7 +142,8 @@ impl BestTransactions { loop { match self.new_transaction_receiver.as_mut()?.try_recv() { Ok(tx) => { - if let Some(last_priority) = &self.last_priority && + if !self.allow_updates_out_of_order && + let Some(last_priority) = &self.last_priority && &tx.priority > last_priority { // we skip transactions if we already yielded a transaction with lower @@ -278,6 +285,10 @@ impl crate::traits::BestTransactions for BestTransaction self.last_priority.take(); } + fn allow_updates_out_of_order(&mut self) { + self.allow_updates_out_of_order = true; + } + fn skip_blobs(&mut self) { self.set_skip_blobs(true); } @@ -355,6 +366,10 @@ where self.best.no_updates() } + fn allow_updates_out_of_order(&mut self) { + self.best.allow_updates_out_of_order() + } + fn skip_blobs(&mut self) { self.set_skip_blobs(true) } @@ -454,6 +469,10 @@ where self.inner.no_updates() } + fn allow_updates_out_of_order(&mut self) { + self.inner.allow_updates_out_of_order() + } + fn set_skip_blobs(&mut self, skip_blobs: bool) { if skip_blobs { self.buffer.retain(|tx| !tx.transaction.is_eip4844()) diff --git a/crates/transaction-pool/src/pool/pending.rs b/crates/transaction-pool/src/pool/pending.rs index 57a70c90c91..7ca27ad9b66 100644 --- a/crates/transaction-pool/src/pool/pending.rs +++ b/crates/transaction-pool/src/pool/pending.rs @@ -109,6 +109,7 @@ impl PendingPool { new_transaction_receiver: Some(self.new_transaction_notifier.subscribe()), last_priority: None, skip_blobs: false, + allow_updates_out_of_order: false, } } diff --git a/crates/transaction-pool/src/traits.rs b/crates/transaction-pool/src/traits.rs index 654ef7e5a87..877e1e6e6f1 100644 --- a/crates/transaction-pool/src/traits.rs +++ b/crates/transaction-pool/src/traits.rs @@ -1119,6 +1119,14 @@ pub trait BestTransactions: Iterator + Send { /// listen to pool updates. fn no_updates(&mut self); + /// Allows newly received transactions to be yielded even if their priority is higher than a + /// transaction that was already yielded. + /// + /// This is useful for long-lived consumers that prefer seeing every update over preserving + /// decreasing priority order. The default implementation leaves the iterator's ordering + /// behavior unchanged. Implementations must still preserve transaction dependency ordering. + fn allow_updates_out_of_order(&mut self) {} + /// Convenience function for [`Self::no_updates`] that returns the iterator again. fn without_updates(mut self) -> Self where @@ -1180,6 +1188,10 @@ where (**self).no_updates(); } + fn allow_updates_out_of_order(&mut self) { + (**self).allow_updates_out_of_order(); + } + fn skip_blobs(&mut self) { (**self).skip_blobs(); } diff --git a/docs/vocs/docs/pages/cli/reth/node.mdx b/docs/vocs/docs/pages/cli/reth/node.mdx index f28c092e49f..a9a333afc63 100644 --- a/docs/vocs/docs/pages/cli/reth/node.mdx +++ b/docs/vocs/docs/pages/cli/reth/node.mdx @@ -1011,6 +1011,24 @@ Engine: --engine.disable-prewarming Disable parallel prewarming + --engine.txpool-prewarming + Enable best-effort txpool transaction prewarming between payloads + + --engine.txpool-prewarming-max-transactions-per-sender + Configure the maximum transactions prewarmed per sender for one parent head + + [default: 16] + + --engine.txpool-prewarming-max-candidate-scan + Configure the maximum fresh txpool candidates considered per prewarming refresh + + [default: 4096] + + --engine.txpool-prewarming-gas-limit-multiplier + Configure the parent gas-limit multiplier used as each refresh's fresh-transaction budget + + [default: 6] + --engine.state-provider-metrics Enable state provider latency metrics. This allows the engine to collect and report stats about how long state provider calls took during execution, but this does introduce slight overhead to state provider calls From ab5e13aadb608012b2f9311ffa3dd37be1cdfa5b Mon Sep 17 00:00:00 2001 From: Sergei Shulepov Date: Mon, 13 Jul 2026 18:52:58 +0200 Subject: [PATCH 02/32] pep tidy ups --- crates/engine/primitives/src/config.rs | 2 +- crates/engine/tree/src/tree/txpool_prewarm.rs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/engine/primitives/src/config.rs b/crates/engine/primitives/src/config.rs index 7edfe982d74..84e31d5b10f 100644 --- a/crates/engine/primitives/src/config.rs +++ b/crates/engine/primitives/src/config.rs @@ -117,7 +117,7 @@ impl TxPoolPrewarmingConfig { }; /// Returns whether this configuration can launch work. - pub const fn should_prewarm(self) -> bool { + pub const fn should_prewarm(&self) -> bool { self.enabled && self.max_transactions_per_sender > 0 && self.max_candidate_scan > 0 && diff --git a/crates/engine/tree/src/tree/txpool_prewarm.rs b/crates/engine/tree/src/tree/txpool_prewarm.rs index 8c08c816941..acdda8ec016 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm.rs @@ -244,7 +244,7 @@ impl TxPoolWorkerActivity { generation: u64, ) -> Option { let mut active = self.active.lock().expect("txpool worker activity lock poisoned"); - if publication.is_paused() || !publication.is_current(generation) { + if *active || publication.is_paused() || !publication.is_current(generation) { return None } *active = true; @@ -612,7 +612,7 @@ fn txpool_prewarm_loop( // The deep clone happens privately. Only the short pointer swap below is serialized // with `newPayload` snapshot acquisition. let snapshot = cache.snapshot(job.parent_hash); - let entries = snapshot.entry_counts(); + let (accounts, storage, bytecodes) = snapshot.entry_counts(); if publication.publish(active_generation, snapshot) { let selection = inflight.take().expect("published selection is in flight"); for transaction in &selection.transactions { @@ -628,9 +628,9 @@ fn txpool_prewarm_loop( transactions = selection.len(), scanned = selection.scanned, selected_gas = selection.selected_gas, - accounts = entries.0, - storage = entries.1, - bytecodes = entries.2, + accounts, + storage, + bytecodes, "published txpool prewarming snapshot" ); } else { From 107f1ec383eb89993c88daf74acce25fbed866fb Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:14:06 +0300 Subject: [PATCH 03/32] fix(engine): refresh exhausted txpool prewarm iterator (#26377) --- crates/engine/tree/src/tree/txpool_prewarm.rs | 51 +++++++++++++++++-- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/crates/engine/tree/src/tree/txpool_prewarm.rs b/crates/engine/tree/src/tree/txpool_prewarm.rs index acdda8ec016..21187be0b6c 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm.rs @@ -541,7 +541,7 @@ fn txpool_prewarm_loop( std::thread::sleep(TXPOOL_REFRESH_INTERVAL); continue } - let (selection, cursor_panicked) = select_best_transactions( + let (selection, cursor_panicked, cursor_exhausted) = select_best_transactions( best_txs.as_mut().expect("best transactions opened for active parent"), &mut deferred, job.block_gas_limit, @@ -558,6 +558,22 @@ fn txpool_prewarm_loop( "txpool best-transactions iterator panicked; retiring it for this parent" ); } + if cursor_exhausted { + match catch_unwind(AssertUnwindSafe(|| { + source.best_transactions(job.parent_hash) + })) { + Ok(Some(opened)) => best_txs = Some(opened), + Ok(None) => {} + Err(_) => { + best_txs_failed = true; + warn!( + target: "engine::tree::txpool_prewarm", + parent_hash = ?job.parent_hash, + "reopening txpool best-transactions iterator panicked; retiring it for this parent" + ); + } + } + } let canceled = selection.canceled; if !selection.is_empty() { inflight = Some(selection); @@ -662,7 +678,7 @@ fn select_best_transactions( warmed: &HashSet, warmed_per_sender: &AddressMap, stop: &AtomicBool, -) -> (TxPoolPrewarmSelection, bool) { +) -> (TxPoolPrewarmSelection, bool, bool) { let gas_limit = block_gas_limit.saturating_mul(config.gas_limit_multiplier); let mut selection = TxPoolPrewarmSelection::default(); let mut selected_per_sender = AddressMap::::default(); @@ -678,10 +694,10 @@ fn select_best_transactions( } else { match catch_unwind(AssertUnwindSafe(|| best_txs.next())) { Ok(transaction) => transaction, - Err(_) => return (selection, true), + Err(_) => return (selection, true, false), } }; - let Some(transaction) = transaction else { break }; + let Some(transaction) = transaction else { return (selection, false, true) }; if warmed.contains(&transaction.hash) { selection.scanned += 1; continue @@ -712,7 +728,7 @@ fn select_best_transactions( selection.transactions.push(transaction); } - (selection, false) + (selection, false, false) } /// Merges fresh transactions into the published nonce-ordered prefixes for affected senders. @@ -854,3 +870,28 @@ fn cached_value(status: reth_execution_cache::CachedStatus) -> T { fn nonzero_storage_value(value: StorageValue) -> Option { (!value.is_zero()).then_some(value) } + +#[cfg(test)] +mod tests { + use super::*; + use reth_ethereum_primitives::EthPrimitives; + + #[test] + fn reports_exhausted_best_transactions_iterator() { + let mut transactions: TxPoolPrewarmTransactions = + Box::new(std::iter::empty()); + let (selection, panicked, exhausted) = select_best_transactions( + &mut transactions, + &mut None, + 30_000_000, + TxPoolPrewarmingConfig::DEFAULT, + &HashSet::default(), + &AddressMap::default(), + &AtomicBool::new(false), + ); + + assert!(selection.is_empty()); + assert!(!panicked); + assert!(exhausted); + } +} From b20c91b64735ff059a2262021d4ceffb9b9bd302 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:59:47 +0000 Subject: [PATCH 04/32] feat(engine): enable txpool prewarming by default --- crates/engine/primitives/src/config.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/primitives/src/config.rs b/crates/engine/primitives/src/config.rs index 84e31d5b10f..db81f08737d 100644 --- a/crates/engine/primitives/src/config.rs +++ b/crates/engine/primitives/src/config.rs @@ -108,9 +108,9 @@ impl TxPoolPrewarmingConfig { pub const DEFAULT_MAX_CANDIDATE_SCAN: usize = 4096; /// Default gas-budget multiplier. pub const DEFAULT_GAS_LIMIT_MULTIPLIER: u64 = 6; - /// Default configuration. The feature is opt-in. + /// Default configuration. pub const DEFAULT: Self = Self { - enabled: false, + enabled: true, max_transactions_per_sender: Self::DEFAULT_MAX_TRANSACTIONS_PER_SENDER, max_candidate_scan: Self::DEFAULT_MAX_CANDIDATE_SCAN, gas_limit_multiplier: Self::DEFAULT_GAS_LIMIT_MULTIPLIER, From 6ca0c1a4a9317d37d5b7d1fa72eae28cb90fb6fd Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:02:07 +0000 Subject: [PATCH 05/32] feat(engine): enable txpool prewarming CLI by default --- crates/node/core/src/args/engine.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/node/core/src/args/engine.rs b/crates/node/core/src/args/engine.rs index bf175b1d7d1..b0cb04eb129 100644 --- a/crates/node/core/src/args/engine.rs +++ b/crates/node/core/src/args/engine.rs @@ -386,7 +386,7 @@ pub struct EngineArgs { pub prewarming_disabled: bool, /// Enable best-effort txpool transaction prewarming between payloads. - #[arg(long = "engine.txpool-prewarming", default_value_t = DefaultEngineValues::get_global().txpool_prewarming_enabled)] + #[arg(long = "engine.txpool-prewarming", default_value_t = true)] pub txpool_prewarming_enabled: bool, /// Configure the maximum transactions prewarmed per sender for one parent head. From 38d145d0a1fb529adb8a3df236b216a63c99b05f Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:04:06 +0000 Subject: [PATCH 06/32] Revert "feat(engine): enable txpool prewarming CLI by default" --- crates/node/core/src/args/engine.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/node/core/src/args/engine.rs b/crates/node/core/src/args/engine.rs index b0cb04eb129..bf175b1d7d1 100644 --- a/crates/node/core/src/args/engine.rs +++ b/crates/node/core/src/args/engine.rs @@ -386,7 +386,7 @@ pub struct EngineArgs { pub prewarming_disabled: bool, /// Enable best-effort txpool transaction prewarming between payloads. - #[arg(long = "engine.txpool-prewarming", default_value_t = true)] + #[arg(long = "engine.txpool-prewarming", default_value_t = DefaultEngineValues::get_global().txpool_prewarming_enabled)] pub txpool_prewarming_enabled: bool, /// Configure the maximum transactions prewarmed per sender for one parent head. From 9d2b7a44947611ceccad12c911da6198704739c7 Mon Sep 17 00:00:00 2001 From: Alexey Shekhirin Date: Wed, 15 Jul 2026 15:19:07 +0100 Subject: [PATCH 07/32] address and b256 maps --- crates/engine/execution-cache/src/txpool.rs | 27 ++++++++----------- crates/engine/tree/src/tree/txpool_prewarm.rs | 2 +- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/crates/engine/execution-cache/src/txpool.rs b/crates/engine/execution-cache/src/txpool.rs index cdac61c665b..2cff03fce54 100644 --- a/crates/engine/execution-cache/src/txpool.rs +++ b/crates/engine/execution-cache/src/txpool.rs @@ -1,7 +1,7 @@ //! Reusable cache and immutable snapshots for txpool-driven state prewarming. use crate::CachedStatus; -use alloy_primitives::{map::HashMap, Address, StorageKey, StorageValue, B256}; +use alloy_primitives::{Address, B256, StorageKey, StorageValue, map::{AddressMap, B256Map, HashMap}}; use parking_lot::RwLock; use reth_primitives_traits::{Account, Bytecode}; use std::sync::Arc; @@ -12,17 +12,12 @@ use std::sync::Arc; /// concurrently. [`Self::clear`] retains the maps' allocations for the next head. #[derive(Debug, Default)] pub struct TxPoolPrewarmCache { - accounts: RwLock>>, + accounts: RwLock>>, storage: RwLock>, - bytecodes: RwLock>>, + bytecodes: RwLock>>, } impl TxPoolPrewarmCache { - /// Creates an empty reusable cache. - pub fn new() -> Self { - Self::default() - } - /// Clears all cached state while retaining the backing allocations. pub fn clear(&self) { self.accounts.write().clear(); @@ -113,14 +108,6 @@ pub struct TxPoolPrewarmCacheSnapshot { inner: Arc, } -#[derive(Debug)] -struct TxPoolPrewarmCacheSnapshotInner { - parent_hash: B256, - accounts: HashMap>, - storage: HashMap<(Address, StorageKey), StorageValue>, - bytecodes: HashMap>, -} - impl TxPoolPrewarmCacheSnapshot { /// Returns the hash of the state this snapshot was warmed against. pub fn parent_hash(&self) -> B256 { @@ -147,3 +134,11 @@ impl TxPoolPrewarmCacheSnapshot { (self.inner.accounts.len(), self.inner.storage.len(), self.inner.bytecodes.len()) } } + +#[derive(Debug)] +struct TxPoolPrewarmCacheSnapshotInner { + parent_hash: B256, + accounts: AddressMap>, + storage: HashMap<(Address, StorageKey), StorageValue>, + bytecodes: B256Map>, +} diff --git a/crates/engine/tree/src/tree/txpool_prewarm.rs b/crates/engine/tree/src/tree/txpool_prewarm.rs index 21187be0b6c..8ac3f98feed 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm.rs @@ -431,7 +431,7 @@ fn txpool_prewarm_loop( P: BlockReader + StateProviderFactory + StateReader + Clone + Send + Sync + 'static, Evm: ConfigureEvm + 'static, { - let cache = TxPoolPrewarmCache::new(); + let cache = TxPoolPrewarmCache::default(); let mut warmed = HashSet::default(); let mut warmed_per_sender = AddressMap::::default(); let mut sender_prefixes = AddressMap::>>::default(); From 42d65eb6744889d603113a66f343313611973d1f Mon Sep 17 00:00:00 2001 From: Alexey Shekhirin Date: Thu, 16 Jul 2026 10:56:35 +0100 Subject: [PATCH 08/32] simplify --- crates/engine/execution-cache/src/txpool.rs | 5 +- crates/engine/primitives/src/config.rs | 43 +- crates/engine/tree/src/tree/mod.rs | 3 +- .../engine/tree/src/tree/payload_validator.rs | 28 +- crates/engine/tree/src/tree/txpool_prewarm.rs | 450 ++++-------------- crates/ethereum/evm/src/lib.rs | 23 +- crates/evm/evm/src/lib.rs | 1 + crates/node/builder/src/txpool_prewarm.rs | 1 + crates/node/core/src/args/engine.rs | 62 +-- crates/transaction-pool/src/pool/best.rs | 17 + 10 files changed, 158 insertions(+), 475 deletions(-) diff --git a/crates/engine/execution-cache/src/txpool.rs b/crates/engine/execution-cache/src/txpool.rs index 2cff03fce54..8bf297860fd 100644 --- a/crates/engine/execution-cache/src/txpool.rs +++ b/crates/engine/execution-cache/src/txpool.rs @@ -1,7 +1,10 @@ //! Reusable cache and immutable snapshots for txpool-driven state prewarming. use crate::CachedStatus; -use alloy_primitives::{Address, B256, StorageKey, StorageValue, map::{AddressMap, B256Map, HashMap}}; +use alloy_primitives::{ + map::{AddressMap, B256Map, HashMap}, + Address, StorageKey, StorageValue, B256, +}; use parking_lot::RwLock; use reth_primitives_traits::{Account, Bytecode}; use std::sync::Arc; diff --git a/crates/engine/primitives/src/config.rs b/crates/engine/primitives/src/config.rs index db81f08737d..5f0382beb69 100644 --- a/crates/engine/primitives/src/config.rs +++ b/crates/engine/primitives/src/config.rs @@ -92,36 +92,15 @@ pub fn has_enough_parallelism() -> bool { pub struct TxPoolPrewarmingConfig { /// Whether txpool transaction prewarming is enabled. pub enabled: bool, - /// Maximum transactions to prewarm for one sender while warming one parent head. - pub max_transactions_per_sender: usize, - /// Maximum fresh candidates considered from the txpool for one refresh. - pub max_candidate_scan: usize, - /// Multiplier applied to the parent block gas limit for one refresh's fresh-transaction gas - /// budget. - pub gas_limit_multiplier: u64, } impl TxPoolPrewarmingConfig { - /// Default per-sender transaction cap. - pub const DEFAULT_MAX_TRANSACTIONS_PER_SENDER: usize = 16; - /// Default candidate scan cap. - pub const DEFAULT_MAX_CANDIDATE_SCAN: usize = 4096; - /// Default gas-budget multiplier. - pub const DEFAULT_GAS_LIMIT_MULTIPLIER: u64 = 6; /// Default configuration. - pub const DEFAULT: Self = Self { - enabled: true, - max_transactions_per_sender: Self::DEFAULT_MAX_TRANSACTIONS_PER_SENDER, - max_candidate_scan: Self::DEFAULT_MAX_CANDIDATE_SCAN, - gas_limit_multiplier: Self::DEFAULT_GAS_LIMIT_MULTIPLIER, - }; + pub const DEFAULT: Self = Self { enabled: false }; /// Returns whether this configuration can launch work. pub const fn should_prewarm(&self) -> bool { - self.enabled && - self.max_transactions_per_sender > 0 && - self.max_candidate_scan > 0 && - self.gas_limit_multiplier > 0 + self.enabled } /// Enables or disables txpool prewarming. @@ -129,24 +108,6 @@ impl TxPoolPrewarmingConfig { self.enabled = enabled; self } - - /// Sets the per-sender transaction cap. - pub const fn with_max_transactions_per_sender(mut self, max: usize) -> Self { - self.max_transactions_per_sender = max; - self - } - - /// Sets the candidate scan cap. - pub const fn with_max_candidate_scan(mut self, max: usize) -> Self { - self.max_candidate_scan = max; - self - } - - /// Sets the gas-budget multiplier. - pub const fn with_gas_limit_multiplier(mut self, multiplier: u64) -> Self { - self.gas_limit_multiplier = multiplier; - self - } } impl Default for TxPoolPrewarmingConfig { diff --git a/crates/engine/tree/src/tree/mod.rs b/crates/engine/tree/src/tree/mod.rs index 96a0ec9502e..72556dea760 100644 --- a/crates/engine/tree/src/tree/mod.rs +++ b/crates/engine/tree/src/tree/mod.rs @@ -80,8 +80,7 @@ pub use reth_execution_cache::{ TxPoolPrewarmCacheSnapshot, }; pub use txpool_prewarm::{ - TxPoolPrewarmSelection, TxPoolPrewarmSource, TxPoolPrewarmTransaction, - TxPoolPrewarmTransactions, + TxPoolPrewarmSource, TxPoolPrewarmTransaction, TxPoolPrewarmTransactions, }; pub use types::{ExecutionEnv, ValidationOutcome, ValidationOutput}; diff --git a/crates/engine/tree/src/tree/payload_validator.rs b/crates/engine/tree/src/tree/payload_validator.rs index 014977da4ef..bfa2c4d1d35 100644 --- a/crates/engine/tree/src/tree/payload_validator.rs +++ b/crates/engine/tree/src/tree/payload_validator.rs @@ -393,7 +393,6 @@ where &self.runtime, Arc::new(source), self.evm_config.clone(), - config, )); self } @@ -494,13 +493,8 @@ where Evm: ConfigureEngineEvm, { let parent_hash = input.parent_hash(); - let (txpool_snapshot, _txpool_prewarm_guard) = - if let Some(prewarmer) = self.txpool_prewarm.as_ref() { - let (snapshot, guard) = prewarmer.pause_and_snapshot(parent_hash); - (snapshot, Some(guard)) - } else { - (None, None) - }; + let txpool_snapshot = + self.txpool_prewarm.as_ref().and_then(|prewarmer| prewarmer.snapshot(parent_hash)); let _jit_pause = JitPauseGuard::new(&self.evm_config); // Fetch parent block. This goes to memory most of the time unless the parent block is @@ -1907,6 +1901,20 @@ where state: &EngineApiTreeState, ) { let Some(txpool_prewarm) = self.txpool_prewarm.as_ref() else { return }; + let parent_parent = match self.sealed_header_by_hash(header.parent_hash(), state) { + Ok(Some(parent_parent)) => parent_parent, + Ok(None) => return, + Err(err) => { + trace!( + target: "engine::tree::txpool_prewarm", + %err, + block_hash = ?header.hash(), + parent_hash = ?header.parent_hash(), + "failed to fetch parent header for txpool prewarming" + ); + return + } + }; let provider_builder = match self.state_provider_builder(header.hash(), state) { Ok(Some(provider_builder)) => provider_builder, Ok(None) => return, @@ -1920,9 +1928,9 @@ where return } }; - match self.evm_config.txpool_prewarm_env(header.header()) { + match self.evm_config.txpool_prewarm_env(header.header(), parent_parent.header()) { Ok(Some(evm_env)) => { - txpool_prewarm.start(header.hash(), evm_env, provider_builder, header.gas_limit()) + txpool_prewarm.start(header.hash(), evm_env, provider_builder) } Ok(None) => {} Err(err) => { diff --git a/crates/engine/tree/src/tree/txpool_prewarm.rs b/crates/engine/tree/src/tree/txpool_prewarm.rs index 8ac3f98feed..88a934ba0ad 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm.rs @@ -3,13 +3,9 @@ use crate::tree::{ StateProviderBuilder, StateProviderDatabase, TxPoolPrewarmCache, TxPoolPrewarmCacheSnapshot, }; -use alloy_consensus::{transaction::Recovered, Transaction as _}; +use alloy_consensus::transaction::Recovered; use alloy_evm::Evm; -use alloy_primitives::{ - map::{AddressMap, HashSet}, - Address, BlockNumber, StorageKey, StorageValue, B256, -}; -use reth_engine_primitives::TxPoolPrewarmingConfig; +use alloy_primitives::{Address, BlockNumber, StorageKey, StorageValue, B256}; use reth_evm::{ConfigureEvm, EvmEnvFor}; use reth_primitives_traits::{NodePrimitives, TxTy}; use reth_provider::{BlockReader, StateProviderBox, StateProviderFactory, StateReader}; @@ -17,16 +13,15 @@ use reth_revm::{database::EvmStateProvider, db::State}; use std::{ fmt::Debug, marker::PhantomData, - panic::{catch_unwind, AssertUnwindSafe}, sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, Arc, Condvar, Mutex, RwLock, }, time::{Duration, Instant}, }; -use tracing::{debug, trace, warn}; +use tracing::{debug, trace}; -/// Delay between txpool refreshes after a completed or empty selection. +/// Maximum interval between snapshot publications and delay when no transaction is ready. const TXPOOL_REFRESH_INTERVAL: Duration = Duration::from_millis(100); /// Delay while waiting for pool maintenance to advance to the state being warmed. @@ -45,37 +40,6 @@ pub struct TxPoolPrewarmTransaction { pub transaction: Recovered>, } -/// Transactions selected for one refresh of the reusable txpool cache. -#[derive(Debug, Clone)] -pub struct TxPoolPrewarmSelection { - /// Fresh selected transactions in block-builder order. - pub transactions: Vec>, - /// Fresh candidates scanned while selecting. - pub scanned: usize, - /// Sum of selected transaction gas limits. - pub selected_gas: u64, - /// Whether selection observed cancellation. - pub canceled: bool, -} - -impl Default for TxPoolPrewarmSelection { - fn default() -> Self { - Self { transactions: Vec::new(), scanned: 0, selected_gas: 0, canceled: false } - } -} - -impl TxPoolPrewarmSelection { - /// Returns whether no transactions were selected. - pub const fn is_empty(&self) -> bool { - self.transactions.is_empty() - } - - /// Returns the selected transaction count. - pub const fn len(&self) -> usize { - self.transactions.len() - } -} - /// A live, forward-only view of the pool's best transactions for one canonical parent. /// /// Returning [`None`](Iterator::next) only means no transaction is currently ready. The same @@ -101,7 +65,6 @@ struct TxPoolPrewarmJob> parent_hash: B256, evm_env: EvmEnvFor, provider_builder: StateProviderBuilder, - block_gas_limit: u64, } /// Latest-wins canonical-head request slot shared with the worker. @@ -155,7 +118,7 @@ impl TxPoolPrewarmRequests { } } -/// Linearization point shared by worker publication and payload snapshot acquisition. +/// Coordinates stale-publication rejection and the latest completed snapshot. #[derive(Debug, Default)] struct TxPoolSnapshotPublication { generation: AtomicU64, @@ -170,7 +133,7 @@ impl TxPoolSnapshotPublication { if self.paused.load(Ordering::Acquire) { return None } - let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1; + let generation = self.generation.fetch_add(1, Ordering::AcqRel) + 1; *snapshot = None; Some(generation) } @@ -180,23 +143,22 @@ impl TxPoolSnapshotPublication { if self.paused.load(Ordering::Acquire) { return None } - Some(self.generation.fetch_add(1, Ordering::SeqCst) + 1) + Some(self.generation.fetch_add(1, Ordering::AcqRel) + 1) } fn cancel(&self) -> u64 { let _snapshot = self.snapshot.write().expect("txpool snapshot publication lock poisoned"); self.paused.store(true, Ordering::Release); - self.generation.fetch_add(1, Ordering::SeqCst) + 1 + self.generation.fetch_add(1, Ordering::AcqRel) + 1 } - fn cancel_and_snapshot(&self, parent_hash: B256) -> (Option, u64) { - let snapshot = self.snapshot.write().expect("txpool snapshot publication lock poisoned"); - self.paused.store(true, Ordering::Release); - let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1; - ( - snapshot.as_ref().filter(|snapshot| snapshot.parent_hash() == parent_hash).cloned(), - generation, - ) + fn snapshot(&self, parent_hash: B256) -> Option { + self.snapshot + .read() + .expect("txpool snapshot publication lock poisoned") + .as_ref() + .filter(|snapshot| snapshot.parent_hash() == parent_hash) + .cloned() } fn publish(&self, generation: u64, snapshot: TxPoolPrewarmCacheSnapshot) -> bool { @@ -225,12 +187,6 @@ impl TxPoolSnapshotPublication { } } -/// Resumes head tracking after payload validation, including every early-return path. -pub(crate) struct TxPoolPrewarmPauseGuard { - publication: Arc, - generation: u64, -} - #[derive(Debug, Default)] struct TxPoolWorkerActivity { active: Mutex, @@ -272,12 +228,6 @@ impl Drop for TxPoolWorkerActivityGuard { } } -impl Drop for TxPoolPrewarmPauseGuard { - fn drop(&mut self) { - self.publication.resume(self.generation); - } -} - /// Coordinates a long-lived worker and the latest completed immutable snapshot. pub(crate) struct TxPoolPrewarmHandle where @@ -346,7 +296,6 @@ where runtime: &reth_tasks::Runtime, source: Arc>, evm_config: Evm, - config: TxPoolPrewarmingConfig, ) -> Self { let requests = Arc::new(TxPoolPrewarmRequests::new()); let worker_requests = Arc::clone(&requests); @@ -360,7 +309,6 @@ where worker_requests, source, evm_config, - config, worker_publication, worker_activity, ) @@ -375,20 +323,9 @@ where } } - /// Cancels the active wave and returns the latest fully published snapshot for `parent_hash`. - /// - /// Taking the publication write lock linearizes cancellation with publication. The worker - /// either published a completed snapshot before this method and it is returned, or observes the - /// new generation and discards its unfinished wave. - pub(crate) fn pause_and_snapshot( - &self, - parent_hash: B256, - ) -> (Option, TxPoolPrewarmPauseGuard) { - let (snapshot, generation) = self.publication.cancel_and_snapshot(parent_hash); - ( - snapshot, - TxPoolPrewarmPauseGuard { publication: Arc::clone(&self.publication), generation }, - ) + /// Returns the latest fully published snapshot for `parent_hash`. + pub(crate) fn snapshot(&self, parent_hash: B256) -> Option { + self.publication.snapshot(parent_hash) } /// Starts continuous warming for the latest canonical head. @@ -397,14 +334,8 @@ where parent_hash: B256, evm_env: EvmEnvFor, provider_builder: StateProviderBuilder, - block_gas_limit: u64, ) { - self.requests.replace(TxPoolPrewarmJob { - parent_hash, - evm_env, - provider_builder, - block_gas_limit, - }); + self.requests.replace(TxPoolPrewarmJob { parent_hash, evm_env, provider_builder }); } } @@ -423,7 +354,6 @@ fn txpool_prewarm_loop( requests: Arc>>, source: Arc>, evm_config: Evm, - config: TxPoolPrewarmingConfig, publication: Arc, activity: Arc, ) where @@ -432,14 +362,8 @@ fn txpool_prewarm_loop( Evm: ConfigureEvm + 'static, { let cache = TxPoolPrewarmCache::default(); - let mut warmed = HashSet::default(); - let mut warmed_per_sender = AddressMap::::default(); - let mut sender_prefixes = AddressMap::>>::default(); let mut best_txs: Option> = None; let mut best_txs_parent_hash = None; - let mut best_txs_failed = false; - let mut deferred = None; - let mut inflight: Option> = None; let mut pending = None; let mut cache_parent_hash = None; @@ -468,31 +392,13 @@ fn txpool_prewarm_loop( let job = pending.take().expect("matching canonical request exists"); if best_txs_parent_hash != Some(job.parent_hash) { - let opened = - catch_unwind(AssertUnwindSafe(|| source.best_transactions(job.parent_hash))); - let opened = match opened { - Ok(Some(opened)) => opened, - Ok(None) => { - pending = Some(job); - std::thread::sleep(TXPOOL_HEAD_POLL_INTERVAL); - continue - } - Err(_) => { - warn!( - target: "engine::tree::txpool_prewarm", - parent_hash = ?job.parent_hash, - "opening txpool best-transactions iterator panicked" - ); - pending = Some(job); - std::thread::sleep(TXPOOL_REFRESH_INTERVAL); - continue - } + let Some(opened) = source.best_transactions(job.parent_hash) else { + pending = Some(job); + std::thread::sleep(TXPOOL_HEAD_POLL_INTERVAL); + continue }; best_txs = Some(opened); best_txs_parent_hash = Some(job.parent_hash); - best_txs_failed = false; - deferred = None; - inflight = None; } if source.tracked_block_hash() != job.parent_hash { @@ -513,9 +419,6 @@ fn txpool_prewarm_loop( }; if new_parent { cache.clear(); - warmed.clear(); - warmed_per_sender.clear(); - sender_prefixes.clear(); cache_parent_hash = Some(job.parent_hash); } debug!( @@ -536,83 +439,18 @@ fn txpool_prewarm_loop( break } - if inflight.is_none() { - if best_txs_failed { - std::thread::sleep(TXPOOL_REFRESH_INTERVAL); - continue - } - let (selection, cursor_panicked, cursor_exhausted) = select_best_transactions( - best_txs.as_mut().expect("best transactions opened for active parent"), - &mut deferred, - job.block_gas_limit, - config, - &warmed, - &warmed_per_sender, - &publication.paused, - ); - if cursor_panicked { - best_txs_failed = true; - warn!( - target: "engine::tree::txpool_prewarm", - parent_hash = ?job.parent_hash, - "txpool best-transactions iterator panicked; retiring it for this parent" - ); - } - if cursor_exhausted { - match catch_unwind(AssertUnwindSafe(|| { - source.best_transactions(job.parent_hash) - })) { - Ok(Some(opened)) => best_txs = Some(opened), - Ok(None) => {} - Err(_) => { - best_txs_failed = true; - warn!( - target: "engine::tree::txpool_prewarm", - parent_hash = ?job.parent_hash, - "reopening txpool best-transactions iterator panicked; retiring it for this parent" - ); - } - } - } - let canceled = selection.canceled; - if !selection.is_empty() { - inflight = Some(selection); - } - if canceled || !publication.is_current(active_generation) || publication.is_paused() - { - break - } - if inflight.is_none() { - std::thread::sleep(TXPOOL_REFRESH_INTERVAL); - continue - } - } - - let sender_transactions = transactions_with_prefixes( - &sender_prefixes, - &inflight.as_ref().expect("non-empty selection is in flight").transactions, - ); let Some(activity_guard) = activity.try_enter(&publication, active_generation) else { break }; - let completed = catch_unwind(AssertUnwindSafe(|| { - prewarm_transactions( - &evm_config, - &job, - &cache, - &sender_transactions, - active_generation, - &publication, - ) - })) - .unwrap_or_else(|_| { - warn!( - target: "engine::tree::txpool_prewarm", - parent_hash = ?job.parent_hash, - "txpool prewarming batch panicked" - ); - false - }); + let (completed, transaction_count) = prewarm_transactions( + &evm_config, + &job, + &cache, + best_txs.as_mut().expect("best transactions opened for active parent"), + Instant::now() + TXPOOL_REFRESH_INTERVAL, + active_generation, + &publication, + ); drop(activity_guard); if !completed { @@ -624,26 +462,20 @@ fn txpool_prewarm_loop( if !publication.is_current(active_generation) { break } + if transaction_count == 0 { + std::thread::sleep(TXPOOL_REFRESH_INTERVAL); + continue + } // The deep clone happens privately. Only the short pointer swap below is serialized // with `newPayload` snapshot acquisition. let snapshot = cache.snapshot(job.parent_hash); let (accounts, storage, bytecodes) = snapshot.entry_counts(); if publication.publish(active_generation, snapshot) { - let selection = inflight.take().expect("published selection is in flight"); - for transaction in &selection.transactions { - warmed.insert(transaction.hash); - *warmed_per_sender.entry(transaction.sender).or_default() += 1; - } - for (sender, transactions) in sender_transactions { - sender_prefixes.insert(sender, transactions); - } debug!( target: "engine::tree::txpool_prewarm", parent_hash = ?job.parent_hash, - transactions = selection.len(), - scanned = selection.scanned, - selected_gas = selection.selected_gas, + transactions = transaction_count, accounts, storage, bytecodes, @@ -652,8 +484,6 @@ fn txpool_prewarm_loop( } else { break } - - std::thread::sleep(TXPOOL_REFRESH_INTERVAL); } if !requests.is_closed() && @@ -665,156 +495,63 @@ fn txpool_prewarm_loop( } } -/// Selects the next bounded batch from a live best-transactions iterator. -/// -/// `best_txs` is forward-only and retained for the entire canonical parent. A transaction that -/// would overflow a non-empty batch is deferred rather than discarded, so the next batch resumes -/// at that transaction without reopening the iterator. -fn select_best_transactions( - best_txs: &mut TxPoolPrewarmTransactions, - deferred: &mut Option>, - block_gas_limit: u64, - config: TxPoolPrewarmingConfig, - warmed: &HashSet, - warmed_per_sender: &AddressMap, - stop: &AtomicBool, -) -> (TxPoolPrewarmSelection, bool, bool) { - let gas_limit = block_gas_limit.saturating_mul(config.gas_limit_multiplier); - let mut selection = TxPoolPrewarmSelection::default(); - let mut selected_per_sender = AddressMap::::default(); - - while selection.scanned < config.max_candidate_scan { - if stop.load(Ordering::Relaxed) { - selection.canceled = true; - break - } - - let transaction = if let Some(transaction) = deferred.take() { - Some(transaction) - } else { - match catch_unwind(AssertUnwindSafe(|| best_txs.next())) { - Ok(transaction) => transaction, - Err(_) => return (selection, true, false), - } - }; - let Some(transaction) = transaction else { return (selection, false, true) }; - if warmed.contains(&transaction.hash) { - selection.scanned += 1; - continue - } - - let selected_for_sender = selected_per_sender.entry(transaction.sender).or_default(); - let already_warmed = - warmed_per_sender.get(&transaction.sender).copied().unwrap_or_default(); - if already_warmed.saturating_add(*selected_for_sender) >= config.max_transactions_per_sender - { - selection.scanned += 1; - continue - } - - let tx_gas_limit = transaction.transaction.gas_limit(); - if tx_gas_limit > gas_limit { - selection.scanned += 1; - continue - } - if selection.selected_gas.saturating_add(tx_gas_limit) > gas_limit { - *deferred = Some(transaction); - break - } - - selection.scanned += 1; - *selected_for_sender += 1; - selection.selected_gas = selection.selected_gas.saturating_add(tx_gas_limit); - selection.transactions.push(transaction); - } - - (selection, false, false) -} - -/// Merges fresh transactions into the published nonce-ordered prefixes for affected senders. -/// -/// Replaying the bounded prefix lets a later delta observe speculative state produced by earlier -/// transactions from the same sender. A replacement overwrites the transaction at its nonce. -fn transactions_with_prefixes( - prefixes: &AddressMap>>, - fresh: &[TxPoolPrewarmTransaction], -) -> AddressMap>> { - let mut merged = AddressMap::>>::default(); - for transaction in fresh { - let transactions = merged - .entry(transaction.sender) - .or_insert_with(|| prefixes.get(&transaction.sender).cloned().unwrap_or_default()); - if let Some(existing) = transactions - .iter_mut() - .find(|existing| existing.transaction.nonce() == transaction.transaction.nonce()) - { - *existing = transaction.clone(); - } else { - transactions.push(transaction.clone()); - } - } - for transactions in merged.values_mut() { - transactions.sort_unstable_by_key(|transaction| transaction.transaction.nonce()); - } - merged -} - -fn prewarm_transactions( +fn prewarm_transactions( evm_config: &Evm, job: &TxPoolPrewarmJob, cache: &TxPoolPrewarmCache, - transactions: &AddressMap>>, + transactions: I, + deadline: Instant, generation: u64, publication: &TxPoolSnapshotPublication, -) -> bool +) -> (bool, usize) where N: NodePrimitives, P: BlockReader + StateProviderFactory + StateReader + Clone, Evm: ConfigureEvm, + I: IntoIterator>, { - for sender_transactions in transactions.values() { + let state_provider = match job.provider_builder.build() { + Ok(provider) => provider, + Err(err) => { + trace!( + target: "engine::tree::txpool_prewarm", + %err, + parent_hash = ?job.parent_hash, + "failed to build txpool prewarming state provider" + ); + return (false, 0) + } + }; + let state_provider = TxPoolPrewarmStateProvider { inner: state_provider, cache }; + let state_provider = StateProviderDatabase::new(state_provider); + let mut state = State::builder().with_database(state_provider).build(); + let mut evm_env = job.evm_env.clone(); + evm_env.cfg_env.disable_nonce_check = true; + evm_env.cfg_env.disable_balance_check = true; + let mut evm = evm_config.evm_with_env(&mut state, evm_env); + + let mut transaction_count = 0; + for transaction in transactions { if !publication.is_current(generation) { - return false + return (false, transaction_count) } - - let state_provider = match job.provider_builder.build() { - Ok(provider) => provider, - Err(err) => { - trace!( - target: "engine::tree::txpool_prewarm", - %err, - parent_hash = ?job.parent_hash, - "failed to build txpool prewarming state provider" - ); - return false - } - }; - let state_provider = TxPoolPrewarmStateProvider { inner: state_provider, cache }; - let state_provider = StateProviderDatabase::new(state_provider); - let mut state = State::builder().with_database(state_provider).build(); - let mut evm_env = job.evm_env.clone(); - evm_env.cfg_env.disable_nonce_check = true; - evm_env.cfg_env.disable_balance_check = true; - let mut evm = evm_config.evm_with_env(&mut state, evm_env); - - for transaction in sender_transactions { - if !publication.is_current(generation) { - return false - } - - if let Err(err) = evm.transact_commit(transaction.transaction.clone()) { - trace!( - target: "engine::tree::txpool_prewarm", - %err, - tx_hash = ?transaction.hash, - sender = %transaction.sender, - "speculative txpool transaction execution failed" - ); - } + transaction_count += 1; + + if let Err(err) = evm.transact(transaction.transaction) { + trace!( + target: "engine::tree::txpool_prewarm", + %err, + tx_hash = ?transaction.hash, + sender = %transaction.sender, + "speculative txpool transaction execution failed" + ); + } + if Instant::now() >= deadline { + break } } - true + (true, transaction_count) } /// Provider that fills only the reusable txpool-prewarm cache. @@ -874,24 +611,21 @@ fn nonzero_storage_value(value: StorageValue) -> Option { #[cfg(test)] mod tests { use super::*; - use reth_ethereum_primitives::EthPrimitives; #[test] - fn reports_exhausted_best_transactions_iterator() { - let mut transactions: TxPoolPrewarmTransactions = - Box::new(std::iter::empty()); - let (selection, panicked, exhausted) = select_best_transactions( - &mut transactions, - &mut None, - 30_000_000, - TxPoolPrewarmingConfig::DEFAULT, - &HashSet::default(), - &AddressMap::default(), - &AtomicBool::new(false), + fn reading_snapshot_does_not_interrupt_publication() { + let publication = TxPoolSnapshotPublication::default(); + let generation = publication.begin_head().expect("publication should be active"); + let parent_hash = B256::repeat_byte(0x01); + let snapshot = TxPoolPrewarmCache::default().snapshot(parent_hash); + assert!(publication.publish(generation, snapshot)); + + assert_eq!( + publication.snapshot(parent_hash).map(|snapshot| snapshot.parent_hash()), + Some(parent_hash) ); - - assert!(selection.is_empty()); - assert!(!panicked); - assert!(exhausted); + assert!(publication.snapshot(B256::ZERO).is_none()); + assert!(publication.is_current(generation)); + assert!(!publication.is_paused()); } } diff --git a/crates/ethereum/evm/src/lib.rs b/crates/ethereum/evm/src/lib.rs index 86dcea7a802..fda01a49cc2 100644 --- a/crates/ethereum/evm/src/lib.rs +++ b/crates/ethereum/evm/src/lib.rs @@ -227,11 +227,16 @@ where )) } - fn txpool_prewarm_env(&self, parent: &Header) -> Result, Self::Error> { + fn txpool_prewarm_env( + &self, + parent: &Header, + parent_parent: &Header, + ) -> Result, Self::Error> { + let block_time = parent.timestamp.saturating_sub(parent_parent.timestamp); self.next_evm_env( parent, &NextBlockEnvAttributes { - timestamp: parent.timestamp.saturating_add(12), + timestamp: parent.timestamp.saturating_add(block_time), suggested_fee_recipient: parent.beneficiary, prev_randao: alloy_primitives::B256::ZERO, gas_limit: parent.gas_limit, @@ -414,6 +419,20 @@ mod tests { assert_eq!(cfg_env.chain_id, chain_spec.chain().id()); } + #[test] + fn txpool_prewarm_env_uses_parent_block_time() { + let evm_config = EthEvmConfig::mainnet(); + let parent_parent = Header { timestamp: 1_000, ..Default::default() }; + let parent = Header { timestamp: 1_007, ..Default::default() }; + + let env = evm_config + .txpool_prewarm_env(&parent, &parent_parent) + .unwrap() + .expect("ethereum supports txpool prewarming"); + + assert_eq!(env.block_env.timestamp, U256::from(1_014)); + } + #[test] fn test_evm_with_env_default_spec() { let evm_config = EthEvmConfig::mainnet(); diff --git a/crates/evm/evm/src/lib.rs b/crates/evm/evm/src/lib.rs index 64ff75521c9..db4cec8a0dd 100644 --- a/crates/evm/evm/src/lib.rs +++ b/crates/evm/evm/src/lib.rs @@ -248,6 +248,7 @@ pub trait ConfigureEvm: Clone + Debug + Send + Sync + Unpin { fn txpool_prewarm_env( &self, _parent: &HeaderTy, + _parent_parent: &HeaderTy, ) -> Result>, Self::Error> { Ok(None) } diff --git a/crates/node/builder/src/txpool_prewarm.rs b/crates/node/builder/src/txpool_prewarm.rs index a6eae6c4fa2..f41b41772e0 100644 --- a/crates/node/builder/src/txpool_prewarm.rs +++ b/crates/node/builder/src/txpool_prewarm.rs @@ -50,6 +50,7 @@ where block_info.pending_blob_fee.map(|fee| u64::try_from(fee).unwrap_or(u64::MAX)), )); best.allow_updates_out_of_order(); + best.skip_blobs(); if self.pool.block_info().last_seen_block_hash != parent_hash { return None diff --git a/crates/node/core/src/args/engine.rs b/crates/node/core/src/args/engine.rs index bf175b1d7d1..c1b49535baa 100644 --- a/crates/node/core/src/args/engine.rs +++ b/crates/node/core/src/args/engine.rs @@ -33,9 +33,6 @@ pub struct DefaultEngineValues { state_cache_disabled: bool, prewarming_disabled: bool, txpool_prewarming_enabled: bool, - txpool_prewarming_max_transactions_per_sender: usize, - txpool_prewarming_max_candidate_scan: usize, - txpool_prewarming_gas_limit_multiplier: u64, state_provider_metrics: bool, cross_block_cache_size: usize, state_root_task_compare_updates: bool, @@ -115,24 +112,6 @@ impl DefaultEngineValues { self } - /// Set the default maximum transactions prewarmed per sender and parent head - pub const fn with_txpool_prewarming_max_transactions_per_sender(mut self, v: usize) -> Self { - self.txpool_prewarming_max_transactions_per_sender = v; - self - } - - /// Set the default maximum fresh txpool candidates considered per refresh - pub const fn with_txpool_prewarming_max_candidate_scan(mut self, v: usize) -> Self { - self.txpool_prewarming_max_candidate_scan = v; - self - } - - /// Set the default txpool prewarming gas-limit multiplier - pub const fn with_txpool_prewarming_gas_limit_multiplier(mut self, v: u64) -> Self { - self.txpool_prewarming_gas_limit_multiplier = v; - self - } - /// Set whether to enable state provider metrics by default pub const fn with_state_provider_metrics(mut self, v: bool) -> Self { self.state_provider_metrics = v; @@ -291,12 +270,6 @@ impl Default for DefaultEngineValues { state_cache_disabled: false, prewarming_disabled: false, txpool_prewarming_enabled: TxPoolPrewarmingConfig::DEFAULT.enabled, - txpool_prewarming_max_transactions_per_sender: - TxPoolPrewarmingConfig::DEFAULT_MAX_TRANSACTIONS_PER_SENDER, - txpool_prewarming_max_candidate_scan: - TxPoolPrewarmingConfig::DEFAULT_MAX_CANDIDATE_SCAN, - txpool_prewarming_gas_limit_multiplier: - TxPoolPrewarmingConfig::DEFAULT_GAS_LIMIT_MULTIPLIER, state_provider_metrics: false, cross_block_cache_size: DEFAULT_CROSS_BLOCK_CACHE_SIZE_MB, state_root_task_compare_updates: false, @@ -389,18 +362,6 @@ pub struct EngineArgs { #[arg(long = "engine.txpool-prewarming", default_value_t = DefaultEngineValues::get_global().txpool_prewarming_enabled)] pub txpool_prewarming_enabled: bool, - /// Configure the maximum transactions prewarmed per sender for one parent head. - #[arg(long = "engine.txpool-prewarming-max-transactions-per-sender", default_value_t = DefaultEngineValues::get_global().txpool_prewarming_max_transactions_per_sender, value_parser = RangedU64ValueParser::::new().range(1..))] - pub txpool_prewarming_max_transactions_per_sender: usize, - - /// Configure the maximum fresh txpool candidates considered per prewarming refresh. - #[arg(long = "engine.txpool-prewarming-max-candidate-scan", default_value_t = DefaultEngineValues::get_global().txpool_prewarming_max_candidate_scan, value_parser = RangedU64ValueParser::::new().range(1..))] - pub txpool_prewarming_max_candidate_scan: usize, - - /// Configure the parent gas-limit multiplier used as each refresh's fresh-transaction budget. - #[arg(long = "engine.txpool-prewarming-gas-limit-multiplier", default_value_t = DefaultEngineValues::get_global().txpool_prewarming_gas_limit_multiplier, value_parser = RangedU64ValueParser::::new().range(1..))] - pub txpool_prewarming_gas_limit_multiplier: u64, - /// CAUTION: This CLI flag has no effect anymore. The parallel sparse trie is always enabled. #[deprecated] #[arg(long = "engine.parallel-sparse-trie", default_value = "true", hide = true)] @@ -607,9 +568,6 @@ impl Default for EngineArgs { state_cache_disabled, prewarming_disabled, txpool_prewarming_enabled, - txpool_prewarming_max_transactions_per_sender, - txpool_prewarming_max_candidate_scan, - txpool_prewarming_gas_limit_multiplier, state_provider_metrics, cross_block_cache_size, state_root_task_compare_updates, @@ -646,9 +604,6 @@ impl Default for EngineArgs { state_cache_disabled, prewarming_disabled, txpool_prewarming_enabled, - txpool_prewarming_max_transactions_per_sender, - txpool_prewarming_max_candidate_scan, - txpool_prewarming_gas_limit_multiplier, parallel_sparse_trie_enabled: true, parallel_sparse_trie_disabled: false, state_provider_metrics, @@ -722,13 +677,7 @@ impl EngineArgs { .without_state_cache(self.state_cache_disabled) .without_prewarming(self.prewarming_disabled) .with_txpool_prewarming( - TxPoolPrewarmingConfig::DEFAULT - .with_enabled(self.txpool_prewarming_enabled) - .with_max_transactions_per_sender( - self.txpool_prewarming_max_transactions_per_sender, - ) - .with_max_candidate_scan(self.txpool_prewarming_max_candidate_scan) - .with_gas_limit_multiplier(self.txpool_prewarming_gas_limit_multiplier), + TxPoolPrewarmingConfig::DEFAULT.with_enabled(self.txpool_prewarming_enabled), ) .with_state_provider_metrics(self.state_provider_metrics) .with_always_compare_trie_updates(self.state_root_task_compare_updates) @@ -849,9 +798,6 @@ mod tests { state_cache_disabled: true, prewarming_disabled: true, txpool_prewarming_enabled: true, - txpool_prewarming_max_transactions_per_sender: 8, - txpool_prewarming_max_candidate_scan: 1024, - txpool_prewarming_gas_limit_multiplier: 3, parallel_sparse_trie_enabled: true, parallel_sparse_trie_disabled: false, state_provider_metrics: true, @@ -898,12 +844,6 @@ mod tests { "--engine.disable-state-cache", "--engine.disable-prewarming", "--engine.txpool-prewarming", - "--engine.txpool-prewarming-max-transactions-per-sender", - "8", - "--engine.txpool-prewarming-max-candidate-scan", - "1024", - "--engine.txpool-prewarming-gas-limit-multiplier", - "3", "--engine.state-provider-metrics", "--engine.cross-block-cache-size", "256", diff --git a/crates/transaction-pool/src/pool/best.rs b/crates/transaction-pool/src/pool/best.rs index 69f91124502..e3e214dd9d8 100644 --- a/crates/transaction-pool/src/pool/best.rs +++ b/crates/transaction-pool/src/pool/best.rs @@ -1126,6 +1126,23 @@ mod tests { assert!(best.new_transaction_receiver.is_none()); } + #[test] + fn test_best_transactions_yields_updates_after_empty() { + let mut pool = PendingPool::new(MockOrdering::default()); + let mut best = pool.best(); + best.allow_updates_out_of_order(); + + assert!(best.next().is_none()); + + let mut f = MockTransactionFactory::default(); + let tx = MockTransaction::eip1559().rng_hash(); + let valid_tx = Arc::new(f.validated(tx)); + let expected_hash = *valid_tx.hash(); + pool.add_transaction(valid_tx, 0); + + assert_eq!(*best.next().expect("new transaction should be yielded").hash(), expected_hash); + } + #[test] fn test_best_update_transaction_priority() { let mut pool = PendingPool::new(MockOrdering::default()); From c4757602798c66f3d3710317747cfe5f114dda94 Mon Sep 17 00:00:00 2001 From: Alexey Shekhirin Date: Thu, 16 Jul 2026 13:37:13 +0100 Subject: [PATCH 09/32] remove marker --- crates/engine/tree/src/tree/txpool_prewarm.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/engine/tree/src/tree/txpool_prewarm.rs b/crates/engine/tree/src/tree/txpool_prewarm.rs index 88a934ba0ad..1b1779c5aaf 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm.rs @@ -27,8 +27,6 @@ const TXPOOL_REFRESH_INTERVAL: Duration = Duration::from_millis(100); /// Delay while waiting for pool maintenance to advance to the state being warmed. const TXPOOL_HEAD_POLL_INTERVAL: Duration = Duration::from_millis(10); -type TxPoolPrewarmMarker = PhantomData (N, P, Evm)>; - /// A transaction selected from the txpool for cache-only prewarming. #[derive(Debug, Clone)] pub struct TxPoolPrewarmTransaction { @@ -238,7 +236,6 @@ where publication: Arc, activity: Arc, wait_generation: AtomicU64, - _marker: TxPoolPrewarmMarker, } impl Debug for TxPoolPrewarmHandle @@ -319,7 +316,6 @@ where publication, activity, wait_generation: AtomicU64::new(0), - _marker: PhantomData, } } From cd0448103e501e7a900ca7077f5a76ebbaab0936 Mon Sep 17 00:00:00 2001 From: Alexey Shekhirin Date: Thu, 16 Jul 2026 14:16:04 +0100 Subject: [PATCH 10/32] simplify publication --- crates/engine/execution-cache/src/txpool.rs | 25 +- .../engine/tree/src/tree/payload_validator.rs | 4 +- crates/engine/tree/src/tree/txpool_prewarm.rs | 334 ++++++++---------- 3 files changed, 157 insertions(+), 206 deletions(-) diff --git a/crates/engine/execution-cache/src/txpool.rs b/crates/engine/execution-cache/src/txpool.rs index 8bf297860fd..86ef94c3358 100644 --- a/crates/engine/execution-cache/src/txpool.rs +++ b/crates/engine/execution-cache/src/txpool.rs @@ -1,6 +1,5 @@ //! Reusable cache and immutable snapshots for txpool-driven state prewarming. -use crate::CachedStatus; use alloy_primitives::{ map::{AddressMap, B256Map, HashMap}, Address, StorageKey, StorageValue, B256, @@ -48,18 +47,18 @@ impl TxPoolPrewarmCache { &self, address: Address, f: impl FnOnce() -> Result, E>, - ) -> Result>, E> { + ) -> Result, E> { if let Some(account) = self.accounts.read().get(&address).copied() { - return Ok(CachedStatus::Cached(account)) + return Ok(account) } let account = f()?; let mut accounts = self.accounts.write(); if let Some(cached) = accounts.get(&address).copied() { - Ok(CachedStatus::Cached(cached)) + Ok(cached) } else { accounts.insert(address, account); - Ok(CachedStatus::NotCached(account)) + Ok(account) } } @@ -69,18 +68,18 @@ impl TxPoolPrewarmCache { address: Address, key: StorageKey, f: impl FnOnce() -> Result, - ) -> Result, E> { + ) -> Result { if let Some(value) = self.storage.read().get(&(address, key)).copied() { - return Ok(CachedStatus::Cached(value)) + return Ok(value) } let value = f()?; let mut storage = self.storage.write(); if let Some(cached) = storage.get(&(address, key)).copied() { - Ok(CachedStatus::Cached(cached)) + Ok(cached) } else { storage.insert((address, key), value); - Ok(CachedStatus::NotCached(value)) + Ok(value) } } @@ -89,18 +88,18 @@ impl TxPoolPrewarmCache { &self, code_hash: B256, f: impl FnOnce() -> Result, E>, - ) -> Result>, E> { + ) -> Result, E> { if let Some(code) = self.bytecodes.read().get(&code_hash).cloned() { - return Ok(CachedStatus::Cached(code)) + return Ok(code) } let code = f()?; let mut bytecodes = self.bytecodes.write(); if let Some(cached) = bytecodes.get(&code_hash).cloned() { - Ok(CachedStatus::Cached(cached)) + Ok(cached) } else { bytecodes.insert(code_hash, code.clone()); - Ok(CachedStatus::NotCached(code)) + Ok(code) } } } diff --git a/crates/engine/tree/src/tree/payload_validator.rs b/crates/engine/tree/src/tree/payload_validator.rs index bfa2c4d1d35..4a9de6d55cc 100644 --- a/crates/engine/tree/src/tree/payload_validator.rs +++ b/crates/engine/tree/src/tree/payload_validator.rs @@ -1929,9 +1929,7 @@ where } }; match self.evm_config.txpool_prewarm_env(header.header(), parent_parent.header()) { - Ok(Some(evm_env)) => { - txpool_prewarm.start(header.hash(), evm_env, provider_builder) - } + Ok(Some(evm_env)) => txpool_prewarm.start(header.hash(), evm_env, provider_builder), Ok(None) => {} Err(err) => { trace!( diff --git a/crates/engine/tree/src/tree/txpool_prewarm.rs b/crates/engine/tree/src/tree/txpool_prewarm.rs index 1b1779c5aaf..68741b43958 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm.rs @@ -12,10 +12,9 @@ use reth_provider::{BlockReader, StateProviderBox, StateProviderFactory, StateRe use reth_revm::{database::EvmStateProvider, db::State}; use std::{ fmt::Debug, - marker::PhantomData, sync::{ - atomic::{AtomicBool, AtomicU64, Ordering}, - Arc, Condvar, Mutex, RwLock, + atomic::{AtomicBool, Ordering}, + Arc, Condvar, Mutex, }, time::{Duration, Instant}, }; @@ -116,113 +115,105 @@ impl TxPoolPrewarmRequests { } } -/// Coordinates stale-publication rejection and the latest completed snapshot. +/// Coordinates snapshot publication and worker quiescence. #[derive(Debug, Default)] struct TxPoolSnapshotPublication { - generation: AtomicU64, paused: AtomicBool, - snapshot: RwLock>, + state: Mutex, + idle: Condvar, +} + +#[derive(Debug, Default)] +struct TxPoolSnapshotPublicationState { + active: bool, + snapshot: Option, } impl TxPoolSnapshotPublication { - fn begin_head(&self) -> Option { - let mut snapshot = - self.snapshot.write().expect("txpool snapshot publication lock poisoned"); + fn begin_head(&self) -> bool { + let mut state = self.state.lock().expect("txpool snapshot publication lock poisoned"); if self.paused.load(Ordering::Acquire) { - return None + return false } - let generation = self.generation.fetch_add(1, Ordering::AcqRel) + 1; - *snapshot = None; - Some(generation) + state.snapshot = None; + true } - fn resume_head(&self) -> Option { - let _snapshot = self.snapshot.write().expect("txpool snapshot publication lock poisoned"); - if self.paused.load(Ordering::Acquire) { - return None + fn pause(&self) { + self.paused.store(true, Ordering::Release); + } + + fn pause_and_wait(&self) -> Duration { + let start = Instant::now(); + self.pause(); + let mut state = self.state.lock().expect("txpool snapshot publication lock poisoned"); + while state.active { + state = self.idle.wait(state).expect("txpool snapshot publication lock poisoned"); } - Some(self.generation.fetch_add(1, Ordering::AcqRel) + 1) + start.elapsed() } - fn cancel(&self) -> u64 { - let _snapshot = self.snapshot.write().expect("txpool snapshot publication lock poisoned"); - self.paused.store(true, Ordering::Release); - self.generation.fetch_add(1, Ordering::AcqRel) + 1 + fn resume(&self) { + self.paused.store(false, Ordering::Release); + } + + fn try_enter(self: &Arc) -> Option { + let mut state = self.state.lock().expect("txpool snapshot publication lock poisoned"); + if state.active || self.paused.load(Ordering::Acquire) { + return None + } + state.active = true; + Some(TxPoolPrewarmWaveGuard { publication: Arc::clone(self), active: true }) + } + + fn finish_wave(&self, snapshot: Option) -> bool { + let mut state = self.state.lock().expect("txpool snapshot publication lock poisoned"); + let published = if self.paused.load(Ordering::Acquire) { + false + } else if let Some(snapshot) = snapshot { + state.snapshot = Some(snapshot); + true + } else { + false + }; + state.active = false; + self.idle.notify_all(); + published } fn snapshot(&self, parent_hash: B256) -> Option { - self.snapshot - .read() + self.state + .lock() .expect("txpool snapshot publication lock poisoned") + .snapshot .as_ref() .filter(|snapshot| snapshot.parent_hash() == parent_hash) .cloned() } - fn publish(&self, generation: u64, snapshot: TxPoolPrewarmCacheSnapshot) -> bool { - let mut published = - self.snapshot.write().expect("txpool snapshot publication lock poisoned"); - if self.generation.load(Ordering::Acquire) != generation { - return false - } - *published = Some(snapshot); - true - } - - fn is_current(&self, generation: u64) -> bool { - self.generation.load(Ordering::Acquire) == generation - } - fn is_paused(&self) -> bool { self.paused.load(Ordering::Acquire) } - - fn resume(&self, generation: u64) { - let _snapshot = self.snapshot.write().expect("txpool snapshot publication lock poisoned"); - if self.generation.load(Ordering::Acquire) == generation { - self.paused.store(false, Ordering::Release); - } - } } -#[derive(Debug, Default)] -struct TxPoolWorkerActivity { - active: Mutex, - idle: Condvar, +struct TxPoolPrewarmWaveGuard { + publication: Arc, + active: bool, } -impl TxPoolWorkerActivity { - fn try_enter( - self: &Arc, - publication: &TxPoolSnapshotPublication, - generation: u64, - ) -> Option { - let mut active = self.active.lock().expect("txpool worker activity lock poisoned"); - if *active || publication.is_paused() || !publication.is_current(generation) { - return None - } - *active = true; - Some(TxPoolWorkerActivityGuard { activity: Arc::clone(self) }) - } - - fn wait_until_idle(&self) -> Duration { - let start = Instant::now(); - let mut active = self.active.lock().expect("txpool worker activity lock poisoned"); - while *active { - active = self.idle.wait(active).expect("txpool worker activity lock poisoned"); - } - start.elapsed() +impl TxPoolPrewarmWaveGuard { + fn publish(mut self, snapshot: TxPoolPrewarmCacheSnapshot) -> bool { + let published = self.publication.finish_wave(Some(snapshot)); + self.active = false; + published } } -struct TxPoolWorkerActivityGuard { - activity: Arc, -} - -impl Drop for TxPoolWorkerActivityGuard { +impl Drop for TxPoolPrewarmWaveGuard { fn drop(&mut self) { - *self.activity.active.lock().expect("txpool worker activity lock poisoned") = false; - self.activity.idle.notify_all(); + if self.active { + self.publication.finish_wave(None); + } } } @@ -234,8 +225,6 @@ where { requests: Arc>>, publication: Arc, - activity: Arc, - wait_generation: AtomicU64, } impl Debug for TxPoolPrewarmHandle @@ -244,18 +233,12 @@ where Evm: ConfigureEvm, { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let state = + self.publication.state.lock().expect("txpool snapshot publication lock poisoned"); f.debug_struct("TxPoolPrewarmHandle") - .field("generation", &self.publication.generation.load(Ordering::Relaxed)) - .field( - "published", - &self - .publication - .snapshot - .read() - .expect("txpool snapshot publication lock poisoned") - .as_ref() - .map(|s| s.parent_hash()), - ) + .field("paused", &self.publication.is_paused()) + .field("active", &state.active) + .field("published", &state.snapshot.as_ref().map(|s| s.parent_hash())) .finish_non_exhaustive() } } @@ -267,17 +250,12 @@ where { /// Cancels speculative work and waits for any provider-backed EVM call to release its state. pub(crate) fn cancel_and_wait(&self) -> Duration { - let generation = self.publication.cancel(); - self.wait_generation.store(generation, Ordering::Release); - self.activity.wait_until_idle() + self.publication.pause_and_wait() } /// Resumes work after an explicit cache wait, including early-return payload paths. pub(crate) fn resume_after_wait(&self) { - let generation = self.wait_generation.swap(0, Ordering::AcqRel); - if generation > 0 { - self.publication.resume(generation); - } + self.publication.resume(); } } @@ -298,25 +276,12 @@ where let worker_requests = Arc::clone(&requests); let publication = Arc::new(TxPoolSnapshotPublication::default()); let worker_publication = Arc::clone(&publication); - let activity = Arc::new(TxPoolWorkerActivity::default()); - let worker_activity = Arc::clone(&activity); runtime.spawn_blocking_named("txpool-prewarm", move || { - txpool_prewarm_loop( - worker_requests, - source, - evm_config, - worker_publication, - worker_activity, - ) + txpool_prewarm_loop(worker_requests, source, evm_config, worker_publication) }); - Self { - requests, - publication, - activity, - wait_generation: AtomicU64::new(0), - } + Self { requests, publication } } /// Returns the latest fully published snapshot for `parent_hash`. @@ -341,17 +306,56 @@ where Evm: ConfigureEvm, { fn drop(&mut self) { - self.publication.cancel(); + self.publication.pause(); self.requests.close(); } } +/// Provider that fills only the reusable txpool-prewarm cache. +struct TxPoolPrewarmStateProvider<'a> { + inner: StateProviderBox, + cache: &'a TxPoolPrewarmCache, +} + +impl EvmStateProvider for TxPoolPrewarmStateProvider<'_> { + fn basic_account( + &self, + address: &Address, + ) -> reth_errors::ProviderResult> { + self.cache + .get_or_try_insert_account_with(*address, || self.inner.basic_account(address)) + } + + fn block_hash(&self, number: BlockNumber) -> reth_errors::ProviderResult> { + EvmStateProvider::block_hash(&self.inner, number) + } + + fn bytecode_by_hash( + &self, + code_hash: &B256, + ) -> reth_errors::ProviderResult> { + self.cache + .get_or_try_insert_code_with(*code_hash, || self.inner.bytecode_by_hash(code_hash)) + } + + fn storage( + &self, + account: Address, + storage_key: StorageKey, + ) -> reth_errors::ProviderResult> { + self.cache + .get_or_try_insert_storage_with(account, storage_key, || { + self.inner.storage(account, storage_key).map(Option::unwrap_or_default) + }) + .map(|value| (!value.is_zero()).then_some(value)) + } +} + fn txpool_prewarm_loop( requests: Arc>>, source: Arc>, evm_config: Evm, publication: Arc, - activity: Arc, ) where N: NodePrimitives, P: BlockReader + StateProviderFactory + StateReader + Clone + Send + Sync + 'static, @@ -405,14 +409,12 @@ fn txpool_prewarm_loop( // Wait for pool maintenance to catch up to the authoritative canonical-head request // before invalidating the previous publication. let new_parent = cache_parent_hash != Some(job.parent_hash); - let active_generation = - if new_parent { publication.begin_head() } else { publication.resume_head() }; - let Some(active_generation) = active_generation else { + if new_parent && !publication.begin_head() { if pending.is_none() { pending = Some(job); } continue - }; + } if new_parent { cache.clear(); cache_parent_hash = Some(job.parent_hash); @@ -423,7 +425,7 @@ fn txpool_prewarm_loop( "started txpool prewarming" ); - while publication.is_current(active_generation) && !publication.is_paused() { + while !publication.is_paused() { if let Some(request) = requests.take() { pending = Some(request); } @@ -435,39 +437,32 @@ fn txpool_prewarm_loop( break } - let Some(activity_guard) = activity.try_enter(&publication, active_generation) else { - break - }; + let Some(activity_guard) = publication.try_enter() else { break }; let (completed, transaction_count) = prewarm_transactions( &evm_config, &job, &cache, best_txs.as_mut().expect("best transactions opened for active parent"), Instant::now() + TXPOOL_REFRESH_INTERVAL, - active_generation, &publication, ); - drop(activity_guard); if !completed { - if publication.is_current(active_generation) && !publication.is_paused() { + drop(activity_guard); + if !publication.is_paused() { std::thread::sleep(TXPOOL_REFRESH_INTERVAL); } break } - if !publication.is_current(active_generation) { - break - } if transaction_count == 0 { + drop(activity_guard); std::thread::sleep(TXPOOL_REFRESH_INTERVAL); continue } - // The deep clone happens privately. Only the short pointer swap below is serialized - // with `newPayload` snapshot acquisition. let snapshot = cache.snapshot(job.parent_hash); let (accounts, storage, bytecodes) = snapshot.entry_counts(); - if publication.publish(active_generation, snapshot) { + if activity_guard.publish(snapshot) { debug!( target: "engine::tree::txpool_prewarm", parent_hash = ?job.parent_hash, @@ -497,7 +492,6 @@ fn prewarm_transactions( cache: &TxPoolPrewarmCache, transactions: I, deadline: Instant, - generation: u64, publication: &TxPoolSnapshotPublication, ) -> (bool, usize) where @@ -528,7 +522,7 @@ where let mut transaction_count = 0; for transaction in transactions { - if !publication.is_current(generation) { + if publication.is_paused() { return (false, transaction_count) } transaction_count += 1; @@ -550,59 +544,6 @@ where (true, transaction_count) } -/// Provider that fills only the reusable txpool-prewarm cache. -struct TxPoolPrewarmStateProvider<'a> { - inner: StateProviderBox, - cache: &'a TxPoolPrewarmCache, -} - -impl EvmStateProvider for TxPoolPrewarmStateProvider<'_> { - fn basic_account( - &self, - address: &Address, - ) -> reth_errors::ProviderResult> { - self.cache - .get_or_try_insert_account_with(*address, || self.inner.basic_account(address)) - .map(cached_value) - } - - fn block_hash(&self, number: BlockNumber) -> reth_errors::ProviderResult> { - EvmStateProvider::block_hash(&self.inner, number) - } - - fn bytecode_by_hash( - &self, - code_hash: &B256, - ) -> reth_errors::ProviderResult> { - self.cache - .get_or_try_insert_code_with(*code_hash, || self.inner.bytecode_by_hash(code_hash)) - .map(cached_value) - } - - fn storage( - &self, - account: Address, - storage_key: StorageKey, - ) -> reth_errors::ProviderResult> { - self.cache - .get_or_try_insert_storage_with(account, storage_key, || { - self.inner.storage(account, storage_key).map(Option::unwrap_or_default) - }) - .map(cached_value) - .map(nonzero_storage_value) - } -} - -fn cached_value(status: reth_execution_cache::CachedStatus) -> T { - match status { - reth_execution_cache::CachedStatus::Cached(value) | - reth_execution_cache::CachedStatus::NotCached(value) => value, - } -} - -fn nonzero_storage_value(value: StorageValue) -> Option { - (!value.is_zero()).then_some(value) -} #[cfg(test)] mod tests { @@ -610,18 +551,31 @@ mod tests { #[test] fn reading_snapshot_does_not_interrupt_publication() { - let publication = TxPoolSnapshotPublication::default(); - let generation = publication.begin_head().expect("publication should be active"); + let publication = Arc::new(TxPoolSnapshotPublication::default()); + assert!(publication.begin_head()); + let activity_guard = publication.try_enter().expect("publication should be active"); let parent_hash = B256::repeat_byte(0x01); let snapshot = TxPoolPrewarmCache::default().snapshot(parent_hash); - assert!(publication.publish(generation, snapshot)); + assert!(activity_guard.publish(snapshot)); assert_eq!( publication.snapshot(parent_hash).map(|snapshot| snapshot.parent_hash()), Some(parent_hash) ); assert!(publication.snapshot(B256::ZERO).is_none()); - assert!(publication.is_current(generation)); assert!(!publication.is_paused()); } + + #[test] + fn paused_wave_is_not_published() { + let publication = Arc::new(TxPoolSnapshotPublication::default()); + assert!(publication.begin_head()); + let activity_guard = publication.try_enter().expect("publication should be active"); + + publication.pause(); + let parent_hash = B256::repeat_byte(0x01); + let snapshot = TxPoolPrewarmCache::default().snapshot(parent_hash); + assert!(!activity_guard.publish(snapshot)); + assert!(publication.snapshot(parent_hash).is_none()); + } } From e4c5e7486a69eb43b00ae5838cd20de26674c0f1 Mon Sep 17 00:00:00 2001 From: Alexey Shekhirin Date: Thu, 16 Jul 2026 14:46:10 +0100 Subject: [PATCH 11/32] simplify args --- crates/engine/primitives/src/config.rs | 47 ++++--------------- .../engine/tree/src/tree/payload_validator.rs | 3 +- crates/engine/tree/src/tree/txpool_prewarm.rs | 4 +- crates/node/builder/src/rpc.rs | 2 +- crates/node/core/src/args/engine.rs | 12 ++--- 5 files changed, 17 insertions(+), 51 deletions(-) diff --git a/crates/engine/primitives/src/config.rs b/crates/engine/primitives/src/config.rs index 5f0382beb69..0096e031345 100644 --- a/crates/engine/primitives/src/config.rs +++ b/crates/engine/primitives/src/config.rs @@ -87,35 +87,6 @@ pub fn has_enough_parallelism() -> bool { false } -/// Configuration for best-effort txpool transaction prewarming. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct TxPoolPrewarmingConfig { - /// Whether txpool transaction prewarming is enabled. - pub enabled: bool, -} - -impl TxPoolPrewarmingConfig { - /// Default configuration. - pub const DEFAULT: Self = Self { enabled: false }; - - /// Returns whether this configuration can launch work. - pub const fn should_prewarm(&self) -> bool { - self.enabled - } - - /// Enables or disables txpool prewarming. - pub const fn with_enabled(mut self, enabled: bool) -> Self { - self.enabled = enabled; - self - } -} - -impl Default for TxPoolPrewarmingConfig { - fn default() -> Self { - Self::DEFAULT - } -} - /// The configuration of the engine tree. #[derive(Debug, Clone)] pub struct TreeConfig { @@ -151,8 +122,8 @@ pub struct TreeConfig { disable_state_cache: bool, /// Whether to disable parallel prewarming. disable_prewarming: bool, - /// Configuration for txpool-driven prewarming between payloads. - txpool_prewarming: TxPoolPrewarmingConfig, + /// Whether txpool-driven prewarming between payloads is enabled. + txpool_prewarming: bool, /// Whether to enable state provider metrics. state_provider_metrics: bool, /// Cross-block cache size in bytes. @@ -259,7 +230,7 @@ impl Default for TreeConfig { always_compare_trie_updates: false, disable_state_cache: false, disable_prewarming: false, - txpool_prewarming: TxPoolPrewarmingConfig::DEFAULT, + txpool_prewarming: false, state_provider_metrics: false, cross_block_cache_size: DEFAULT_CROSS_BLOCK_CACHE_SIZE, has_enough_parallelism: has_enough_parallelism(), @@ -336,7 +307,7 @@ impl TreeConfig { always_compare_trie_updates, disable_state_cache, disable_prewarming, - txpool_prewarming: TxPoolPrewarmingConfig::DEFAULT, + txpool_prewarming: false, state_provider_metrics, cross_block_cache_size, has_enough_parallelism, @@ -433,8 +404,8 @@ impl TreeConfig { self.disable_prewarming } - /// Returns txpool prewarming configuration. - pub const fn txpool_prewarming(&self) -> TxPoolPrewarmingConfig { + /// Returns whether txpool prewarming is enabled. + pub const fn txpool_prewarming(&self) -> bool { self.txpool_prewarming } @@ -557,9 +528,9 @@ impl TreeConfig { self } - /// Sets txpool transaction prewarming configuration. - pub const fn with_txpool_prewarming(mut self, config: TxPoolPrewarmingConfig) -> Self { - self.txpool_prewarming = config; + /// Enables or disables txpool transaction prewarming. + pub const fn with_txpool_prewarming(mut self, enabled: bool) -> Self { + self.txpool_prewarming = enabled; self } diff --git a/crates/engine/tree/src/tree/payload_validator.rs b/crates/engine/tree/src/tree/payload_validator.rs index 4a9de6d55cc..23006908bae 100644 --- a/crates/engine/tree/src/tree/payload_validator.rs +++ b/crates/engine/tree/src/tree/payload_validator.rs @@ -382,8 +382,7 @@ where mut self, source: impl crate::tree::TxPoolPrewarmSource + 'static, ) -> Self { - let config = self.config.txpool_prewarming(); - if !config.should_prewarm() || + if !self.config.txpool_prewarming() || self.config.disable_state_cache() || self.config.disable_prewarming() { diff --git a/crates/engine/tree/src/tree/txpool_prewarm.rs b/crates/engine/tree/src/tree/txpool_prewarm.rs index 68741b43958..2a570fee55d 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm.rs @@ -322,8 +322,7 @@ impl EvmStateProvider for TxPoolPrewarmStateProvider<'_> { &self, address: &Address, ) -> reth_errors::ProviderResult> { - self.cache - .get_or_try_insert_account_with(*address, || self.inner.basic_account(address)) + self.cache.get_or_try_insert_account_with(*address, || self.inner.basic_account(address)) } fn block_hash(&self, number: BlockNumber) -> reth_errors::ProviderResult> { @@ -544,7 +543,6 @@ where (true, transaction_count) } - #[cfg(test)] mod tests { use super::*; diff --git a/crates/node/builder/src/rpc.rs b/crates/node/builder/src/rpc.rs index 4f4222d3c84..2b7c7c0054b 100644 --- a/crates/node/builder/src/rpc.rs +++ b/crates/node/builder/src/rpc.rs @@ -1483,7 +1483,7 @@ where ctx.node.task_executor().clone(), ); - if txpool_prewarming.should_prewarm() && txpool_prewarming_available { + if txpool_prewarming && txpool_prewarming_available { validator = validator.with_txpool_prewarm_source(PoolTxPoolPrewarmSource::< PrimitivesTy, _, diff --git a/crates/node/core/src/args/engine.rs b/crates/node/core/src/args/engine.rs index c1b49535baa..10717bd0bcc 100644 --- a/crates/node/core/src/args/engine.rs +++ b/crates/node/core/src/args/engine.rs @@ -7,9 +7,9 @@ use clap::{ 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, TxPoolPrewarmingConfig, DEFAULT_INVALID_HEADER_HIT_EVICTION_THRESHOLD, - DEFAULT_MULTIPROOF_TASK_CHUNK_SIZE, DEFAULT_PERSISTENCE_BACKPRESSURE_THRESHOLD, - DEFAULT_SPARSE_TRIE_MAX_HOT_ACCOUNTS, DEFAULT_SPARSE_TRIE_MAX_HOT_SLOTS, + TreeConfig, DEFAULT_INVALID_HEADER_HIT_EVICTION_THRESHOLD, DEFAULT_MULTIPROOF_TASK_CHUNK_SIZE, + DEFAULT_PERSISTENCE_BACKPRESSURE_THRESHOLD, DEFAULT_SPARSE_TRIE_MAX_HOT_ACCOUNTS, + DEFAULT_SPARSE_TRIE_MAX_HOT_SLOTS, }; use std::{sync::OnceLock, time::Duration}; @@ -269,7 +269,7 @@ impl Default for DefaultEngineValues { invalid_header_hit_eviction_threshold: DEFAULT_INVALID_HEADER_HIT_EVICTION_THRESHOLD, state_cache_disabled: false, prewarming_disabled: false, - txpool_prewarming_enabled: TxPoolPrewarmingConfig::DEFAULT.enabled, + txpool_prewarming_enabled: false, state_provider_metrics: false, cross_block_cache_size: DEFAULT_CROSS_BLOCK_CACHE_SIZE_MB, state_root_task_compare_updates: false, @@ -676,9 +676,7 @@ impl EngineArgs { .with_invalid_header_hit_eviction_threshold(self.invalid_header_hit_eviction_threshold) .without_state_cache(self.state_cache_disabled) .without_prewarming(self.prewarming_disabled) - .with_txpool_prewarming( - TxPoolPrewarmingConfig::DEFAULT.with_enabled(self.txpool_prewarming_enabled), - ) + .with_txpool_prewarming(self.txpool_prewarming_enabled) .with_state_provider_metrics(self.state_provider_metrics) .with_always_compare_trie_updates(self.state_root_task_compare_updates) .with_cross_block_cache_size(self.cross_block_cache_size * 1024 * 1024) From 1073a3671ee968ae538b6848812d97109bf8f6f8 Mon Sep 17 00:00:00 2001 From: Alexey Shekhirin Date: Thu, 16 Jul 2026 15:20:18 +0100 Subject: [PATCH 12/32] simplify on_canonical_head_changed --- crates/engine/tree/src/tree/mod.rs | 24 +++++------- .../engine/tree/src/tree/payload_validator.rs | 37 +++++++++---------- 2 files changed, 26 insertions(+), 35 deletions(-) diff --git a/crates/engine/tree/src/tree/mod.rs b/crates/engine/tree/src/tree/mod.rs index 72556dea760..b52f5f801a1 100644 --- a/crates/engine/tree/src/tree/mod.rs +++ b/crates/engine/tree/src/tree/mod.rs @@ -1267,23 +1267,17 @@ where return Ok(Some(TreeOutcome::new(outcome))); } - let notify_canonical_head = self.payload_validator.canonical_head_notifications_enabled(); - let tip = if attrs.is_some() || notify_canonical_head { - self.sealed_header_by_hash(self.state.tree_state.canonical_block_hash())? - } else { - None - }; - if notify_canonical_head && let Some(tip) = tip.as_ref() { - self.payload_validator.on_canonical_head_changed(tip, &self.state); - } + self.payload_validator.on_canonical_head_changed(state.head_block_hash, &self.state); // Process payload attributes if the head is already canonical if let Some(attr) = attrs { - let tip = tip.ok_or_else(|| { - // If we can't find the canonical block, then something is wrong and we need - // to return an error - ProviderError::HeaderNotFound(state.head_block_hash.into()) - })?; + let tip = self + .sealed_header_by_hash(self.state.tree_state.canonical_block_hash())? + .ok_or_else(|| { + // If we can't find the canonical block, then something is wrong and we need + // to return an error + ProviderError::HeaderNotFound(state.head_block_hash.into()) + })?; // Clone only when we actually need to process the attributes let updated = self.process_payload_attributes(attr.clone(), &tip, state); return Ok(Some(TreeOutcome::new(updated))); @@ -2737,7 +2731,7 @@ where // update the tracked in-memory state with the new chain self.canonical_in_memory_state.update_chain(chain_update); self.canonical_in_memory_state.set_canonical_head(tip.clone()); - self.payload_validator.on_canonical_head_changed(&tip, &self.state); + self.payload_validator.on_canonical_head_changed(tip.hash(), &self.state); // Update metrics based on new tip self.metrics.tree.canonical_chain_height.set(tip.number() as f64); diff --git a/crates/engine/tree/src/tree/payload_validator.rs b/crates/engine/tree/src/tree/payload_validator.rs index 23006908bae..66a72b22447 100644 --- a/crates/engine/tree/src/tree/payload_validator.rs +++ b/crates/engine/tree/src/tree/payload_validator.rs @@ -1793,17 +1793,7 @@ pub trait EngineValidator< ) -> ProviderResult>; /// Registers the exact state for a new canonical head with background cache prewarming. - fn on_canonical_head_changed( - &self, - _header: &SealedHeader, - _state: &EngineApiTreeState, - ) { - } - - /// Returns whether canonical-head notifications require otherwise-unneeded provider work. - fn canonical_head_notifications_enabled(&self) -> bool { - false - } + fn on_canonical_head_changed(&self, _hash: B256, _state: &EngineApiTreeState) {} /// Prepares the resources loaned to a payload builder job. /// @@ -1894,12 +1884,22 @@ where )) } - fn on_canonical_head_changed( - &self, - header: &SealedHeader, - state: &EngineApiTreeState, - ) { + fn on_canonical_head_changed(&self, hash: B256, state: &EngineApiTreeState) { let Some(txpool_prewarm) = self.txpool_prewarm.as_ref() else { return }; + + let header = match self.sealed_header_by_hash(hash, state) { + Ok(Some(header)) => header, + Ok(None) => return, + Err(err) => { + trace!( + target: "engine::tree::txpool_prewarm", + %err, + block_hash = ?hash, + "failed to fetch canonical header for txpool prewarming" + ); + return + } + }; let parent_parent = match self.sealed_header_by_hash(header.parent_hash(), state) { Ok(Some(parent_parent)) => parent_parent, Ok(None) => return, @@ -1914,6 +1914,7 @@ where return } }; + let provider_builder = match self.state_provider_builder(header.hash(), state) { Ok(Some(provider_builder)) => provider_builder, Ok(None) => return, @@ -1941,10 +1942,6 @@ where } } - fn canonical_head_notifications_enabled(&self) -> bool { - self.txpool_prewarm.is_some() - } - fn payload_builder_resources( &self, parent_hash: B256, From fab16b34c96a67c60ee51161a1aaff3e7dc92ca5 Mon Sep 17 00:00:00 2001 From: Alexey Shekhirin Date: Thu, 16 Jul 2026 16:27:15 +0100 Subject: [PATCH 13/32] simplify more --- crates/engine/tree/src/tree/mod.rs | 66 ++++++++++++------ .../src/tree/payload_processor/prewarm.rs | 25 ++----- .../engine/tree/src/tree/payload_validator.rs | 5 +- crates/engine/tree/src/tree/txpool_prewarm.rs | 68 ++++++++++--------- crates/node/builder/src/txpool_prewarm.rs | 4 -- 5 files changed, 90 insertions(+), 78 deletions(-) diff --git a/crates/engine/tree/src/tree/mod.rs b/crates/engine/tree/src/tree/mod.rs index b52f5f801a1..306dbf9fb9d 100644 --- a/crates/engine/tree/src/tree/mod.rs +++ b/crates/engine/tree/src/tree/mod.rs @@ -66,6 +66,7 @@ mod trie_updates; mod txpool_prewarm; pub mod types; +use self::txpool_prewarm::TxPoolPrewarmPauseGuard; use crate::{persistence::PersistenceResult, tree::error::AdvancePersistenceError}; pub use block_buffer::BlockBuffer; pub use invalid_headers::InvalidHeaderCache; @@ -1752,23 +1753,30 @@ where Duration::ZERO }; - let cache_wait = wait_for_caches - .then(|| self.payload_validator.wait_for_caches()); - - let start = Instant::now(); let gas_used = payload.gas_used(); let num_hash = payload.num_hash(); - let mut output = self.on_new_payload(payload); - if cache_wait.is_some() { - self.payload_validator.resume_caches(); - } - let latency = start.elapsed(); - self.metrics.engine.new_payload.update_response_metrics( - start, - &mut self.metrics.engine.forkchoice_updated.latest_finish_at, - &output, - gas_used, - ); + + let (mut output, latency, cache_wait) = { + let guard = wait_for_caches + .then(|| self.payload_validator.wait_for_caches()); + let durations = guard.as_ref().map(CacheWaitGuard::durations); + + let start = Instant::now(); + let output = self.on_new_payload(payload); + let latency = start.elapsed(); + self.metrics.engine.new_payload.update_response_metrics( + start, + &mut self + .metrics + .engine + .forkchoice_updated + .latest_finish_at, + &output, + gas_used, + ); + + (output, latency, durations) + }; let maybe_event = output.as_mut().ok().and_then(|out| out.event.take()); @@ -1785,7 +1793,7 @@ where BeaconOnNewPayloadError::Internal(Box::new(e)) })) { - error!(target: "engine::tree", payload=?num_hash, elapsed=?start.elapsed(), "Failed to send event: {err:?}"); + error!(target: "engine::tree", payload=?num_hash, elapsed=?latency, "Failed to send event: {err:?}"); self.metrics .engine .failed_new_payload_response_deliveries @@ -3479,6 +3487,25 @@ pub struct CacheWaitDurations { pub sparse_trie: Duration, } +/// Keeps speculative cache work suspended while a payload is being processed. +#[derive(Debug)] +#[must_use = "dropping the guard resumes speculative cache work"] +pub struct CacheWaitGuard { + durations: CacheWaitDurations, + _txpool: Option, +} + +impl CacheWaitGuard { + const fn new(durations: CacheWaitDurations, txpool: Option) -> Self { + Self { durations, _txpool: txpool } + } + + /// Returns the time spent waiting for each cache. + pub const fn durations(&self) -> CacheWaitDurations { + self.durations + } +} + /// Trait for types that can wait for caches to become available. /// /// This is used by `reth_newPayload` endpoint to ensure that payload processing @@ -3486,9 +3513,6 @@ pub struct CacheWaitDurations { pub trait WaitForCaches { /// Waits for cache updates to complete. /// - /// Returns the time spent waiting for each cache separately. - fn wait_for_caches(&self) -> CacheWaitDurations; - - /// Resumes cache work suspended by [`Self::wait_for_caches`]. - fn resume_caches(&self) {} + /// Returns a guard that resumes suspended work when dropped. + fn wait_for_caches(&self) -> CacheWaitGuard; } diff --git a/crates/engine/tree/src/tree/payload_processor/prewarm.rs b/crates/engine/tree/src/tree/payload_processor/prewarm.rs index 58f90c58e01..502dd0e0184 100644 --- a/crates/engine/tree/src/tree/payload_processor/prewarm.rs +++ b/crates/engine/tree/src/tree/payload_processor/prewarm.rs @@ -24,7 +24,6 @@ use alloy_primitives::{keccak256, B256, U256}; use metrics::{Counter, Gauge, Histogram}; use rayon::prelude::*; use reth_evm::{execute::ExecutableTxFor, ConfigureEvm, Evm, EvmFor, RecoveredTx, SpecFor}; -use reth_execution_cache::CacheFillMode; use reth_metrics::Metrics; use reth_primitives_traits::{Account, FastInstant as Instant, NodePrimitives}; use reth_provider::{ @@ -699,29 +698,17 @@ where return; } }; - let boxed: Box = match &self.saved_cache { - Some(saved) => { - let caches = saved.cache().clone(); - if self.disable_bal_batch_io { - Box::new( - CachedStateProvider::new_with_mode( - inner, - caches, - CacheFillMode::LookupOnly, - None, - None, - ) - .with_txpool_snapshot(self.env.txpool_snapshot.clone()), - ) - } else { + let boxed: Box = + match (self.disable_bal_batch_io, &self.saved_cache) { + (false, Some(saved)) => { + let caches = saved.cache().clone(); Box::new( CachedStateProvider::new_prewarm(inner, caches) .with_txpool_snapshot(self.env.txpool_snapshot.clone()), ) } - } - None => Box::new(inner), - }; + _ => Box::new(inner), + }; *provider = Some(boxed); } let account_reader = provider.as_ref().expect("provider just initialized"); diff --git a/crates/engine/tree/src/tree/payload_validator.rs b/crates/engine/tree/src/tree/payload_validator.rs index 66a72b22447..2c7ea39008b 100644 --- a/crates/engine/tree/src/tree/payload_validator.rs +++ b/crates/engine/tree/src/tree/payload_validator.rs @@ -102,8 +102,9 @@ use crate::tree::{ precompile_cache::{CachedPrecompile, CachedPrecompileMetrics, PrecompileCacheMap}, txpool_prewarm::TxPoolPrewarmHandle, types::{InsertPayloadResult, ValidationOutput}, - CacheWaitDurations, CachedStateProvider, EngineApiMetrics, EngineApiTreeState, ExecutionEnv, - PayloadHandle, StateProviderBuilder, StateProviderDatabase, TreeConfig, WaitForCaches, + CacheWaitDurations, CacheWaitGuard, CachedStateProvider, EngineApiMetrics, EngineApiTreeState, + ExecutionEnv, PayloadHandle, StateProviderBuilder, StateProviderDatabase, TreeConfig, + WaitForCaches, }; use alloy_consensus::transaction::{Either, TxHashRef}; use alloy_eip7928::{bal::DecodedBal, compute_block_access_list_hash, BlockAccessList}; diff --git a/crates/engine/tree/src/tree/txpool_prewarm.rs b/crates/engine/tree/src/tree/txpool_prewarm.rs index 2a570fee55d..774a148b274 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm.rs @@ -46,9 +46,6 @@ pub type TxPoolPrewarmTransactions = /// Source of txpool transactions for best-effort cache prewarming. pub trait TxPoolPrewarmSource: Send + Sync + Debug { - /// Returns the canonical block hash currently tracked by the pool. - fn tracked_block_hash(&self) -> B256; - /// Opens a live best-transactions iterator for `parent_hash`. /// /// The worker opens this once per canonical parent and retains it across empty polls, snapshot @@ -129,6 +126,25 @@ struct TxPoolSnapshotPublicationState { snapshot: Option, } +/// Keeps txpool prewarming paused until the cache-sensitive work is complete. +#[derive(Debug)] +pub(super) struct TxPoolPrewarmPauseGuard { + publication: Arc, + wait_duration: Duration, +} + +impl TxPoolPrewarmPauseGuard { + pub(super) const fn wait_duration(&self) -> Duration { + self.wait_duration + } +} + +impl Drop for TxPoolPrewarmPauseGuard { + fn drop(&mut self) { + self.publication.resume(); + } +} + impl TxPoolSnapshotPublication { fn begin_head(&self) -> bool { let mut state = self.state.lock().expect("txpool snapshot publication lock poisoned"); @@ -143,14 +159,14 @@ impl TxPoolSnapshotPublication { self.paused.store(true, Ordering::Release); } - fn pause_and_wait(&self) -> Duration { + fn pause_and_wait(self: &Arc) -> TxPoolPrewarmPauseGuard { let start = Instant::now(); self.pause(); let mut state = self.state.lock().expect("txpool snapshot publication lock poisoned"); while state.active { state = self.idle.wait(state).expect("txpool snapshot publication lock poisoned"); } - start.elapsed() + TxPoolPrewarmPauseGuard { publication: Arc::clone(self), wait_duration: start.elapsed() } } fn resume(&self) { @@ -249,14 +265,9 @@ where Evm: ConfigureEvm, { /// Cancels speculative work and waits for any provider-backed EVM call to release its state. - pub(crate) fn cancel_and_wait(&self) -> Duration { + pub(crate) fn cancel_and_wait(&self) -> TxPoolPrewarmPauseGuard { self.publication.pause_and_wait() } - - /// Resumes work after an explicit cache wait, including early-return payload paths. - pub(crate) fn resume_after_wait(&self) { - self.publication.resume(); - } } impl TxPoolPrewarmHandle @@ -381,14 +392,7 @@ fn txpool_prewarm_loop( continue } - let tracked_hash = source.tracked_block_hash(); - if pending.as_ref().is_none_or(|job| job.parent_hash != tracked_hash) { - if let Some(request) = requests.wait(Some(TXPOOL_HEAD_POLL_INTERVAL)) { - pending = Some(request); - } - continue - } - let job = pending.take().expect("matching canonical request exists"); + let job = pending.take().expect("canonical request exists"); if best_txs_parent_hash != Some(job.parent_hash) { let Some(opened) = source.best_transactions(job.parent_hash) else { @@ -400,11 +404,6 @@ fn txpool_prewarm_loop( best_txs_parent_hash = Some(job.parent_hash); } - if source.tracked_block_hash() != job.parent_hash { - pending = Some(job); - continue - } - // Wait for pool maintenance to catch up to the authoritative canonical-head request // before invalidating the previous publication. let new_parent = cache_parent_hash != Some(job.parent_hash); @@ -427,12 +426,9 @@ fn txpool_prewarm_loop( while !publication.is_paused() { if let Some(request) = requests.take() { pending = Some(request); - } - if requests.is_closed() || publication.is_paused() { break } - - if source.tracked_block_hash() != job.parent_hash { + if requests.is_closed() || publication.is_paused() { break } @@ -476,10 +472,7 @@ fn txpool_prewarm_loop( } } - if !requests.is_closed() && - pending.is_none() && - source.tracked_block_hash() == job.parent_hash - { + if !requests.is_closed() && pending.is_none() { pending = Some(job); } } @@ -576,4 +569,15 @@ mod tests { assert!(!activity_guard.publish(snapshot)); assert!(publication.snapshot(parent_hash).is_none()); } + + #[test] + fn pause_guard_resumes_publication_on_drop() { + let publication = Arc::new(TxPoolSnapshotPublication::default()); + + let guard = publication.pause_and_wait(); + assert!(publication.is_paused()); + + drop(guard); + assert!(!publication.is_paused()); + } } diff --git a/crates/node/builder/src/txpool_prewarm.rs b/crates/node/builder/src/txpool_prewarm.rs index f41b41772e0..ff394fec89f 100644 --- a/crates/node/builder/src/txpool_prewarm.rs +++ b/crates/node/builder/src/txpool_prewarm.rs @@ -34,10 +34,6 @@ where + Debug + 'static, { - fn tracked_block_hash(&self) -> B256 { - self.pool.block_info().last_seen_block_hash - } - fn best_transactions(&self, parent_hash: B256) -> Option> { let block_info = self.pool.block_info(); if block_info.last_seen_block_hash != parent_hash { From 626bae2c712f74f6c956c038a389ab8954ee658e Mon Sep 17 00:00:00 2001 From: Alexey Shekhirin Date: Thu, 16 Jul 2026 17:07:00 +0100 Subject: [PATCH 14/32] misc --- .../engine/tree/src/tree/payload_validator.rs | 8 +------- crates/engine/tree/src/tree/txpool_prewarm.rs | 14 +++++++------- crates/node/builder/src/rpc.rs | 17 +++++++---------- crates/node/builder/src/txpool_prewarm.rs | 10 +++------- 4 files changed, 18 insertions(+), 31 deletions(-) diff --git a/crates/engine/tree/src/tree/payload_validator.rs b/crates/engine/tree/src/tree/payload_validator.rs index 2c7ea39008b..34247451f15 100644 --- a/crates/engine/tree/src/tree/payload_validator.rs +++ b/crates/engine/tree/src/tree/payload_validator.rs @@ -379,16 +379,10 @@ where } /// Installs the txpool source and starts the persistent cache-prewarming worker. - pub fn with_txpool_prewarm_source( + pub fn with_txpool_prewarming( mut self, source: impl crate::tree::TxPoolPrewarmSource + 'static, ) -> Self { - if !self.config.txpool_prewarming() || - self.config.disable_state_cache() || - self.config.disable_prewarming() - { - return self - } self.txpool_prewarm = Some(TxPoolPrewarmHandle::spawn( &self.runtime, Arc::new(source), diff --git a/crates/engine/tree/src/tree/txpool_prewarm.rs b/crates/engine/tree/src/tree/txpool_prewarm.rs index 774a148b274..bbe5d7a45be 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm.rs @@ -26,6 +26,13 @@ const TXPOOL_REFRESH_INTERVAL: Duration = Duration::from_millis(100); /// Delay while waiting for pool maintenance to advance to the state being warmed. const TXPOOL_HEAD_POLL_INTERVAL: Duration = Duration::from_millis(10); +/// A live, forward-only view of the pool's best transactions for one canonical parent. +/// +/// Returning [`None`](Iterator::next) only means no transaction is currently ready. The same +/// iterator can yield transactions that become pending later. +pub type TxPoolPrewarmTransactions = + Box> + Send>; + /// A transaction selected from the txpool for cache-only prewarming. #[derive(Debug, Clone)] pub struct TxPoolPrewarmTransaction { @@ -37,13 +44,6 @@ pub struct TxPoolPrewarmTransaction { pub transaction: Recovered>, } -/// A live, forward-only view of the pool's best transactions for one canonical parent. -/// -/// Returning [`None`](Iterator::next) only means no transaction is currently ready. The same -/// iterator can yield transactions that become pending later. -pub type TxPoolPrewarmTransactions = - Box> + Send>; - /// Source of txpool transactions for best-effort cache prewarming. pub trait TxPoolPrewarmSource: Send + Sync + Debug { /// Opens a live best-transactions iterator for `parent_hash`. diff --git a/crates/node/builder/src/rpc.rs b/crates/node/builder/src/rpc.rs index 2b7c7c0054b..2317948157e 100644 --- a/crates/node/builder/src/rpc.rs +++ b/crates/node/builder/src/rpc.rs @@ -13,7 +13,7 @@ pub use reth_rpc_builder::{ pub use reth_trie_db::ChangesetCache; use crate::{ - invalid_block_hook::InvalidBlockHookExt, txpool_prewarm::PoolTxPoolPrewarmSource, + invalid_block_hook::InvalidBlockHookExt, txpool_prewarm::TransactionPoolPrewarmSource, ConfigureEngineEvm, ConsensusEngineEvent, ConsensusEngineHandle, }; use alloy_rpc_types::engine::ClientVersionV1; @@ -1469,8 +1469,6 @@ where let invalid_block_hook = ctx.create_invalid_block_hook(&data_dir).await?; let txpool_prewarming = tree_config.txpool_prewarming(); - let txpool_prewarming_available = - !tree_config.disable_state_cache() && !tree_config.disable_prewarming(); let mut validator = BasicEngineValidator::new( ctx.node.provider().clone(), std::sync::Arc::new(ctx.node.consensus().clone()), @@ -1483,13 +1481,12 @@ where ctx.node.task_executor().clone(), ); - if txpool_prewarming && txpool_prewarming_available { - validator = validator.with_txpool_prewarm_source(PoolTxPoolPrewarmSource::< - PrimitivesTy, - _, - >::new( - ctx.node.pool().clone() - )); + if txpool_prewarming { + validator = + validator.with_txpool_prewarming(TransactionPoolPrewarmSource::< + PrimitivesTy, + _, + >::new(ctx.node.pool().clone())); } Ok(validator) diff --git a/crates/node/builder/src/txpool_prewarm.rs b/crates/node/builder/src/txpool_prewarm.rs index ff394fec89f..6e84924a804 100644 --- a/crates/node/builder/src/txpool_prewarm.rs +++ b/crates/node/builder/src/txpool_prewarm.rs @@ -12,19 +12,19 @@ use std::{fmt::Debug, marker::PhantomData}; /// [`TransactionPool`]-backed [`TxPoolPrewarmSource`]. #[derive(Debug)] -pub(crate) struct PoolTxPoolPrewarmSource { +pub(crate) struct TransactionPoolPrewarmSource { pool: P, _marker: PhantomData, } -impl PoolTxPoolPrewarmSource { +impl TransactionPoolPrewarmSource { /// Creates a new txpool prewarm source. pub(crate) const fn new(pool: P) -> Self { Self { pool, _marker: PhantomData } } } -impl TxPoolPrewarmSource for PoolTxPoolPrewarmSource +impl TxPoolPrewarmSource for TransactionPoolPrewarmSource where N: NodePrimitives, P: TransactionPool>> @@ -48,10 +48,6 @@ where best.allow_updates_out_of_order(); best.skip_blobs(); - if self.pool.block_info().last_seen_block_hash != parent_hash { - return None - } - Some(Box::new(best.map(|transaction| TxPoolPrewarmTransaction { hash: *transaction.hash(), sender: transaction.sender(), From ccc465f1dcf4a19229bb000053cdffe088cffdff Mon Sep 17 00:00:00 2001 From: Sergei Shulepov Date: Fri, 17 Jul 2026 22:40:06 +0200 Subject: [PATCH 15/32] refactor --- Cargo.lock | 1 + crates/engine/execution-cache/src/txpool.rs | 114 +--- crates/engine/tree/Cargo.toml | 1 + crates/engine/tree/src/tree/mod.rs | 71 +-- .../engine/tree/src/tree/payload_validator.rs | 77 ++- crates/engine/tree/src/tree/txpool_prewarm.rs | 583 ------------------ .../tree/src/tree/txpool_prewarm/cache.rs | 146 +++++ .../src/tree/txpool_prewarm/coordination.rs | 357 +++++++++++ .../tree/src/tree/txpool_prewarm/mod.rs | 123 ++++ .../tree/src/tree/txpool_prewarm/worker.rs | 163 +++++ crates/engine/tree/src/tree/types.rs | 2 + crates/ethereum/evm/src/lib.rs | 8 +- crates/evm/evm/src/lib.rs | 2 +- crates/node/builder/src/rpc.rs | 6 +- crates/node/builder/src/txpool_prewarm.rs | 15 +- 15 files changed, 895 insertions(+), 774 deletions(-) delete mode 100644 crates/engine/tree/src/tree/txpool_prewarm.rs create mode 100644 crates/engine/tree/src/tree/txpool_prewarm/cache.rs create mode 100644 crates/engine/tree/src/tree/txpool_prewarm/coordination.rs create mode 100644 crates/engine/tree/src/tree/txpool_prewarm/mod.rs create mode 100644 crates/engine/tree/src/tree/txpool_prewarm/worker.rs diff --git a/Cargo.lock b/Cargo.lock index 4190505730c..2c9d0969e30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8354,6 +8354,7 @@ dependencies = [ "metrics", "metrics-util", "moka", + "parking_lot", "proptest", "rand 0.8.6", "rand 0.9.4", diff --git a/crates/engine/execution-cache/src/txpool.rs b/crates/engine/execution-cache/src/txpool.rs index 86ef94c3358..7d6a1ef2e9f 100644 --- a/crates/engine/execution-cache/src/txpool.rs +++ b/crates/engine/execution-cache/src/txpool.rs @@ -1,116 +1,36 @@ -//! Reusable cache and immutable snapshots for txpool-driven state prewarming. +//! Immutable snapshots produced by txpool-driven state prewarming. use alloy_primitives::{ map::{AddressMap, B256Map, HashMap}, Address, StorageKey, StorageValue, B256, }; -use parking_lot::RwLock; use reth_primitives_traits::{Account, Bytecode}; use std::sync::Arc; -/// A mutable state-read cache reused by the txpool prewarmer between canonical heads. -/// -/// The prewarmer is the only logical writer, although its transaction workers can fill the cache -/// concurrently. [`Self::clear`] retains the maps' allocations for the next head. -#[derive(Debug, Default)] -pub struct TxPoolPrewarmCache { - accounts: RwLock>>, - storage: RwLock>, - bytecodes: RwLock>>, +/// A deep, immutable txpool-prewarm cache snapshot for one parent state. +#[derive(Clone, Debug)] +pub struct TxPoolPrewarmCacheSnapshot { + inner: Arc, } -impl TxPoolPrewarmCache { - /// Clears all cached state while retaining the backing allocations. - pub fn clear(&self) { - self.accounts.write().clear(); - self.storage.write().clear(); - self.bytecodes.write().clear(); - } - - /// Creates a deep, immutable snapshot tagged with the state hash it was warmed against. - /// - /// Callers must only snapshot at a completed prewarming-wave boundary, when no workers are - /// writing to the cache. - pub fn snapshot(&self, parent_hash: B256) -> TxPoolPrewarmCacheSnapshot { - TxPoolPrewarmCacheSnapshot { +impl TxPoolPrewarmCacheSnapshot { + /// Creates a snapshot from fully owned cache maps. + pub fn from_parts( + parent_hash: B256, + accounts: AddressMap>, + storage: HashMap<(Address, StorageKey), StorageValue>, + bytecodes: B256Map>, + ) -> Self { + Self { inner: Arc::new(TxPoolPrewarmCacheSnapshotInner { parent_hash, - accounts: self.accounts.read().clone(), - storage: self.storage.read().clone(), - bytecodes: self.bytecodes.read().clone(), + accounts, + storage, + bytecodes, }), } } - /// Gets an account or fills it using `f`. - pub fn get_or_try_insert_account_with( - &self, - address: Address, - f: impl FnOnce() -> Result, E>, - ) -> Result, E> { - if let Some(account) = self.accounts.read().get(&address).copied() { - return Ok(account) - } - - let account = f()?; - let mut accounts = self.accounts.write(); - if let Some(cached) = accounts.get(&address).copied() { - Ok(cached) - } else { - accounts.insert(address, account); - Ok(account) - } - } - - /// Gets a storage value or fills it using `f`. - pub fn get_or_try_insert_storage_with( - &self, - address: Address, - key: StorageKey, - f: impl FnOnce() -> Result, - ) -> Result { - if let Some(value) = self.storage.read().get(&(address, key)).copied() { - return Ok(value) - } - - let value = f()?; - let mut storage = self.storage.write(); - if let Some(cached) = storage.get(&(address, key)).copied() { - Ok(cached) - } else { - storage.insert((address, key), value); - Ok(value) - } - } - - /// Gets bytecode or fills it using `f`. - pub fn get_or_try_insert_code_with( - &self, - code_hash: B256, - f: impl FnOnce() -> Result, E>, - ) -> Result, E> { - if let Some(code) = self.bytecodes.read().get(&code_hash).cloned() { - return Ok(code) - } - - let code = f()?; - let mut bytecodes = self.bytecodes.write(); - if let Some(cached) = bytecodes.get(&code_hash).cloned() { - Ok(cached) - } else { - bytecodes.insert(code_hash, code.clone()); - Ok(code) - } - } -} - -/// A deep, immutable txpool-prewarm cache snapshot for one parent state. -#[derive(Clone, Debug)] -pub struct TxPoolPrewarmCacheSnapshot { - inner: Arc, -} - -impl TxPoolPrewarmCacheSnapshot { /// Returns the hash of the state this snapshot was warmed against. pub fn parent_hash(&self) -> B256 { self.inner.parent_hash diff --git a/crates/engine/tree/Cargo.toml b/crates/engine/tree/Cargo.toml index cd69acb4ed1..2c8c94b74a7 100644 --- a/crates/engine/tree/Cargo.toml +++ b/crates/engine/tree/Cargo.toml @@ -50,6 +50,7 @@ revm.workspace = true # common futures.workspace = true +parking_lot.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["rt", "rt-multi-thread", "sync", "macros"] } moka = { workspace = true, features = ["sync"] } diff --git a/crates/engine/tree/src/tree/mod.rs b/crates/engine/tree/src/tree/mod.rs index 306dbf9fb9d..73b1b172a1d 100644 --- a/crates/engine/tree/src/tree/mod.rs +++ b/crates/engine/tree/src/tree/mod.rs @@ -66,7 +66,6 @@ mod trie_updates; mod txpool_prewarm; pub mod types; -use self::txpool_prewarm::TxPoolPrewarmPauseGuard; use crate::{persistence::PersistenceResult, tree::error::AdvancePersistenceError}; pub use block_buffer::BlockBuffer; pub use invalid_headers::InvalidHeaderCache; @@ -77,11 +76,11 @@ pub use persistence_state::PersistenceState; pub use reth_engine_primitives::TreeConfig; pub use reth_execution_cache::{ CachedStateCacheMetrics, CachedStateMetrics, CachedStateMetricsSource, CachedStateProvider, - ExecutionCache, PayloadExecutionCache, SavedCache, TxPoolPrewarmCache, - TxPoolPrewarmCacheSnapshot, + ExecutionCache, PayloadExecutionCache, SavedCache, TxPoolPrewarmCacheSnapshot, }; pub use txpool_prewarm::{ - TxPoolPrewarmSource, TxPoolPrewarmTransaction, TxPoolPrewarmTransactions, + Source as TxPoolPrewarmSource, Transaction as TxPoolPrewarmTransaction, + Transactions as TxPoolPrewarmTransactions, }; pub use types::{ExecutionEnv, ValidationOutcome, ValidationOutput}; @@ -1753,30 +1752,20 @@ where Duration::ZERO }; + let cache_wait = wait_for_caches + .then(|| self.payload_validator.wait_for_caches()); + + let start = Instant::now(); let gas_used = payload.gas_used(); let num_hash = payload.num_hash(); - - let (mut output, latency, cache_wait) = { - let guard = wait_for_caches - .then(|| self.payload_validator.wait_for_caches()); - let durations = guard.as_ref().map(CacheWaitGuard::durations); - - let start = Instant::now(); - let output = self.on_new_payload(payload); - let latency = start.elapsed(); - self.metrics.engine.new_payload.update_response_metrics( - start, - &mut self - .metrics - .engine - .forkchoice_updated - .latest_finish_at, - &output, - gas_used, - ); - - (output, latency, durations) - }; + let mut output = self.on_new_payload(payload); + let latency = start.elapsed(); + self.metrics.engine.new_payload.update_response_metrics( + start, + &mut self.metrics.engine.forkchoice_updated.latest_finish_at, + &output, + gas_used, + ); let maybe_event = output.as_mut().ok().and_then(|out| out.event.take()); @@ -1793,7 +1782,12 @@ where BeaconOnNewPayloadError::Internal(Box::new(e)) })) { - error!(target: "engine::tree", payload=?num_hash, elapsed=?latency, "Failed to send event: {err:?}"); + error!( + target: "engine::tree", + payload=?num_hash, + elapsed=?latency, + "Failed to send event: {err:?}" + ); self.metrics .engine .failed_new_payload_response_deliveries @@ -3487,25 +3481,6 @@ pub struct CacheWaitDurations { pub sparse_trie: Duration, } -/// Keeps speculative cache work suspended while a payload is being processed. -#[derive(Debug)] -#[must_use = "dropping the guard resumes speculative cache work"] -pub struct CacheWaitGuard { - durations: CacheWaitDurations, - _txpool: Option, -} - -impl CacheWaitGuard { - const fn new(durations: CacheWaitDurations, txpool: Option) -> Self { - Self { durations, _txpool: txpool } - } - - /// Returns the time spent waiting for each cache. - pub const fn durations(&self) -> CacheWaitDurations { - self.durations - } -} - /// Trait for types that can wait for caches to become available. /// /// This is used by `reth_newPayload` endpoint to ensure that payload processing @@ -3513,6 +3488,6 @@ impl CacheWaitGuard { pub trait WaitForCaches { /// Waits for cache updates to complete. /// - /// Returns a guard that resumes suspended work when dropped. - fn wait_for_caches(&self) -> CacheWaitGuard; + /// Returns the time spent waiting for each cache separately. + fn wait_for_caches(&self) -> CacheWaitDurations; } diff --git a/crates/engine/tree/src/tree/payload_validator.rs b/crates/engine/tree/src/tree/payload_validator.rs index 34247451f15..19dd1c7b18d 100644 --- a/crates/engine/tree/src/tree/payload_validator.rs +++ b/crates/engine/tree/src/tree/payload_validator.rs @@ -100,11 +100,10 @@ use crate::tree::{ instrumented_state::{InstrumentedStateProvider, StateProviderMetrics, StateProviderStats}, payload_processor::PayloadProcessor, precompile_cache::{CachedPrecompile, CachedPrecompileMetrics, PrecompileCacheMap}, - txpool_prewarm::TxPoolPrewarmHandle, + txpool_prewarm, types::{InsertPayloadResult, ValidationOutput}, - CacheWaitDurations, CacheWaitGuard, CachedStateProvider, EngineApiMetrics, EngineApiTreeState, - ExecutionEnv, PayloadHandle, StateProviderBuilder, StateProviderDatabase, TreeConfig, - WaitForCaches, + CacheWaitDurations, CachedStateProvider, EngineApiMetrics, EngineApiTreeState, ExecutionEnv, + PayloadHandle, StateProviderBuilder, StateProviderDatabase, TreeConfig, WaitForCaches, }; use alloy_consensus::transaction::{Either, TxHashRef}; use alloy_eip7928::{bal::DecodedBal, compute_block_access_list_hash, BlockAccessList}; @@ -298,8 +297,10 @@ where #[debug(skip)] state_root_strategy: Arc>, /// Persistent txpool prewarming worker and its latest immutable snapshot. + /// + /// None if txpool prewarming is disabled. #[debug(skip)] - txpool_prewarm: Option>, + txpool_prewarm: Option>, } impl BasicEngineValidator @@ -383,7 +384,7 @@ where mut self, source: impl crate::tree::TxPoolPrewarmSource + 'static, ) -> Self { - self.txpool_prewarm = Some(TxPoolPrewarmHandle::spawn( + self.txpool_prewarm = Some(txpool_prewarm::Handle::spawn( &self.runtime, Arc::new(source), self.evm_config.clone(), @@ -487,6 +488,7 @@ where Evm: ConfigureEngineEvm, { let parent_hash = input.parent_hash(); + let _txpool_pause = self.txpool_prewarm.as_ref().map(txpool_prewarm::Handle::pause); let txpool_snapshot = self.txpool_prewarm.as_ref().and_then(|prewarmer| prewarmer.snapshot(parent_hash)); let _jit_pause = JitPauseGuard::new(&self.evm_config); @@ -594,9 +596,8 @@ where gas_used: input.gas_used(), withdrawals: input.withdrawals().map(|w| w.to_vec()), decoded_bal: decoded_bal.as_ref().map(Arc::clone), - txpool_snapshot, + txpool_snapshot: txpool_snapshot.clone(), }; - let validation_txpool_snapshot = env.txpool_snapshot.clone(); // Get an iterator over the transactions in the payload let txs = self.tx_iterator_for(&input)?; @@ -695,7 +696,7 @@ where cache_metrics.clone(), cache_stats.clone(), ) - .with_txpool_snapshot(validation_txpool_snapshot.clone()), + .with_txpool_snapshot(txpool_snapshot.clone()), ) as StateProviderBox } else { provider @@ -1787,7 +1788,9 @@ pub trait EngineValidator< block: BuiltPayloadExecutedBlock, ) -> ProviderResult>; - /// Registers the exact state for a new canonical head with background cache prewarming. + /// Notifies the validator that `hash` is the current canonical head. + /// + /// This may also be called when a forkchoice update reaffirms the existing head. fn on_canonical_head_changed(&self, _hash: B256, _state: &EngineApiTreeState) {} /// Prepares the resources loaned to a payload builder job. @@ -1882,7 +1885,9 @@ where fn on_canonical_head_changed(&self, hash: B256, state: &EngineApiTreeState) { let Some(txpool_prewarm) = self.txpool_prewarm.as_ref() else { return }; - let header = match self.sealed_header_by_hash(hash, state) { + // Obtain the headers for both the new canonical head and its parent. Those will be used to + // create the txpool prewarm EVM environment as a parent and a grandparent respectively. + let parent = match self.sealed_header_by_hash(hash, state) { Ok(Some(header)) => header, Ok(None) => return, Err(err) => { @@ -1895,46 +1900,49 @@ where return } }; - let parent_parent = match self.sealed_header_by_hash(header.parent_hash(), state) { + let grandparent = match self.sealed_header_by_hash(parent.parent_hash(), state) { Ok(Some(parent_parent)) => parent_parent, Ok(None) => return, Err(err) => { trace!( target: "engine::tree::txpool_prewarm", %err, - block_hash = ?header.hash(), - parent_hash = ?header.parent_hash(), + block_hash = ?parent.hash(), + parent_hash = ?parent.parent_hash(), "failed to fetch parent header for txpool prewarming" ); return } }; + let evm_env = + match self.evm_config.txpool_prewarm_env(parent.header(), grandparent.header()) { + Ok(Some(evm_env)) => evm_env, + Ok(None) => return, + Err(err) => { + trace!( + target: "engine::tree::txpool_prewarm", + %err, + block_hash = ?parent.hash(), + "failed to derive canonical txpool prewarming environment" + ); + return + } + }; - let provider_builder = match self.state_provider_builder(header.hash(), state) { + let provider_builder = match self.state_provider_builder(parent.hash(), state) { Ok(Some(provider_builder)) => provider_builder, Ok(None) => return, Err(err) => { trace!( target: "engine::tree::txpool_prewarm", %err, - block_hash = ?header.hash(), + block_hash = ?parent.hash(), "failed to derive canonical txpool prewarming provider" ); return } }; - match self.evm_config.txpool_prewarm_env(header.header(), parent_parent.header()) { - Ok(Some(evm_env)) => txpool_prewarm.start(header.hash(), evm_env, provider_builder), - Ok(None) => {} - Err(err) => { - trace!( - target: "engine::tree::txpool_prewarm", - %err, - block_hash = ?header.hash(), - "failed to derive canonical txpool prewarming environment" - ); - } - } + txpool_prewarm.start(parent.hash(), evm_env, provider_builder) } fn payload_builder_resources( @@ -1950,9 +1958,16 @@ where .then(|| self.payload_processor.cache_for(parent_hash)); let state_root_handle = self.payload_state_root_handle_for(parent_hash, parent_header, timestamp, state); - - PayloadBuilderResources::new(execution_cache, state_root_handle) - .with_lease(PayloadBuilderLease::new(JitPauseGuard::new(&self.evm_config))) + let mut resources = PayloadBuilderResources::new(execution_cache, state_root_handle) + .with_lease(PayloadBuilderLease::new(JitPauseGuard::new(&self.evm_config))); + // If the txpool prewarming is enabled then we should disable it for the duration + // of the payload builder job. This is done by obtaining a lease that will release + // the txpool prewarm when dropped. + if let Some(txpool_prewarm) = self.txpool_prewarm.as_ref() { + let txpool_lease = PayloadBuilderLease::new(txpool_prewarm.pause()); + resources = resources.with_lease(txpool_lease); + } + resources } } diff --git a/crates/engine/tree/src/tree/txpool_prewarm.rs b/crates/engine/tree/src/tree/txpool_prewarm.rs deleted file mode 100644 index bbe5d7a45be..00000000000 --- a/crates/engine/tree/src/tree/txpool_prewarm.rs +++ /dev/null @@ -1,583 +0,0 @@ -//! Txpool-driven state prewarming and immutable snapshot publication. - -use crate::tree::{ - StateProviderBuilder, StateProviderDatabase, TxPoolPrewarmCache, TxPoolPrewarmCacheSnapshot, -}; -use alloy_consensus::transaction::Recovered; -use alloy_evm::Evm; -use alloy_primitives::{Address, BlockNumber, StorageKey, StorageValue, B256}; -use reth_evm::{ConfigureEvm, EvmEnvFor}; -use reth_primitives_traits::{NodePrimitives, TxTy}; -use reth_provider::{BlockReader, StateProviderBox, StateProviderFactory, StateReader}; -use reth_revm::{database::EvmStateProvider, db::State}; -use std::{ - fmt::Debug, - sync::{ - atomic::{AtomicBool, Ordering}, - Arc, Condvar, Mutex, - }, - time::{Duration, Instant}, -}; -use tracing::{debug, trace}; - -/// Maximum interval between snapshot publications and delay when no transaction is ready. -const TXPOOL_REFRESH_INTERVAL: Duration = Duration::from_millis(100); - -/// Delay while waiting for pool maintenance to advance to the state being warmed. -const TXPOOL_HEAD_POLL_INTERVAL: Duration = Duration::from_millis(10); - -/// A live, forward-only view of the pool's best transactions for one canonical parent. -/// -/// Returning [`None`](Iterator::next) only means no transaction is currently ready. The same -/// iterator can yield transactions that become pending later. -pub type TxPoolPrewarmTransactions = - Box> + Send>; - -/// A transaction selected from the txpool for cache-only prewarming. -#[derive(Debug, Clone)] -pub struct TxPoolPrewarmTransaction { - /// Transaction hash. - pub hash: B256, - /// Recovered sender. - pub sender: Address, - /// Recovered consensus transaction. - pub transaction: Recovered>, -} - -/// Source of txpool transactions for best-effort cache prewarming. -pub trait TxPoolPrewarmSource: Send + Sync + Debug { - /// Opens a live best-transactions iterator for `parent_hash`. - /// - /// The worker opens this once per canonical parent and retains it across empty polls, snapshot - /// publications, and validation pauses. Sources should return [`None`] if they are not yet - /// tracking `parent_hash`. - fn best_transactions(&self, parent_hash: B256) -> Option>; -} - -/// A request to warm txpool transactions against one fully validated parent state. -struct TxPoolPrewarmJob> { - parent_hash: B256, - evm_env: EvmEnvFor, - provider_builder: StateProviderBuilder, -} - -/// Latest-wins canonical-head request slot shared with the worker. -struct TxPoolPrewarmRequests { - latest: Mutex>, - available: Condvar, - closed: AtomicBool, -} - -impl TxPoolPrewarmRequests { - const fn new() -> Self { - Self { latest: Mutex::new(None), available: Condvar::new(), closed: AtomicBool::new(false) } - } - - fn replace(&self, request: J) { - *self.latest.lock().expect("txpool prewarm request lock poisoned") = Some(request); - self.available.notify_one(); - } - - fn take(&self) -> Option { - self.latest.lock().expect("txpool prewarm request lock poisoned").take() - } - - fn wait(&self, timeout: Option) -> Option { - let mut latest = self.latest.lock().expect("txpool prewarm request lock poisoned"); - while latest.is_none() && !self.closed.load(Ordering::Acquire) { - if let Some(timeout) = timeout { - let (guard, result) = self - .available - .wait_timeout(latest, timeout) - .expect("txpool prewarm request lock poisoned"); - latest = guard; - if result.timed_out() { - break - } - } else { - latest = self.available.wait(latest).expect("txpool prewarm request lock poisoned"); - } - } - latest.take() - } - - fn close(&self) { - let _latest = self.latest.lock().expect("txpool prewarm request lock poisoned"); - self.closed.store(true, Ordering::Release); - self.available.notify_all(); - } - - fn is_closed(&self) -> bool { - self.closed.load(Ordering::Acquire) - } -} - -/// Coordinates snapshot publication and worker quiescence. -#[derive(Debug, Default)] -struct TxPoolSnapshotPublication { - paused: AtomicBool, - state: Mutex, - idle: Condvar, -} - -#[derive(Debug, Default)] -struct TxPoolSnapshotPublicationState { - active: bool, - snapshot: Option, -} - -/// Keeps txpool prewarming paused until the cache-sensitive work is complete. -#[derive(Debug)] -pub(super) struct TxPoolPrewarmPauseGuard { - publication: Arc, - wait_duration: Duration, -} - -impl TxPoolPrewarmPauseGuard { - pub(super) const fn wait_duration(&self) -> Duration { - self.wait_duration - } -} - -impl Drop for TxPoolPrewarmPauseGuard { - fn drop(&mut self) { - self.publication.resume(); - } -} - -impl TxPoolSnapshotPublication { - fn begin_head(&self) -> bool { - let mut state = self.state.lock().expect("txpool snapshot publication lock poisoned"); - if self.paused.load(Ordering::Acquire) { - return false - } - state.snapshot = None; - true - } - - fn pause(&self) { - self.paused.store(true, Ordering::Release); - } - - fn pause_and_wait(self: &Arc) -> TxPoolPrewarmPauseGuard { - let start = Instant::now(); - self.pause(); - let mut state = self.state.lock().expect("txpool snapshot publication lock poisoned"); - while state.active { - state = self.idle.wait(state).expect("txpool snapshot publication lock poisoned"); - } - TxPoolPrewarmPauseGuard { publication: Arc::clone(self), wait_duration: start.elapsed() } - } - - fn resume(&self) { - self.paused.store(false, Ordering::Release); - } - - fn try_enter(self: &Arc) -> Option { - let mut state = self.state.lock().expect("txpool snapshot publication lock poisoned"); - if state.active || self.paused.load(Ordering::Acquire) { - return None - } - state.active = true; - Some(TxPoolPrewarmWaveGuard { publication: Arc::clone(self), active: true }) - } - - fn finish_wave(&self, snapshot: Option) -> bool { - let mut state = self.state.lock().expect("txpool snapshot publication lock poisoned"); - let published = if self.paused.load(Ordering::Acquire) { - false - } else if let Some(snapshot) = snapshot { - state.snapshot = Some(snapshot); - true - } else { - false - }; - state.active = false; - self.idle.notify_all(); - published - } - - fn snapshot(&self, parent_hash: B256) -> Option { - self.state - .lock() - .expect("txpool snapshot publication lock poisoned") - .snapshot - .as_ref() - .filter(|snapshot| snapshot.parent_hash() == parent_hash) - .cloned() - } - - fn is_paused(&self) -> bool { - self.paused.load(Ordering::Acquire) - } -} - -struct TxPoolPrewarmWaveGuard { - publication: Arc, - active: bool, -} - -impl TxPoolPrewarmWaveGuard { - fn publish(mut self, snapshot: TxPoolPrewarmCacheSnapshot) -> bool { - let published = self.publication.finish_wave(Some(snapshot)); - self.active = false; - published - } -} - -impl Drop for TxPoolPrewarmWaveGuard { - fn drop(&mut self) { - if self.active { - self.publication.finish_wave(None); - } - } -} - -/// Coordinates a long-lived worker and the latest completed immutable snapshot. -pub(crate) struct TxPoolPrewarmHandle -where - N: NodePrimitives, - Evm: ConfigureEvm, -{ - requests: Arc>>, - publication: Arc, -} - -impl Debug for TxPoolPrewarmHandle -where - N: NodePrimitives, - Evm: ConfigureEvm, -{ - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let state = - self.publication.state.lock().expect("txpool snapshot publication lock poisoned"); - f.debug_struct("TxPoolPrewarmHandle") - .field("paused", &self.publication.is_paused()) - .field("active", &state.active) - .field("published", &state.snapshot.as_ref().map(|s| s.parent_hash())) - .finish_non_exhaustive() - } -} - -impl TxPoolPrewarmHandle -where - N: NodePrimitives, - Evm: ConfigureEvm, -{ - /// Cancels speculative work and waits for any provider-backed EVM call to release its state. - pub(crate) fn cancel_and_wait(&self) -> TxPoolPrewarmPauseGuard { - self.publication.pause_and_wait() - } -} - -impl TxPoolPrewarmHandle -where - N: NodePrimitives, - P: BlockReader + StateProviderFactory + StateReader + Clone + Send + Sync + 'static, - Evm: ConfigureEvm + 'static, -{ - /// Spawns the long-lived worker. Its mutable cache remains owned by this worker and is cleared - /// between heads without releasing map capacity. - pub(crate) fn spawn( - runtime: &reth_tasks::Runtime, - source: Arc>, - evm_config: Evm, - ) -> Self { - let requests = Arc::new(TxPoolPrewarmRequests::new()); - let worker_requests = Arc::clone(&requests); - let publication = Arc::new(TxPoolSnapshotPublication::default()); - let worker_publication = Arc::clone(&publication); - - runtime.spawn_blocking_named("txpool-prewarm", move || { - txpool_prewarm_loop(worker_requests, source, evm_config, worker_publication) - }); - - Self { requests, publication } - } - - /// Returns the latest fully published snapshot for `parent_hash`. - pub(crate) fn snapshot(&self, parent_hash: B256) -> Option { - self.publication.snapshot(parent_hash) - } - - /// Starts continuous warming for the latest canonical head. - pub(crate) fn start( - &self, - parent_hash: B256, - evm_env: EvmEnvFor, - provider_builder: StateProviderBuilder, - ) { - self.requests.replace(TxPoolPrewarmJob { parent_hash, evm_env, provider_builder }); - } -} - -impl Drop for TxPoolPrewarmHandle -where - N: NodePrimitives, - Evm: ConfigureEvm, -{ - fn drop(&mut self) { - self.publication.pause(); - self.requests.close(); - } -} - -/// Provider that fills only the reusable txpool-prewarm cache. -struct TxPoolPrewarmStateProvider<'a> { - inner: StateProviderBox, - cache: &'a TxPoolPrewarmCache, -} - -impl EvmStateProvider for TxPoolPrewarmStateProvider<'_> { - fn basic_account( - &self, - address: &Address, - ) -> reth_errors::ProviderResult> { - self.cache.get_or_try_insert_account_with(*address, || self.inner.basic_account(address)) - } - - fn block_hash(&self, number: BlockNumber) -> reth_errors::ProviderResult> { - EvmStateProvider::block_hash(&self.inner, number) - } - - fn bytecode_by_hash( - &self, - code_hash: &B256, - ) -> reth_errors::ProviderResult> { - self.cache - .get_or_try_insert_code_with(*code_hash, || self.inner.bytecode_by_hash(code_hash)) - } - - fn storage( - &self, - account: Address, - storage_key: StorageKey, - ) -> reth_errors::ProviderResult> { - self.cache - .get_or_try_insert_storage_with(account, storage_key, || { - self.inner.storage(account, storage_key).map(Option::unwrap_or_default) - }) - .map(|value| (!value.is_zero()).then_some(value)) - } -} - -fn txpool_prewarm_loop( - requests: Arc>>, - source: Arc>, - evm_config: Evm, - publication: Arc, -) where - N: NodePrimitives, - P: BlockReader + StateProviderFactory + StateReader + Clone + Send + Sync + 'static, - Evm: ConfigureEvm + 'static, -{ - let cache = TxPoolPrewarmCache::default(); - let mut best_txs: Option> = None; - let mut best_txs_parent_hash = None; - let mut pending = None; - let mut cache_parent_hash = None; - - while !requests.is_closed() { - if let Some(request) = requests.take() { - pending = Some(request); - } - if pending.is_none() { - pending = requests.wait(None); - continue - } - if publication.is_paused() { - if let Some(request) = requests.wait(Some(TXPOOL_HEAD_POLL_INTERVAL)) { - pending = Some(request); - } - continue - } - - let job = pending.take().expect("canonical request exists"); - - if best_txs_parent_hash != Some(job.parent_hash) { - let Some(opened) = source.best_transactions(job.parent_hash) else { - pending = Some(job); - std::thread::sleep(TXPOOL_HEAD_POLL_INTERVAL); - continue - }; - best_txs = Some(opened); - best_txs_parent_hash = Some(job.parent_hash); - } - - // Wait for pool maintenance to catch up to the authoritative canonical-head request - // before invalidating the previous publication. - let new_parent = cache_parent_hash != Some(job.parent_hash); - if new_parent && !publication.begin_head() { - if pending.is_none() { - pending = Some(job); - } - continue - } - if new_parent { - cache.clear(); - cache_parent_hash = Some(job.parent_hash); - } - debug!( - target: "engine::tree::txpool_prewarm", - parent_hash = ?job.parent_hash, - "started txpool prewarming" - ); - - while !publication.is_paused() { - if let Some(request) = requests.take() { - pending = Some(request); - break - } - if requests.is_closed() || publication.is_paused() { - break - } - - let Some(activity_guard) = publication.try_enter() else { break }; - let (completed, transaction_count) = prewarm_transactions( - &evm_config, - &job, - &cache, - best_txs.as_mut().expect("best transactions opened for active parent"), - Instant::now() + TXPOOL_REFRESH_INTERVAL, - &publication, - ); - - if !completed { - drop(activity_guard); - if !publication.is_paused() { - std::thread::sleep(TXPOOL_REFRESH_INTERVAL); - } - break - } - if transaction_count == 0 { - drop(activity_guard); - std::thread::sleep(TXPOOL_REFRESH_INTERVAL); - continue - } - - let snapshot = cache.snapshot(job.parent_hash); - let (accounts, storage, bytecodes) = snapshot.entry_counts(); - if activity_guard.publish(snapshot) { - debug!( - target: "engine::tree::txpool_prewarm", - parent_hash = ?job.parent_hash, - transactions = transaction_count, - accounts, - storage, - bytecodes, - "published txpool prewarming snapshot" - ); - } else { - break - } - } - - if !requests.is_closed() && pending.is_none() { - pending = Some(job); - } - } -} - -fn prewarm_transactions( - evm_config: &Evm, - job: &TxPoolPrewarmJob, - cache: &TxPoolPrewarmCache, - transactions: I, - deadline: Instant, - publication: &TxPoolSnapshotPublication, -) -> (bool, usize) -where - N: NodePrimitives, - P: BlockReader + StateProviderFactory + StateReader + Clone, - Evm: ConfigureEvm, - I: IntoIterator>, -{ - let state_provider = match job.provider_builder.build() { - Ok(provider) => provider, - Err(err) => { - trace!( - target: "engine::tree::txpool_prewarm", - %err, - parent_hash = ?job.parent_hash, - "failed to build txpool prewarming state provider" - ); - return (false, 0) - } - }; - let state_provider = TxPoolPrewarmStateProvider { inner: state_provider, cache }; - let state_provider = StateProviderDatabase::new(state_provider); - let mut state = State::builder().with_database(state_provider).build(); - let mut evm_env = job.evm_env.clone(); - evm_env.cfg_env.disable_nonce_check = true; - evm_env.cfg_env.disable_balance_check = true; - let mut evm = evm_config.evm_with_env(&mut state, evm_env); - - let mut transaction_count = 0; - for transaction in transactions { - if publication.is_paused() { - return (false, transaction_count) - } - transaction_count += 1; - - if let Err(err) = evm.transact(transaction.transaction) { - trace!( - target: "engine::tree::txpool_prewarm", - %err, - tx_hash = ?transaction.hash, - sender = %transaction.sender, - "speculative txpool transaction execution failed" - ); - } - if Instant::now() >= deadline { - break - } - } - - (true, transaction_count) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn reading_snapshot_does_not_interrupt_publication() { - let publication = Arc::new(TxPoolSnapshotPublication::default()); - assert!(publication.begin_head()); - let activity_guard = publication.try_enter().expect("publication should be active"); - let parent_hash = B256::repeat_byte(0x01); - let snapshot = TxPoolPrewarmCache::default().snapshot(parent_hash); - assert!(activity_guard.publish(snapshot)); - - assert_eq!( - publication.snapshot(parent_hash).map(|snapshot| snapshot.parent_hash()), - Some(parent_hash) - ); - assert!(publication.snapshot(B256::ZERO).is_none()); - assert!(!publication.is_paused()); - } - - #[test] - fn paused_wave_is_not_published() { - let publication = Arc::new(TxPoolSnapshotPublication::default()); - assert!(publication.begin_head()); - let activity_guard = publication.try_enter().expect("publication should be active"); - - publication.pause(); - let parent_hash = B256::repeat_byte(0x01); - let snapshot = TxPoolPrewarmCache::default().snapshot(parent_hash); - assert!(!activity_guard.publish(snapshot)); - assert!(publication.snapshot(parent_hash).is_none()); - } - - #[test] - fn pause_guard_resumes_publication_on_drop() { - let publication = Arc::new(TxPoolSnapshotPublication::default()); - - let guard = publication.pause_and_wait(); - assert!(publication.is_paused()); - - drop(guard); - assert!(!publication.is_paused()); - } -} diff --git a/crates/engine/tree/src/tree/txpool_prewarm/cache.rs b/crates/engine/tree/src/tree/txpool_prewarm/cache.rs new file mode 100644 index 00000000000..32f3fe96d79 --- /dev/null +++ b/crates/engine/tree/src/tree/txpool_prewarm/cache.rs @@ -0,0 +1,146 @@ +use crate::tree::TxPoolPrewarmCacheSnapshot as Snapshot; +use alloy_primitives::{ + map::{AddressMap, B256Map, HashMap}, + Address, BlockNumber, StorageKey, StorageValue, B256, +}; +use reth_primitives_traits::{Account, Bytecode}; +use reth_provider::StateProviderBox; +use reth_revm::database::EvmStateProvider; +use std::cell::RefCell; + +/// The read-through cache for the txpool prewarming worker. +/// +/// Wrapped through [`Self::state_provider`] and passed to the EVM to collect state reads against +/// the pre-state for the given parent hash block. +/// +/// Supports creating a [`Snapshot`] of the current cache state. +#[derive(Debug, Default)] +pub(super) struct Cache { + // NOTE: RefCell is required for implementing the EvmStateProvider trait because its getters + // are defined accepting &self. + inner: RefCell, + /// The hash of the parent block, at which state this cache is based. + parent_hash: Option, +} + +#[derive(Debug, Default)] +struct CacheInner { + accounts: AddressMap>, + storage: HashMap<(Address, StorageKey), StorageValue>, + bytecodes: B256Map>, +} + +impl Cache { + /// Clears the cache and sets the parent hash to the given value. + pub(super) fn reset(&mut self, parent_hash: B256) { + self.parent_hash = Some(parent_hash); + + let mut cache = self.inner.borrow_mut(); + cache.accounts.clear(); + cache.storage.clear(); + cache.bytecodes.clear(); + } + + /// Clones the current cache state into a [`Snapshot`]. + /// + /// Must be preceded by a call to [`Self::reset`]. + pub(super) fn snapshot(&self) -> Snapshot { + let cache = self.inner.borrow(); + Snapshot::from_parts( + self.parent_hash.expect("cache is reset before snapshotting"), + cache.accounts.clone(), + cache.storage.clone(), + cache.bytecodes.clone(), + ) + } + + pub(super) fn parent_hash(&self) -> Option { + self.parent_hash + } + + pub(super) fn state_provider(&self, inner: StateProviderBox) -> CacheStateProvider<'_> { + CacheStateProvider { inner, cache: self } + } + + fn get_or_try_insert_account_with( + &self, + address: Address, + f: impl FnOnce() -> Result, E>, + ) -> Result, E> { + if let Some(account) = self.inner.borrow().accounts.get(&address).copied() { + return Ok(account) + } + + let account = f()?; + self.inner.borrow_mut().accounts.insert(address, account); + Ok(account) + } + + fn get_or_try_insert_storage_with( + &self, + address: Address, + key: StorageKey, + f: impl FnOnce() -> Result, + ) -> Result { + if let Some(value) = self.inner.borrow().storage.get(&(address, key)).copied() { + return Ok(value) + } + + let value = f()?; + self.inner.borrow_mut().storage.insert((address, key), value); + Ok(value) + } + + fn get_or_try_insert_code_with( + &self, + code_hash: B256, + f: impl FnOnce() -> Result, E>, + ) -> Result, E> { + if let Some(code) = self.inner.borrow().bytecodes.get(&code_hash).cloned() { + return Ok(code) + } + + let code = f()?; + self.inner.borrow_mut().bytecodes.insert(code_hash, code.clone()); + Ok(code) + } +} + +/// Provider that fills only the reusable txpool-prewarm cache. +pub(super) struct CacheStateProvider<'a> { + inner: StateProviderBox, + cache: &'a Cache, +} + +impl EvmStateProvider for CacheStateProvider<'_> { + fn basic_account( + &self, + address: &Address, + ) -> reth_errors::ProviderResult> { + self.cache.get_or_try_insert_account_with(*address, || self.inner.basic_account(address)) + } + + fn block_hash(&self, number: BlockNumber) -> reth_errors::ProviderResult> { + EvmStateProvider::block_hash(&self.inner, number) + } + + fn bytecode_by_hash( + &self, + code_hash: &B256, + ) -> reth_errors::ProviderResult> { + self.cache + .get_or_try_insert_code_with(*code_hash, || self.inner.bytecode_by_hash(code_hash)) + } + + fn storage( + &self, + account: Address, + storage_key: StorageKey, + ) -> reth_errors::ProviderResult> { + self.cache + .get_or_try_insert_storage_with(account, storage_key, || { + self.inner.storage(account, storage_key).map(Option::unwrap_or_default) + }) + .map(|value| (!value.is_zero()).then_some(value)) + } +} diff --git a/crates/engine/tree/src/tree/txpool_prewarm/coordination.rs b/crates/engine/tree/src/tree/txpool_prewarm/coordination.rs new file mode 100644 index 00000000000..92f458c3078 --- /dev/null +++ b/crates/engine/tree/src/tree/txpool_prewarm/coordination.rs @@ -0,0 +1,357 @@ +use crate::tree::TxPoolPrewarmCacheSnapshot as Snapshot; +use alloy_primitives::B256; +use parking_lot::{Condvar, Mutex}; +use std::{ + fmt::Debug, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Weak, + }, + time::Duration, +}; + +/// Shared synchronization state for the txpool prewarming worker. +pub(super) struct Coordinator { + state: Mutex>, + /// Condition variable paired with the `state` mutex. + /// + /// Waiters evaluate their predicates while holding `state` and pass that mutex guard + /// here while sleeping. + /// + /// Notifications wake the worker when a new job, pause transition, or shutdown + /// changes its run condition, and wake pause callers when the active worker finishes. + /// + /// Timed retry waits also use this condition variable so state changes preempt the polling + /// delay. + changed: Condvar, + /// This is used to interrupt the ongoing worker activity and make it check the changes to + /// the state. For example, when there is a new job to be done, or when the worker needs to + /// be paused or shut down. + /// + /// This flag could be set by any thread holding the state lock, but is typically set by + /// the thread holding the handle. + activity_interrupted: AtomicBool, +} + +impl Debug for Coordinator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let state = self.state.lock(); + f.debug_struct("Coordinator") + .field("shutdown", &state.shutdown) + .field("pause_count", &state.pause_count) + .field("active", &state.active) + .field("published", &state.snapshot.as_ref().map(|snapshot| snapshot.parent_hash())) + .finish_non_exhaustive() + } +} + +impl Coordinator { + pub(super) const fn new() -> Self { + Self { + state: Mutex::new(State { + latest_job: None, + shutdown: false, + pause_count: 0, + active: false, + snapshot: None, + }), + changed: Condvar::new(), + activity_interrupted: AtomicBool::new(false), + } + } + + pub(super) fn set_job(&self, job: J) { + let replaced = { + let mut state = self.state.lock(); + if state.shutdown { + return + } + self.activity_interrupted.store(true, Ordering::Release); + state.latest_job.replace(job) + }; + drop(replaced); + self.changed.notify_all(); + } + + /// Waits until a job may run and marks the worker active before releasing the state lock. + pub(super) fn begin_activity( + self: &Arc, + has_current_job: bool, + ) -> Result<(Option, ActivityGuard), Shutdown> { + let mut state = self.state.lock(); + loop { + if state.shutdown { + return Err(Shutdown) + } + let has_runnable_job = has_current_job || state.latest_job.is_some(); + if state.active || state.pause_count != 0 || !has_runnable_job { + self.changed.wait(&mut state); + continue + } + + let replacement = state.latest_job.take(); + // Every interruption writer holds this same lock, so resetting here cannot lose an + // interruption request for the activity being started. + self.activity_interrupted.store(false, Ordering::Release); + state.active = true; + return Ok(( + replacement, + ActivityGuard { coordinator: Arc::clone(self), disarmed: false }, + )) + } + } + + /// Waits for a control-plane change or until polling should be retried. + pub(super) fn wait_for_change(&self, timeout: Duration) { + let mut state = self.state.lock(); + if !state.has_pending_interruption() { + self.changed.wait_for(&mut state, timeout); + } + } + + pub(super) fn pause(self: &Arc) -> PauseGuard { + let mut state = self.state.lock(); + state.pause_count = + state.pause_count.checked_add(1).expect("txpool prewarm pause count overflow"); + self.activity_interrupted.store(true, Ordering::Release); + self.changed.notify_all(); + while state.active { + self.changed.wait(&mut state); + } + PauseGuard { coordinator: Arc::downgrade(self) } + } + + pub(super) fn snapshot(&self, parent_hash: B256) -> Option { + self.state + .lock() + .snapshot + .as_ref() + .filter(|snapshot| snapshot.parent_hash() == parent_hash) + .cloned() + } + + pub(super) fn shutdown(&self) { + let latest = { + let mut state = self.state.lock(); + state.shutdown = true; + self.activity_interrupted.store(true, Ordering::Release); + state.latest_job.take() + }; + drop(latest); + self.changed.notify_all(); + } + + fn resume(&self) { + let mut state = self.state.lock(); + state.pause_count = state + .pause_count + .checked_sub(1) + .expect("txpool prewarm resumed without a matching pause"); + let resumed = state.pause_count == 0; + drop(state); + if resumed { + self.changed.notify_all(); + } + } + + fn finish_activity(&self, snapshot: Option) -> bool { + let mut state = self.state.lock(); + debug_assert!(state.active); + let published = if !state.has_pending_interruption() && + let Some(snapshot) = snapshot + { + state.snapshot = Some(snapshot); + true + } else { + false + }; + state.active = false; + self.changed.notify_all(); + published + } +} + +struct State { + latest_job: Option, + /// Whether the prewarming worker should shut down. + shutdown: bool, + /// How many pause guards are currently active. + /// + /// When it is non-zero, the worker will pause. When it reaches zero, the worker will resume. + pause_count: u64, + /// Whether the worker is in the active section right now. + active: bool, + /// The current snapshot of the prewarm cache based at some parent state. + snapshot: Option, +} + +impl State { + /// If true the worker should consult with the control-plane to determine if it should change + /// its course. + const fn has_pending_interruption(&self) -> bool { + self.shutdown || self.pause_count != 0 || self.latest_job.is_some() + } +} + +#[derive(Debug)] +pub(super) struct Shutdown; + +/// Keeps txpool prewarming paused until the cache-sensitive work is complete. +pub(super) struct PauseGuard { + coordinator: Weak>, +} + +impl Drop for PauseGuard { + fn drop(&mut self) { + if let Some(coordinator) = self.coordinator.upgrade() { + coordinator.resume(); + } + } +} + +pub(super) struct ActivityGuard { + coordinator: Arc>, + disarmed: bool, +} + +impl ActivityGuard { + pub(super) fn is_interrupted(&self) -> bool { + self.coordinator.activity_interrupted.load(Ordering::Acquire) + } + + pub(super) fn publish(mut self, snapshot: Snapshot) -> bool { + let published = self.coordinator.finish_activity(Some(snapshot)); + self.disarmed = true; + published + } +} + +impl Drop for ActivityGuard { + fn drop(&mut self) { + if !self.disarmed { + self.coordinator.finish_activity(None); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{sync::mpsc, thread, time::Instant}; + + type TestCoordinator = Coordinator; + + fn coordinator() -> Arc { + Arc::new(TestCoordinator::new()) + } + + fn snapshot(parent_hash: B256) -> Snapshot { + Snapshot::from_parts( + parent_hash, + Default::default(), + Default::default(), + Default::default(), + ) + } + + #[test] + fn snapshot_is_published_for_matching_parent() { + let coordinator = coordinator(); + let (_, activity_guard) = + coordinator.begin_activity(true).expect("prewarming should become active"); + let parent_hash = B256::repeat_byte(0x01); + assert!(activity_guard.publish(snapshot(parent_hash))); + + assert_eq!( + coordinator.snapshot(parent_hash).map(|snapshot| snapshot.parent_hash()), + Some(parent_hash) + ); + assert!(coordinator.snapshot(B256::ZERO).is_none()); + } + + #[test] + fn newest_job_interrupts_activity() { + let coordinator = coordinator(); + let (_, activity_guard) = + coordinator.begin_activity(true).expect("prewarming should become active"); + + coordinator.set_job(1); + coordinator.set_job(2); + assert!(activity_guard.is_interrupted()); + let parent_hash = B256::repeat_byte(0x01); + assert!(!activity_guard.publish(snapshot(parent_hash))); + assert!(coordinator.snapshot(parent_hash).is_none()); + + let (replacement, next_activity_guard) = + coordinator.begin_activity(false).expect("latest job should become active"); + assert_eq!(replacement, Some(2)); + assert!(!next_activity_guard.is_interrupted()); + } + + #[test] + fn pause_waits_for_activity_and_rejects_publication() { + let coordinator = coordinator(); + let (_, activity_guard) = + coordinator.begin_activity(true).expect("prewarming should become active"); + let waiter_coordinator = Arc::clone(&coordinator); + let (guard_tx, guard_rx) = mpsc::channel(); + let waiter = thread::spawn(move || { + let guard = waiter_coordinator.pause(); + guard_tx.send(guard).expect("pause guard receiver dropped"); + }); + + let deadline = Instant::now() + Duration::from_secs(1); + while coordinator.state.lock().pause_count == 0 { + assert!(Instant::now() < deadline, "pause request was not observed"); + thread::yield_now(); + } + assert!(guard_rx.try_recv().is_err()); + assert!(activity_guard.is_interrupted()); + + let parent_hash = B256::repeat_byte(0x01); + assert!(!activity_guard.publish(snapshot(parent_hash))); + let guard = guard_rx + .recv_timeout(Duration::from_secs(1)) + .expect("pause did not finish after activity stopped"); + waiter.join().expect("pause waiter panicked"); + assert!(coordinator.snapshot(parent_hash).is_none()); + + drop(guard); + assert_eq!(coordinator.state.lock().pause_count, 0); + } + + #[test] + fn pause_guard_does_not_retain_coordinator() { + let coordinator = coordinator(); + let weak_coordinator = Arc::downgrade(&coordinator); + let guard = coordinator.pause(); + + drop(coordinator); + assert!(weak_coordinator.upgrade().is_none()); + drop(guard); + } + + #[test] + fn overlapping_pause_guards_resume_after_last_drop() { + let coordinator = coordinator(); + + let first = coordinator.pause(); + let second = coordinator.pause(); + drop(first); + assert_eq!(coordinator.state.lock().pause_count, 1); + + drop(second); + assert_eq!(coordinator.state.lock().pause_count, 0); + assert!(coordinator.begin_activity(true).is_ok()); + } + + #[test] + fn shutdown_wakes_waiting_worker() { + let coordinator = coordinator(); + let waiter_coordinator = Arc::clone(&coordinator); + let waiter = thread::spawn(move || waiter_coordinator.begin_activity(false).is_err()); + + coordinator.shutdown(); + assert!(waiter.join().expect("worker waiter panicked")); + } +} diff --git a/crates/engine/tree/src/tree/txpool_prewarm/mod.rs b/crates/engine/tree/src/tree/txpool_prewarm/mod.rs new file mode 100644 index 00000000000..054d2de032b --- /dev/null +++ b/crates/engine/tree/src/tree/txpool_prewarm/mod.rs @@ -0,0 +1,123 @@ +//! Txpool-driven state prewarming and immutable snapshot publication. + +mod cache; +mod coordination; +mod worker; + +use self::coordination::Coordinator; +use crate::tree::{StateProviderBuilder, TxPoolPrewarmCacheSnapshot}; +use alloy_consensus::transaction::Recovered; +use alloy_primitives::{Address, B256}; +use reth_evm::{ConfigureEvm, EvmEnvFor}; +use reth_primitives_traits::{NodePrimitives, TxTy}; +use reth_provider::{BlockReader, StateProviderFactory, StateReader}; +use std::{fmt::Debug, sync::Arc}; + +/// Coordinates a long-lived worker and the latest completed immutable snapshot. +pub(crate) struct Handle +where + N: NodePrimitives, + Evm: ConfigureEvm, +{ + coordinator: Arc>>, +} + +impl Debug for Handle +where + N: NodePrimitives, + Evm: ConfigureEvm, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Handle").field("coordinator", &self.coordinator).finish() + } +} + +impl Handle +where + N: NodePrimitives, + P: BlockReader + StateProviderFactory + StateReader + Clone + Send + Sync + 'static, + Evm: ConfigureEvm + 'static, +{ + /// Spawns the long-lived worker. Its mutable cache remains owned by this worker and is cleared + /// between heads without releasing map capacity. + pub(crate) fn spawn( + runtime: &reth_tasks::Runtime, + source: Arc>, + evm_config: Evm, + ) -> Self { + let coordinator = Arc::new(Coordinator::new()); + runtime.spawn_blocking_named("txpool-prewarm", { + let coordinator = Arc::clone(&coordinator); + move || worker::run(coordinator, source, evm_config) + }); + Self { coordinator } + } + + /// Pauses speculative work. + /// + /// Returns a guard that will resume the worker when dropped. There could be multiple + /// outstanding guards, in which case the worker will not resume until all guards are dropped. + pub(crate) fn pause(&self) -> impl Drop + Send + 'static { + self.coordinator.pause() + } + + /// Returns the latest fully published snapshot for `parent_hash`, or `None` if no snapshot is + /// available for that hash. + pub(crate) fn snapshot(&self, parent_hash: B256) -> Option { + self.coordinator.snapshot(parent_hash) + } + + /// Starts continuous warming for the latest canonical head. + pub(crate) fn start( + &self, + parent_hash: B256, + evm_env: EvmEnvFor, + provider_builder: StateProviderBuilder, + ) { + self.coordinator.set_job(Job { parent_hash, evm_env, provider_builder }); + } +} + +impl Drop for Handle +where + N: NodePrimitives, + Evm: ConfigureEvm, +{ + fn drop(&mut self) { + self.coordinator.shutdown(); + } +} + +/// A live, forward-only view of the pool's best transactions for one canonical parent. +/// +/// Returning [`None`](Iterator::next) only means no transaction is currently ready. The same +/// iterator can yield transactions that become pending later. +pub type Transactions = Box> + Send>; + +/// A transaction selected from the txpool for cache-only prewarming. +#[derive(Debug, Clone)] +pub struct Transaction { + /// Transaction hash. + pub hash: B256, + /// Recovered sender. + pub sender: Address, + /// Recovered consensus transaction. + pub transaction: Recovered>, +} + +/// Source of txpool transactions for best-effort cache prewarming. +pub trait Source: Send + Sync + Debug { + /// Opens a live best-transactions iterator for `parent_hash`. + /// + /// The worker opens this once per canonical parent and retains it across empty polls, snapshot + /// publications, and validation pauses. Sources should return [`None`] if they are not yet + /// tracking `parent_hash`. + fn best_transactions(&self, parent_hash: B256) -> Option>; +} + +/// A request to warm txpool transactions against one fully validated parent state. +struct Job> { + parent_hash: B256, + evm_env: EvmEnvFor, + provider_builder: StateProviderBuilder, +} diff --git a/crates/engine/tree/src/tree/txpool_prewarm/worker.rs b/crates/engine/tree/src/tree/txpool_prewarm/worker.rs new file mode 100644 index 00000000000..ed95ee6296b --- /dev/null +++ b/crates/engine/tree/src/tree/txpool_prewarm/worker.rs @@ -0,0 +1,163 @@ +use super::{ + cache::Cache, + coordination::{Coordinator, Shutdown}, + Job, Source, Transactions, +}; +use crate::tree::StateProviderDatabase; +use alloy_evm::Evm; +use reth_evm::ConfigureEvm; +use reth_primitives_traits::NodePrimitives; +use reth_provider::{BlockReader, StateProviderFactory, StateReader}; +use reth_revm::db::State; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; +use tracing::{debug, trace}; + +/// Maximum interval between snapshot publications and delay when no transaction is ready. +const REFRESH_INTERVAL: Duration = Duration::from_millis(100); + +/// Delay while waiting for pool maintenance to advance to the state being warmed. +const HEAD_POLL_INTERVAL: Duration = Duration::from_millis(10); + +pub(super) fn run( + coordinator: Arc>>, + source: Arc>, + evm_config: Evm, +) where + N: NodePrimitives, + P: BlockReader + StateProviderFactory + StateReader + Clone + Send + Sync + 'static, + Evm: ConfigureEvm + 'static, +{ + let mut cache = Cache::default(); + let mut current_job = None; + let mut best_transactions: Option> = None; + let mut best_transactions_parent_hash = None; + + loop { + let (replacement, activity_guard) = match coordinator.begin_activity(current_job.is_some()) + { + Ok(activity) => activity, + Err(Shutdown) => break, + }; + if let Some(job) = replacement { + if best_transactions_parent_hash != Some(job.parent_hash) { + best_transactions = None; + best_transactions_parent_hash = None; + } + current_job = Some(job); + } + + let job = current_job.as_ref().expect("runnable job exists"); + + if best_transactions_parent_hash != Some(job.parent_hash) { + let Some(opened) = source.best_transactions(job.parent_hash) else { + drop(activity_guard); + coordinator.wait_for_change(HEAD_POLL_INTERVAL); + continue + }; + best_transactions = Some(opened); + best_transactions_parent_hash = Some(job.parent_hash); + } + + if cache.parent_hash() != Some(job.parent_hash) { + cache.reset(job.parent_hash); + debug!( + target: "engine::tree::txpool_prewarm", + parent_hash = ?job.parent_hash, + "started txpool prewarming" + ); + } + + let transaction_count = prewarm_batch( + &evm_config, + job, + &cache, + best_transactions.as_mut().expect("best transactions opened for active parent"), + || activity_guard.is_interrupted(), + ); + + if activity_guard.is_interrupted() { + drop(activity_guard); + continue + } + if transaction_count == 0 { + drop(activity_guard); + coordinator.wait_for_change(REFRESH_INTERVAL); + continue + } + + let snapshot = cache.snapshot(); + let (accounts, storage, bytecodes) = snapshot.entry_counts(); + if activity_guard.publish(snapshot) { + debug!( + target: "engine::tree::txpool_prewarm", + parent_hash = ?job.parent_hash, + transactions = transaction_count, + accounts, + storage, + bytecodes, + "published txpool prewarming snapshot" + ); + } + } +} + +fn prewarm_batch( + evm_config: &Evm, + job: &Job, + cache: &Cache, + transactions: &mut Transactions, + is_interrupted: impl Fn() -> bool, +) -> usize +where + N: NodePrimitives, + P: BlockReader + StateProviderFactory + StateReader + Clone, + Evm: ConfigureEvm, +{ + if is_interrupted() { + return 0 + } + + let state_provider = match job.provider_builder.build() { + Ok(provider) => provider, + Err(err) => { + trace!( + target: "engine::tree::txpool_prewarm", + %err, + parent_hash = ?job.parent_hash, + "failed to build txpool prewarming state provider" + ); + return 0 + } + }; + let state_provider = cache.state_provider(state_provider); + let state_provider = StateProviderDatabase::new(state_provider); + let mut state = State::builder().with_database(state_provider).build(); + let mut evm_env = job.evm_env.clone(); + evm_env.cfg_env.disable_nonce_check = true; + evm_env.cfg_env.disable_balance_check = true; + let mut evm = evm_config.evm_with_env(&mut state, evm_env); + + let deadline = Instant::now() + REFRESH_INTERVAL; + let mut transaction_count = 0; + loop { + if is_interrupted() || Instant::now() >= deadline { + break + } + let Some(transaction) = transactions.next() else { break }; + if let Err(err) = evm.transact(transaction.transaction) { + trace!( + target: "engine::tree::txpool_prewarm", + %err, + tx_hash = ?transaction.hash, + sender = %transaction.sender, + "speculative txpool transaction execution failed" + ); + } + transaction_count += 1; + } + + transaction_count +} diff --git a/crates/engine/tree/src/tree/types.rs b/crates/engine/tree/src/tree/types.rs index 680d2501501..effab8bd947 100644 --- a/crates/engine/tree/src/tree/types.rs +++ b/crates/engine/tree/src/tree/types.rs @@ -36,6 +36,8 @@ pub struct ExecutionEnv { /// Used to validate and optimize execution. pub decoded_bal: Option>, /// Latest completed txpool-prewarm snapshot for this block's parent state. + /// + /// Can be None if txpool prewarming is disabled or the snapshot is not ready for some reason. pub txpool_snapshot: Option, } diff --git a/crates/ethereum/evm/src/lib.rs b/crates/ethereum/evm/src/lib.rs index fda01a49cc2..719075b66ca 100644 --- a/crates/ethereum/evm/src/lib.rs +++ b/crates/ethereum/evm/src/lib.rs @@ -230,9 +230,9 @@ where fn txpool_prewarm_env( &self, parent: &Header, - parent_parent: &Header, + grandparent: &Header, ) -> Result, Self::Error> { - let block_time = parent.timestamp.saturating_sub(parent_parent.timestamp); + let block_time = parent.timestamp.saturating_sub(grandparent.timestamp); self.next_evm_env( parent, &NextBlockEnvAttributes { @@ -422,11 +422,11 @@ mod tests { #[test] fn txpool_prewarm_env_uses_parent_block_time() { let evm_config = EthEvmConfig::mainnet(); - let parent_parent = Header { timestamp: 1_000, ..Default::default() }; + let grandparent = Header { timestamp: 1_000, ..Default::default() }; let parent = Header { timestamp: 1_007, ..Default::default() }; let env = evm_config - .txpool_prewarm_env(&parent, &parent_parent) + .txpool_prewarm_env(&parent, &grandparent) .unwrap() .expect("ethereum supports txpool prewarming"); diff --git a/crates/evm/evm/src/lib.rs b/crates/evm/evm/src/lib.rs index db4cec8a0dd..fbe332759f5 100644 --- a/crates/evm/evm/src/lib.rs +++ b/crates/evm/evm/src/lib.rs @@ -248,7 +248,7 @@ pub trait ConfigureEvm: Clone + Debug + Send + Sync + Unpin { fn txpool_prewarm_env( &self, _parent: &HeaderTy, - _parent_parent: &HeaderTy, + _grandparent: &HeaderTy, ) -> Result>, Self::Error> { Ok(None) } diff --git a/crates/node/builder/src/rpc.rs b/crates/node/builder/src/rpc.rs index 2317948157e..6cabd2a291f 100644 --- a/crates/node/builder/src/rpc.rs +++ b/crates/node/builder/src/rpc.rs @@ -13,8 +13,8 @@ pub use reth_rpc_builder::{ pub use reth_trie_db::ChangesetCache; use crate::{ - invalid_block_hook::InvalidBlockHookExt, txpool_prewarm::TransactionPoolPrewarmSource, - ConfigureEngineEvm, ConsensusEngineEvent, ConsensusEngineHandle, + invalid_block_hook::InvalidBlockHookExt, txpool_prewarm, ConfigureEngineEvm, + ConsensusEngineEvent, ConsensusEngineHandle, }; use alloy_rpc_types::engine::ClientVersionV1; use alloy_rpc_types_engine::ExecutionData; @@ -1483,7 +1483,7 @@ where if txpool_prewarming { validator = - validator.with_txpool_prewarming(TransactionPoolPrewarmSource::< + validator.with_txpool_prewarming(txpool_prewarm::Source::< PrimitivesTy, _, >::new(ctx.node.pool().clone())); diff --git a/crates/node/builder/src/txpool_prewarm.rs b/crates/node/builder/src/txpool_prewarm.rs index 6e84924a804..5ad45c246b9 100644 --- a/crates/node/builder/src/txpool_prewarm.rs +++ b/crates/node/builder/src/txpool_prewarm.rs @@ -2,7 +2,8 @@ use alloy_primitives::B256; use reth_engine_tree::tree::{ - TxPoolPrewarmSource, TxPoolPrewarmTransaction, TxPoolPrewarmTransactions, + TxPoolPrewarmSource as PrewarmSource, TxPoolPrewarmTransaction as Transaction, + TxPoolPrewarmTransactions as Transactions, }; use reth_primitives_traits::{NodePrimitives, TxTy}; use reth_transaction_pool::{ @@ -10,21 +11,21 @@ use reth_transaction_pool::{ }; use std::{fmt::Debug, marker::PhantomData}; -/// [`TransactionPool`]-backed [`TxPoolPrewarmSource`]. +/// [`TransactionPool`]-backed [`PrewarmSource`]. #[derive(Debug)] -pub(crate) struct TransactionPoolPrewarmSource { +pub(crate) struct Source { pool: P, _marker: PhantomData, } -impl TransactionPoolPrewarmSource { +impl Source { /// Creates a new txpool prewarm source. pub(crate) const fn new(pool: P) -> Self { Self { pool, _marker: PhantomData } } } -impl TxPoolPrewarmSource for TransactionPoolPrewarmSource +impl PrewarmSource for Source where N: NodePrimitives, P: TransactionPool>> @@ -34,7 +35,7 @@ where + Debug + 'static, { - fn best_transactions(&self, parent_hash: B256) -> Option> { + fn best_transactions(&self, parent_hash: B256) -> Option> { let block_info = self.pool.block_info(); if block_info.last_seen_block_hash != parent_hash { return None @@ -48,7 +49,7 @@ where best.allow_updates_out_of_order(); best.skip_blobs(); - Some(Box::new(best.map(|transaction| TxPoolPrewarmTransaction { + Some(Box::new(best.map(|transaction| Transaction { hash: *transaction.hash(), sender: transaction.sender(), transaction: transaction.transaction.clone_into_consensus(), From 26440ce6117367bf9f0b702d06b6aae526f71e73 Mon Sep 17 00:00:00 2001 From: Sergei Shulepov Date: Fri, 17 Jul 2026 22:51:34 +0200 Subject: [PATCH 16/32] please clippy --- crates/engine/tree/src/tree/txpool_prewarm/cache.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/tree/src/tree/txpool_prewarm/cache.rs b/crates/engine/tree/src/tree/txpool_prewarm/cache.rs index 32f3fe96d79..a4c5d6d7edb 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm/cache.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm/cache.rs @@ -54,7 +54,7 @@ impl Cache { ) } - pub(super) fn parent_hash(&self) -> Option { + pub(super) const fn parent_hash(&self) -> Option { self.parent_hash } From 0246cf93d6817c366a5a751ab0484535eb6e6bc6 Mon Sep 17 00:00:00 2001 From: Sergei Shulepov Date: Mon, 20 Jul 2026 11:36:43 +0200 Subject: [PATCH 17/32] Tidy ups. --- crates/engine/tree/src/tree/txpool_prewarm/coordination.rs | 3 +++ crates/engine/tree/src/tree/txpool_prewarm/worker.rs | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/engine/tree/src/tree/txpool_prewarm/coordination.rs b/crates/engine/tree/src/tree/txpool_prewarm/coordination.rs index 92f458c3078..fe02209382a 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm/coordination.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm/coordination.rs @@ -74,6 +74,9 @@ impl Coordinator { } /// Waits until a job may run and marks the worker active before releasing the state lock. + /// + /// has_current_job indicates whether there is already a current job running. If there is none + /// the method will wait for a job to become available. pub(super) fn begin_activity( self: &Arc, has_current_job: bool, diff --git a/crates/engine/tree/src/tree/txpool_prewarm/worker.rs b/crates/engine/tree/src/tree/txpool_prewarm/worker.rs index ed95ee6296b..b1230c92bfe 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm/worker.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm/worker.rs @@ -49,7 +49,10 @@ pub(super) fn run( current_job = Some(job); } - let job = current_job.as_ref().expect("runnable job exists"); + // UNWRAP: `begin_activity` returns when there is a runnable job available. + // that is, either there is already a current job or when a new job becomes available. + // either way, `current_job` is guaranteed to be `Some` by this point. + let job = current_job.as_ref().unwrap(); if best_transactions_parent_hash != Some(job.parent_hash) { let Some(opened) = source.best_transactions(job.parent_hash) else { From f55cf8928bf7837ecc7286187a0007b87a721b0f Mon Sep 17 00:00:00 2001 From: Sergei Shulepov Date: Mon, 20 Jul 2026 11:45:42 +0200 Subject: [PATCH 18/32] please clippy --- crates/engine/tree/src/tree/txpool_prewarm/coordination.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/tree/src/tree/txpool_prewarm/coordination.rs b/crates/engine/tree/src/tree/txpool_prewarm/coordination.rs index fe02209382a..8fd3a8d4d06 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm/coordination.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm/coordination.rs @@ -75,7 +75,7 @@ impl Coordinator { /// Waits until a job may run and marks the worker active before releasing the state lock. /// - /// has_current_job indicates whether there is already a current job running. If there is none + /// `has_current_job` indicates whether there is already a current job running. If there is none /// the method will wait for a job to become available. pub(super) fn begin_activity( self: &Arc, From 3eb8dd2acc9a929914dc39e9e5aa520d8a725a77 Mon Sep 17 00:00:00 2001 From: Sergei Shulepov Date: Mon, 20 Jul 2026 11:45:54 +0200 Subject: [PATCH 19/32] update book --- docs/vocs/docs/pages/cli/reth/node.mdx | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/docs/vocs/docs/pages/cli/reth/node.mdx b/docs/vocs/docs/pages/cli/reth/node.mdx index a9a333afc63..374e3e20b1d 100644 --- a/docs/vocs/docs/pages/cli/reth/node.mdx +++ b/docs/vocs/docs/pages/cli/reth/node.mdx @@ -1014,21 +1014,6 @@ Engine: --engine.txpool-prewarming Enable best-effort txpool transaction prewarming between payloads - --engine.txpool-prewarming-max-transactions-per-sender - Configure the maximum transactions prewarmed per sender for one parent head - - [default: 16] - - --engine.txpool-prewarming-max-candidate-scan - Configure the maximum fresh txpool candidates considered per prewarming refresh - - [default: 4096] - - --engine.txpool-prewarming-gas-limit-multiplier - Configure the parent gas-limit multiplier used as each refresh's fresh-transaction budget - - [default: 6] - --engine.state-provider-metrics Enable state provider latency metrics. This allows the engine to collect and report stats about how long state provider calls took during execution, but this does introduce slight overhead to state provider calls From 724b6840f0381c1f6dbe2057807fd4f614cdc450 Mon Sep 17 00:00:00 2001 From: Sergei Shulepov Date: Mon, 20 Jul 2026 18:31:20 +0200 Subject: [PATCH 20/32] Flip defaults for txpool prewarming --- crates/engine/primitives/src/config.rs | 16 +++++++- .../ethereum/node/tests/e2e/selfdestruct.rs | 20 ++++++++-- crates/node/core/src/args/engine.rs | 39 ++++++++++++------- docs/vocs/docs/pages/cli/reth/node.mdx | 4 +- 4 files changed, 58 insertions(+), 21 deletions(-) diff --git a/crates/engine/primitives/src/config.rs b/crates/engine/primitives/src/config.rs index 0096e031345..486b84c6588 100644 --- a/crates/engine/primitives/src/config.rs +++ b/crates/engine/primitives/src/config.rs @@ -230,7 +230,7 @@ impl Default for TreeConfig { always_compare_trie_updates: false, disable_state_cache: false, disable_prewarming: false, - txpool_prewarming: false, + txpool_prewarming: true, state_provider_metrics: false, cross_block_cache_size: DEFAULT_CROSS_BLOCK_CACHE_SIZE, has_enough_parallelism: has_enough_parallelism(), @@ -307,7 +307,7 @@ impl TreeConfig { always_compare_trie_updates, disable_state_cache, disable_prewarming, - txpool_prewarming: false, + txpool_prewarming: true, state_provider_metrics, cross_block_cache_size, has_enough_parallelism, @@ -534,6 +534,12 @@ impl TreeConfig { self } + /// Setter for whether to disable txpool transaction prewarming. + pub const fn without_txpool_prewarming(mut self, disabled: bool) -> Self { + self.txpool_prewarming = !disabled; + self + } + /// Setter for whether to always compare trie updates from the state root task to the trie /// updates from the regular state root calculation. pub const fn with_always_compare_trie_updates( @@ -797,6 +803,12 @@ impl TreeConfig { mod tests { use super::TreeConfig; + #[test] + fn txpool_prewarming_is_enabled_by_default_and_can_be_disabled() { + assert!(TreeConfig::default().txpool_prewarming()); + assert!(!TreeConfig::default().without_txpool_prewarming(true).txpool_prewarming()); + } + #[test] fn state_root_task_requires_parallelism_without_overrides() { assert!(TreeConfig::default().with_has_enough_parallelism(true).use_state_root_task()); diff --git a/crates/ethereum/node/tests/e2e/selfdestruct.rs b/crates/ethereum/node/tests/e2e/selfdestruct.rs index 626f9d3b5e0..34b62f25c73 100644 --- a/crates/ethereum/node/tests/e2e/selfdestruct.rs +++ b/crates/ethereum/node/tests/e2e/selfdestruct.rs @@ -141,7 +141,10 @@ fn selfdestruct_contract_init_code() -> Bytes { async fn test_selfdestruct_post_dencun() -> eyre::Result<()> { reth_tracing::init_test_tracing(); - let tree_config = TreeConfig::default().without_prewarming(true).without_state_cache(false); + let tree_config = TreeConfig::default() + .without_prewarming(true) + .without_txpool_prewarming(true) + .without_state_cache(false); let (mut nodes, wallet) = setup_engine::(1, cancun_spec(), false, tree_config, eth_payload_attributes) .await?; @@ -235,7 +238,10 @@ async fn test_selfdestruct_post_dencun() -> eyre::Result<()> { async fn test_selfdestruct_same_tx_post_dencun() -> eyre::Result<()> { reth_tracing::init_test_tracing(); - let tree_config = TreeConfig::default().without_prewarming(true).without_state_cache(false); + let tree_config = TreeConfig::default() + .without_prewarming(true) + .without_txpool_prewarming(true) + .without_state_cache(false); let (mut nodes, wallet) = setup_engine::(1, cancun_spec(), false, tree_config, eth_payload_attributes) .await?; @@ -310,7 +316,10 @@ async fn test_selfdestruct_same_tx_post_dencun() -> eyre::Result<()> { async fn test_selfdestruct_pre_dencun() -> eyre::Result<()> { reth_tracing::init_test_tracing(); - let tree_config = TreeConfig::default().without_prewarming(true).without_state_cache(false); + let tree_config = TreeConfig::default() + .without_prewarming(true) + .without_txpool_prewarming(true) + .without_state_cache(false); let (mut nodes, wallet) = setup_engine::( 1, shanghai_spec(), @@ -420,7 +429,10 @@ async fn test_selfdestruct_pre_dencun() -> eyre::Result<()> { async fn test_selfdestruct_same_tx_preexisting_account_post_dencun() -> eyre::Result<()> { reth_tracing::init_test_tracing(); - let tree_config = TreeConfig::default().without_prewarming(true).without_state_cache(false); + let tree_config = TreeConfig::default() + .without_prewarming(true) + .without_txpool_prewarming(true) + .without_state_cache(false); let (mut nodes, wallet) = setup_engine::(1, cancun_spec(), false, tree_config, eth_payload_attributes) .await?; diff --git a/crates/node/core/src/args/engine.rs b/crates/node/core/src/args/engine.rs index 10717bd0bcc..f14b840d576 100644 --- a/crates/node/core/src/args/engine.rs +++ b/crates/node/core/src/args/engine.rs @@ -32,7 +32,7 @@ pub struct DefaultEngineValues { invalid_header_hit_eviction_threshold: u8, state_cache_disabled: bool, prewarming_disabled: bool, - txpool_prewarming_enabled: bool, + txpool_prewarming_disabled: bool, state_provider_metrics: bool, cross_block_cache_size: usize, state_root_task_compare_updates: bool, @@ -106,9 +106,9 @@ impl DefaultEngineValues { self } - /// Set whether to enable txpool prewarming by default - pub const fn with_txpool_prewarming_enabled(mut self, v: bool) -> Self { - self.txpool_prewarming_enabled = v; + /// Set whether to disable txpool prewarming by default + pub const fn with_txpool_prewarming_disabled(mut self, v: bool) -> Self { + self.txpool_prewarming_disabled = v; self } @@ -269,7 +269,7 @@ impl Default for DefaultEngineValues { invalid_header_hit_eviction_threshold: DEFAULT_INVALID_HEADER_HIT_EVICTION_THRESHOLD, state_cache_disabled: false, prewarming_disabled: false, - txpool_prewarming_enabled: false, + txpool_prewarming_disabled: false, state_provider_metrics: false, cross_block_cache_size: DEFAULT_CROSS_BLOCK_CACHE_SIZE_MB, state_root_task_compare_updates: false, @@ -358,9 +358,9 @@ pub struct EngineArgs { #[arg(long = "engine.disable-prewarming", alias = "engine.disable-caching-and-prewarming", default_value_t = DefaultEngineValues::get_global().prewarming_disabled)] pub prewarming_disabled: bool, - /// Enable best-effort txpool transaction prewarming between payloads. - #[arg(long = "engine.txpool-prewarming", default_value_t = DefaultEngineValues::get_global().txpool_prewarming_enabled)] - pub txpool_prewarming_enabled: bool, + /// Disable best-effort txpool transaction prewarming between payloads. + #[arg(long = "engine.disable-txpool-prewarming", default_value_t = DefaultEngineValues::get_global().txpool_prewarming_disabled)] + pub txpool_prewarming_disabled: bool, /// CAUTION: This CLI flag has no effect anymore. The parallel sparse trie is always enabled. #[deprecated] @@ -567,7 +567,7 @@ impl Default for EngineArgs { invalid_header_hit_eviction_threshold, state_cache_disabled, prewarming_disabled, - txpool_prewarming_enabled, + txpool_prewarming_disabled, state_provider_metrics, cross_block_cache_size, state_root_task_compare_updates, @@ -603,7 +603,7 @@ impl Default for EngineArgs { caching_and_prewarming_enabled: true, state_cache_disabled, prewarming_disabled, - txpool_prewarming_enabled, + txpool_prewarming_disabled, parallel_sparse_trie_enabled: true, parallel_sparse_trie_disabled: false, state_provider_metrics, @@ -676,7 +676,7 @@ impl EngineArgs { .with_invalid_header_hit_eviction_threshold(self.invalid_header_hit_eviction_threshold) .without_state_cache(self.state_cache_disabled) .without_prewarming(self.prewarming_disabled) - .with_txpool_prewarming(self.txpool_prewarming_enabled) + .without_txpool_prewarming(self.txpool_prewarming_disabled) .with_state_provider_metrics(self.state_provider_metrics) .with_always_compare_trie_updates(self.state_root_task_compare_updates) .with_cross_block_cache_size(self.cross_block_cache_size * 1024 * 1024) @@ -733,6 +733,19 @@ mod tests { ); } + #[test] + fn txpool_prewarming_is_enabled_by_default_and_can_be_disabled() { + let args = CommandParser::::parse_from(["reth"]).args; + assert!(!args.txpool_prewarming_disabled); + assert!(args.tree_config().txpool_prewarming()); + + let args = + CommandParser::::parse_from(["reth", "--engine.disable-txpool-prewarming"]) + .args; + assert!(args.txpool_prewarming_disabled); + assert!(!args.tree_config().txpool_prewarming()); + } + #[test] fn default_backpressure_threshold_uses_parsed_persistence_args() { let args = CommandParser::::parse_from([ @@ -795,7 +808,7 @@ mod tests { caching_and_prewarming_enabled: true, state_cache_disabled: true, prewarming_disabled: true, - txpool_prewarming_enabled: true, + txpool_prewarming_disabled: true, parallel_sparse_trie_enabled: true, parallel_sparse_trie_disabled: false, state_provider_metrics: true, @@ -841,7 +854,7 @@ mod tests { "--engine.legacy-state-root", "--engine.disable-state-cache", "--engine.disable-prewarming", - "--engine.txpool-prewarming", + "--engine.disable-txpool-prewarming", "--engine.state-provider-metrics", "--engine.cross-block-cache-size", "256", diff --git a/docs/vocs/docs/pages/cli/reth/node.mdx b/docs/vocs/docs/pages/cli/reth/node.mdx index 374e3e20b1d..ccd0aa298cf 100644 --- a/docs/vocs/docs/pages/cli/reth/node.mdx +++ b/docs/vocs/docs/pages/cli/reth/node.mdx @@ -1011,8 +1011,8 @@ Engine: --engine.disable-prewarming Disable parallel prewarming - --engine.txpool-prewarming - Enable best-effort txpool transaction prewarming between payloads + --engine.disable-txpool-prewarming + Disable best-effort txpool transaction prewarming between payloads --engine.state-provider-metrics Enable state provider latency metrics. This allows the engine to collect and report stats about how long state provider calls took during execution, but this does introduce slight overhead to state provider calls From 34f12a545aee3c6f574b944d51c60751f675e3bd Mon Sep 17 00:00:00 2001 From: Sergei Shulepov Date: Tue, 21 Jul 2026 14:55:18 +0200 Subject: [PATCH 21/32] remove N --- crates/node/builder/src/rpc.rs | 7 ++----- crates/node/builder/src/txpool_prewarm.rs | 17 +++++++---------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/crates/node/builder/src/rpc.rs b/crates/node/builder/src/rpc.rs index 6cabd2a291f..300b9b721c4 100644 --- a/crates/node/builder/src/rpc.rs +++ b/crates/node/builder/src/rpc.rs @@ -1482,11 +1482,8 @@ where ); if txpool_prewarming { - validator = - validator.with_txpool_prewarming(txpool_prewarm::Source::< - PrimitivesTy, - _, - >::new(ctx.node.pool().clone())); + validator = validator + .with_txpool_prewarming(txpool_prewarm::Source::new(ctx.node.pool().clone())); } Ok(validator) diff --git a/crates/node/builder/src/txpool_prewarm.rs b/crates/node/builder/src/txpool_prewarm.rs index 5ad45c246b9..8f4eb21e980 100644 --- a/crates/node/builder/src/txpool_prewarm.rs +++ b/crates/node/builder/src/txpool_prewarm.rs @@ -9,23 +9,20 @@ use reth_primitives_traits::{NodePrimitives, TxTy}; use reth_transaction_pool::{ BestTransactions, BestTransactionsAttributes, PoolTransaction, TransactionPool, }; -use std::{fmt::Debug, marker::PhantomData}; +use std::fmt::Debug; /// [`TransactionPool`]-backed [`PrewarmSource`]. #[derive(Debug)] -pub(crate) struct Source { - pool: P, - _marker: PhantomData, -} +pub(crate) struct Source

(P); -impl Source { +impl

Source

{ /// Creates a new txpool prewarm source. pub(crate) const fn new(pool: P) -> Self { - Self { pool, _marker: PhantomData } + Self(pool) } } -impl PrewarmSource for Source +impl PrewarmSource for Source

where N: NodePrimitives, P: TransactionPool>> @@ -36,13 +33,13 @@ where + 'static, { fn best_transactions(&self, parent_hash: B256) -> Option> { - let block_info = self.pool.block_info(); + let block_info = self.0.block_info(); if block_info.last_seen_block_hash != parent_hash { return None } let mut best = - self.pool.best_transactions_with_attributes(BestTransactionsAttributes::new( + self.0.best_transactions_with_attributes(BestTransactionsAttributes::new( block_info.pending_basefee, block_info.pending_blob_fee.map(|fee| u64::try_from(fee).unwrap_or(u64::MAX)), )); From a744b34da9f6962badcac16c67585e83e0b4ee2b Mon Sep 17 00:00:00 2001 From: Sergei Shulepov Date: Tue, 21 Jul 2026 16:14:10 +0200 Subject: [PATCH 22/32] chan refactor --- .../tree/src/tree/txpool_prewarm/control.rs | 194 +++++++ .../src/tree/txpool_prewarm/coordination.rs | 360 ------------- .../tree/src/tree/txpool_prewarm/mod.rs | 32 +- .../tree/src/tree/txpool_prewarm/worker.rs | 489 +++++++++++++++--- crates/node/builder/src/txpool_prewarm.rs | 9 +- 5 files changed, 627 insertions(+), 457 deletions(-) create mode 100644 crates/engine/tree/src/tree/txpool_prewarm/control.rs delete mode 100644 crates/engine/tree/src/tree/txpool_prewarm/coordination.rs diff --git a/crates/engine/tree/src/tree/txpool_prewarm/control.rs b/crates/engine/tree/src/tree/txpool_prewarm/control.rs new file mode 100644 index 00000000000..e6459bfa59e --- /dev/null +++ b/crates/engine/tree/src/tree/txpool_prewarm/control.rs @@ -0,0 +1,194 @@ +use crate::tree::TxPoolPrewarmCacheSnapshot as Snapshot; +use alloy_primitives::B256; +use crossbeam_channel::{bounded, unbounded, Receiver, Sender}; +use parking_lot::RwLock; +use std::{ + fmt::Debug, + sync::{Arc, Weak}, +}; + +/// Sending side of the txpool prewarming worker's control channel. +pub(super) struct Control { + commands: Sender>, + publication: Publication, +} + +impl Debug for Control { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Control") + .field( + "published", + &self.publication.read().as_ref().map(|snapshot| snapshot.parent_hash()), + ) + .finish_non_exhaustive() + } +} + +impl Control { + pub(super) fn new() -> (Arc, Receiver>) { + let (commands, receiver) = unbounded(); + let publication = Arc::new(RwLock::new(None)); + (Arc::new(Self { commands, publication }), receiver) + } + + pub(super) fn publication(&self) -> Publication { + Arc::clone(&self.publication) + } + + pub(super) fn start(&self, job: J) { + let _ = self.commands.send(Command::Start(job)); + } + + pub(super) fn pause(self: &Arc) -> PauseGuard { + let (acknowledge, acknowledged) = bounded(1); + let mut guard = PauseGuard { control: Arc::downgrade(self), armed: false }; + if self.commands.send(Command::Pause(acknowledge)).is_ok() { + // Arm before waiting so unwinding the caller still releases a pause that the worker + // may already have observed. + guard.armed = true; + let _ = acknowledged.recv(); + } + guard + } + + pub(super) fn snapshot(&self, parent_hash: B256) -> Option { + self.publication + .read() + .as_ref() + .filter(|snapshot| snapshot.parent_hash() == parent_hash) + .cloned() + } +} + +/// Immutable snapshot publication shared by the handle and worker. +pub(super) type Publication = Arc>>; + +/// Commands sent to the txpool prewarming worker. +pub(super) enum Command { + /// Starts prewarming for a canonical head, replacing any previous job. + Start(J), + /// Pauses prewarming and acknowledges once the worker has released its active resources. + Pause(Sender<()>), + /// Releases one active pause. + Resume, +} + +/// Keeps txpool prewarming paused until the cache-sensitive work is complete. +pub(super) struct PauseGuard { + control: Weak>, + armed: bool, +} + +impl Drop for PauseGuard { + fn drop(&mut self) { + if self.armed && + let Some(control) = self.control.upgrade() + { + let _ = control.commands.send(Command::Resume); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{sync::mpsc, thread, time::Duration}; + + type TestControl = Control; + + fn control() -> (Arc, Receiver>) { + TestControl::new() + } + + fn snapshot(parent_hash: B256) -> Snapshot { + Snapshot::from_parts( + parent_hash, + Default::default(), + Default::default(), + Default::default(), + ) + } + + fn request_pause( + control: Arc, + receiver: &Receiver>, + ) -> PauseGuard { + let (guard_tx, guard_rx) = mpsc::channel(); + let waiter = thread::spawn(move || { + guard_tx.send(control.pause()).expect("pause guard receiver dropped"); + }); + + let command = + receiver.recv_timeout(Duration::from_secs(1)).expect("pause command was not received"); + let Command::Pause(acknowledge) = command else { panic!("expected pause command") }; + assert!(guard_rx.try_recv().is_err()); + acknowledge.send(()).expect("pause waiter dropped"); + + let guard = guard_rx + .recv_timeout(Duration::from_secs(1)) + .expect("pause did not finish after acknowledgement"); + waiter.join().expect("pause waiter panicked"); + guard + } + + #[test] + fn snapshot_is_published_for_matching_parent() { + let (control, _receiver) = control(); + let parent_hash = B256::repeat_byte(0x01); + *control.publication.write() = Some(snapshot(parent_hash)); + + assert_eq!( + control.snapshot(parent_hash).map(|snapshot| snapshot.parent_hash()), + Some(parent_hash) + ); + assert!(control.snapshot(B256::ZERO).is_none()); + } + + #[test] + fn start_sends_job() { + let (control, receiver) = control(); + control.start(1); + + assert!(matches!(receiver.try_recv(), Ok(Command::Start(1)))); + } + + #[test] + fn pause_waits_for_acknowledgement_and_resumes_on_drop() { + let (control, receiver) = control(); + let guard = request_pause(Arc::clone(&control), &receiver); + + drop(guard); + assert!(matches!(receiver.try_recv(), Ok(Command::Resume))); + } + + #[test] + fn pause_guard_does_not_retain_control() { + let (control, receiver) = control(); + let weak_control = Arc::downgrade(&control); + let guard = request_pause(Arc::clone(&control), &receiver); + + drop(control); + assert!(weak_control.upgrade().is_none()); + drop(guard); + } + + #[test] + fn overlapping_pause_guards_send_matching_resumes() { + let (control, receiver) = control(); + let first = request_pause(Arc::clone(&control), &receiver); + let second = request_pause(Arc::clone(&control), &receiver); + + drop(first); + assert!(matches!(receiver.try_recv(), Ok(Command::Resume))); + drop(second); + assert!(matches!(receiver.try_recv(), Ok(Command::Resume))); + } + + #[test] + fn dropping_control_disconnects_worker() { + let (control, receiver) = control(); + drop(control); + + assert!(receiver.recv().is_err()); + } +} diff --git a/crates/engine/tree/src/tree/txpool_prewarm/coordination.rs b/crates/engine/tree/src/tree/txpool_prewarm/coordination.rs deleted file mode 100644 index 8fd3a8d4d06..00000000000 --- a/crates/engine/tree/src/tree/txpool_prewarm/coordination.rs +++ /dev/null @@ -1,360 +0,0 @@ -use crate::tree::TxPoolPrewarmCacheSnapshot as Snapshot; -use alloy_primitives::B256; -use parking_lot::{Condvar, Mutex}; -use std::{ - fmt::Debug, - sync::{ - atomic::{AtomicBool, Ordering}, - Arc, Weak, - }, - time::Duration, -}; - -/// Shared synchronization state for the txpool prewarming worker. -pub(super) struct Coordinator { - state: Mutex>, - /// Condition variable paired with the `state` mutex. - /// - /// Waiters evaluate their predicates while holding `state` and pass that mutex guard - /// here while sleeping. - /// - /// Notifications wake the worker when a new job, pause transition, or shutdown - /// changes its run condition, and wake pause callers when the active worker finishes. - /// - /// Timed retry waits also use this condition variable so state changes preempt the polling - /// delay. - changed: Condvar, - /// This is used to interrupt the ongoing worker activity and make it check the changes to - /// the state. For example, when there is a new job to be done, or when the worker needs to - /// be paused or shut down. - /// - /// This flag could be set by any thread holding the state lock, but is typically set by - /// the thread holding the handle. - activity_interrupted: AtomicBool, -} - -impl Debug for Coordinator { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let state = self.state.lock(); - f.debug_struct("Coordinator") - .field("shutdown", &state.shutdown) - .field("pause_count", &state.pause_count) - .field("active", &state.active) - .field("published", &state.snapshot.as_ref().map(|snapshot| snapshot.parent_hash())) - .finish_non_exhaustive() - } -} - -impl Coordinator { - pub(super) const fn new() -> Self { - Self { - state: Mutex::new(State { - latest_job: None, - shutdown: false, - pause_count: 0, - active: false, - snapshot: None, - }), - changed: Condvar::new(), - activity_interrupted: AtomicBool::new(false), - } - } - - pub(super) fn set_job(&self, job: J) { - let replaced = { - let mut state = self.state.lock(); - if state.shutdown { - return - } - self.activity_interrupted.store(true, Ordering::Release); - state.latest_job.replace(job) - }; - drop(replaced); - self.changed.notify_all(); - } - - /// Waits until a job may run and marks the worker active before releasing the state lock. - /// - /// `has_current_job` indicates whether there is already a current job running. If there is none - /// the method will wait for a job to become available. - pub(super) fn begin_activity( - self: &Arc, - has_current_job: bool, - ) -> Result<(Option, ActivityGuard), Shutdown> { - let mut state = self.state.lock(); - loop { - if state.shutdown { - return Err(Shutdown) - } - let has_runnable_job = has_current_job || state.latest_job.is_some(); - if state.active || state.pause_count != 0 || !has_runnable_job { - self.changed.wait(&mut state); - continue - } - - let replacement = state.latest_job.take(); - // Every interruption writer holds this same lock, so resetting here cannot lose an - // interruption request for the activity being started. - self.activity_interrupted.store(false, Ordering::Release); - state.active = true; - return Ok(( - replacement, - ActivityGuard { coordinator: Arc::clone(self), disarmed: false }, - )) - } - } - - /// Waits for a control-plane change or until polling should be retried. - pub(super) fn wait_for_change(&self, timeout: Duration) { - let mut state = self.state.lock(); - if !state.has_pending_interruption() { - self.changed.wait_for(&mut state, timeout); - } - } - - pub(super) fn pause(self: &Arc) -> PauseGuard { - let mut state = self.state.lock(); - state.pause_count = - state.pause_count.checked_add(1).expect("txpool prewarm pause count overflow"); - self.activity_interrupted.store(true, Ordering::Release); - self.changed.notify_all(); - while state.active { - self.changed.wait(&mut state); - } - PauseGuard { coordinator: Arc::downgrade(self) } - } - - pub(super) fn snapshot(&self, parent_hash: B256) -> Option { - self.state - .lock() - .snapshot - .as_ref() - .filter(|snapshot| snapshot.parent_hash() == parent_hash) - .cloned() - } - - pub(super) fn shutdown(&self) { - let latest = { - let mut state = self.state.lock(); - state.shutdown = true; - self.activity_interrupted.store(true, Ordering::Release); - state.latest_job.take() - }; - drop(latest); - self.changed.notify_all(); - } - - fn resume(&self) { - let mut state = self.state.lock(); - state.pause_count = state - .pause_count - .checked_sub(1) - .expect("txpool prewarm resumed without a matching pause"); - let resumed = state.pause_count == 0; - drop(state); - if resumed { - self.changed.notify_all(); - } - } - - fn finish_activity(&self, snapshot: Option) -> bool { - let mut state = self.state.lock(); - debug_assert!(state.active); - let published = if !state.has_pending_interruption() && - let Some(snapshot) = snapshot - { - state.snapshot = Some(snapshot); - true - } else { - false - }; - state.active = false; - self.changed.notify_all(); - published - } -} - -struct State { - latest_job: Option, - /// Whether the prewarming worker should shut down. - shutdown: bool, - /// How many pause guards are currently active. - /// - /// When it is non-zero, the worker will pause. When it reaches zero, the worker will resume. - pause_count: u64, - /// Whether the worker is in the active section right now. - active: bool, - /// The current snapshot of the prewarm cache based at some parent state. - snapshot: Option, -} - -impl State { - /// If true the worker should consult with the control-plane to determine if it should change - /// its course. - const fn has_pending_interruption(&self) -> bool { - self.shutdown || self.pause_count != 0 || self.latest_job.is_some() - } -} - -#[derive(Debug)] -pub(super) struct Shutdown; - -/// Keeps txpool prewarming paused until the cache-sensitive work is complete. -pub(super) struct PauseGuard { - coordinator: Weak>, -} - -impl Drop for PauseGuard { - fn drop(&mut self) { - if let Some(coordinator) = self.coordinator.upgrade() { - coordinator.resume(); - } - } -} - -pub(super) struct ActivityGuard { - coordinator: Arc>, - disarmed: bool, -} - -impl ActivityGuard { - pub(super) fn is_interrupted(&self) -> bool { - self.coordinator.activity_interrupted.load(Ordering::Acquire) - } - - pub(super) fn publish(mut self, snapshot: Snapshot) -> bool { - let published = self.coordinator.finish_activity(Some(snapshot)); - self.disarmed = true; - published - } -} - -impl Drop for ActivityGuard { - fn drop(&mut self) { - if !self.disarmed { - self.coordinator.finish_activity(None); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::{sync::mpsc, thread, time::Instant}; - - type TestCoordinator = Coordinator; - - fn coordinator() -> Arc { - Arc::new(TestCoordinator::new()) - } - - fn snapshot(parent_hash: B256) -> Snapshot { - Snapshot::from_parts( - parent_hash, - Default::default(), - Default::default(), - Default::default(), - ) - } - - #[test] - fn snapshot_is_published_for_matching_parent() { - let coordinator = coordinator(); - let (_, activity_guard) = - coordinator.begin_activity(true).expect("prewarming should become active"); - let parent_hash = B256::repeat_byte(0x01); - assert!(activity_guard.publish(snapshot(parent_hash))); - - assert_eq!( - coordinator.snapshot(parent_hash).map(|snapshot| snapshot.parent_hash()), - Some(parent_hash) - ); - assert!(coordinator.snapshot(B256::ZERO).is_none()); - } - - #[test] - fn newest_job_interrupts_activity() { - let coordinator = coordinator(); - let (_, activity_guard) = - coordinator.begin_activity(true).expect("prewarming should become active"); - - coordinator.set_job(1); - coordinator.set_job(2); - assert!(activity_guard.is_interrupted()); - let parent_hash = B256::repeat_byte(0x01); - assert!(!activity_guard.publish(snapshot(parent_hash))); - assert!(coordinator.snapshot(parent_hash).is_none()); - - let (replacement, next_activity_guard) = - coordinator.begin_activity(false).expect("latest job should become active"); - assert_eq!(replacement, Some(2)); - assert!(!next_activity_guard.is_interrupted()); - } - - #[test] - fn pause_waits_for_activity_and_rejects_publication() { - let coordinator = coordinator(); - let (_, activity_guard) = - coordinator.begin_activity(true).expect("prewarming should become active"); - let waiter_coordinator = Arc::clone(&coordinator); - let (guard_tx, guard_rx) = mpsc::channel(); - let waiter = thread::spawn(move || { - let guard = waiter_coordinator.pause(); - guard_tx.send(guard).expect("pause guard receiver dropped"); - }); - - let deadline = Instant::now() + Duration::from_secs(1); - while coordinator.state.lock().pause_count == 0 { - assert!(Instant::now() < deadline, "pause request was not observed"); - thread::yield_now(); - } - assert!(guard_rx.try_recv().is_err()); - assert!(activity_guard.is_interrupted()); - - let parent_hash = B256::repeat_byte(0x01); - assert!(!activity_guard.publish(snapshot(parent_hash))); - let guard = guard_rx - .recv_timeout(Duration::from_secs(1)) - .expect("pause did not finish after activity stopped"); - waiter.join().expect("pause waiter panicked"); - assert!(coordinator.snapshot(parent_hash).is_none()); - - drop(guard); - assert_eq!(coordinator.state.lock().pause_count, 0); - } - - #[test] - fn pause_guard_does_not_retain_coordinator() { - let coordinator = coordinator(); - let weak_coordinator = Arc::downgrade(&coordinator); - let guard = coordinator.pause(); - - drop(coordinator); - assert!(weak_coordinator.upgrade().is_none()); - drop(guard); - } - - #[test] - fn overlapping_pause_guards_resume_after_last_drop() { - let coordinator = coordinator(); - - let first = coordinator.pause(); - let second = coordinator.pause(); - drop(first); - assert_eq!(coordinator.state.lock().pause_count, 1); - - drop(second); - assert_eq!(coordinator.state.lock().pause_count, 0); - assert!(coordinator.begin_activity(true).is_ok()); - } - - #[test] - fn shutdown_wakes_waiting_worker() { - let coordinator = coordinator(); - let waiter_coordinator = Arc::clone(&coordinator); - let waiter = thread::spawn(move || waiter_coordinator.begin_activity(false).is_err()); - - coordinator.shutdown(); - assert!(waiter.join().expect("worker waiter panicked")); - } -} diff --git a/crates/engine/tree/src/tree/txpool_prewarm/mod.rs b/crates/engine/tree/src/tree/txpool_prewarm/mod.rs index 054d2de032b..01dc20ae875 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm/mod.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm/mod.rs @@ -1,10 +1,10 @@ //! Txpool-driven state prewarming and immutable snapshot publication. mod cache; -mod coordination; +mod control; mod worker; -use self::coordination::Coordinator; +use self::control::Control; use crate::tree::{StateProviderBuilder, TxPoolPrewarmCacheSnapshot}; use alloy_consensus::transaction::Recovered; use alloy_primitives::{Address, B256}; @@ -19,7 +19,7 @@ where N: NodePrimitives, Evm: ConfigureEvm, { - coordinator: Arc>>, + control: Arc>>, } impl Debug for Handle @@ -28,7 +28,7 @@ where Evm: ConfigureEvm, { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Handle").field("coordinator", &self.coordinator).finish() + f.debug_struct("Handle").field("control", &self.control).finish() } } @@ -45,12 +45,12 @@ where source: Arc>, evm_config: Evm, ) -> Self { - let coordinator = Arc::new(Coordinator::new()); + let (control, commands) = Control::new(); + let publication = control.publication(); runtime.spawn_blocking_named("txpool-prewarm", { - let coordinator = Arc::clone(&coordinator); - move || worker::run(coordinator, source, evm_config) + move || worker::run(commands, publication, source, evm_config) }); - Self { coordinator } + Self { control } } /// Pauses speculative work. @@ -58,13 +58,13 @@ where /// Returns a guard that will resume the worker when dropped. There could be multiple /// outstanding guards, in which case the worker will not resume until all guards are dropped. pub(crate) fn pause(&self) -> impl Drop + Send + 'static { - self.coordinator.pause() + self.control.pause() } /// Returns the latest fully published snapshot for `parent_hash`, or `None` if no snapshot is /// available for that hash. pub(crate) fn snapshot(&self, parent_hash: B256) -> Option { - self.coordinator.snapshot(parent_hash) + self.control.snapshot(parent_hash) } /// Starts continuous warming for the latest canonical head. @@ -74,17 +74,7 @@ where evm_env: EvmEnvFor, provider_builder: StateProviderBuilder, ) { - self.coordinator.set_job(Job { parent_hash, evm_env, provider_builder }); - } -} - -impl Drop for Handle -where - N: NodePrimitives, - Evm: ConfigureEvm, -{ - fn drop(&mut self) { - self.coordinator.shutdown(); + self.control.start(Job { parent_hash, evm_env, provider_builder }); } } diff --git a/crates/engine/tree/src/tree/txpool_prewarm/worker.rs b/crates/engine/tree/src/tree/txpool_prewarm/worker.rs index b1230c92bfe..664172dd674 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm/worker.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm/worker.rs @@ -1,10 +1,12 @@ use super::{ cache::Cache, - coordination::{Coordinator, Shutdown}, + control::{Command, Publication}, Job, Source, Transactions, }; use crate::tree::StateProviderDatabase; use alloy_evm::Evm; +use alloy_primitives::B256; +use crossbeam_channel::{Receiver, RecvTimeoutError, TryRecvError}; use reth_evm::ConfigureEvm; use reth_primitives_traits::NodePrimitives; use reth_provider::{BlockReader, StateProviderFactory, StateReader}; @@ -22,7 +24,8 @@ const REFRESH_INTERVAL: Duration = Duration::from_millis(100); const HEAD_POLL_INTERVAL: Duration = Duration::from_millis(10); pub(super) fn run( - coordinator: Arc>>, + commands: Receiver>>, + publication: Publication, source: Arc>, evm_config: Evm, ) where @@ -30,79 +33,76 @@ pub(super) fn run( P: BlockReader + StateProviderFactory + StateReader + Clone + Send + Sync + 'static, Evm: ConfigureEvm + 'static, { - let mut cache = Cache::default(); - let mut current_job = None; - let mut best_transactions: Option> = None; - let mut best_transactions_parent_hash = None; + // Dropping the control sender is the worker's normal shutdown signal. + let _ = run_until_disconnected(commands, publication, source, evm_config); +} - loop { - let (replacement, activity_guard) = match coordinator.begin_activity(current_job.is_some()) - { - Ok(activity) => activity, - Err(Shutdown) => break, - }; - if let Some(job) = replacement { - if best_transactions_parent_hash != Some(job.parent_hash) { - best_transactions = None; - best_transactions_parent_hash = None; - } - current_job = Some(job); - } +fn run_until_disconnected( + commands: Receiver>>, + publication: Publication, + source: Arc>, + evm_config: Evm, +) -> Result<(), ChannelDisconnected> +where + N: NodePrimitives, + P: BlockReader + StateProviderFactory + StateReader + Clone + Send + Sync + 'static, + Evm: ConfigureEvm + 'static, +{ + let mut state = WorkerState::new(); - // UNWRAP: `begin_activity` returns when there is a runnable job available. - // that is, either there is already a current job or when a new job becomes available. - // either way, `current_job` is guaranteed to be `Some` by this point. - let job = current_job.as_ref().unwrap(); + loop { + drain_commands(&commands, &mut state)?; + let parent_hash = wait_for_runnable_job(&commands, &mut state)?; - if best_transactions_parent_hash != Some(job.parent_hash) { - let Some(opened) = source.best_transactions(job.parent_hash) else { - drop(activity_guard); - coordinator.wait_for_change(HEAD_POLL_INTERVAL); + if state.best_transactions_parent_hash != Some(parent_hash) { + let Some(opened) = source.best_transactions(parent_hash) else { + wait_for_command(&commands, &mut state, HEAD_POLL_INTERVAL)?; continue }; - best_transactions = Some(opened); - best_transactions_parent_hash = Some(job.parent_hash); + state.best_transactions = Some(opened); + state.best_transactions_parent_hash = Some(parent_hash); } - if cache.parent_hash() != Some(job.parent_hash) { - cache.reset(job.parent_hash); + if state.cache.parent_hash() != Some(parent_hash) { + state.cache.reset(parent_hash); + state.unpublished_transactions = 0; debug!( target: "engine::tree::txpool_prewarm", - parent_hash = ?job.parent_hash, + ?parent_hash, "started txpool prewarming" ); } - let transaction_count = prewarm_batch( - &evm_config, - job, - &cache, - best_transactions.as_mut().expect("best transactions opened for active parent"), - || activity_guard.is_interrupted(), - ); + // `wait_for_runnable_job` returned this job's parent hash, and the branch above either + // retained or opened its matching transaction iterator. No command has been processed + // since, so both are still installed here. + let job = state.current_job.as_ref().unwrap(); + let transactions = state.best_transactions.as_mut().unwrap(); + let outcome = prewarm_batch(&evm_config, job, &state.cache, transactions, &commands)?; - if activity_guard.is_interrupted() { - drop(activity_guard); - continue - } - if transaction_count == 0 { - drop(activity_guard); - coordinator.wait_for_change(REFRESH_INTERVAL); - continue - } + match outcome { + BatchOutcome::Completed { executed, end } => { + state.record_unpublished(executed); + match publish_if_dirty(&commands, &publication, &mut state)? { + PublishOutcome::Interrupted => continue, + PublishOutcome::Published | PublishOutcome::Unchanged => {} + } - let snapshot = cache.snapshot(); - let (accounts, storage, bytecodes) = snapshot.entry_counts(); - if activity_guard.publish(snapshot) { - debug!( - target: "engine::tree::txpool_prewarm", - parent_hash = ?job.parent_hash, - transactions = transaction_count, - accounts, - storage, - bytecodes, - "published txpool prewarming snapshot" - ); + if end == BatchEnd::Empty { + wait_for_command(&commands, &mut state, REFRESH_INTERVAL)?; + } + } + BatchOutcome::Interrupted { executed, command } => { + state.record_unpublished(executed); + state.apply_command(command); + } + BatchOutcome::ProviderUnavailable => { + match publish_if_dirty(&commands, &publication, &mut state)? { + PublishOutcome::Interrupted => continue, + PublishOutcome::Published | PublishOutcome::Unchanged => {} + } + wait_for_command(&commands, &mut state, REFRESH_INTERVAL)?; + } } } } @@ -112,15 +112,17 @@ fn prewarm_batch( job: &Job, cache: &Cache, transactions: &mut Transactions, - is_interrupted: impl Fn() -> bool, -) -> usize + commands: &Receiver>>, +) -> Result>, ChannelDisconnected> where N: NodePrimitives, P: BlockReader + StateProviderFactory + StateReader + Clone, Evm: ConfigureEvm, { - if is_interrupted() { - return 0 + match commands.try_recv() { + Ok(command) => return Ok(BatchOutcome::Interrupted { executed: 0, command }), + Err(TryRecvError::Disconnected) => return Err(ChannelDisconnected), + Err(TryRecvError::Empty) => {} } let state_provider = match job.provider_builder.build() { @@ -132,7 +134,7 @@ where parent_hash = ?job.parent_hash, "failed to build txpool prewarming state provider" ); - return 0 + return Ok(BatchOutcome::ProviderUnavailable) } }; let state_provider = cache.state_provider(state_provider); @@ -144,12 +146,21 @@ where let mut evm = evm_config.evm_with_env(&mut state, evm_env); let deadline = Instant::now() + REFRESH_INTERVAL; - let mut transaction_count = 0; + let mut executed = 0; loop { - if is_interrupted() || Instant::now() >= deadline { - break + match commands.try_recv() { + Ok(command) => return Ok(BatchOutcome::Interrupted { executed, command }), + Err(TryRecvError::Disconnected) => return Err(ChannelDisconnected), + Err(TryRecvError::Empty) => {} + } + + if Instant::now() >= deadline { + return Ok(BatchOutcome::Completed { executed, end: BatchEnd::Deadline }) } - let Some(transaction) = transactions.next() else { break }; + + let Some(transaction) = transactions.next() else { + return Ok(BatchOutcome::Completed { executed, end: BatchEnd::Empty }) + }; if let Err(err) = evm.transact(transaction.transaction) { trace!( target: "engine::tree::txpool_prewarm", @@ -159,8 +170,344 @@ where "speculative txpool transaction execution failed" ); } - transaction_count += 1; + executed += 1; } +} - transaction_count +fn wait_for_runnable_job( + commands: &Receiver>, + state: &mut WorkerState, +) -> Result +where + J: JobParent, + N: NodePrimitives, +{ + loop { + if state.pause_count == 0 && + let Some(job) = state.current_job.as_ref() + { + return Ok(job.parent_hash()) + } + + let command = commands.recv().map_err(|_| ChannelDisconnected)?; + state.apply_command(command); + drain_commands(commands, state)?; + } +} + +fn wait_for_command( + commands: &Receiver>, + state: &mut WorkerState, + timeout: Duration, +) -> Result<(), ChannelDisconnected> +where + J: JobParent, + N: NodePrimitives, +{ + match commands.recv_timeout(timeout) { + Ok(command) => { + state.apply_command(command); + drain_commands(commands, state)?; + Ok(()) + } + Err(RecvTimeoutError::Timeout) => Ok(()), + Err(RecvTimeoutError::Disconnected) => Err(ChannelDisconnected), + } +} + +fn drain_commands( + commands: &Receiver>, + state: &mut WorkerState, +) -> Result +where + J: JobParent, + N: NodePrimitives, +{ + let mut result = DrainOutcome::default(); + loop { + match commands.try_recv() { + Ok(command) => result.invalidated_batch |= state.apply_command(command), + Err(TryRecvError::Empty) => return Ok(result), + Err(TryRecvError::Disconnected) => return Err(ChannelDisconnected), + } + } +} + +fn publish_if_dirty( + commands: &Receiver>, + publication: &Publication, + state: &mut WorkerState, +) -> Result +where + J: JobParent, + N: NodePrimitives, +{ + let drained = drain_commands(commands, state)?; + if drained.invalidated_batch { + return Ok(PublishOutcome::Interrupted) + } + if state.unpublished_transactions == 0 { + return Ok(PublishOutcome::Unchanged) + } + + let snapshot = state.cache.snapshot(); + let drained = drain_commands(commands, state)?; + if drained.invalidated_batch { + return Ok(PublishOutcome::Interrupted) + } + + let transactions = state.unpublished_transactions; + let parent_hash = snapshot.parent_hash(); + let (accounts, storage, bytecodes) = snapshot.entry_counts(); + *publication.write() = Some(snapshot); + state.unpublished_transactions = 0; + debug!( + target: "engine::tree::txpool_prewarm", + ?parent_hash, + transactions, + accounts, + storage, + bytecodes, + "published txpool prewarming snapshot" + ); + Ok(PublishOutcome::Published) +} + +trait JobParent { + fn parent_hash(&self) -> B256; +} + +impl JobParent for Job +where + N: NodePrimitives, + Evm: ConfigureEvm, +{ + fn parent_hash(&self) -> B256 { + self.parent_hash + } +} + +struct WorkerState +where + J: JobParent, + N: NodePrimitives, +{ + current_job: Option, + best_transactions: Option>, + best_transactions_parent_hash: Option, + pause_count: u64, + cache: Cache, + /// Number of transactions whose cache reads have not yet been published. + unpublished_transactions: usize, +} + +impl WorkerState +where + J: JobParent, + N: NodePrimitives, +{ + fn new() -> Self { + Self { + current_job: None, + best_transactions: None, + best_transactions_parent_hash: None, + pause_count: 0, + cache: Cache::default(), + unpublished_transactions: 0, + } + } + + /// Applies `command` while no EVM or state provider is active. + /// + /// Returns whether the command invalidates a batch being considered for publication. + fn apply_command(&mut self, command: Command) -> bool { + match command { + Command::Start(job) => { + if self.best_transactions_parent_hash != Some(job.parent_hash()) { + self.best_transactions = None; + self.best_transactions_parent_hash = None; + } + self.current_job = Some(job); + true + } + Command::Pause(acknowledge) => { + self.pause_count = + self.pause_count.checked_add(1).expect("txpool prewarm pause count overflow"); + let _ = acknowledge.send(()); + true + } + Command::Resume => { + self.pause_count = self + .pause_count + .checked_sub(1) + .expect("txpool prewarm resumed without a matching pause"); + false + } + } + } + + const fn record_unpublished(&mut self, transactions: usize) { + self.unpublished_transactions = self.unpublished_transactions.saturating_add(transactions); + } +} + +#[derive(Debug, Default)] +struct DrainOutcome { + invalidated_batch: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ChannelDisconnected; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BatchEnd { + Deadline, + Empty, +} + +enum BatchOutcome { + Completed { executed: usize, end: BatchEnd }, + Interrupted { executed: usize, command: Command }, + ProviderUnavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PublishOutcome { + Published, + Unchanged, + Interrupted, +} + +#[cfg(test)] +mod tests { + use super::*; + use crossbeam_channel::{bounded, unbounded}; + use parking_lot::RwLock; + use reth_ethereum_primitives::EthPrimitives; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + struct TestJob(B256); + + impl JobParent for TestJob { + fn parent_hash(&self) -> B256 { + self.0 + } + } + + fn state() -> WorkerState { + WorkerState::new() + } + + fn publication() -> Publication { + Arc::new(RwLock::new(None)) + } + + #[test] + fn newest_job_wins() { + let (sender, receiver) = unbounded(); + let mut state = state(); + let first = B256::repeat_byte(0x01); + let second = B256::repeat_byte(0x02); + sender.send(Command::Start(TestJob(first))).unwrap(); + sender.send(Command::Start(TestJob(second))).unwrap(); + + let drained = drain_commands(&receiver, &mut state).unwrap(); + assert!(drained.invalidated_batch); + assert_eq!(state.current_job, Some(TestJob(second))); + } + + #[test] + fn overlapping_pauses_resume_after_last_command() { + let (sender, receiver) = unbounded(); + let mut state = state(); + let (first_ack, first_acknowledged) = bounded(1); + let (second_ack, second_acknowledged) = bounded(1); + sender.send(Command::Pause(first_ack)).unwrap(); + sender.send(Command::Pause(second_ack)).unwrap(); + + drain_commands(&receiver, &mut state).unwrap(); + first_acknowledged.recv().unwrap(); + second_acknowledged.recv().unwrap(); + assert_eq!(state.pause_count, 2); + + sender.send(Command::Resume).unwrap(); + drain_commands(&receiver, &mut state).unwrap(); + assert_eq!(state.pause_count, 1); + + sender.send(Command::Resume).unwrap(); + drain_commands(&receiver, &mut state).unwrap(); + assert_eq!(state.pause_count, 0); + } + + #[test] + fn start_while_paused_replaces_pending_job() { + let (sender, receiver) = unbounded(); + let mut state = state(); + let (acknowledge, acknowledged) = bounded(1); + sender.send(Command::Pause(acknowledge)).unwrap(); + sender.send(Command::Start(TestJob(B256::repeat_byte(0x01)))).unwrap(); + sender.send(Command::Start(TestJob(B256::repeat_byte(0x02)))).unwrap(); + + drain_commands(&receiver, &mut state).unwrap(); + acknowledged.recv().unwrap(); + assert_eq!(state.current_job, Some(TestJob(B256::repeat_byte(0x02)))); + assert_eq!(state.pause_count, 1); + } + + #[test] + fn runnable_wait_returns_current_parent() { + let (sender, receiver) = unbounded(); + let mut state = state(); + let parent_hash = B256::repeat_byte(0x01); + sender.send(Command::Start(TestJob(parent_hash))).unwrap(); + + assert_eq!(wait_for_runnable_job(&receiver, &mut state), Ok(parent_hash)); + } + + #[test] + fn dirty_cache_is_published_without_new_transactions() { + let (_sender, receiver) = unbounded(); + let publication = publication(); + let mut state = state(); + let parent_hash = B256::repeat_byte(0x01); + state.cache.reset(parent_hash); + state.unpublished_transactions = 1; + + assert_eq!( + publish_if_dirty(&receiver, &publication, &mut state), + Ok(PublishOutcome::Published) + ); + assert_eq!(state.unpublished_transactions, 0); + assert_eq!( + publication.read().as_ref().map(|snapshot| snapshot.parent_hash()), + Some(parent_hash) + ); + } + + #[test] + fn new_job_suppresses_dirty_publication() { + let (sender, receiver) = unbounded(); + let publication = publication(); + let mut state = state(); + let parent_hash = B256::repeat_byte(0x01); + state.cache.reset(parent_hash); + state.unpublished_transactions = 1; + sender.send(Command::Start(TestJob(B256::repeat_byte(0x02)))).unwrap(); + + assert_eq!( + publish_if_dirty(&receiver, &publication, &mut state), + Ok(PublishOutcome::Interrupted) + ); + assert_eq!(state.unpublished_transactions, 1); + assert!(publication.read().is_none()); + } + + #[test] + fn disconnected_channel_is_explicit() { + let (sender, receiver) = unbounded(); + let mut state = state(); + drop(sender); + + assert!(matches!(drain_commands(&receiver, &mut state), Err(ChannelDisconnected))); + } } diff --git a/crates/node/builder/src/txpool_prewarm.rs b/crates/node/builder/src/txpool_prewarm.rs index 8f4eb21e980..c895ffa1578 100644 --- a/crates/node/builder/src/txpool_prewarm.rs +++ b/crates/node/builder/src/txpool_prewarm.rs @@ -38,11 +38,10 @@ where return None } - let mut best = - self.0.best_transactions_with_attributes(BestTransactionsAttributes::new( - block_info.pending_basefee, - block_info.pending_blob_fee.map(|fee| u64::try_from(fee).unwrap_or(u64::MAX)), - )); + let mut best = self.0.best_transactions_with_attributes(BestTransactionsAttributes::new( + block_info.pending_basefee, + block_info.pending_blob_fee.map(|fee| u64::try_from(fee).unwrap_or(u64::MAX)), + )); best.allow_updates_out_of_order(); best.skip_blobs(); From b463a2b5e14050ce536371d65e9adb7967f818c2 Mon Sep 17 00:00:00 2001 From: Sergei Shulepov Date: Tue, 21 Jul 2026 16:30:15 +0200 Subject: [PATCH 23/32] refactor --- .../tree/src/tree/txpool_prewarm/control.rs | 15 +- .../tree/src/tree/txpool_prewarm/mod.rs | 3 +- .../tree/src/tree/txpool_prewarm/worker.rs | 140 +++++++----------- 3 files changed, 67 insertions(+), 91 deletions(-) diff --git a/crates/engine/tree/src/tree/txpool_prewarm/control.rs b/crates/engine/tree/src/tree/txpool_prewarm/control.rs index e6459bfa59e..577d0d41059 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm/control.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm/control.rs @@ -35,8 +35,8 @@ impl Control { Arc::clone(&self.publication) } - pub(super) fn start(&self, job: J) { - let _ = self.commands.send(Command::Start(job)); + pub(super) fn start(&self, parent_hash: B256, job: J) { + let _ = self.commands.send(Command::Start { parent_hash, job }); } pub(super) fn pause(self: &Arc) -> PauseGuard { @@ -66,7 +66,7 @@ pub(super) type Publication = Arc>>; /// Commands sent to the txpool prewarming worker. pub(super) enum Command { /// Starts prewarming for a canonical head, replacing any previous job. - Start(J), + Start { parent_hash: B256, job: J }, /// Pauses prewarming and acknowledges once the worker has released its active resources. Pause(Sender<()>), /// Releases one active pause. @@ -147,9 +147,14 @@ mod tests { #[test] fn start_sends_job() { let (control, receiver) = control(); - control.start(1); + let parent_hash = B256::repeat_byte(0x01); + control.start(parent_hash, 1); - assert!(matches!(receiver.try_recv(), Ok(Command::Start(1)))); + assert!(matches!( + receiver.try_recv(), + Ok(Command::Start { parent_hash: received_parent, job: 1 }) + if received_parent == parent_hash + )); } #[test] diff --git a/crates/engine/tree/src/tree/txpool_prewarm/mod.rs b/crates/engine/tree/src/tree/txpool_prewarm/mod.rs index 01dc20ae875..5a655630853 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm/mod.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm/mod.rs @@ -74,7 +74,7 @@ where evm_env: EvmEnvFor, provider_builder: StateProviderBuilder, ) { - self.control.start(Job { parent_hash, evm_env, provider_builder }); + self.control.start(parent_hash, Job { evm_env, provider_builder }); } } @@ -107,7 +107,6 @@ pub trait Source: Send + Sync + Debug { /// A request to warm txpool transactions against one fully validated parent state. struct Job> { - parent_hash: B256, evm_env: EvmEnvFor, provider_builder: StateProviderBuilder, } diff --git a/crates/engine/tree/src/tree/txpool_prewarm/worker.rs b/crates/engine/tree/src/tree/txpool_prewarm/worker.rs index 664172dd674..1ba799fbae2 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm/worker.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm/worker.rs @@ -65,7 +65,7 @@ where if state.cache.parent_hash() != Some(parent_hash) { state.cache.reset(parent_hash); - state.unpublished_transactions = 0; + state.cache_dirty = false; debug!( target: "engine::tree::txpool_prewarm", ?parent_hash, @@ -76,16 +76,17 @@ where // `wait_for_runnable_job` returned this job's parent hash, and the branch above either // retained or opened its matching transaction iterator. No command has been processed // since, so both are still installed here. - let job = state.current_job.as_ref().unwrap(); + let (_, job) = state.current_job.as_ref().unwrap(); let transactions = state.best_transactions.as_mut().unwrap(); - let outcome = prewarm_batch(&evm_config, job, &state.cache, transactions, &commands)?; + let outcome = + prewarm_batch(&evm_config, parent_hash, job, &state.cache, transactions, &commands)?; match outcome { BatchOutcome::Completed { executed, end } => { - state.record_unpublished(executed); + state.cache_dirty |= executed != 0; match publish_if_dirty(&commands, &publication, &mut state)? { PublishOutcome::Interrupted => continue, - PublishOutcome::Published | PublishOutcome::Unchanged => {} + PublishOutcome::Ready => {} } if end == BatchEnd::Empty { @@ -93,13 +94,13 @@ where } } BatchOutcome::Interrupted { executed, command } => { - state.record_unpublished(executed); + state.cache_dirty |= executed != 0; state.apply_command(command); } BatchOutcome::ProviderUnavailable => { match publish_if_dirty(&commands, &publication, &mut state)? { PublishOutcome::Interrupted => continue, - PublishOutcome::Published | PublishOutcome::Unchanged => {} + PublishOutcome::Ready => {} } wait_for_command(&commands, &mut state, REFRESH_INTERVAL)?; } @@ -109,6 +110,7 @@ where fn prewarm_batch( evm_config: &Evm, + parent_hash: B256, job: &Job, cache: &Cache, transactions: &mut Transactions, @@ -131,7 +133,7 @@ where trace!( target: "engine::tree::txpool_prewarm", %err, - parent_hash = ?job.parent_hash, + ?parent_hash, "failed to build txpool prewarming state provider" ); return Ok(BatchOutcome::ProviderUnavailable) @@ -179,14 +181,13 @@ fn wait_for_runnable_job( state: &mut WorkerState, ) -> Result where - J: JobParent, N: NodePrimitives, { loop { if state.pause_count == 0 && - let Some(job) = state.current_job.as_ref() + let Some((parent_hash, _)) = state.current_job.as_ref() { - return Ok(job.parent_hash()) + return Ok(*parent_hash) } let command = commands.recv().map_err(|_| ChannelDisconnected)?; @@ -201,7 +202,6 @@ fn wait_for_command( timeout: Duration, ) -> Result<(), ChannelDisconnected> where - J: JobParent, N: NodePrimitives, { match commands.recv_timeout(timeout) { @@ -220,13 +220,16 @@ fn drain_commands( state: &mut WorkerState, ) -> Result where - J: JobParent, N: NodePrimitives, { let mut result = DrainOutcome::default(); loop { match commands.try_recv() { - Ok(command) => result.invalidated_batch |= state.apply_command(command), + Ok(command) => { + if state.apply_command(command) == DrainOutcome::Interrupted { + result = DrainOutcome::Interrupted; + } + } Err(TryRecvError::Empty) => return Ok(result), Err(TryRecvError::Disconnected) => return Err(ChannelDisconnected), } @@ -239,71 +242,50 @@ fn publish_if_dirty( state: &mut WorkerState, ) -> Result where - J: JobParent, N: NodePrimitives, { - let drained = drain_commands(commands, state)?; - if drained.invalidated_batch { + if drain_commands(commands, state)? == DrainOutcome::Interrupted { return Ok(PublishOutcome::Interrupted) } - if state.unpublished_transactions == 0 { - return Ok(PublishOutcome::Unchanged) + if !state.cache_dirty { + return Ok(PublishOutcome::Ready) } let snapshot = state.cache.snapshot(); - let drained = drain_commands(commands, state)?; - if drained.invalidated_batch { + if drain_commands(commands, state)? == DrainOutcome::Interrupted { return Ok(PublishOutcome::Interrupted) } - let transactions = state.unpublished_transactions; let parent_hash = snapshot.parent_hash(); let (accounts, storage, bytecodes) = snapshot.entry_counts(); *publication.write() = Some(snapshot); - state.unpublished_transactions = 0; + state.cache_dirty = false; debug!( target: "engine::tree::txpool_prewarm", ?parent_hash, - transactions, accounts, storage, bytecodes, "published txpool prewarming snapshot" ); - Ok(PublishOutcome::Published) -} - -trait JobParent { - fn parent_hash(&self) -> B256; -} - -impl JobParent for Job -where - N: NodePrimitives, - Evm: ConfigureEvm, -{ - fn parent_hash(&self) -> B256 { - self.parent_hash - } + Ok(PublishOutcome::Ready) } struct WorkerState where - J: JobParent, N: NodePrimitives, { - current_job: Option, + current_job: Option<(B256, J)>, best_transactions: Option>, best_transactions_parent_hash: Option, pause_count: u64, cache: Cache, - /// Number of transactions whose cache reads have not yet been published. - unpublished_transactions: usize, + /// Whether the cache contains reads that have not yet been published. + cache_dirty: bool, } impl WorkerState where - J: JobParent, N: NodePrimitives, { fn new() -> Self { @@ -313,47 +295,45 @@ where best_transactions_parent_hash: None, pause_count: 0, cache: Cache::default(), - unpublished_transactions: 0, + cache_dirty: false, } } /// Applies `command` while no EVM or state provider is active. /// - /// Returns whether the command invalidates a batch being considered for publication. - fn apply_command(&mut self, command: Command) -> bool { + /// Returns whether the command interrupts a batch being considered for publication. + fn apply_command(&mut self, command: Command) -> DrainOutcome { match command { - Command::Start(job) => { - if self.best_transactions_parent_hash != Some(job.parent_hash()) { + Command::Start { parent_hash, job } => { + if self.best_transactions_parent_hash != Some(parent_hash) { self.best_transactions = None; self.best_transactions_parent_hash = None; } - self.current_job = Some(job); - true + self.current_job = Some((parent_hash, job)); + DrainOutcome::Interrupted } Command::Pause(acknowledge) => { self.pause_count = self.pause_count.checked_add(1).expect("txpool prewarm pause count overflow"); let _ = acknowledge.send(()); - true + DrainOutcome::Interrupted } Command::Resume => { self.pause_count = self .pause_count .checked_sub(1) .expect("txpool prewarm resumed without a matching pause"); - false + DrainOutcome::Ready } } } - - const fn record_unpublished(&mut self, transactions: usize) { - self.unpublished_transactions = self.unpublished_transactions.saturating_add(transactions); - } } -#[derive(Debug, Default)] -struct DrainOutcome { - invalidated_batch: bool, +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +enum DrainOutcome { + #[default] + Ready, + Interrupted, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -373,8 +353,7 @@ enum BatchOutcome { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PublishOutcome { - Published, - Unchanged, + Ready, Interrupted, } @@ -385,14 +364,7 @@ mod tests { use parking_lot::RwLock; use reth_ethereum_primitives::EthPrimitives; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - struct TestJob(B256); - - impl JobParent for TestJob { - fn parent_hash(&self) -> B256 { - self.0 - } - } + type TestJob = u64; fn state() -> WorkerState { WorkerState::new() @@ -408,12 +380,12 @@ mod tests { let mut state = state(); let first = B256::repeat_byte(0x01); let second = B256::repeat_byte(0x02); - sender.send(Command::Start(TestJob(first))).unwrap(); - sender.send(Command::Start(TestJob(second))).unwrap(); + sender.send(Command::Start { parent_hash: first, job: 1 }).unwrap(); + sender.send(Command::Start { parent_hash: second, job: 2 }).unwrap(); let drained = drain_commands(&receiver, &mut state).unwrap(); - assert!(drained.invalidated_batch); - assert_eq!(state.current_job, Some(TestJob(second))); + assert_eq!(drained, DrainOutcome::Interrupted); + assert_eq!(state.current_job, Some((second, 2))); } #[test] @@ -445,12 +417,12 @@ mod tests { let mut state = state(); let (acknowledge, acknowledged) = bounded(1); sender.send(Command::Pause(acknowledge)).unwrap(); - sender.send(Command::Start(TestJob(B256::repeat_byte(0x01)))).unwrap(); - sender.send(Command::Start(TestJob(B256::repeat_byte(0x02)))).unwrap(); + sender.send(Command::Start { parent_hash: B256::repeat_byte(0x01), job: 1 }).unwrap(); + sender.send(Command::Start { parent_hash: B256::repeat_byte(0x02), job: 2 }).unwrap(); drain_commands(&receiver, &mut state).unwrap(); acknowledged.recv().unwrap(); - assert_eq!(state.current_job, Some(TestJob(B256::repeat_byte(0x02)))); + assert_eq!(state.current_job, Some((B256::repeat_byte(0x02), 2))); assert_eq!(state.pause_count, 1); } @@ -459,7 +431,7 @@ mod tests { let (sender, receiver) = unbounded(); let mut state = state(); let parent_hash = B256::repeat_byte(0x01); - sender.send(Command::Start(TestJob(parent_hash))).unwrap(); + sender.send(Command::Start { parent_hash, job: 1 }).unwrap(); assert_eq!(wait_for_runnable_job(&receiver, &mut state), Ok(parent_hash)); } @@ -471,13 +443,13 @@ mod tests { let mut state = state(); let parent_hash = B256::repeat_byte(0x01); state.cache.reset(parent_hash); - state.unpublished_transactions = 1; + state.cache_dirty = true; assert_eq!( publish_if_dirty(&receiver, &publication, &mut state), - Ok(PublishOutcome::Published) + Ok(PublishOutcome::Ready) ); - assert_eq!(state.unpublished_transactions, 0); + assert!(!state.cache_dirty); assert_eq!( publication.read().as_ref().map(|snapshot| snapshot.parent_hash()), Some(parent_hash) @@ -491,14 +463,14 @@ mod tests { let mut state = state(); let parent_hash = B256::repeat_byte(0x01); state.cache.reset(parent_hash); - state.unpublished_transactions = 1; - sender.send(Command::Start(TestJob(B256::repeat_byte(0x02)))).unwrap(); + state.cache_dirty = true; + sender.send(Command::Start { parent_hash: B256::repeat_byte(0x02), job: 2 }).unwrap(); assert_eq!( publish_if_dirty(&receiver, &publication, &mut state), Ok(PublishOutcome::Interrupted) ); - assert_eq!(state.unpublished_transactions, 1); + assert!(state.cache_dirty); assert!(publication.read().is_none()); } From 419b70dccc1ccd778d6cb3edad8b5df8f4a06698 Mon Sep 17 00:00:00 2001 From: Sergei Shulepov Date: Tue, 21 Jul 2026 17:32:06 +0200 Subject: [PATCH 24/32] simplify --- .../tree/src/tree/txpool_prewarm/cache.rs | 54 +- .../tree/src/tree/txpool_prewarm/mod.rs | 4 +- .../tree/src/tree/txpool_prewarm/worker.rs | 631 ++++++++---------- 3 files changed, 344 insertions(+), 345 deletions(-) diff --git a/crates/engine/tree/src/tree/txpool_prewarm/cache.rs b/crates/engine/tree/src/tree/txpool_prewarm/cache.rs index a4c5d6d7edb..9227b4d7a0e 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm/cache.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm/cache.rs @@ -28,6 +28,8 @@ struct CacheInner { accounts: AddressMap>, storage: HashMap<(Address, StorageKey), StorageValue>, bytecodes: B256Map>, + /// Whether entries were added since the last [`Cache::snapshot`] or [`Cache::reset`]. + dirty: bool, } impl Cache { @@ -39,13 +41,16 @@ impl Cache { cache.accounts.clear(); cache.storage.clear(); cache.bytecodes.clear(); + cache.dirty = false; } - /// Clones the current cache state into a [`Snapshot`]. + /// Clones the current cache state into a [`Snapshot`] and marks the cache clean: + /// [`Self::is_dirty`] returns `false` until the next read-through insert. /// /// Must be preceded by a call to [`Self::reset`]. pub(super) fn snapshot(&self) -> Snapshot { - let cache = self.inner.borrow(); + let mut cache = self.inner.borrow_mut(); + cache.dirty = false; Snapshot::from_parts( self.parent_hash.expect("cache is reset before snapshotting"), cache.accounts.clone(), @@ -54,6 +59,11 @@ impl Cache { ) } + /// Whether the cache holds reads not yet captured by [`Self::snapshot`]. + pub(super) fn is_dirty(&self) -> bool { + self.inner.borrow().dirty + } + pub(super) const fn parent_hash(&self) -> Option { self.parent_hash } @@ -72,7 +82,9 @@ impl Cache { } let account = f()?; - self.inner.borrow_mut().accounts.insert(address, account); + let mut cache = self.inner.borrow_mut(); + cache.accounts.insert(address, account); + cache.dirty = true; Ok(account) } @@ -87,7 +99,9 @@ impl Cache { } let value = f()?; - self.inner.borrow_mut().storage.insert((address, key), value); + let mut cache = self.inner.borrow_mut(); + cache.storage.insert((address, key), value); + cache.dirty = true; Ok(value) } @@ -101,7 +115,9 @@ impl Cache { } let code = f()?; - self.inner.borrow_mut().bytecodes.insert(code_hash, code.clone()); + let mut cache = self.inner.borrow_mut(); + cache.bytecodes.insert(code_hash, code.clone()); + cache.dirty = true; Ok(code) } } @@ -144,3 +160,31 @@ impl EvmStateProvider for CacheStateProvider<'_> { .map(|value| (!value.is_zero()).then_some(value)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dirty_tracks_inserts_not_hits() { + let mut cache = Cache::default(); + cache.reset(B256::repeat_byte(0x01)); + assert!(!cache.is_dirty()); + + cache.get_or_try_insert_account_with(Address::ZERO, || Ok::<_, ()>(None)).unwrap(); + assert!(cache.is_dirty()); + + cache.snapshot(); + assert!(!cache.is_dirty()); + + // A cache hit adds nothing worth republishing. + cache.get_or_try_insert_account_with(Address::ZERO, || Ok::<_, ()>(None)).unwrap(); + assert!(!cache.is_dirty()); + + cache + .get_or_try_insert_account_with(Address::repeat_byte(0x02), || Ok::<_, ()>(None)) + .unwrap(); + cache.reset(B256::repeat_byte(0x02)); + assert!(!cache.is_dirty()); + } +} diff --git a/crates/engine/tree/src/tree/txpool_prewarm/mod.rs b/crates/engine/tree/src/tree/txpool_prewarm/mod.rs index 5a655630853..63ce5ed77a5 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm/mod.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm/mod.rs @@ -47,8 +47,8 @@ where ) -> Self { let (control, commands) = Control::new(); let publication = control.publication(); - runtime.spawn_blocking_named("txpool-prewarm", { - move || worker::run(commands, publication, source, evm_config) + runtime.spawn_critical_os_thread("txpool-prewarm", "txpool prewarm worker", async move { + worker::Worker::new(commands, publication, source, evm_config).run() }); Self { control } } diff --git a/crates/engine/tree/src/tree/txpool_prewarm/worker.rs b/crates/engine/tree/src/tree/txpool_prewarm/worker.rs index 1ba799fbae2..981c13735d3 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm/worker.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm/worker.rs @@ -23,463 +23,418 @@ const REFRESH_INTERVAL: Duration = Duration::from_millis(100); /// Delay while waiting for pool maintenance to advance to the state being warmed. const HEAD_POLL_INTERVAL: Duration = Duration::from_millis(10); -pub(super) fn run( - commands: Receiver>>, - publication: Publication, - source: Arc>, - evm_config: Evm, -) where +/// The txpool prewarming worker. +/// +/// A long-lived loop that speculatively executes the pool's best transactions on top of the +/// current canonical state, recording every state read in a reusable [`Cache`]. Roughly every +/// [`REFRESH_INTERVAL`] it publishes an immutable snapshot of that cache, which block validation +/// and payload building use to seed their own caches. +/// +/// The worker is driven by [`Command`]s: `Start` points it at a new parent state, and +/// `Pause`/`Resume` bracket cache-sensitive work elsewhere. Commands are only applied between +/// batches, never while an EVM or state provider is alive. +pub(super) struct Worker +where N: NodePrimitives, - P: BlockReader + StateProviderFactory + StateReader + Clone + Send + Sync + 'static, - Evm: ConfigureEvm + 'static, + Evm: ConfigureEvm, { - // Dropping the control sender is the worker's normal shutdown signal. - let _ = run_until_disconnected(commands, publication, source, evm_config); -} - -fn run_until_disconnected( + /// Control commands from the [`Handle`](super::Handle). commands: Receiver>>, + /// Shared slot the latest snapshot is published into. publication: Publication, + /// The txpool view transactions are drawn from. source: Arc>, + /// Configures the EVM used for speculative execution. evm_config: Evm, -) -> Result<(), ChannelDisconnected> + /// The parent state to warm, from the most recent `Start` command. + job: Option<(B256, Job)>, + /// Outstanding pauses; the worker only warms while this is zero. + pauses: u64, + /// Read-through cache filled by execution. Retained across heads so map capacity is reused; + /// [`Cache::reset`] re-keys it when the parent changes. + cache: Cache, + /// Live best-transactions iterator, tagged with the parent it was opened for. + transactions: Option<(B256, Transactions)>, +} + +impl Worker where N: NodePrimitives, - P: BlockReader + StateProviderFactory + StateReader + Clone + Send + Sync + 'static, - Evm: ConfigureEvm + 'static, + P: BlockReader + StateProviderFactory + StateReader + Clone, + Evm: ConfigureEvm, { - let mut state = WorkerState::new(); + pub(super) fn new( + commands: Receiver>>, + publication: Publication, + source: Arc>, + evm_config: Evm, + ) -> Self { + Self { + commands, + publication, + source, + evm_config, + job: None, + pauses: 0, + cache: Cache::default(), + transactions: None, + } + } - loop { - drain_commands(&commands, &mut state)?; - let parent_hash = wait_for_runnable_job(&commands, &mut state)?; + /// Runs until the control side is dropped, which is the worker's shutdown signal. + pub(super) fn run(mut self) { + let _ = self.run_until_disconnected(); + } - if state.best_transactions_parent_hash != Some(parent_hash) { - let Some(opened) = source.best_transactions(parent_hash) else { - wait_for_command(&commands, &mut state, HEAD_POLL_INTERVAL)?; - continue - }; - state.best_transactions = Some(opened); - state.best_transactions_parent_hash = Some(parent_hash); - } + fn run_until_disconnected(&mut self) -> Result<(), ChannelDisconnected> { + loop { + let parent_hash = self.wait_until_runnable()?; - if state.cache.parent_hash() != Some(parent_hash) { - state.cache.reset(parent_hash); - state.cache_dirty = false; - debug!( - target: "engine::tree::txpool_prewarm", - ?parent_hash, - "started txpool prewarming" - ); - } + // The pool tracks canonical heads on its own schedule; check back shortly if it is + // not tracking this parent yet. + if !self.open_transactions(parent_hash) { + self.idle(HEAD_POLL_INTERVAL)?; + continue + } - // `wait_for_runnable_job` returned this job's parent hash, and the branch above either - // retained or opened its matching transaction iterator. No command has been processed - // since, so both are still installed here. - let (_, job) = state.current_job.as_ref().unwrap(); - let transactions = state.best_transactions.as_mut().unwrap(); - let outcome = - prewarm_batch(&evm_config, parent_hash, job, &state.cache, transactions, &commands)?; - - match outcome { - BatchOutcome::Completed { executed, end } => { - state.cache_dirty |= executed != 0; - match publish_if_dirty(&commands, &publication, &mut state)? { - PublishOutcome::Interrupted => continue, - PublishOutcome::Ready => {} - } - - if end == BatchEnd::Empty { - wait_for_command(&commands, &mut state, REFRESH_INTERVAL)?; - } + if self.cache.parent_hash() != Some(parent_hash) { + self.cache.reset(parent_hash); + debug!( + target: "engine::tree::txpool_prewarm", + ?parent_hash, + "started txpool prewarming" + ); } - BatchOutcome::Interrupted { executed, command } => { - state.cache_dirty |= executed != 0; - state.apply_command(command); + + let batch = self.warm_one_batch(); + + // A pending command may pause the worker or point it at a new parent: apply it (at + // the top of the loop) before spending time on publication. + if !self.commands.is_empty() { + continue } - BatchOutcome::ProviderUnavailable => { - match publish_if_dirty(&commands, &publication, &mut state)? { - PublishOutcome::Interrupted => continue, - PublishOutcome::Ready => {} - } - wait_for_command(&commands, &mut state, REFRESH_INTERVAL)?; + self.publish_snapshot_if_dirty(); + if batch == BatchEnd::OutOfWork { + self.idle(REFRESH_INTERVAL)?; } } } -} -fn prewarm_batch( - evm_config: &Evm, - parent_hash: B256, - job: &Job, - cache: &Cache, - transactions: &mut Transactions, - commands: &Receiver>>, -) -> Result>, ChannelDisconnected> -where - N: NodePrimitives, - P: BlockReader + StateProviderFactory + StateReader + Clone, - Evm: ConfigureEvm, -{ - match commands.try_recv() { - Ok(command) => return Ok(BatchOutcome::Interrupted { executed: 0, command }), - Err(TryRecvError::Disconnected) => return Err(ChannelDisconnected), - Err(TryRecvError::Empty) => {} - } + /// Blocks until the worker holds a job and no pauses are outstanding, applying every command + /// that arrives in the meantime. Returns the parent hash to warm. + fn wait_until_runnable(&mut self) -> Result { + loop { + self.apply_pending_commands()?; - let state_provider = match job.provider_builder.build() { - Ok(provider) => provider, - Err(err) => { - trace!( - target: "engine::tree::txpool_prewarm", - %err, - ?parent_hash, - "failed to build txpool prewarming state provider" - ); - return Ok(BatchOutcome::ProviderUnavailable) - } - }; - let state_provider = cache.state_provider(state_provider); - let state_provider = StateProviderDatabase::new(state_provider); - let mut state = State::builder().with_database(state_provider).build(); - let mut evm_env = job.evm_env.clone(); - evm_env.cfg_env.disable_nonce_check = true; - evm_env.cfg_env.disable_balance_check = true; - let mut evm = evm_config.evm_with_env(&mut state, evm_env); - - let deadline = Instant::now() + REFRESH_INTERVAL; - let mut executed = 0; - loop { - match commands.try_recv() { - Ok(command) => return Ok(BatchOutcome::Interrupted { executed, command }), - Err(TryRecvError::Disconnected) => return Err(ChannelDisconnected), - Err(TryRecvError::Empty) => {} - } + if self.pauses == 0 && + let Some((parent_hash, _)) = self.job.as_ref() + { + return Ok(*parent_hash) + } - if Instant::now() >= deadline { - return Ok(BatchOutcome::Completed { executed, end: BatchEnd::Deadline }) + let command = self.commands.recv().map_err(|_| ChannelDisconnected)?; + self.apply(command); } + } - let Some(transaction) = transactions.next() else { - return Ok(BatchOutcome::Completed { executed, end: BatchEnd::Empty }) - }; - if let Err(err) = evm.transact(transaction.transaction) { - trace!( - target: "engine::tree::txpool_prewarm", - %err, - tx_hash = ?transaction.hash, - sender = %transaction.sender, - "speculative txpool transaction execution failed" - ); + /// Ensures the transaction iterator matches `parent_hash`, opening a fresh one when the head + /// changed. Returns `false` while the pool is not yet tracking that parent. + fn open_transactions(&mut self, parent_hash: B256) -> bool { + if self.transactions.as_ref().is_none_or(|(parent, _)| *parent != parent_hash) { + // Release the stale iterator before asking the pool for a new one. + self.transactions = None; + self.transactions = self + .source + .best_transactions(parent_hash) + .map(|transactions| (parent_hash, transactions)); } - executed += 1; + self.transactions.is_some() } -} -fn wait_for_runnable_job( - commands: &Receiver>, - state: &mut WorkerState, -) -> Result -where - N: NodePrimitives, -{ - loop { - if state.pause_count == 0 && - let Some((parent_hash, _)) = state.current_job.as_ref() - { - return Ok(*parent_hash) + /// Speculatively executes pool transactions against the parent state for at most + /// [`REFRESH_INTERVAL`], filling the cache with every state read. + /// + /// Stops early once the pool has no transaction ready or a command arrives. Commands are + /// never consumed here: a pending command merely ends the batch and is applied by the main + /// loop after the EVM and state provider built here are dropped. + fn warm_one_batch(&mut self) -> BatchEnd { + let (_, job) = self.job.as_ref().expect("wait_until_runnable installed a job"); + let (parent_hash, transactions) = + self.transactions.as_mut().expect("open_transactions installed an iterator"); + + // Building a state provider opens a database transaction; don't bother under a pending + // command. + if !self.commands.is_empty() { + return BatchEnd::MoreWork } - let command = commands.recv().map_err(|_| ChannelDisconnected)?; - state.apply_command(command); - drain_commands(commands, state)?; + let state_provider = match job.provider_builder.build() { + Ok(provider) => provider, + Err(err) => { + trace!( + target: "engine::tree::txpool_prewarm", + %err, + ?parent_hash, + "failed to build txpool prewarming state provider" + ); + return BatchEnd::OutOfWork + } + }; + let state_provider = StateProviderDatabase::new(self.cache.state_provider(state_provider)); + let mut state = State::builder().with_database(state_provider).build(); + let mut evm_env = job.evm_env.clone(); + evm_env.cfg_env.disable_nonce_check = true; + evm_env.cfg_env.disable_balance_check = true; + let mut evm = self.evm_config.evm_with_env(&mut state, evm_env); + + let deadline = Instant::now() + REFRESH_INTERVAL; + while self.commands.is_empty() && Instant::now() < deadline { + let Some(transaction) = transactions.next() else { return BatchEnd::OutOfWork }; + if let Err(err) = evm.transact(transaction.transaction) { + trace!( + target: "engine::tree::txpool_prewarm", + %err, + tx_hash = ?transaction.hash, + sender = %transaction.sender, + "speculative txpool transaction execution failed" + ); + } + } + BatchEnd::MoreWork } -} -fn wait_for_command( - commands: &Receiver>, - state: &mut WorkerState, - timeout: Duration, -) -> Result<(), ChannelDisconnected> -where - N: NodePrimitives, -{ - match commands.recv_timeout(timeout) { - Ok(command) => { - state.apply_command(command); - drain_commands(commands, state)?; - Ok(()) + /// Publishes a fresh snapshot if the cache gained reads since the last publication. + fn publish_snapshot_if_dirty(&self) { + if !self.cache.is_dirty() { + return } - Err(RecvTimeoutError::Timeout) => Ok(()), - Err(RecvTimeoutError::Disconnected) => Err(ChannelDisconnected), + + let snapshot = self.cache.snapshot(); + let parent_hash = snapshot.parent_hash(); + let (accounts, storage, bytecodes) = snapshot.entry_counts(); + *self.publication.write() = Some(snapshot); + debug!( + target: "engine::tree::txpool_prewarm", + ?parent_hash, + accounts, + storage, + bytecodes, + "published txpool prewarming snapshot" + ); } -} -fn drain_commands( - commands: &Receiver>, - state: &mut WorkerState, -) -> Result -where - N: NodePrimitives, -{ - let mut result = DrainOutcome::default(); - loop { - match commands.try_recv() { + /// Rests until a command arrives (applying it) or `timeout` elapses, whichever comes first. + fn idle(&mut self, timeout: Duration) -> Result<(), ChannelDisconnected> { + match self.commands.recv_timeout(timeout) { Ok(command) => { - if state.apply_command(command) == DrainOutcome::Interrupted { - result = DrainOutcome::Interrupted; - } + self.apply(command); + Ok(()) } - Err(TryRecvError::Empty) => return Ok(result), - Err(TryRecvError::Disconnected) => return Err(ChannelDisconnected), + Err(RecvTimeoutError::Timeout) => Ok(()), + Err(RecvTimeoutError::Disconnected) => Err(ChannelDisconnected), } } -} - -fn publish_if_dirty( - commands: &Receiver>, - publication: &Publication, - state: &mut WorkerState, -) -> Result -where - N: NodePrimitives, -{ - if drain_commands(commands, state)? == DrainOutcome::Interrupted { - return Ok(PublishOutcome::Interrupted) - } - if !state.cache_dirty { - return Ok(PublishOutcome::Ready) - } - - let snapshot = state.cache.snapshot(); - if drain_commands(commands, state)? == DrainOutcome::Interrupted { - return Ok(PublishOutcome::Interrupted) - } - let parent_hash = snapshot.parent_hash(); - let (accounts, storage, bytecodes) = snapshot.entry_counts(); - *publication.write() = Some(snapshot); - state.cache_dirty = false; - debug!( - target: "engine::tree::txpool_prewarm", - ?parent_hash, - accounts, - storage, - bytecodes, - "published txpool prewarming snapshot" - ); - Ok(PublishOutcome::Ready) -} - -struct WorkerState -where - N: NodePrimitives, -{ - current_job: Option<(B256, J)>, - best_transactions: Option>, - best_transactions_parent_hash: Option, - pause_count: u64, - cache: Cache, - /// Whether the cache contains reads that have not yet been published. - cache_dirty: bool, -} - -impl WorkerState -where - N: NodePrimitives, -{ - fn new() -> Self { - Self { - current_job: None, - best_transactions: None, - best_transactions_parent_hash: None, - pause_count: 0, - cache: Cache::default(), - cache_dirty: false, + /// Applies every command already sitting in the channel, without blocking. + fn apply_pending_commands(&mut self) -> Result<(), ChannelDisconnected> { + loop { + match self.commands.try_recv() { + Ok(command) => self.apply(command), + Err(TryRecvError::Empty) => return Ok(()), + Err(TryRecvError::Disconnected) => return Err(ChannelDisconnected), + } } } - /// Applies `command` while no EVM or state provider is active. + /// Applies a single command. /// - /// Returns whether the command interrupts a batch being considered for publication. - fn apply_command(&mut self, command: Command) -> DrainOutcome { + /// Only called while no EVM or state provider is alive, so acknowledging a pause here + /// guarantees the worker holds no execution resources. + fn apply(&mut self, command: Command>) { match command { - Command::Start { parent_hash, job } => { - if self.best_transactions_parent_hash != Some(parent_hash) { - self.best_transactions = None; - self.best_transactions_parent_hash = None; - } - self.current_job = Some((parent_hash, job)); - DrainOutcome::Interrupted - } + Command::Start { parent_hash, job } => self.job = Some((parent_hash, job)), Command::Pause(acknowledge) => { - self.pause_count = - self.pause_count.checked_add(1).expect("txpool prewarm pause count overflow"); + self.pauses = + self.pauses.checked_add(1).expect("txpool prewarm pause count overflow"); let _ = acknowledge.send(()); - DrainOutcome::Interrupted } Command::Resume => { - self.pause_count = self - .pause_count + self.pauses = self + .pauses .checked_sub(1) .expect("txpool prewarm resumed without a matching pause"); - DrainOutcome::Ready } } } } -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -enum DrainOutcome { - #[default] - Ready, - Interrupted, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct ChannelDisconnected; - +/// Why a warming batch stopped. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum BatchEnd { - Deadline, - Empty, -} - -enum BatchOutcome { - Completed { executed: usize, end: BatchEnd }, - Interrupted { executed: usize, command: Command }, - ProviderUnavailable, + /// The refresh deadline passed or a command interrupted the batch; more transactions may be + /// ready right away. + MoreWork, + /// Nothing to execute right now: the pool had no transaction ready, or no state provider + /// could be built. + OutOfWork, } +/// The control channel closed: every sender is dropped and the worker shuts down. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PublishOutcome { - Ready, - Interrupted, -} +struct ChannelDisconnected; #[cfg(test)] mod tests { use super::*; - use crossbeam_channel::{bounded, unbounded}; + use crate::tree::StateProviderBuilder; + use alloy_primitives::Address; + use crossbeam_channel::{bounded, unbounded, Sender}; use parking_lot::RwLock; use reth_ethereum_primitives::EthPrimitives; + use reth_evm_ethereum::EthEvmConfig; + use reth_provider::test_utils::MockEthProvider; + use reth_revm::database::EvmStateProvider; + use std::sync::atomic::{AtomicUsize, Ordering}; + + type TestJob = Job; + type TestWorker = Worker; + + /// A pool stub that counts how often an iterator is opened. + #[derive(Debug, Default)] + struct PoolStub(AtomicUsize); + + impl Source for PoolStub { + fn best_transactions(&self, _parent_hash: B256) -> Option> { + self.0.fetch_add(1, Ordering::Relaxed); + Some(Box::new(std::iter::empty())) + } + } - type TestJob = u64; + fn worker() -> (Sender>, Arc, TestWorker) { + let (sender, commands) = unbounded(); + let source = Arc::new(PoolStub::default()); + let worker = Worker::new( + commands, + Arc::new(RwLock::new(None)), + source.clone(), + EthEvmConfig::mainnet(), + ); + (sender, source, worker) + } - fn state() -> WorkerState { - WorkerState::new() + fn start(parent_hash: B256) -> Command { + Command::Start { + parent_hash, + job: Job { + evm_env: Default::default(), + provider_builder: StateProviderBuilder::new( + MockEthProvider::default(), + parent_hash, + None, + ), + }, + } } - fn publication() -> Publication { - Arc::new(RwLock::new(None)) + fn current_parent(worker: &TestWorker) -> Option { + worker.job.as_ref().map(|(parent_hash, _)| *parent_hash) } #[test] fn newest_job_wins() { - let (sender, receiver) = unbounded(); - let mut state = state(); - let first = B256::repeat_byte(0x01); - let second = B256::repeat_byte(0x02); - sender.send(Command::Start { parent_hash: first, job: 1 }).unwrap(); - sender.send(Command::Start { parent_hash: second, job: 2 }).unwrap(); + let (sender, _pool, mut worker) = worker(); + sender.send(start(B256::repeat_byte(0x01))).unwrap(); + sender.send(start(B256::repeat_byte(0x02))).unwrap(); - let drained = drain_commands(&receiver, &mut state).unwrap(); - assert_eq!(drained, DrainOutcome::Interrupted); - assert_eq!(state.current_job, Some((second, 2))); + worker.apply_pending_commands().unwrap(); + assert_eq!(current_parent(&worker), Some(B256::repeat_byte(0x02))); } #[test] fn overlapping_pauses_resume_after_last_command() { - let (sender, receiver) = unbounded(); - let mut state = state(); + let (sender, _pool, mut worker) = worker(); let (first_ack, first_acknowledged) = bounded(1); let (second_ack, second_acknowledged) = bounded(1); sender.send(Command::Pause(first_ack)).unwrap(); sender.send(Command::Pause(second_ack)).unwrap(); - drain_commands(&receiver, &mut state).unwrap(); + worker.apply_pending_commands().unwrap(); first_acknowledged.recv().unwrap(); second_acknowledged.recv().unwrap(); - assert_eq!(state.pause_count, 2); + assert_eq!(worker.pauses, 2); sender.send(Command::Resume).unwrap(); - drain_commands(&receiver, &mut state).unwrap(); - assert_eq!(state.pause_count, 1); + worker.apply_pending_commands().unwrap(); + assert_eq!(worker.pauses, 1); sender.send(Command::Resume).unwrap(); - drain_commands(&receiver, &mut state).unwrap(); - assert_eq!(state.pause_count, 0); + worker.apply_pending_commands().unwrap(); + assert_eq!(worker.pauses, 0); } #[test] fn start_while_paused_replaces_pending_job() { - let (sender, receiver) = unbounded(); - let mut state = state(); + let (sender, _pool, mut worker) = worker(); let (acknowledge, acknowledged) = bounded(1); sender.send(Command::Pause(acknowledge)).unwrap(); - sender.send(Command::Start { parent_hash: B256::repeat_byte(0x01), job: 1 }).unwrap(); - sender.send(Command::Start { parent_hash: B256::repeat_byte(0x02), job: 2 }).unwrap(); + sender.send(start(B256::repeat_byte(0x01))).unwrap(); + sender.send(start(B256::repeat_byte(0x02))).unwrap(); - drain_commands(&receiver, &mut state).unwrap(); + worker.apply_pending_commands().unwrap(); acknowledged.recv().unwrap(); - assert_eq!(state.current_job, Some((B256::repeat_byte(0x02), 2))); - assert_eq!(state.pause_count, 1); + assert_eq!(current_parent(&worker), Some(B256::repeat_byte(0x02))); + assert_eq!(worker.pauses, 1); } #[test] fn runnable_wait_returns_current_parent() { - let (sender, receiver) = unbounded(); - let mut state = state(); + let (sender, _pool, mut worker) = worker(); let parent_hash = B256::repeat_byte(0x01); - sender.send(Command::Start { parent_hash, job: 1 }).unwrap(); + sender.send(start(parent_hash)).unwrap(); - assert_eq!(wait_for_runnable_job(&receiver, &mut state), Ok(parent_hash)); + assert_eq!(worker.wait_until_runnable(), Ok(parent_hash)); } #[test] - fn dirty_cache_is_published_without_new_transactions() { - let (_sender, receiver) = unbounded(); - let publication = publication(); - let mut state = state(); - let parent_hash = B256::repeat_byte(0x01); - state.cache.reset(parent_hash); - state.cache_dirty = true; + fn transactions_iterator_is_reused_per_parent() { + let (_sender, pool, mut worker) = worker(); + let first = B256::repeat_byte(0x01); + let second = B256::repeat_byte(0x02); - assert_eq!( - publish_if_dirty(&receiver, &publication, &mut state), - Ok(PublishOutcome::Ready) - ); - assert!(!state.cache_dirty); - assert_eq!( - publication.read().as_ref().map(|snapshot| snapshot.parent_hash()), - Some(parent_hash) - ); + assert!(worker.open_transactions(first)); + assert!(worker.open_transactions(first)); + assert_eq!(pool.0.load(Ordering::Relaxed), 1); + + assert!(worker.open_transactions(second)); + assert_eq!(pool.0.load(Ordering::Relaxed), 2); } #[test] - fn new_job_suppresses_dirty_publication() { - let (sender, receiver) = unbounded(); - let publication = publication(); - let mut state = state(); + fn publishes_only_when_cache_gained_reads() { + let (_sender, _pool, mut worker) = worker(); let parent_hash = B256::repeat_byte(0x01); - state.cache.reset(parent_hash); - state.cache_dirty = true; - sender.send(Command::Start { parent_hash: B256::repeat_byte(0x02), job: 2 }).unwrap(); + worker.cache.reset(parent_hash); + worker.publish_snapshot_if_dirty(); + assert!(worker.publication.read().is_none()); + + // A read-through miss is what marks the cache dirty. + let state_provider = MockEthProvider::default().latest().unwrap(); + worker.cache.state_provider(state_provider).basic_account(&Address::ZERO).unwrap(); + + worker.publish_snapshot_if_dirty(); assert_eq!( - publish_if_dirty(&receiver, &publication, &mut state), - Ok(PublishOutcome::Interrupted) + worker.publication.read().as_ref().map(|snapshot| snapshot.parent_hash()), + Some(parent_hash) ); - assert!(state.cache_dirty); - assert!(publication.read().is_none()); + assert!(!worker.cache.is_dirty()); } #[test] fn disconnected_channel_is_explicit() { - let (sender, receiver) = unbounded(); - let mut state = state(); + let (sender, _pool, mut worker) = worker(); drop(sender); - assert!(matches!(drain_commands(&receiver, &mut state), Err(ChannelDisconnected))); + assert!(matches!(worker.apply_pending_commands(), Err(ChannelDisconnected))); } } From 06f1d4e2814c5506279c77e6fbd804928cc5f6f0 Mon Sep 17 00:00:00 2001 From: Sergei Shulepov Date: Tue, 21 Jul 2026 17:53:09 +0200 Subject: [PATCH 25/32] rename --- .../tree/src/tree/txpool_prewarm/worker.rs | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/engine/tree/src/tree/txpool_prewarm/worker.rs b/crates/engine/tree/src/tree/txpool_prewarm/worker.rs index 981c13735d3..e334e91b609 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm/worker.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm/worker.rs @@ -114,7 +114,7 @@ where continue } self.publish_snapshot_if_dirty(); - if batch == BatchEnd::OutOfWork { + if batch == BatchEnd::Rest { self.idle(REFRESH_INTERVAL)?; } } @@ -165,7 +165,7 @@ where // Building a state provider opens a database transaction; don't bother under a pending // command. if !self.commands.is_empty() { - return BatchEnd::MoreWork + return BatchEnd::GoAgain } let state_provider = match job.provider_builder.build() { @@ -177,7 +177,7 @@ where ?parent_hash, "failed to build txpool prewarming state provider" ); - return BatchEnd::OutOfWork + return BatchEnd::Rest } }; let state_provider = StateProviderDatabase::new(self.cache.state_provider(state_provider)); @@ -189,7 +189,7 @@ where let deadline = Instant::now() + REFRESH_INTERVAL; while self.commands.is_empty() && Instant::now() < deadline { - let Some(transaction) = transactions.next() else { return BatchEnd::OutOfWork }; + let Some(transaction) = transactions.next() else { return BatchEnd::Rest }; if let Err(err) = evm.transact(transaction.transaction) { trace!( target: "engine::tree::txpool_prewarm", @@ -200,7 +200,7 @@ where ); } } - BatchEnd::MoreWork + BatchEnd::GoAgain } /// Publishes a fresh snapshot if the cache gained reads since the last publication. @@ -268,15 +268,15 @@ where } } -/// Why a warming batch stopped. +/// What to do after a warming batch. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum BatchEnd { - /// The refresh deadline passed or a command interrupted the batch; more transactions may be - /// ready right away. - MoreWork, - /// Nothing to execute right now: the pool had no transaction ready, or no state provider + /// Batch again right away: the refresh deadline passed or a command interrupted the batch + /// while transactions may still be ready. + GoAgain, + /// Idle until something changes: the pool had no transaction ready, or no state provider /// could be built. - OutOfWork, + Rest, } /// The control channel closed: every sender is dropped and the worker shuts down. From fe1d1c78e5e40a07c01fbd0578ecf12a347f1430 Mon Sep 17 00:00:00 2001 From: Sergei Shulepov Date: Tue, 21 Jul 2026 17:56:25 +0200 Subject: [PATCH 26/32] test suite cleanup --- .../tree/src/tree/txpool_prewarm/worker.rs | 327 ++++++++++++------ 1 file changed, 224 insertions(+), 103 deletions(-) diff --git a/crates/engine/tree/src/tree/txpool_prewarm/worker.rs b/crates/engine/tree/src/tree/txpool_prewarm/worker.rs index e334e91b609..530905f361b 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm/worker.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm/worker.rs @@ -285,156 +285,277 @@ struct ChannelDisconnected; #[cfg(test)] mod tests { - use super::*; - use crate::tree::StateProviderBuilder; - use alloy_primitives::Address; + use super::{super::Transaction as PoolTransaction, *}; + use crate::tree::{StateProviderBuilder, TxPoolPrewarmCacheSnapshot as Snapshot}; + use alloy_consensus::{transaction::Recovered, Signed, TxLegacy}; + use alloy_primitives::{Address, Signature, TxKind, U256}; use crossbeam_channel::{bounded, unbounded, Sender}; - use parking_lot::RwLock; - use reth_ethereum_primitives::EthPrimitives; + use parking_lot::{Mutex, RwLock}; + use reth_ethereum_primitives::{EthPrimitives, TransactionSigned}; use reth_evm_ethereum::EthEvmConfig; use reth_provider::test_utils::MockEthProvider; - use reth_revm::database::EvmStateProvider; - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::{ + collections::{HashMap, VecDeque}, + sync::atomic::{AtomicUsize, Ordering}, + thread::{self, JoinHandle}, + }; + + /// Upper bound on any single wait; failures surface as panics well before CI timeouts. + const WAIT_LIMIT: Duration = Duration::from_secs(5); + const POLL_INTERVAL: Duration = Duration::from_millis(2); type TestJob = Job; - type TestWorker = Worker; - /// A pool stub that counts how often an iterator is opened. - #[derive(Debug, Default)] - struct PoolStub(AtomicUsize); + /// Drives a live worker thread through its public seams only: commands in, the publication + /// slot and the scripted pool out. + struct Harness { + commands: Sender>, + publication: Publication, + pool: Arc, + worker: Option>, + } - impl Source for PoolStub { - fn best_transactions(&self, _parent_hash: B256) -> Option> { - self.0.fetch_add(1, Ordering::Relaxed); - Some(Box::new(std::iter::empty())) + impl Harness { + fn spawn() -> Self { + let (commands, receiver) = unbounded(); + let publication: Publication = Arc::new(RwLock::new(None)); + let pool = Arc::new(ScriptedPool::default()); + let worker = thread::spawn({ + let publication = Arc::clone(&publication); + let source: Arc> = pool.clone(); + move || Worker::new(receiver, publication, source, EthEvmConfig::mainnet()).run() + }); + Self { commands, publication, pool, worker: Some(worker) } } - } - fn worker() -> (Sender>, Arc, TestWorker) { - let (sender, commands) = unbounded(); - let source = Arc::new(PoolStub::default()); - let worker = Worker::new( - commands, - Arc::new(RwLock::new(None)), - source.clone(), - EthEvmConfig::mainnet(), - ); - (sender, source, worker) - } + /// Drops the control channel and waits for the worker thread to exit. + fn shutdown(mut self) { + let (disconnected, _) = unbounded(); + self.commands = disconnected; + let worker = self.worker.take().expect("worker already joined"); + wait_until("the worker thread exits", || worker.is_finished()); + worker.join().unwrap(); + } - fn start(parent_hash: B256) -> Command { - Command::Start { - parent_hash, - job: Job { + /// Points the worker at `parent_hash`, as [`Handle::start`](super::super::Handle) does. + fn start(&self, parent_hash: B256) { + let job = Job { evm_env: Default::default(), provider_builder: StateProviderBuilder::new( MockEthProvider::default(), parent_hash, None, ), - }, + }; + self.commands.send(Command::Start { parent_hash, job }).unwrap(); + } + + /// Pauses the worker and waits for the acknowledgement. Once this returns the worker is + /// quiescent until [`Self::resume`]. + fn pause(&self) { + let (acknowledge, acknowledged) = bounded(1); + self.commands.send(Command::Pause(acknowledge)).unwrap(); + acknowledged.recv_timeout(WAIT_LIMIT).expect("pause was not acknowledged"); + } + + fn resume(&self) { + self.commands.send(Command::Resume).unwrap(); + } + + /// Blocks until a published snapshot satisfies `accept`. + fn published(&self, accept: impl Fn(&Snapshot) -> bool) -> Snapshot { + let deadline = Instant::now() + WAIT_LIMIT; + loop { + let snapshot = self.publication.read().as_ref().cloned(); + if let Some(snapshot) = snapshot && + accept(&snapshot) + { + return snapshot + } + assert!(Instant::now() < deadline, "timed out waiting for a matching snapshot"); + thread::sleep(POLL_INTERVAL); + } + } + + fn published_for(&self, parent_hash: B256) -> Snapshot { + self.published(|snapshot| snapshot.parent_hash() == parent_hash) + } + + fn published_entry_counts(&self) -> Option<(usize, usize, usize)> { + self.publication.read().as_ref().map(|snapshot| snapshot.entry_counts()) } } - fn current_parent(worker: &TestWorker) -> Option { - worker.job.as_ref().map(|(parent_hash, _)| *parent_hash) + impl Drop for Harness { + fn drop(&mut self) { + // Disconnect and reap the worker thread so it cannot outlive the test. + let (disconnected, _) = unbounded(); + self.commands = disconnected; + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } } - #[test] - fn newest_job_wins() { - let (sender, _pool, mut worker) = worker(); - sender.send(start(B256::repeat_byte(0x01))).unwrap(); - sender.send(start(B256::repeat_byte(0x02))).unwrap(); + /// A pool the tests script per parent: [`Self::push`] hands a transaction to the iterator + /// opened for that parent, and unknown parents read as "not tracking this head yet". + #[derive(Debug, Default)] + struct ScriptedPool { + queues: Arc>>>>, + /// Iterators handed out; the worker is expected to open exactly one per parent. + opened: AtomicUsize, + /// Polls answered with "not tracking this head yet". + not_ready: AtomicUsize, + } + + impl ScriptedPool { + fn push(&self, parent_hash: B256, transaction: PoolTransaction) { + self.queues.lock().entry(parent_hash).or_default().push_back(transaction); + } + } - worker.apply_pending_commands().unwrap(); - assert_eq!(current_parent(&worker), Some(B256::repeat_byte(0x02))); + impl Source for ScriptedPool { + fn best_transactions(&self, parent_hash: B256) -> Option> { + if !self.queues.lock().contains_key(&parent_hash) { + self.not_ready.fetch_add(1, Ordering::Relaxed); + return None + } + self.opened.fetch_add(1, Ordering::Relaxed); + let queues = Arc::clone(&self.queues); + Some(Box::new(std::iter::from_fn(move || { + queues.lock().get_mut(&parent_hash)?.pop_front() + }))) + } + } + + /// A signed transfer to `recipient`. The signature is a dummy: the worker executes with the + /// attached sender and disabled nonce/balance checks, so it is never recovered or validated. + fn transfer(recipient: u8) -> PoolTransaction { + let transaction = TxLegacy { + gas_limit: 21_000, + to: TxKind::Call(Address::repeat_byte(recipient)), + value: U256::from(1), + ..Default::default() + }; + let hash = B256::repeat_byte(recipient); + let signed = TransactionSigned::Legacy(Signed::new_unchecked( + transaction, + Signature::test_signature(), + hash, + )); + let sender = Address::repeat_byte(0xAA); + PoolTransaction { hash, sender, transaction: Recovered::new_unchecked(signed, sender) } + } + + fn wait_until(what: &str, condition: impl Fn() -> bool) { + let deadline = Instant::now() + WAIT_LIMIT; + while !condition() { + assert!(Instant::now() < deadline, "timed out waiting until {what}"); + thread::sleep(POLL_INTERVAL); + } } #[test] - fn overlapping_pauses_resume_after_last_command() { - let (sender, _pool, mut worker) = worker(); - let (first_ack, first_acknowledged) = bounded(1); - let (second_ack, second_acknowledged) = bounded(1); - sender.send(Command::Pause(first_ack)).unwrap(); - sender.send(Command::Pause(second_ack)).unwrap(); - - worker.apply_pending_commands().unwrap(); - first_acknowledged.recv().unwrap(); - second_acknowledged.recv().unwrap(); - assert_eq!(worker.pauses, 2); - - sender.send(Command::Resume).unwrap(); - worker.apply_pending_commands().unwrap(); - assert_eq!(worker.pauses, 1); - - sender.send(Command::Resume).unwrap(); - worker.apply_pending_commands().unwrap(); - assert_eq!(worker.pauses, 0); + fn warms_pool_transactions_into_a_published_snapshot() { + let harness = Harness::spawn(); + let parent_hash = B256::repeat_byte(0x01); + + // Start before the pool tracks the head, covering the poll-and-retry path. + harness.start(parent_hash); + wait_until("the untracked head is polled", || { + harness.pool.not_ready.load(Ordering::Relaxed) >= 1 + }); + harness.pool.push(parent_hash, transfer(0xB0)); + + let snapshot = harness.published_for(parent_hash); + let (accounts, _, _) = snapshot.entry_counts(); + assert!(accounts >= 1, "speculative execution should cache account reads"); } #[test] - fn start_while_paused_replaces_pending_job() { - let (sender, _pool, mut worker) = worker(); - let (acknowledge, acknowledged) = bounded(1); - sender.send(Command::Pause(acknowledge)).unwrap(); - sender.send(start(B256::repeat_byte(0x01))).unwrap(); - sender.send(start(B256::repeat_byte(0x02))).unwrap(); - - worker.apply_pending_commands().unwrap(); - acknowledged.recv().unwrap(); - assert_eq!(current_parent(&worker), Some(B256::repeat_byte(0x02))); - assert_eq!(worker.pauses, 1); + fn pause_quiesces_the_worker_until_resume() { + let harness = Harness::spawn(); + let parent_hash = B256::repeat_byte(0x01); + harness.start(parent_hash); + harness.pool.push(parent_hash, transfer(0xB0)); + let before = harness.published_for(parent_hash).entry_counts(); + + harness.pause(); + harness.pool.push(parent_hash, transfer(0xB1)); + thread::sleep(REFRESH_INTERVAL * 2); + assert_eq!( + harness.published_entry_counts(), + Some(before), + "a paused worker must not publish" + ); + + harness.resume(); + harness.published(|snapshot| snapshot.entry_counts() != before); } #[test] - fn runnable_wait_returns_current_parent() { - let (sender, _pool, mut worker) = worker(); + fn overlapping_pauses_require_matching_resumes() { + let harness = Harness::spawn(); let parent_hash = B256::repeat_byte(0x01); - sender.send(start(parent_hash)).unwrap(); + harness.start(parent_hash); + harness.pool.push(parent_hash, transfer(0xB0)); + let before = harness.published_for(parent_hash).entry_counts(); + + harness.pause(); + harness.pause(); + harness.pool.push(parent_hash, transfer(0xB1)); + harness.resume(); + thread::sleep(REFRESH_INTERVAL * 2); + assert_eq!( + harness.published_entry_counts(), + Some(before), + "one resume must not release two pauses" + ); - assert_eq!(worker.wait_until_runnable(), Ok(parent_hash)); + harness.resume(); + harness.published(|snapshot| snapshot.entry_counts() != before); } #[test] - fn transactions_iterator_is_reused_per_parent() { - let (_sender, pool, mut worker) = worker(); + fn reuses_the_iterator_per_head_and_reopens_on_switch() { + let harness = Harness::spawn(); let first = B256::repeat_byte(0x01); let second = B256::repeat_byte(0x02); - assert!(worker.open_transactions(first)); - assert!(worker.open_transactions(first)); - assert_eq!(pool.0.load(Ordering::Relaxed), 1); - - assert!(worker.open_transactions(second)); - assert_eq!(pool.0.load(Ordering::Relaxed), 2); + harness.start(first); + harness.pool.push(first, transfer(0xB0)); + let before = harness.published_for(first).entry_counts(); + harness.pool.push(first, transfer(0xB1)); + harness.published(|snapshot| snapshot.entry_counts() != before); + assert_eq!(harness.pool.opened.load(Ordering::Relaxed), 1, "one iterator per head"); + + harness.start(second); + harness.pool.push(second, transfer(0xB2)); + harness.published_for(second); + assert_eq!(harness.pool.opened.load(Ordering::Relaxed), 2); } #[test] - fn publishes_only_when_cache_gained_reads() { - let (_sender, _pool, mut worker) = worker(); - let parent_hash = B256::repeat_byte(0x01); - worker.cache.reset(parent_hash); - - worker.publish_snapshot_if_dirty(); - assert!(worker.publication.read().is_none()); + fn newest_start_wins() { + let harness = Harness::spawn(); + let stale = B256::repeat_byte(0x01); + let newest = B256::repeat_byte(0x02); - // A read-through miss is what marks the cache dirty. - let state_provider = MockEthProvider::default().latest().unwrap(); - worker.cache.state_provider(state_provider).basic_account(&Address::ZERO).unwrap(); + harness.start(stale); + harness.start(newest); + harness.pool.push(newest, transfer(0xB0)); - worker.publish_snapshot_if_dirty(); - assert_eq!( - worker.publication.read().as_ref().map(|snapshot| snapshot.parent_hash()), - Some(parent_hash) - ); - assert!(!worker.cache.is_dirty()); + harness.published_for(newest); } #[test] - fn disconnected_channel_is_explicit() { - let (sender, _pool, mut worker) = worker(); - drop(sender); + fn shuts_down_when_control_is_dropped() { + let harness = Harness::spawn(); + let parent_hash = B256::repeat_byte(0x01); + harness.start(parent_hash); + harness.pool.push(parent_hash, transfer(0xB0)); + harness.published_for(parent_hash); - assert!(matches!(worker.apply_pending_commands(), Err(ChannelDisconnected))); + harness.shutdown(); } } From 46c6cf02ee56a5cef0e9003801dfe75507ed3a20 Mon Sep 17 00:00:00 2001 From: Sergei Shulepov Date: Tue, 21 Jul 2026 18:12:42 +0200 Subject: [PATCH 27/32] refactor(engine): drop txpool_prewarm_env, warm with the parent env Removes the ConfigureEvm::txpool_prewarm_env hook whose Ok(None) default made txpool prewarming a silent no-op for non-ethereum nodes. The worker now reuses the canonical head's own EVM env and additionally disables the basefee check: the predicted attributes were mocked anyway, and fee viability is the pool's concern, not the warmer's. Co-Authored-By: Claude Fable 5 --- crates/engine/tree/Cargo.toml | 2 +- .../engine/tree/src/tree/payload_validator.rs | 29 ++++---------- .../tree/src/tree/txpool_prewarm/worker.rs | 4 ++ crates/ethereum/evm/src/lib.rs | 38 ------------------- crates/evm/evm/src/lib.rs | 13 ------- 5 files changed, 13 insertions(+), 73 deletions(-) diff --git a/crates/engine/tree/Cargo.toml b/crates/engine/tree/Cargo.toml index 2c8c94b74a7..95cb5dbc21d 100644 --- a/crates/engine/tree/Cargo.toml +++ b/crates/engine/tree/Cargo.toml @@ -28,7 +28,7 @@ reth-primitives-traits = { workspace = true, features = ["rayon", "dashmap"] } reth-ethereum-primitives.workspace = true reth-provider.workspace = true reth-prune.workspace = true -reth-revm = { workspace = true, features = ["optional-balance-check"] } +reth-revm = { workspace = true, features = ["optional-balance-check", "optional-no-base-fee"] } reth-stages-api.workspace = true reth-tasks = { workspace = true, features = ["rayon"] } reth-trie-parallel.workspace = true diff --git a/crates/engine/tree/src/tree/payload_validator.rs b/crates/engine/tree/src/tree/payload_validator.rs index 19dd1c7b18d..4b524c60fa5 100644 --- a/crates/engine/tree/src/tree/payload_validator.rs +++ b/crates/engine/tree/src/tree/payload_validator.rs @@ -1885,8 +1885,8 @@ where fn on_canonical_head_changed(&self, hash: B256, state: &EngineApiTreeState) { let Some(txpool_prewarm) = self.txpool_prewarm.as_ref() else { return }; - // Obtain the headers for both the new canonical head and its parent. Those will be used to - // create the txpool prewarm EVM environment as a parent and a grandparent respectively. + // Obtain the header of the new canonical head; pool transactions are warmed on top of + // its state. let parent = match self.sealed_header_by_hash(hash, state) { Ok(Some(header)) => header, Ok(None) => return, @@ -1900,34 +1900,21 @@ where return } }; - let grandparent = match self.sealed_header_by_hash(parent.parent_hash(), state) { - Ok(Some(parent_parent)) => parent_parent, - Ok(None) => return, + // Warming reuses the head block's own environment rather than predicting the next + // block's: attribute-level accuracy (timestamp, basefee) doesn't matter for collecting + // state reads, and the worker disables the fee checks a stale basefee would trip. + let evm_env = match self.evm_config.evm_env(parent.header()) { + Ok(evm_env) => evm_env, Err(err) => { trace!( target: "engine::tree::txpool_prewarm", %err, block_hash = ?parent.hash(), - parent_hash = ?parent.parent_hash(), - "failed to fetch parent header for txpool prewarming" + "failed to derive canonical txpool prewarming environment" ); return } }; - let evm_env = - match self.evm_config.txpool_prewarm_env(parent.header(), grandparent.header()) { - Ok(Some(evm_env)) => evm_env, - Ok(None) => return, - Err(err) => { - trace!( - target: "engine::tree::txpool_prewarm", - %err, - block_hash = ?parent.hash(), - "failed to derive canonical txpool prewarming environment" - ); - return - } - }; let provider_builder = match self.state_provider_builder(parent.hash(), state) { Ok(Some(provider_builder)) => provider_builder, diff --git a/crates/engine/tree/src/tree/txpool_prewarm/worker.rs b/crates/engine/tree/src/tree/txpool_prewarm/worker.rs index 530905f361b..023db1a4707 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm/worker.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm/worker.rs @@ -182,9 +182,13 @@ where }; let state_provider = StateProviderDatabase::new(self.cache.state_provider(state_provider)); let mut state = State::builder().with_database(state_provider).build(); + // The environment is the head block's own, not a predicted next-block one, and execution + // is out of context by design: transaction viability is the pool's business, so nonce, + // balance and (one-block-stale) basefee checks must not gate which state gets warmed. let mut evm_env = job.evm_env.clone(); evm_env.cfg_env.disable_nonce_check = true; evm_env.cfg_env.disable_balance_check = true; + evm_env.cfg_env.disable_base_fee = true; let mut evm = self.evm_config.evm_with_env(&mut state, evm_env); let deadline = Instant::now() + REFRESH_INTERVAL; diff --git a/crates/ethereum/evm/src/lib.rs b/crates/ethereum/evm/src/lib.rs index 719075b66ca..5fb989c3ea8 100644 --- a/crates/ethereum/evm/src/lib.rs +++ b/crates/ethereum/evm/src/lib.rs @@ -227,30 +227,6 @@ where )) } - fn txpool_prewarm_env( - &self, - parent: &Header, - grandparent: &Header, - ) -> Result, Self::Error> { - let block_time = parent.timestamp.saturating_sub(grandparent.timestamp); - self.next_evm_env( - parent, - &NextBlockEnvAttributes { - timestamp: parent.timestamp.saturating_add(block_time), - suggested_fee_recipient: parent.beneficiary, - prev_randao: alloy_primitives::B256::ZERO, - gas_limit: parent.gas_limit, - parent_beacon_block_root: parent - .parent_beacon_block_root - .map(|_| Default::default()), - withdrawals: parent.withdrawals_root.map(|_| Default::default()), - extra_data: parent.extra_data.clone(), - slot_number: parent.slot_number.map(|slot| slot.saturating_add(1)), - }, - ) - .map(Some) - } - fn context_for_block<'a>( &self, block: &'a SealedBlock, @@ -419,20 +395,6 @@ mod tests { assert_eq!(cfg_env.chain_id, chain_spec.chain().id()); } - #[test] - fn txpool_prewarm_env_uses_parent_block_time() { - let evm_config = EthEvmConfig::mainnet(); - let grandparent = Header { timestamp: 1_000, ..Default::default() }; - let parent = Header { timestamp: 1_007, ..Default::default() }; - - let env = evm_config - .txpool_prewarm_env(&parent, &grandparent) - .unwrap() - .expect("ethereum supports txpool prewarming"); - - assert_eq!(env.block_env.timestamp, U256::from(1_014)); - } - #[test] fn test_evm_with_env_default_spec() { let evm_config = EthEvmConfig::mainnet(); diff --git a/crates/evm/evm/src/lib.rs b/crates/evm/evm/src/lib.rs index fbe332759f5..e9ef87ad4e3 100644 --- a/crates/evm/evm/src/lib.rs +++ b/crates/evm/evm/src/lib.rs @@ -240,19 +240,6 @@ pub trait ConfigureEvm: Clone + Debug + Send + Sync + Unpin { attributes: &Self::NextBlockEnvCtx, ) -> Result, Self::Error>; - /// Returns a predicted next-block environment for txpool cache prewarming. - /// - /// Implementations should return `None` when they cannot derive a safe prediction without - /// consensus-layer payload attributes. The prediction only controls which parent-state reads - /// are warmed; speculative writes are never published. - fn txpool_prewarm_env( - &self, - _parent: &HeaderTy, - _grandparent: &HeaderTy, - ) -> Result>, Self::Error> { - Ok(None) - } - /// Returns the configured [`BlockExecutorFactory::ExecutionCtx`] for a given block. fn context_for_block<'a>( &self, From d1672b5a03a9b49b1e9b1b9310bdbd9de1009002 Mon Sep 17 00:00:00 2001 From: Sergei Shulepov Date: Tue, 21 Jul 2026 18:19:47 +0200 Subject: [PATCH 28/32] refactor(engine): back the txpool prewarm cache with CachedReads Replaces the hand-rolled read-through cache and its EvmStateProvider wrapper with reth-revm's CachedReads, layered under revm State via as_db_mut. The published snapshot keeps its lookup API but wraps Arc, so cache-tier consumers are unchanged. Publication is gated on entry-count growth, which replaces the dirty flag. Co-Authored-By: Claude Fable 5 --- crates/engine/execution-cache/src/txpool.rs | 101 +++++++--- .../tree/src/tree/txpool_prewarm/cache.rs | 190 ------------------ .../tree/src/tree/txpool_prewarm/control.rs | 7 +- .../tree/src/tree/txpool_prewarm/mod.rs | 5 +- .../tree/src/tree/txpool_prewarm/worker.rs | 56 ++++-- 5 files changed, 108 insertions(+), 251 deletions(-) delete mode 100644 crates/engine/tree/src/tree/txpool_prewarm/cache.rs diff --git a/crates/engine/execution-cache/src/txpool.rs b/crates/engine/execution-cache/src/txpool.rs index 7d6a1ef2e9f..66eed02eeb2 100644 --- a/crates/engine/execution-cache/src/txpool.rs +++ b/crates/engine/execution-cache/src/txpool.rs @@ -1,66 +1,101 @@ //! Immutable snapshots produced by txpool-driven state prewarming. -use alloy_primitives::{ - map::{AddressMap, B256Map, HashMap}, - Address, StorageKey, StorageValue, B256, -}; +use alloy_primitives::{Address, StorageKey, StorageValue, B256, U256}; use reth_primitives_traits::{Account, Bytecode}; +use reth_revm::cached::CachedReads; use std::sync::Arc; /// A deep, immutable txpool-prewarm cache snapshot for one parent state. +/// +/// Wraps the [`CachedReads`] collected by speculative execution so cache tiers can serve lookups +/// from it directly. #[derive(Clone, Debug)] pub struct TxPoolPrewarmCacheSnapshot { - inner: Arc, + parent_hash: B256, + reads: Arc, } impl TxPoolPrewarmCacheSnapshot { - /// Creates a snapshot from fully owned cache maps. - pub fn from_parts( - parent_hash: B256, - accounts: AddressMap>, - storage: HashMap<(Address, StorageKey), StorageValue>, - bytecodes: B256Map>, - ) -> Self { - Self { - inner: Arc::new(TxPoolPrewarmCacheSnapshotInner { - parent_hash, - accounts, - storage, - bytecodes, - }), - } + /// Creates a snapshot of `reads` collected against `parent_hash`'s state. + pub const fn new(parent_hash: B256, reads: Arc) -> Self { + Self { parent_hash, reads } } /// Returns the hash of the state this snapshot was warmed against. - pub fn parent_hash(&self) -> B256 { - self.inner.parent_hash + pub const fn parent_hash(&self) -> B256 { + self.parent_hash } /// Returns a cached account, preserving cached non-existence. pub fn account(&self, address: &Address) -> Option> { - self.inner.accounts.get(address).copied() + self.reads.accounts.get(address).map(|account| account.info.as_ref().map(Account::from)) } /// Returns a cached storage value, preserving cached zero values. pub fn storage(&self, address: Address, key: StorageKey) -> Option { - self.inner.storage.get(&(address, key)).copied() + self.reads.accounts.get(&address)?.storage.get(&U256::from_be_bytes(key.0)).copied() } - /// Returns cached bytecode, preserving cached non-existence. + /// Returns cached bytecode. + /// + /// [`CachedReads`] stores empty bytecode for code it could not find, so an empty entry reads + /// as a miss and the caller's fallback tier decides. pub fn bytecode(&self, code_hash: &B256) -> Option> { - self.inner.bytecodes.get(code_hash).cloned() + let code = self.reads.contracts.get(code_hash)?; + (!code.is_empty()).then(|| Some(Bytecode(code.clone()))) } /// Returns `(accounts, storage slots, bytecodes)` in the snapshot. pub fn entry_counts(&self) -> (usize, usize, usize) { - (self.inner.accounts.len(), self.inner.storage.len(), self.inner.bytecodes.len()) + ( + self.reads.accounts.len(), + self.reads.accounts.values().map(|account| account.storage.len()).sum(), + self.reads.contracts.len(), + ) } } -#[derive(Debug)] -struct TxPoolPrewarmCacheSnapshotInner { - parent_hash: B256, - accounts: AddressMap>, - storage: HashMap<(Address, StorageKey), StorageValue>, - bytecodes: B256Map>, +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::map::U256Map; + use reth_revm::{ + cached::CachedAccount, + revm::{bytecode::Bytecode as RevmBytecode, state::AccountInfo}, + }; + + #[test] + fn lookups_preserve_cache_semantics() { + let owner = Address::repeat_byte(0x01); + let missing = Address::repeat_byte(0x02); + let code_hash = B256::repeat_byte(0x0C); + let empty_code_hash = B256::repeat_byte(0x0D); + + let mut reads = CachedReads::default(); + let mut storage = U256Map::default(); + storage.insert(U256::from(1), U256::from(7)); + storage.insert(U256::from(2), U256::ZERO); + reads.insert_account(owner, AccountInfo { nonce: 3, ..Default::default() }, storage); + reads.accounts.insert(missing, CachedAccount { info: None, storage: Default::default() }); + reads.contracts.insert(code_hash, RevmBytecode::new_raw([0x60, 0x01].into())); + reads.contracts.insert(empty_code_hash, RevmBytecode::default()); + + let snapshot = TxPoolPrewarmCacheSnapshot::new(B256::ZERO, Arc::new(reads)); + + assert_eq!(snapshot.account(&owner).unwrap().unwrap().nonce, 3); + assert_eq!(snapshot.account(&missing), Some(None), "non-existence is a cacheable fact"); + assert_eq!(snapshot.account(&Address::repeat_byte(0x03)), None); + + let slot = |n: u64| B256::from(U256::from(n)); + assert_eq!(snapshot.storage(owner, slot(1)), Some(U256::from(7))); + assert_eq!(snapshot.storage(owner, slot(2)), Some(U256::ZERO), "zero values are hits"); + assert_eq!(snapshot.storage(owner, slot(9)), None); + assert_eq!(snapshot.storage(missing, slot(1)), None); + + assert!(snapshot.bytecode(&code_hash).unwrap().is_some()); + assert_eq!(snapshot.bytecode(&empty_code_hash), None, "empty code reads as a miss"); + assert_eq!(snapshot.bytecode(&B256::repeat_byte(0x0E)), None); + + assert_eq!(snapshot.entry_counts(), (2, 2, 2)); + } } diff --git a/crates/engine/tree/src/tree/txpool_prewarm/cache.rs b/crates/engine/tree/src/tree/txpool_prewarm/cache.rs deleted file mode 100644 index 9227b4d7a0e..00000000000 --- a/crates/engine/tree/src/tree/txpool_prewarm/cache.rs +++ /dev/null @@ -1,190 +0,0 @@ -use crate::tree::TxPoolPrewarmCacheSnapshot as Snapshot; -use alloy_primitives::{ - map::{AddressMap, B256Map, HashMap}, - Address, BlockNumber, StorageKey, StorageValue, B256, -}; -use reth_primitives_traits::{Account, Bytecode}; -use reth_provider::StateProviderBox; -use reth_revm::database::EvmStateProvider; -use std::cell::RefCell; - -/// The read-through cache for the txpool prewarming worker. -/// -/// Wrapped through [`Self::state_provider`] and passed to the EVM to collect state reads against -/// the pre-state for the given parent hash block. -/// -/// Supports creating a [`Snapshot`] of the current cache state. -#[derive(Debug, Default)] -pub(super) struct Cache { - // NOTE: RefCell is required for implementing the EvmStateProvider trait because its getters - // are defined accepting &self. - inner: RefCell, - /// The hash of the parent block, at which state this cache is based. - parent_hash: Option, -} - -#[derive(Debug, Default)] -struct CacheInner { - accounts: AddressMap>, - storage: HashMap<(Address, StorageKey), StorageValue>, - bytecodes: B256Map>, - /// Whether entries were added since the last [`Cache::snapshot`] or [`Cache::reset`]. - dirty: bool, -} - -impl Cache { - /// Clears the cache and sets the parent hash to the given value. - pub(super) fn reset(&mut self, parent_hash: B256) { - self.parent_hash = Some(parent_hash); - - let mut cache = self.inner.borrow_mut(); - cache.accounts.clear(); - cache.storage.clear(); - cache.bytecodes.clear(); - cache.dirty = false; - } - - /// Clones the current cache state into a [`Snapshot`] and marks the cache clean: - /// [`Self::is_dirty`] returns `false` until the next read-through insert. - /// - /// Must be preceded by a call to [`Self::reset`]. - pub(super) fn snapshot(&self) -> Snapshot { - let mut cache = self.inner.borrow_mut(); - cache.dirty = false; - Snapshot::from_parts( - self.parent_hash.expect("cache is reset before snapshotting"), - cache.accounts.clone(), - cache.storage.clone(), - cache.bytecodes.clone(), - ) - } - - /// Whether the cache holds reads not yet captured by [`Self::snapshot`]. - pub(super) fn is_dirty(&self) -> bool { - self.inner.borrow().dirty - } - - pub(super) const fn parent_hash(&self) -> Option { - self.parent_hash - } - - pub(super) fn state_provider(&self, inner: StateProviderBox) -> CacheStateProvider<'_> { - CacheStateProvider { inner, cache: self } - } - - fn get_or_try_insert_account_with( - &self, - address: Address, - f: impl FnOnce() -> Result, E>, - ) -> Result, E> { - if let Some(account) = self.inner.borrow().accounts.get(&address).copied() { - return Ok(account) - } - - let account = f()?; - let mut cache = self.inner.borrow_mut(); - cache.accounts.insert(address, account); - cache.dirty = true; - Ok(account) - } - - fn get_or_try_insert_storage_with( - &self, - address: Address, - key: StorageKey, - f: impl FnOnce() -> Result, - ) -> Result { - if let Some(value) = self.inner.borrow().storage.get(&(address, key)).copied() { - return Ok(value) - } - - let value = f()?; - let mut cache = self.inner.borrow_mut(); - cache.storage.insert((address, key), value); - cache.dirty = true; - Ok(value) - } - - fn get_or_try_insert_code_with( - &self, - code_hash: B256, - f: impl FnOnce() -> Result, E>, - ) -> Result, E> { - if let Some(code) = self.inner.borrow().bytecodes.get(&code_hash).cloned() { - return Ok(code) - } - - let code = f()?; - let mut cache = self.inner.borrow_mut(); - cache.bytecodes.insert(code_hash, code.clone()); - cache.dirty = true; - Ok(code) - } -} - -/// Provider that fills only the reusable txpool-prewarm cache. -pub(super) struct CacheStateProvider<'a> { - inner: StateProviderBox, - cache: &'a Cache, -} - -impl EvmStateProvider for CacheStateProvider<'_> { - fn basic_account( - &self, - address: &Address, - ) -> reth_errors::ProviderResult> { - self.cache.get_or_try_insert_account_with(*address, || self.inner.basic_account(address)) - } - - fn block_hash(&self, number: BlockNumber) -> reth_errors::ProviderResult> { - EvmStateProvider::block_hash(&self.inner, number) - } - - fn bytecode_by_hash( - &self, - code_hash: &B256, - ) -> reth_errors::ProviderResult> { - self.cache - .get_or_try_insert_code_with(*code_hash, || self.inner.bytecode_by_hash(code_hash)) - } - - fn storage( - &self, - account: Address, - storage_key: StorageKey, - ) -> reth_errors::ProviderResult> { - self.cache - .get_or_try_insert_storage_with(account, storage_key, || { - self.inner.storage(account, storage_key).map(Option::unwrap_or_default) - }) - .map(|value| (!value.is_zero()).then_some(value)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn dirty_tracks_inserts_not_hits() { - let mut cache = Cache::default(); - cache.reset(B256::repeat_byte(0x01)); - assert!(!cache.is_dirty()); - - cache.get_or_try_insert_account_with(Address::ZERO, || Ok::<_, ()>(None)).unwrap(); - assert!(cache.is_dirty()); - - cache.snapshot(); - assert!(!cache.is_dirty()); - - // A cache hit adds nothing worth republishing. - cache.get_or_try_insert_account_with(Address::ZERO, || Ok::<_, ()>(None)).unwrap(); - assert!(!cache.is_dirty()); - - cache - .get_or_try_insert_account_with(Address::repeat_byte(0x02), || Ok::<_, ()>(None)) - .unwrap(); - cache.reset(B256::repeat_byte(0x02)); - assert!(!cache.is_dirty()); - } -} diff --git a/crates/engine/tree/src/tree/txpool_prewarm/control.rs b/crates/engine/tree/src/tree/txpool_prewarm/control.rs index 577d0d41059..1a285c4af04 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm/control.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm/control.rs @@ -101,12 +101,7 @@ mod tests { } fn snapshot(parent_hash: B256) -> Snapshot { - Snapshot::from_parts( - parent_hash, - Default::default(), - Default::default(), - Default::default(), - ) + Snapshot::new(parent_hash, Default::default()) } fn request_pause( diff --git a/crates/engine/tree/src/tree/txpool_prewarm/mod.rs b/crates/engine/tree/src/tree/txpool_prewarm/mod.rs index 63ce5ed77a5..f4cdd5da59c 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm/mod.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm/mod.rs @@ -1,6 +1,5 @@ //! Txpool-driven state prewarming and immutable snapshot publication. -mod cache; mod control; mod worker; @@ -38,8 +37,8 @@ where P: BlockReader + StateProviderFactory + StateReader + Clone + Send + Sync + 'static, Evm: ConfigureEvm + 'static, { - /// Spawns the long-lived worker. Its mutable cache remains owned by this worker and is cleared - /// between heads without releasing map capacity. + /// Spawns the long-lived worker, which owns its mutable read cache and starts a fresh one for + /// each new head. pub(crate) fn spawn( runtime: &reth_tasks::Runtime, source: Arc>, diff --git a/crates/engine/tree/src/tree/txpool_prewarm/worker.rs b/crates/engine/tree/src/tree/txpool_prewarm/worker.rs index 023db1a4707..da5cf1fef1c 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm/worker.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm/worker.rs @@ -1,16 +1,15 @@ use super::{ - cache::Cache, control::{Command, Publication}, Job, Source, Transactions, }; -use crate::tree::StateProviderDatabase; +use crate::tree::{StateProviderDatabase, TxPoolPrewarmCacheSnapshot as Snapshot}; use alloy_evm::Evm; use alloy_primitives::B256; use crossbeam_channel::{Receiver, RecvTimeoutError, TryRecvError}; use reth_evm::ConfigureEvm; use reth_primitives_traits::NodePrimitives; use reth_provider::{BlockReader, StateProviderFactory, StateReader}; -use reth_revm::db::State; +use reth_revm::{cached::CachedReads, db::State}; use std::{ sync::Arc, time::{Duration, Instant}, @@ -26,7 +25,7 @@ const HEAD_POLL_INTERVAL: Duration = Duration::from_millis(10); /// The txpool prewarming worker. /// /// A long-lived loop that speculatively executes the pool's best transactions on top of the -/// current canonical state, recording every state read in a reusable [`Cache`]. Roughly every +/// current canonical state, recording every state read in a [`CachedReads`]. Roughly every /// [`REFRESH_INTERVAL`] it publishes an immutable snapshot of that cache, which block validation /// and payload building use to seed their own caches. /// @@ -50,9 +49,13 @@ where job: Option<(B256, Job)>, /// Outstanding pauses; the worker only warms while this is zero. pauses: u64, - /// Read-through cache filled by execution. Retained across heads so map capacity is reused; - /// [`Cache::reset`] re-keys it when the parent changes. - cache: Cache, + /// Read-through cache filled by execution; replaced whenever the warmed parent changes. + cache: CachedReads, + /// Parent whose state the `cache` reads were collected against. + cache_parent: Option, + /// Cache entry counts as of the last publication. The cache only ever grows, so a change + /// means it holds unpublished reads. + published_entries: (usize, usize, usize), /// Live best-transactions iterator, tagged with the parent it was opened for. transactions: Option<(B256, Transactions)>, } @@ -76,7 +79,9 @@ where evm_config, job: None, pauses: 0, - cache: Cache::default(), + cache: CachedReads::default(), + cache_parent: None, + published_entries: (0, 0, 0), transactions: None, } } @@ -97,8 +102,10 @@ where continue } - if self.cache.parent_hash() != Some(parent_hash) { - self.cache.reset(parent_hash); + if self.cache_parent != Some(parent_hash) { + self.cache = CachedReads::default(); + self.cache_parent = Some(parent_hash); + self.published_entries = (0, 0, 0); debug!( target: "engine::tree::txpool_prewarm", ?parent_hash, @@ -180,8 +187,9 @@ where return BatchEnd::Rest } }; - let state_provider = StateProviderDatabase::new(self.cache.state_provider(state_provider)); - let mut state = State::builder().with_database(state_provider).build(); + let mut state = State::builder() + .with_database(self.cache.as_db_mut(StateProviderDatabase::new(state_provider))) + .build(); // The environment is the head block's own, not a predicted next-block one, and execution // is out of context by design: transaction viability is the pool's business, so nonce, // balance and (one-block-stale) basefee checks must not gate which state gets warmed. @@ -208,15 +216,16 @@ where } /// Publishes a fresh snapshot if the cache gained reads since the last publication. - fn publish_snapshot_if_dirty(&self) { - if !self.cache.is_dirty() { + fn publish_snapshot_if_dirty(&mut self) { + let entries = entry_counts(&self.cache); + if entries == self.published_entries { return } - let snapshot = self.cache.snapshot(); - let parent_hash = snapshot.parent_hash(); - let (accounts, storage, bytecodes) = snapshot.entry_counts(); - *self.publication.write() = Some(snapshot); + let parent_hash = self.cache_parent.expect("reads only accumulate after a cache reset"); + *self.publication.write() = Some(Snapshot::new(parent_hash, Arc::new(self.cache.clone()))); + self.published_entries = entries; + let (accounts, storage, bytecodes) = entries; debug!( target: "engine::tree::txpool_prewarm", ?parent_hash, @@ -287,10 +296,19 @@ enum BatchEnd { #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ChannelDisconnected; +/// Returns `(accounts, storage slots, bytecodes)` cached in `reads`. +fn entry_counts(reads: &CachedReads) -> (usize, usize, usize) { + ( + reads.accounts.len(), + reads.accounts.values().map(|account| account.storage.len()).sum(), + reads.contracts.len(), + ) +} + #[cfg(test)] mod tests { use super::{super::Transaction as PoolTransaction, *}; - use crate::tree::{StateProviderBuilder, TxPoolPrewarmCacheSnapshot as Snapshot}; + use crate::tree::StateProviderBuilder; use alloy_consensus::{transaction::Recovered, Signed, TxLegacy}; use alloy_primitives::{Address, Signature, TxKind, U256}; use crossbeam_channel::{bounded, unbounded, Sender}; From 64ff38fd495ab2100e580f98c9797e0de3111d3b Mon Sep 17 00:00:00 2001 From: Sergei Shulepov Date: Thu, 23 Jul 2026 15:14:02 +0200 Subject: [PATCH 29/32] async pause --- .../tree/src/tree/txpool_prewarm/control.rs | 68 ++++++------------- .../tree/src/tree/txpool_prewarm/mod.rs | 4 ++ .../tree/src/tree/txpool_prewarm/worker.rs | 19 +++--- 3 files changed, 35 insertions(+), 56 deletions(-) diff --git a/crates/engine/tree/src/tree/txpool_prewarm/control.rs b/crates/engine/tree/src/tree/txpool_prewarm/control.rs index 1a285c4af04..da71c87c97c 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm/control.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm/control.rs @@ -1,6 +1,6 @@ use crate::tree::TxPoolPrewarmCacheSnapshot as Snapshot; use alloy_primitives::B256; -use crossbeam_channel::{bounded, unbounded, Receiver, Sender}; +use crossbeam_channel::{unbounded, Receiver, Sender}; use parking_lot::RwLock; use std::{ fmt::Debug, @@ -40,15 +40,11 @@ impl Control { } pub(super) fn pause(self: &Arc) -> PauseGuard { - let (acknowledge, acknowledged) = bounded(1); - let mut guard = PauseGuard { control: Arc::downgrade(self), armed: false }; - if self.commands.send(Command::Pause(acknowledge)).is_ok() { - // Arm before waiting so unwinding the caller still releases a pause that the worker - // may already have observed. - guard.armed = true; - let _ = acknowledged.recv(); - } - guard + // Fire-and-forget: the worker never interrupts a transaction mid-execution, so waiting + // for it to observe the pause would only add that execution time to the caller's + // latency without reducing the overlap. + let _ = self.commands.send(Command::Pause); + PauseGuard { control: Arc::downgrade(self) } } pub(super) fn snapshot(&self, parent_hash: B256) -> Option { @@ -67,23 +63,22 @@ pub(super) type Publication = Arc>>; pub(super) enum Command { /// Starts prewarming for a canonical head, replacing any previous job. Start { parent_hash: B256, job: J }, - /// Pauses prewarming and acknowledges once the worker has released its active resources. - Pause(Sender<()>), + /// Pauses prewarming until a matching [`Resume`](Self::Resume). + Pause, /// Releases one active pause. Resume, } -/// Keeps txpool prewarming paused until the cache-sensitive work is complete. +/// One outstanding pause, released when dropped. pub(super) struct PauseGuard { control: Weak>, - armed: bool, } impl Drop for PauseGuard { fn drop(&mut self) { - if self.armed && - let Some(control) = self.control.upgrade() - { + // If the pause was never delivered the channel is disconnected, and this send fails + // just the same, so the worker can never see an unmatched resume. + if let Some(control) = self.control.upgrade() { let _ = control.commands.send(Command::Resume); } } @@ -92,7 +87,6 @@ impl Drop for PauseGuard { #[cfg(test)] mod tests { use super::*; - use std::{sync::mpsc, thread, time::Duration}; type TestControl = Control; @@ -104,28 +98,6 @@ mod tests { Snapshot::new(parent_hash, Default::default()) } - fn request_pause( - control: Arc, - receiver: &Receiver>, - ) -> PauseGuard { - let (guard_tx, guard_rx) = mpsc::channel(); - let waiter = thread::spawn(move || { - guard_tx.send(control.pause()).expect("pause guard receiver dropped"); - }); - - let command = - receiver.recv_timeout(Duration::from_secs(1)).expect("pause command was not received"); - let Command::Pause(acknowledge) = command else { panic!("expected pause command") }; - assert!(guard_rx.try_recv().is_err()); - acknowledge.send(()).expect("pause waiter dropped"); - - let guard = guard_rx - .recv_timeout(Duration::from_secs(1)) - .expect("pause did not finish after acknowledgement"); - waiter.join().expect("pause waiter panicked"); - guard - } - #[test] fn snapshot_is_published_for_matching_parent() { let (control, _receiver) = control(); @@ -153,9 +125,11 @@ mod tests { } #[test] - fn pause_waits_for_acknowledgement_and_resumes_on_drop() { + fn pause_queues_without_waiting_and_resumes_on_drop() { let (control, receiver) = control(); - let guard = request_pause(Arc::clone(&control), &receiver); + // Nothing reads the channel, so this returning at all proves pause does not block. + let guard = control.pause(); + assert!(matches!(receiver.try_recv(), Ok(Command::Pause))); drop(guard); assert!(matches!(receiver.try_recv(), Ok(Command::Resume))); @@ -163,9 +137,9 @@ mod tests { #[test] fn pause_guard_does_not_retain_control() { - let (control, receiver) = control(); + let (control, _receiver) = control(); let weak_control = Arc::downgrade(&control); - let guard = request_pause(Arc::clone(&control), &receiver); + let guard = control.pause(); drop(control); assert!(weak_control.upgrade().is_none()); @@ -175,8 +149,10 @@ mod tests { #[test] fn overlapping_pause_guards_send_matching_resumes() { let (control, receiver) = control(); - let first = request_pause(Arc::clone(&control), &receiver); - let second = request_pause(Arc::clone(&control), &receiver); + let first = control.pause(); + let second = control.pause(); + assert!(matches!(receiver.try_recv(), Ok(Command::Pause))); + assert!(matches!(receiver.try_recv(), Ok(Command::Pause))); drop(first); assert!(matches!(receiver.try_recv(), Ok(Command::Resume))); diff --git a/crates/engine/tree/src/tree/txpool_prewarm/mod.rs b/crates/engine/tree/src/tree/txpool_prewarm/mod.rs index f4cdd5da59c..3070148fd3d 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm/mod.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm/mod.rs @@ -56,6 +56,10 @@ where /// /// Returns a guard that will resume the worker when dropped. There could be multiple /// outstanding guards, in which case the worker will not resume until all guards are dropped. + /// + /// Pausing is asynchronous and never blocks the caller: the worker observes it between + /// transactions, so speculative work may overlap the guard's scope by at most one + /// transaction. pub(crate) fn pause(&self) -> impl Drop + Send + 'static { self.control.pause() } diff --git a/crates/engine/tree/src/tree/txpool_prewarm/worker.rs b/crates/engine/tree/src/tree/txpool_prewarm/worker.rs index da5cf1fef1c..54ecd1ffb74 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm/worker.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm/worker.rs @@ -261,15 +261,14 @@ where /// Applies a single command. /// - /// Only called while no EVM or state provider is alive, so acknowledging a pause here - /// guarantees the worker holds no execution resources. + /// Only called while no EVM or state provider is alive, so a paused worker holds no + /// execution resources. fn apply(&mut self, command: Command>) { match command { Command::Start { parent_hash, job } => self.job = Some((parent_hash, job)), - Command::Pause(acknowledge) => { + Command::Pause => { self.pauses = self.pauses.checked_add(1).expect("txpool prewarm pause count overflow"); - let _ = acknowledge.send(()); } Command::Resume => { self.pauses = self @@ -311,7 +310,7 @@ mod tests { use crate::tree::StateProviderBuilder; use alloy_consensus::{transaction::Recovered, Signed, TxLegacy}; use alloy_primitives::{Address, Signature, TxKind, U256}; - use crossbeam_channel::{bounded, unbounded, Sender}; + use crossbeam_channel::{unbounded, Sender}; use parking_lot::{Mutex, RwLock}; use reth_ethereum_primitives::{EthPrimitives, TransactionSigned}; use reth_evm_ethereum::EthEvmConfig; @@ -372,12 +371,12 @@ mod tests { self.commands.send(Command::Start { parent_hash, job }).unwrap(); } - /// Pauses the worker and waits for the acknowledgement. Once this returns the worker is - /// quiescent until [`Self::resume`]. + /// Pauses the worker, as [`Handle::pause`](super::super::Handle) does. Fire-and-forget + /// is still deterministic: the worker never publishes work it performs after the pause + /// is queued until [`Self::resume`], because publication is skipped while a command is + /// pending and a consumed pause blocks the loop. fn pause(&self) { - let (acknowledge, acknowledged) = bounded(1); - self.commands.send(Command::Pause(acknowledge)).unwrap(); - acknowledged.recv_timeout(WAIT_LIMIT).expect("pause was not acknowledged"); + self.commands.send(Command::Pause).unwrap(); } fn resume(&self) { From ceccd58bd9a6029c809669ae4909aa6d776452fd Mon Sep 17 00:00:00 2001 From: Sergei Shulepov Date: Thu, 23 Jul 2026 15:32:13 +0200 Subject: [PATCH 30/32] opt-in txpool prewarming --- crates/engine/primitives/src/config.rs | 16 +++----- .../ethereum/node/tests/e2e/selfdestruct.rs | 20 ++-------- crates/node/core/src/args/engine.rs | 39 +++++++++---------- docs/vocs/docs/pages/cli/reth/node.mdx | 4 +- 4 files changed, 30 insertions(+), 49 deletions(-) diff --git a/crates/engine/primitives/src/config.rs b/crates/engine/primitives/src/config.rs index f656c022cc6..108db6ee47e 100644 --- a/crates/engine/primitives/src/config.rs +++ b/crates/engine/primitives/src/config.rs @@ -218,7 +218,7 @@ impl Default for TreeConfig { always_compare_trie_updates: false, disable_state_cache: false, disable_prewarming: false, - txpool_prewarming: true, + txpool_prewarming: false, state_provider_metrics: false, cross_block_cache_size: DEFAULT_CROSS_BLOCK_CACHE_SIZE, has_enough_parallelism: has_enough_parallelism(), @@ -291,7 +291,7 @@ impl TreeConfig { always_compare_trie_updates, disable_state_cache, disable_prewarming, - txpool_prewarming: true, + txpool_prewarming: false, state_provider_metrics, cross_block_cache_size, has_enough_parallelism, @@ -516,12 +516,6 @@ impl TreeConfig { self } - /// Setter for whether to disable txpool transaction prewarming. - pub const fn without_txpool_prewarming(mut self, disabled: bool) -> Self { - self.txpool_prewarming = !disabled; - self - } - /// Setter for whether to always compare trie updates from the state root task to the trie /// updates from the regular state root calculation. pub const fn with_always_compare_trie_updates( @@ -764,9 +758,9 @@ mod tests { use super::TreeConfig; #[test] - fn txpool_prewarming_is_enabled_by_default_and_can_be_disabled() { - assert!(TreeConfig::default().txpool_prewarming()); - assert!(!TreeConfig::default().without_txpool_prewarming(true).txpool_prewarming()); + fn txpool_prewarming_is_disabled_by_default_and_can_be_enabled() { + assert!(!TreeConfig::default().txpool_prewarming()); + assert!(TreeConfig::default().with_txpool_prewarming(true).txpool_prewarming()); } #[test] diff --git a/crates/ethereum/node/tests/e2e/selfdestruct.rs b/crates/ethereum/node/tests/e2e/selfdestruct.rs index 34b62f25c73..626f9d3b5e0 100644 --- a/crates/ethereum/node/tests/e2e/selfdestruct.rs +++ b/crates/ethereum/node/tests/e2e/selfdestruct.rs @@ -141,10 +141,7 @@ fn selfdestruct_contract_init_code() -> Bytes { async fn test_selfdestruct_post_dencun() -> eyre::Result<()> { reth_tracing::init_test_tracing(); - let tree_config = TreeConfig::default() - .without_prewarming(true) - .without_txpool_prewarming(true) - .without_state_cache(false); + let tree_config = TreeConfig::default().without_prewarming(true).without_state_cache(false); let (mut nodes, wallet) = setup_engine::(1, cancun_spec(), false, tree_config, eth_payload_attributes) .await?; @@ -238,10 +235,7 @@ async fn test_selfdestruct_post_dencun() -> eyre::Result<()> { async fn test_selfdestruct_same_tx_post_dencun() -> eyre::Result<()> { reth_tracing::init_test_tracing(); - let tree_config = TreeConfig::default() - .without_prewarming(true) - .without_txpool_prewarming(true) - .without_state_cache(false); + let tree_config = TreeConfig::default().without_prewarming(true).without_state_cache(false); let (mut nodes, wallet) = setup_engine::(1, cancun_spec(), false, tree_config, eth_payload_attributes) .await?; @@ -316,10 +310,7 @@ async fn test_selfdestruct_same_tx_post_dencun() -> eyre::Result<()> { async fn test_selfdestruct_pre_dencun() -> eyre::Result<()> { reth_tracing::init_test_tracing(); - let tree_config = TreeConfig::default() - .without_prewarming(true) - .without_txpool_prewarming(true) - .without_state_cache(false); + let tree_config = TreeConfig::default().without_prewarming(true).without_state_cache(false); let (mut nodes, wallet) = setup_engine::( 1, shanghai_spec(), @@ -429,10 +420,7 @@ async fn test_selfdestruct_pre_dencun() -> eyre::Result<()> { async fn test_selfdestruct_same_tx_preexisting_account_post_dencun() -> eyre::Result<()> { reth_tracing::init_test_tracing(); - let tree_config = TreeConfig::default() - .without_prewarming(true) - .without_txpool_prewarming(true) - .without_state_cache(false); + let tree_config = TreeConfig::default().without_prewarming(true).without_state_cache(false); let (mut nodes, wallet) = setup_engine::(1, cancun_spec(), false, tree_config, eth_payload_attributes) .await?; diff --git a/crates/node/core/src/args/engine.rs b/crates/node/core/src/args/engine.rs index 5beadcf0217..1cbc5eb99b7 100644 --- a/crates/node/core/src/args/engine.rs +++ b/crates/node/core/src/args/engine.rs @@ -31,7 +31,7 @@ pub struct DefaultEngineValues { invalid_header_hit_eviction_threshold: u8, state_cache_disabled: bool, prewarming_disabled: bool, - txpool_prewarming_disabled: bool, + txpool_prewarming_enabled: bool, state_provider_metrics: bool, cross_block_cache_size: usize, state_root_task_compare_updates: bool, @@ -103,9 +103,9 @@ impl DefaultEngineValues { self } - /// Set whether to disable txpool prewarming by default - pub const fn with_txpool_prewarming_disabled(mut self, v: bool) -> Self { - self.txpool_prewarming_disabled = v; + /// Set whether to enable txpool prewarming by default + pub const fn with_txpool_prewarming_enabled(mut self, v: bool) -> Self { + self.txpool_prewarming_enabled = v; self } @@ -254,7 +254,7 @@ impl Default for DefaultEngineValues { invalid_header_hit_eviction_threshold: DEFAULT_INVALID_HEADER_HIT_EVICTION_THRESHOLD, state_cache_disabled: false, prewarming_disabled: false, - txpool_prewarming_disabled: false, + txpool_prewarming_enabled: false, state_provider_metrics: false, cross_block_cache_size: DEFAULT_CROSS_BLOCK_CACHE_SIZE_MB, state_root_task_compare_updates: false, @@ -345,9 +345,9 @@ pub struct EngineArgs { #[arg(long = "engine.disable-prewarming", alias = "engine.disable-caching-and-prewarming", default_value_t = DefaultEngineValues::get_global().prewarming_disabled)] pub prewarming_disabled: bool, - /// Disable best-effort txpool transaction prewarming between payloads. - #[arg(long = "engine.disable-txpool-prewarming", default_value_t = DefaultEngineValues::get_global().txpool_prewarming_disabled)] - pub txpool_prewarming_disabled: bool, + /// Enable best-effort txpool transaction prewarming between payloads. + #[arg(long = "engine.txpool-prewarming", default_value_t = DefaultEngineValues::get_global().txpool_prewarming_enabled)] + pub txpool_prewarming_enabled: bool, /// CAUTION: This CLI flag has no effect anymore. The parallel sparse trie is always enabled. #[deprecated] @@ -546,7 +546,7 @@ impl Default for EngineArgs { invalid_header_hit_eviction_threshold, state_cache_disabled, prewarming_disabled, - txpool_prewarming_disabled, + txpool_prewarming_enabled, state_provider_metrics, cross_block_cache_size, state_root_task_compare_updates, @@ -580,7 +580,7 @@ impl Default for EngineArgs { caching_and_prewarming_enabled: true, state_cache_disabled, prewarming_disabled, - txpool_prewarming_disabled, + txpool_prewarming_enabled, parallel_sparse_trie_enabled: true, parallel_sparse_trie_disabled: false, state_provider_metrics, @@ -666,7 +666,7 @@ impl EngineArgs { .with_invalid_header_hit_eviction_threshold(self.invalid_header_hit_eviction_threshold) .without_state_cache(self.state_cache_disabled) .without_prewarming(self.prewarming_disabled) - .without_txpool_prewarming(self.txpool_prewarming_disabled) + .with_txpool_prewarming(self.txpool_prewarming_enabled) .with_state_provider_metrics(self.state_provider_metrics) .with_always_compare_trie_updates(self.state_root_task_compare_updates) .with_cross_block_cache_size(self.cross_block_cache_size * 1024 * 1024) @@ -725,16 +725,15 @@ mod tests { } #[test] - fn txpool_prewarming_is_enabled_by_default_and_can_be_disabled() { + fn txpool_prewarming_is_disabled_by_default_and_can_be_enabled() { let args = CommandParser::::parse_from(["reth"]).args; - assert!(!args.txpool_prewarming_disabled); - assert!(args.tree_config().txpool_prewarming()); + assert!(!args.txpool_prewarming_enabled); + assert!(!args.tree_config().txpool_prewarming()); let args = - CommandParser::::parse_from(["reth", "--engine.disable-txpool-prewarming"]) - .args; - assert!(args.txpool_prewarming_disabled); - assert!(!args.tree_config().txpool_prewarming()); + CommandParser::::parse_from(["reth", "--engine.txpool-prewarming"]).args; + assert!(args.txpool_prewarming_enabled); + assert!(args.tree_config().txpool_prewarming()); } #[test] @@ -799,7 +798,7 @@ mod tests { caching_and_prewarming_enabled: true, state_cache_disabled: true, prewarming_disabled: true, - txpool_prewarming_disabled: true, + txpool_prewarming_enabled: true, parallel_sparse_trie_enabled: true, parallel_sparse_trie_disabled: false, state_provider_metrics: true, @@ -843,7 +842,7 @@ mod tests { "--engine.legacy-state-root", "--engine.disable-state-cache", "--engine.disable-prewarming", - "--engine.disable-txpool-prewarming", + "--engine.txpool-prewarming", "--engine.state-provider-metrics", "--engine.cross-block-cache-size", "256", diff --git a/docs/vocs/docs/pages/cli/reth/node.mdx b/docs/vocs/docs/pages/cli/reth/node.mdx index b6d41579a65..eaec84883dd 100644 --- a/docs/vocs/docs/pages/cli/reth/node.mdx +++ b/docs/vocs/docs/pages/cli/reth/node.mdx @@ -1011,8 +1011,8 @@ Engine: --engine.disable-prewarming Disable parallel prewarming - --engine.disable-txpool-prewarming - Disable best-effort txpool transaction prewarming between payloads + --engine.txpool-prewarming + Enable best-effort txpool transaction prewarming between payloads --engine.state-provider-metrics Enable state provider latency metrics. This allows the engine to collect and report stats about how long state provider calls took during execution, but this does introduce slight overhead to state provider calls From beb29b4c1dbb37e091057cd605e3933d282943ab Mon Sep 17 00:00:00 2001 From: Sergei Shulepov Date: Thu, 23 Jul 2026 18:23:41 +0200 Subject: [PATCH 31/32] Apply Matt's suggestion Co-authored-by: Matthias Seitz --- crates/node/core/src/args/engine.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/node/core/src/args/engine.rs b/crates/node/core/src/args/engine.rs index 1cbc5eb99b7..3a4a4966d70 100644 --- a/crates/node/core/src/args/engine.rs +++ b/crates/node/core/src/args/engine.rs @@ -346,7 +346,11 @@ pub struct EngineArgs { pub prewarming_disabled: bool, /// Enable best-effort txpool transaction prewarming between payloads. - #[arg(long = "engine.txpool-prewarming", default_value_t = DefaultEngineValues::get_global().txpool_prewarming_enabled)] + #[arg( + long = "engine.txpool-prewarming", + default_value_t = DefaultEngineValues::get_global().txpool_prewarming_enabled, + conflicts_with = "state_cache_disabled" + )] pub txpool_prewarming_enabled: bool, /// CAUTION: This CLI flag has no effect anymore. The parallel sparse trie is always enabled. From bb8e0b66a53098efc841681a646a08de21fc1811 Mon Sep 17 00:00:00 2001 From: Sergei Shulepov Date: Thu, 23 Jul 2026 18:45:00 +0200 Subject: [PATCH 32/32] Fix test. --- crates/node/core/src/args/engine.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/node/core/src/args/engine.rs b/crates/node/core/src/args/engine.rs index 3a4a4966d70..b6e0a60c2cb 100644 --- a/crates/node/core/src/args/engine.rs +++ b/crates/node/core/src/args/engine.rs @@ -740,6 +740,18 @@ mod tests { assert!(args.tree_config().txpool_prewarming()); } + #[test] + fn txpool_prewarming_conflicts_with_disabled_state_cache() { + let Err(error) = CommandParser::::try_parse_from([ + "reth", + "--engine.txpool-prewarming", + "--engine.disable-state-cache", + ]) else { + panic!("conflicting flags must be rejected") + }; + assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); + } + #[test] fn default_backpressure_threshold_uses_parsed_persistence_args() { let args = CommandParser::::parse_from([ @@ -802,7 +814,8 @@ mod tests { caching_and_prewarming_enabled: true, state_cache_disabled: true, prewarming_disabled: true, - txpool_prewarming_enabled: true, + // conflicts with --engine.disable-state-cache, covered by its own test below + txpool_prewarming_enabled: false, parallel_sparse_trie_enabled: true, parallel_sparse_trie_disabled: false, state_provider_metrics: true, @@ -846,7 +859,6 @@ mod tests { "--engine.legacy-state-root", "--engine.disable-state-cache", "--engine.disable-prewarming", - "--engine.txpool-prewarming", "--engine.state-provider-metrics", "--engine.cross-block-cache-size", "256",