Compare commits

..

136 Commits

Author SHA1 Message Date
yongkangc
c67534d872 test(engine): cover stale sparse trie checkout drop 2026-03-16 05:14:05 +00:00
yongkangc
cb7ba92e3d fix(engine): tighten shared cache visibility 2026-03-16 05:11:35 +00:00
yongkangc
78ba601e47 fix(node): preserve sparse trie cache kind 2026-03-16 04:55:28 +00:00
yongkangc
ed90ebe0da feat(engine): add sparse trie cache sdk
Expose a public sparse trie cache and checkout lease through EngineSharedCaches, then route the engine payload processor through that API so the SDK surface is exercised in-tree.

Add a small integration smoke test and preserve the existing checkout/store sequencing guard around state-root publication.
2026-03-16 04:55:28 +00:00
yongkangc
f7b45a8585 refactor(node): drop unused builder context resources 2026-03-16 04:53:57 +00:00
yongkangc
64e5a6fdab ci: apply nightly rustfmt
Format the remaining files that fail the repository nightly rustfmt job so the PR can clear the fmt and lint-success checks.
2026-03-16 04:53:57 +00:00
yongkangc
9c826df006 fix(node): make validator shared cache wiring explicit
Keep builder-side shared cache export on BuilderContext, but route validator construction through an explicit cache-aware builder API. This removes the AddOnsContext API break, restores a const BuilderContext::new via lazy ContextResources allocation, and preserves the existing default validator behavior for callers that do not pass shared caches.
2026-03-16 04:53:57 +00:00
yongkangc
1d1398bd6a refactor(engine): route shared caches through context resources 2026-03-16 04:53:57 +00:00
yongkangc
d17405f563 feat(engine): export shared cache handles 2026-03-16 04:53:56 +00:00
Huber
62f48893a9 fix(p2p): respect --bootnodes flag in reth p2p commands (#23040) 2026-03-15 08:51:54 +00:00
github-actions[bot]
5ef6620060 chore(deps): weekly cargo update (#23041)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-03-15 08:45:07 +00:00
Delweng
d3d7fb31d7 fix(txpool): use ceiling division for replacement tx price bump check (#23012)
Signed-off-by: Delweng <delweng@gmail.com>
2026-03-14 07:56:10 +00:00
Delweng
93cb8934ea fix(net): fully remove disconnected peers from transaction state (#23014)
Signed-off-by: Delweng <delweng@gmail.com>
2026-03-14 04:25:53 +00:00
MagicJoshh
a20d1fb1ef fix(rpc): disable fee charge in eth_createAccessList (#23026) 2026-03-14 02:11:16 +00:00
Derek Cofausper
2178b44224 ci(bench): schedule bench job only on runners tagged available (#23027)
Co-authored-by: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com>
2026-03-13 17:33:04 +00:00
stevencartavia
46a6ee49e1 perf(rpc): avoid hash_slow in reward traces (#23011) 2026-03-13 16:08:45 +00:00
Brian Picciano
a047de9200 chore(grafana): update State Root Task dashboard panels (#23020) 2026-03-13 13:48:52 +00:00
Rej Ect
5f9810c01b fix(chain-state): correct return type of NewCanonicalChain::tip() (#23018)
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
2026-03-13 11:30:43 +00:00
Matthias Seitz
792ee9245f fix(pool): prevent sender-id map growth on read-only sender+nonce lookups (#23008)
Co-authored-by: theobhau183919-ux <theobhau183919@gmail.com>
2026-03-13 11:28:11 +00:00
figtracer
035b021837 chore(docker): bump lighthouse to v8.1.2 (#23002)
Co-authored-by: YK <chiayongkang@hotmail.com>
2026-03-13 11:28:10 +00:00
Matthias Seitz
b05a689c46 fix(net): gate serde-only imports behind feature flag (#23010) 2026-03-13 11:16:14 +00:00
MagicJoshh
2baacf93a3 fix(rpc): eth_config returns wrong fork (#23007)
Co-authored-by: DaniPopes <57450786+DaniPopes@users.noreply.github.com>
2026-03-13 10:03:55 +00:00
Derek Cofausper
0d8d48a16e ci: bump state tests runner to depot-ubuntu-latest-8 (#23017)
Co-authored-by: DaniPopes <57450786+DaniPopes@users.noreply.github.com>
2026-03-13 09:08:15 +00:00
Derek Cofausper
26f0e59155 ci: disable PGO by default, rename input to pgo (#23016)
Co-authored-by: DaniPopes <57450786+DaniPopes@users.noreply.github.com>
2026-03-13 09:00:53 +00:00
Abhijit Roy
3b75817086 fix(primitives): enable serde for RPC receipt test in reth-ethereum-primitives (#22983)
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
2026-03-13 08:37:55 +01:00
stevencartavia
9fdafb70f5 perf: avoid redundant seal_slow when hash is known (#23009) 2026-03-13 08:36:29 +01:00
Delweng
28e067432a fix(net): send disconnect on invalid inbound eth messages (#22986)
Signed-off-by: Delweng <delweng@gmail.com>
2026-03-12 22:27:53 +00:00
Derek Cofausper
c73274cc82 chore(bench): limit reth memory to 95% of available RAM (#23005)
Co-authored-by: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com>
2026-03-12 22:21:56 +00:00
Alexey Shekhirin
9060c5059e ci(bench): push OTLP traces and logs to VictoriaTraces/VictoriaLogs (#22999) 2026-03-12 17:47:30 +00:00
Derek Cofausper
b9969c5b1c chore: remove rocksdb and edge feature gates, default to storage v2 (#22954)
Co-authored-by: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com>
2026-03-12 16:59:18 +00:00
Dan Cline
b37b881074 feat(node-builder): add with_rocksdb_provider to NodeBuilder (#22970)
Co-authored-by: Arsenii Kulikov <klkvrr@gmail.com>
2026-03-12 16:42:59 +00:00
Brian Picciano
9b53c4fa39 chore(trie): address arena PR review feedback (#22996)
Co-authored-by: Amp <amp@ampcode.com>
2026-03-12 16:04:02 +00:00
Derek Cofausper
6cd0f843a8 fix(rpc): disable fee charge for eth_estimateGas (#22959)
Co-authored-by: Arsenii Kulikov <62447812+klkvr@users.noreply.github.com>
Co-authored-by: Arsenii Kulikov <klkvrr@gmail.com>
2026-03-12 15:58:07 +00:00
Sergei Shulepov
47f5653a55 fix(bench): guard abba run steps on BENCH_ABBA flag (#22981) 2026-03-12 15:50:40 +00:00
Derek Cofausper
c0f6997352 feat(bench): show baseline/feature CLI args in Slack notification (#22997)
Co-authored-by: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com>
2026-03-12 15:29:57 +00:00
Derek Cofausper
6a62c38498 ci(docker): add disable_pgo input for workflow dispatch (#22960)
Co-authored-by: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com>
Co-authored-by: Alexey Shekhirin <github@shekhirin.com>
2026-03-12 14:12:27 +00:00
Derek Cofausper
294e215077 fix(provider): heal finalized/safe block numbers ahead of highest header (#22995)
Co-authored-by: joshieDo <93316087+joshieDo@users.noreply.github.com>
2026-03-12 13:55:48 +00:00
Brian Picciano
1589f0f684 fix(tasks): install panic handler on all worker pools (#22993)
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: tempo-ai[bot] <195591+tempo-ai[bot]@users.noreply.github.com>
Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com>
2026-03-12 12:30:25 +00:00
Brian Picciano
563399c696 chore: release 1.11.3 (#22991) 2026-03-12 12:08:59 +00:00
Brian Picciano
ea4d354105 test(trie): Integrate trie-debug recorder into ArenaParallelSparseTrie (#22953)
Co-authored-by: Amp <amp@ampcode.com>
2026-03-12 11:45:31 +00:00
Matthias Seitz
6c908ca28f perf(net): avoid collect allocation in tx announcement trace log (#22985) 2026-03-12 12:10:59 +01:00
Derek Cofausper
093621ffa7 feat(payload): add resolve and job-creation latency histograms (#22978)
Co-authored-by: Georgios Konstantopoulos <17802178+gakonst@users.noreply.github.com>
Co-authored-by: YK <46377366+yongkangc@users.noreply.github.com>
2026-03-12 09:17:38 +00:00
John Chase
451a20f0f5 fix(engine): only count precompile cache hit when gas is sufficient (#22968) 2026-03-12 09:14:25 +00:00
Derek Cofausper
a12b91937e refactor(payload): merge redundant impl blocks (#22984)
Co-authored-by: YK <46377366+yongkangc@users.noreply.github.com>
2026-03-12 09:02:27 +00:00
Delweng
7f12e7aaf8 fix(rpc): use -38026 error code for "too many blocks" (#22976)
Signed-off-by: Delweng <delweng@gmail.com>
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
2026-03-12 08:07:00 +00:00
Derek Cofausper
01564a8f7a feat(bench): add no-slack and abba args for exploratory benchmarks (#22942)
Co-authored-by: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com>
2026-03-12 05:32:49 +00:00
Derek Cofausper
a12a32efff feat(engine): add tx_index to execute tx span (#22972)
Co-authored-by: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com>
2026-03-12 00:19:58 +00:00
Arsenii Kulikov
ec59698ef6 fix: don't deadlock on repeated payloads (#22971) 2026-03-11 23:24:43 +00:00
kiyomi
9f69a689b9 fix(ethstats): prevent writer starvation by cloning ConnWrapper to drop (#22805)
Signed-off-by: YZL0v3ZZ <2055877225@qq.com>
2026-03-11 19:17:18 +00:00
Crypto Nomad
4527725c90 fix(reth-bench): preserve RequestsOrHash for engine_newPayloadV4 (#22939) 2026-03-11 19:14:41 +00:00
Crypto Nomad
c57ecb937b fix(era-downloader): ignore NotFound when deleting out-of-range files (#22905) 2026-03-11 19:13:14 +00:00
Arsenii Kulikov
ea12781417 fix: resolve exit future once engine exits (#22956) 2026-03-11 17:57:31 +00:00
Brian Picciano
adfa36e05a fix(trie): ArenaParallelSparseTrie: fix merge_subtrie_updates not cancelling updates/removals (#22947)
Co-authored-by: Amp <amp@ampcode.com>
2026-03-11 17:03:30 +00:00
Derek Cofausper
592c65be82 refactor(trie): box cleared_subtries pool entries (#22950)
Co-authored-by: DaniPopes <57450786+DaniPopes@users.noreply.github.com>
2026-03-11 16:32:28 +00:00
Sergei Shulepov
074daf8a8f refactor(trie): simplify arena clear with drain and remove all_subtries (#22940) 2026-03-11 16:11:08 +00:00
Brian Picciano
bb55687f98 test(trie): Implement TrieTestHarness (#22923)
Co-authored-by: Amp <amp@ampcode.com>
2026-03-11 11:47:34 +00:00
Delweng
460d522443 chore(downloader): simplify the canonical blocks check (#22739)
Signed-off-by: Delweng <delweng@gmail.com>
2026-03-11 11:28:39 +00:00
Sergei Shulepov
a73f510766 refactor(trie): use par_iter sum directly in arena prune (#22938) 2026-03-11 11:20:50 +00:00
Sergei Shulepov
fddf94c166 refactor(trie): extract set_child/remove_child methods on ArenaSparseNodeBranch (#22936) 2026-03-11 11:01:09 +00:00
DaniPopes
ddc3ecaca6 fix(docker): make symbol stripping configurable (#22937) 2026-03-11 09:42:24 +00:00
John Chase
94d34450a6 fix(rpc): disable EIP-7825 tx gas limit cap in eth_createAccessList and eth_estimateGas (#22893) 2026-03-11 09:02:08 +00:00
Dan Cline
df806b8c10 chore(cli): add --with-senders and --with-rocksdb for niche presets (#22933) 2026-03-11 08:54:58 +00:00
Sergei Shulepov
f624225185 perf(engine): offload DeferredDrops deallocation to a persistent background thread (#22908)
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: DaniPopes <57450786+DaniPopes@users.noreply.github.com>
2026-03-11 08:37:55 +00:00
DaniPopes
9d0eab9560 chore: silence arena trie warning (#22928) 2026-03-11 07:47:29 +00:00
DaniPopes
e63ebac380 feat: enable PGO in release and docker workflows (#21441)
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Derek Cofausper <256792747+decofe@users.noreply.github.com>
Co-authored-by: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com>
Co-authored-by: Alexey Shekhirin <github@shekhirin.com>
2026-03-10 23:42:04 +00:00
MergeBot
1a6ba945a0 fix(codecs): return advanced buf from AlloyHeader::from_compact (#22931) 2026-03-10 21:39:04 +00:00
figtracer
999fa0676c feat(download): use snapshots.reth.rs API with --list and --channel flags (#22859) 2026-03-10 21:12:24 +00:00
Dan Cline
d6b1d06772 fix(ci): remove hashing stages from stage-run-test for storage v2 (#22929) 2026-03-10 20:23:53 +00:00
John Chase
cf2c24c072 perf(engine): hoist outer map lookups out of per-slot loops (#22875)
Co-authored-by: DaniPopes <57450786+DaniPopes@users.noreply.github.com>
2026-03-10 20:01:18 +00:00
Dan Cline
406b95b555 fix(ci): remove issue_comment: edited from bench trigger (#22925) 2026-03-10 19:08:00 +00:00
Tim
e406928667 ci(bench): add metrics proxy with subnet binding and tracy upload (#22752) 2026-03-10 18:47:25 +00:00
DaniPopes
01bd1cc5fa chore: rm thunderdome refs (#22927) 2026-03-10 18:47:04 +00:00
Brian Picciano
792c8f2558 feat(trie): ArenaParallelSparseTrie (#22381)
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexey Shekhirin <github@shekhirin.com>
Co-authored-by: Derek Cofausper <256792747+decofe@users.noreply.github.com>
2026-03-10 17:30:11 +00:00
stevencartavia
71cac26187 perf(provider): drop clones before to_plain_state_reverts (#22918) 2026-03-10 16:06:25 +00:00
Emma Jamieson-Hoare
7def9f262a feat: add verisions to the reth download metadata (#22921) 2026-03-10 15:42:34 +00:00
Dan Cline
5ea37acbdb feat(cli): make storage v2 default for new nodes (#22890) 2026-03-10 15:37:55 +00:00
Emma Jamieson-Hoare
aa1cea6a5d chore: bump reth v1.11.2 (#22914) 2026-03-10 13:51:56 +00:00
Derek Cofausper
f238a288c6 fix(bench): retry HTTP 502 errors in block provider (#22916)
Co-authored-by: DaniPopes <57450786+DaniPopes@users.noreply.github.com>
2026-03-10 12:27:22 +00:00
Roman Krasiuk
2580304b41 refactor(txpool): change EthTransactionValidator::validate_stateless return type, accept tx by ref (#22910)
Co-authored-by: Amp <amp@ampcode.com>
2026-03-10 09:50:34 +00:00
stevencartavia
9e3950dbd9 perf(provider): remove unnecessary clones in changeset readers (#22906) 2026-03-10 09:49:19 +00:00
Derek Cofausper
e88e8e70bf refactor(engine): remove unused MultiProofMessage::EmptyProof variant (#22909)
Co-authored-by: YK <46377366+yongkangc@users.noreply.github.com>
2026-03-10 09:21:10 +00:00
Derek Cofausper
73bd474600 revert: use line-tables-only debug info for profiling profile (#22907)
Co-authored-by: Sergei Shulepov <2205845+pepyakin@users.noreply.github.com>
2026-03-10 09:15:05 +00:00
John Chase
be779c90a2 perf(engine): use realistic avg code size for cache budget estimation (#22846)
Co-authored-by: YK <chiayongkang@hotmail.com>
2026-03-10 08:35:40 +00:00
Rej Ect
98fa44d99e fix(stages): set block_range in with_block_range (#22800) 2026-03-10 00:30:53 +00:00
bobtajson
8e89ec7685 fix(trie): remove unnecessary double-wrapping of ProviderError in changeset cache (#22864) 2026-03-10 00:15:07 +00:00
Matthias Seitz
0db52b60c0 fix(op): implement is_system_tx for OpTxEnvelope (#22882) 2026-03-09 23:21:33 +00:00
John Chase
20d53d039e chore(engine): Clean MultiProofTaskMetrics fields (#22872) 2026-03-09 22:58:59 +00:00
MergeBot
9ed3b131b2 fix(reth-bench): add missing serde default for GasRampPayloadFile version field (#22903) 2026-03-09 22:53:45 +00:00
John Chase
07c3467778 fix(cli): include error details in shutdown log message (#22817) 2026-03-09 22:31:45 +00:00
Alexey Shekhirin
12a3022a2a fix(engine): reset execution cache hash on clear (#22895)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:48:14 +00:00
Derek Cofausper
84c85ccef6 feat(metrics): expose CLI args as prometheus metric (#22896)
Co-authored-by: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com>
2026-03-09 18:21:16 +00:00
Derek Cofausper
851f32a4d3 perf: use line-tables-only debug info for profiling profile (#22891)
Co-authored-by: Sergei Shulepov <2205845+pepyakin@users.noreply.github.com>
2026-03-09 17:20:44 +00:00
Brian Picciano
3f81e1894c feat(engine): add --engine.proof-jitter option behind trie-debug (#22889)
Co-authored-by: Amp <amp@ampcode.com>
2026-03-09 17:19:19 +00:00
Brian Picciano
085592dedf test(trie): add generic SparseTrie test suite (#22886)
Co-authored-by: Amp <amp@ampcode.com>
2026-03-09 16:32:46 +00:00
DaniPopes
e28dd31a7e chore: cargo update (#22888) 2026-03-09 15:49:31 +00:00
strmfos
151f92d43a chore(deps): remove duplicate dev-dependencies (#22880) 2026-03-09 08:33:47 +00:00
Matthias Seitz
c1ae2af8ca docs: fix typos and grammar errors across crates (#22877) 2026-03-09 04:49:19 +01:00
Matthias Seitz
cdeba79590 chore: remove stale entries from deny.toml (#22868) 2026-03-08 08:54:23 +01:00
github-actions[bot]
29fbbadc50 chore(deps): weekly cargo update (#22866)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
2026-03-08 07:39:04 +00:00
John Chase
c5107fe23c fix(txpool): treat NotFound as success in blob store cleanup (#22862) 2026-03-08 06:35:39 +00:00
Dan Cline
35e6059924 fix(cli): fix ctrl-C in reth downloads (#22851) 2026-03-08 06:26:55 +00:00
bobtajson
09859a2621 fix(net): remove redundant PendingPoolImportsInfo allocation in TransactionsManager (#22860) 2026-03-07 17:18:02 +00:00
Rej Ect
0aa77e8d90 fix(prune): correct broken test for set_deleted_entries_limit (#22798) 2026-03-07 04:37:01 +00:00
stevencartavia
72190e272b perf(rpc): fetch blocks and receipts concurrently in eth_feeHistory (#22826)
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
2026-03-07 04:26:39 +00:00
Derek Cofausper
6b587560fa fix(payload): clear stale cached payload when new job is created (#22855)
Co-authored-by: joshieDo <93316087+joshieDo@users.noreply.github.com>
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
2026-03-07 03:40:00 +00:00
figtracer
d41589a578 refactor(net): derive DerefMut for NewBlockHashes and NewPooledTransactionHashes66 (#22847)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:02:46 +00:00
Derek Cofausper
8966350c24 feat(bench): add baseline-args and feature-args for reth node (#22844)
Co-authored-by: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com>
2026-03-06 17:18:25 +00:00
Derek Cofausper
4a2456c908 fix(bench): show gas ramp blocks instead of warmup/blocks for big-blocks mode (#22838)
Co-authored-by: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com>
2026-03-06 14:08:40 +00:00
Alexey Shekhirin
99aea38920 feat(engine): slow block logs (#21433)
Co-authored-by: CPerezz <cperezz19@pm.me>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
Co-authored-by: Georgios Konstantopoulos <me@gakonst.com>
Co-authored-by: mattsse <mattsse@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:46:49 +00:00
Derek Cofausper
0da679f87c fix: clean up stale schelk state before bench mount (#22837)
Co-authored-by: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com>
2026-03-06 13:09:33 +00:00
Derek Cofausper
6ca9856ce9 ci(bench): skip wait-time for gas ramp payloads in replay-payloads (#22835)
Co-authored-by: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com>
2026-03-06 12:42:38 +00:00
Derek Cofausper
0b69f6ad7b feat(bench): support reth_newPayload and wait-time args (#22834)
Co-authored-by: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com>
2026-03-06 12:12:23 +00:00
YK
37709c5a99 feat(payload): propagate tracing span across payload builder channel (#22828) 2026-03-06 10:46:20 +00:00
Sergei Shulepov
e6e637a265 perf: LFU-based sparse trie cache (#22766)
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexey Shekhirin <github@shekhirin.com>
2026-03-06 08:37:29 +00:00
Delweng
b3cfe87795 perf(engine): check block itself as invalid ancestor to eliminate duplicate exec (#22794)
Signed-off-by: Delweng <delweng@gmail.com>
Co-authored-by: YK <chiayongkang@hotmail.com>
2026-03-06 07:47:33 +00:00
Derek Cofausper
7402820d62 perf(payload): move sealed block instead of cloning (#22831)
Co-authored-by: tempo-ai[bot] <tempo-ai[bot]@users.noreply.github.com>
Co-authored-by: YK <46377366+yongkangc@users.noreply.github.com>
2026-03-06 06:56:31 +00:00
Julio
cda19b07d6 fix(node): Graceful engine shutdown on node drop (#22698) 2026-03-06 04:32:36 +00:00
stevencartavia
6149ac6c0e perf(rpc): skip block construction in rpc_block_header (#22812) 2026-03-06 03:23:08 +00:00
stevencartavia
e4b553563b perf(rpc): deduplicate pending_block_env_and_cfg in local_pending_block (#22825) 2026-03-06 03:18:24 +00:00
Derek Cofausper
2f4a128112 fix(net): log message kind when session command buffer is full (#22822)
Co-authored-by: Matthias Seitz <19890894+mattsse@users.noreply.github.com>
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 03:07:44 +00:00
stevencartavia
cd480190e9 perf(rpc): derive pending base fee from latest header (#22820) 2026-03-06 02:24:42 +00:00
John Chase
9b1fcd9945 fix(cli): improve error message when snapshot manifest is unavailable (#22814) 2026-03-06 00:08:05 +00:00
Derek Cofausper
39b9c8ae4b feat(net): introduce DefaultNetworkArgs for NetworkArgs (#22801)
Co-authored-by: Matthias Seitz <19890894+mattsse@users.noreply.github.com>
2026-03-05 15:24:45 +00:00
Alexey Shekhirin
c4bd3f145c ci(bench): big blocks in CI benchmarks (#22802)
Co-authored-by: Georgios Konstantopoulos <me@gakonst.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-05 12:40:46 +00:00
John Chase
909157859a feat(rpc): implement debug_intermediateRoots (#22754)
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:12:42 +00:00
Brian Picciano
ea47f1553c fix(trie): Reset proof v2 calculator on error (#22781)
Co-authored-by: Amp <amp@ampcode.com>
2026-03-05 07:44:51 +00:00
stevencartavia
bb12b72e70 refactor(rpc): accept Recovered<Tx> in build_transaction_receipt (#22795)
Co-authored-by: Amp <amp@ampcode.com>
2026-03-05 07:31:09 +00:00
stevencartavia
3a1872411b perf(rpc): reduce redundant DB lookups for receipts (#22724)
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
2026-03-05 04:32:10 +00:00
Derek Cofausper
71c0015862 refactor(tasks): change once! macro to take closure (#22793)
Co-authored-by: DaniPopes <57450786+DaniPopes@users.noreply.github.com>
2026-03-05 04:23:40 +00:00
bobtajson
7c51bc934c fix(net): mark transactions as seen in propagate_hashes_to (#22776) 2026-03-05 03:30:19 +00:00
Delweng
823fbef1c7 perf(net): reorder filters to run cheap checks first (#22785)
Signed-off-by: Delweng <delweng@gmail.com>
2026-03-05 03:29:12 +00:00
dependabot[bot]
d7b5c5e498 chore(deps): bump docker/login-action from 3 to 4 (#22791)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-05 03:25:25 +00:00
dependabot[bot]
2c46aad8e5 chore(deps): bump actions/download-artifact from 7 to 8 (#22790)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-05 03:25:02 +00:00
dependabot[bot]
e15a92a22b chore(deps): bump actions/upload-artifact from 6 to 7 (#22789)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-05 03:24:50 +00:00
Elaela Solis
704292b3d5 fix(rpc): correct call_many block lookup errors (#22759) 2026-03-05 03:23:57 +00:00
figtracer
31fa93889e feat(rpc): add debug_verbosity/vmodule (#21497)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 02:47:09 +00:00
299 changed files with 17009 additions and 5410 deletions

View File

@@ -0,0 +1,5 @@
---
reth-trie-sparse: patch
---
Fixed a bug in `merge_subtrie_updates` where source insertions did not cancel destination removals (and vice versa), causing inconsistent trie updates accumulated across multiple `root()` calls without intermediate `take_updates()`. Added a test covering the cross-cancellation behavior.

View File

@@ -0,0 +1,5 @@
---
reth-tasks: patch
---
Added panic handler to all rayon thread pools that logs panics via `tracing::error` instead of aborting the process.

View File

@@ -0,0 +1,5 @@
---
reth-trie-sparse: patch
---
Refactored arena trie internals by adding a `BranchChildIdx::sibling()` helper, deduplicating `Index`/`NodeArena` type aliases, and replacing `is_empty()` with a `drop_root()` method. Fixed a bug where `cursor.pop()` was called before checking if the leaf was the root node, which could cause incorrect dirty-state propagation.

View File

@@ -0,0 +1,5 @@
---
reth-payload-builder: minor
---
Added observability metrics for payload resolve latency and new payload job creation latency to the payload builder service.

View File

@@ -0,0 +1,10 @@
---
reth-chain-state: minor
reth-engine-primitives: minor
reth-engine-tree: minor
reth-node-core: minor
reth-node-events: minor
reth: patch
---
Added configurable slow block logging (`--engine.slow-block-threshold`) that emits a structured `warn!` log with detailed timing, state-operation counts, and cache hit-rate metrics for blocks whose total processing time exceeds the threshold. Introduced `ExecutionTimingStats`, `CacheStats`, `StateProviderStats`, and `SlowBlockInfo` types to carry execution statistics from block validation through persistence, and refactored `PersistenceResult` to carry commit duration alongside the last persisted block.

View File

@@ -0,0 +1,8 @@
---
reth-engine-primitives: minor
reth-engine-tree: minor
reth-node-core: minor
reth-trie-parallel: minor
---
Added `--engine.proof-jitter` CLI option behind the `trie-debug` feature flag. When set, each proof worker sleeps for a random duration up to the specified value before starting proof computation, useful for stress-testing timing-sensitive proof logic.

View File

@@ -0,0 +1,6 @@
---
reth-trie: patch
reth-trie-sparse: patch
---
Refactored test harness for sparse trie tests by extracting `TrieTestHarness` into a shared `reth-trie` test utility, replacing duplicated inline harness code across multiple test modules. Updated `proof_v2` return type to include an optional root hash, and converted `original_root` and `storage` from public fields to accessor methods.

View File

@@ -0,0 +1,7 @@
---
reth-cli-commands: minor
reth-node-core: minor
reth: patch
---
Made v2 storage the default for all new databases, deprecating the `--storage.v2` flag to a hidden no-op kept for backwards compatibility. Updated CLI reference docs to remove the now-hidden flag from all command help pages.

View File

@@ -0,0 +1,7 @@
---
reth-engine-tree: patch
reth-trie-sparse: patch
reth-tasks: patch
---
Offloaded deallocation of expensive proof node buffers to a persistent background thread (`Runtime::spawn_drop`) to avoid blocking state root computation or lock-holding code.

View File

@@ -0,0 +1,5 @@
---
reth-trie-sparse: minor
---
Added a comprehensive generic `SparseTrie` test suite covering `set_root`, `reveal_nodes`, `update_leaves`, `root`, `take_updates`, `commit_updates`, `prune`, `wipe`/`clear`, `get_leaf_value`, `find_leaf`, `size_hint`, and integration lifecycle scenarios. Tests are stamped out for all concrete `SparseTrie` implementations via a macro.

View File

@@ -0,0 +1,5 @@
---
reth-cli-commands: minor
---
Added `reth_version` field to `SnapshotManifest` to record the Reth version that produced a snapshot. The field is optional and populated automatically during manifest generation.

View File

@@ -0,0 +1,9 @@
---
reth-trie-sparse: minor
reth-engine-primitives: minor
reth-engine-tree: minor
reth-node-core: minor
reth-trie-common: patch
---
Added an arena-based sparse trie implementation (`ArenaParallelSparseTrie`) using `slotmap` arena allocation for node storage, enabling parallel subtrie mutation without per-node hashing overhead. Added `ConfigurableSparseTrie` enum to switch between the arena and hash-map implementations, and a `--engine.enable-arena-sparse-trie` CLI flag to opt in at runtime.

View File

@@ -0,0 +1,5 @@
---
reth-trie: patch
---
Fixed a potential panic in `ProofCalculator` by clearing internal computation state (`branch_stack`, `child_stack`, `branch_path`, etc.) after errors, preventing stale state from causing `usize` underflow panics when the calculator is reused. Added a test verifying correct behavior after simulated mid-computation errors.

View File

@@ -20,6 +20,11 @@
# include dist directory, where the reth binary is located after compilation
!/dist
# include PGO build helper used by Dockerfile.depot
!/.github
!/.github/scripts
!/.github/scripts/build_pgo_bolt.sh
# include licenses
!LICENSE-*

View File

@@ -5,3 +5,4 @@ self-hosted-runner:
- depot-ubuntu-latest-4
- depot-ubuntu-latest-8
- depot-ubuntu-latest-16
- available

276
.github/scripts/bench-metrics-proxy.py vendored Normal file
View File

@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""
Prometheus metrics proxy that fetches from a local reth node and
re-exposes with additional benchmark labels.
Reads labels from a JSON file (updated by local-reth-bench.sh between runs)
and injects them into every Prometheus metric line.
Returns empty 200 when reth is not running (clean Grafana gaps).
"""
import argparse
import ipaddress
import json
import subprocess
import sys
import time
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.request import urlopen
from urllib.error import URLError
def read_labels(path):
try:
with open(path) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
def inject_labels(metrics_bytes, label_str, label_names):
"""Inject labels into Prometheus text format.
Operates on bytes and uses simple string ops instead of regex
for speed on large payloads (reth exposes thousands of metrics).
Skips injecting into lines that already contain any of the label names
to avoid duplicate labels (which Prometheus rejects).
"""
if not label_str:
return metrics_bytes
label_bytes = label_str.encode("utf-8")
# Pre-encode label names for fast duplicate detection
label_name_bytes = [n.encode("utf-8") for n in label_names]
out = []
for line in metrics_bytes.split(b"\n"):
# Skip comments and blank lines
if line.startswith(b"#") or not line:
out.append(line)
continue
brace = line.find(b"{")
space = line.find(b" ")
if space == -1:
# Malformed, pass through
out.append(line)
elif brace != -1 and brace < space:
# Has labels: metric{existing="val"} 123
close = line.find(b"}", brace)
if close == -1:
out.append(line)
continue
# Filter out labels that already exist in this line
existing = line[brace + 1:close]
inject = label_bytes
if existing:
for name in label_name_bytes:
if name + b"=" in existing:
# Rebuild inject string excluding this label
inject = _remove_label(inject, name)
if not inject:
out.append(line)
continue
if close == brace + 1:
# Empty braces: metric{} 123
out.append(line[:close] + inject + line[close:])
else:
out.append(line[:close] + b"," + inject + line[close:])
else:
# No labels: metric 123
out.append(line[:space] + b"{" + label_bytes + b"}" + line[space:])
return b"\n".join(out)
def _remove_label(label_bytes, name):
"""Remove a single label (name=\"...\") from a comma-separated label string."""
parts = []
for part in label_bytes.split(b","):
if not part.startswith(name + b"="):
parts.append(part)
return b",".join(parts)
def build_label_str(labels):
"""Pre-format the label injection string: key1="val1",key2="val2" """
if not labels:
return ""
return ",".join(f'{k}="{v}"' for k, v in sorted(labels.items()))
def build_elapsed_gauge(labels):
"""Build a bench_elapsed_seconds gauge from run_start_epoch in labels."""
start = labels.get("run_start_epoch")
if not start:
return b""
try:
elapsed = time.time() - float(start)
except (ValueError, TypeError):
return b""
# Build labels excluding internal keys
display = {k: v for k, v in labels.items()
if k not in ("run_start_epoch", "reference_epoch")}
lstr = build_label_str(display)
return (
f"# HELP bench_elapsed_seconds Seconds since benchmark run started\n"
f"# TYPE bench_elapsed_seconds gauge\n"
f"bench_elapsed_seconds{{{lstr}}} {elapsed:.1f}\n"
).encode("utf-8")
def compute_timestamp_ms(labels):
"""Compute a synthetic timestamp so all runs share a common time origin.
Returns the timestamp in milliseconds, or None if not enough info.
Uses: reference_epoch + (now - run_start_epoch) → all runs overlay at
the same Grafana time range.
"""
ref = labels.get("reference_epoch")
start = labels.get("run_start_epoch")
if not ref or not start:
return None
try:
elapsed = time.time() - float(start)
return int((float(ref) + elapsed) * 1000)
except (ValueError, TypeError):
return None
def inject_timestamps(metrics_bytes, timestamp_ms):
"""Append a Prometheus timestamp (ms) to every data line.
Prometheus text format: metric{labels} value [timestamp_ms]
Adding timestamps causes Prometheus to store all runs' samples
at the same relative time, enabling natural overlay in Grafana.
"""
if timestamp_ms is None:
return metrics_bytes
ts = str(timestamp_ms).encode("utf-8")
out = []
for line in metrics_bytes.split(b"\n"):
if line.startswith(b"#") or not line:
out.append(line)
else:
out.append(line + b" " + ts)
return b"\n".join(out)
class MetricsHandler(BaseHTTPRequestHandler):
# Use HTTP/1.1 so Content-Length is respected and Prometheus
# doesn't have to rely on connection close to detect end of body.
protocol_version = "HTTP/1.1"
def do_GET(self):
src = self.client_address[0]
try:
resp = urlopen(self.server.upstream, timeout=2)
metrics = resp.read()
except (URLError, ConnectionError, OSError):
# reth not running — return empty 200
self._send(b"")
#print(f" scrape from {src}: empty (reth not running)", flush=True)
return
all_labels = read_labels(self.server.labels_file)
# Internal keys — not injected as Prometheus labels
internal = ("run_start_epoch", "reference_epoch")
labels = {k: v for k, v in all_labels.items() if k not in internal}
label_str = build_label_str(labels)
label_names = sorted(labels.keys())
t0 = time.monotonic()
result = inject_labels(metrics, label_str, label_names)
result += build_elapsed_gauge(all_labels)
ts_ms = compute_timestamp_ms(all_labels)
result = inject_timestamps(result, ts_ms)
dt = time.monotonic() - t0
self._send(result)
print(f" scrape from {src}: {len(metrics)} -> {len(result)} bytes, "
f"inject {dt*1000:.1f}ms", flush=True)
def _send(self, body):
self.send_response(200)
self.send_header("Content-Type", "text/plain; version=0.0.4")
self.send_header("Content-Length", str(len(body)))
self.send_header("Connection", "close")
self.end_headers()
if body:
self.wfile.write(body)
def log_message(self, format, *args):
pass # suppress per-request logging
def resolve_bind_address(subnet_cidr):
"""Find the local IP address that belongs to the given subnet.
Uses ``ip -j addr show`` to enumerate interfaces and returns the first
address that falls within *subnet_cidr* (e.g. ``10.10.0.0/24``).
"""
network = ipaddress.ip_network(subnet_cidr, strict=False)
try:
result = subprocess.run(
["ip", "-j", "addr", "show"],
capture_output=True, text=True, check=True,
)
interfaces = json.loads(result.stdout)
except (subprocess.CalledProcessError, FileNotFoundError, json.JSONDecodeError) as exc:
print(f"Error: cannot enumerate interfaces: {exc}", file=sys.stderr)
sys.exit(1)
for iface in interfaces:
for addr_info in iface.get("addr_info", []):
try:
addr = ipaddress.ip_address(addr_info["local"])
except (KeyError, ValueError):
continue
if addr in network:
return str(addr)
print(f"Error: no interface address found in subnet {subnet_cidr}", file=sys.stderr)
sys.exit(1)
def main():
parser = argparse.ArgumentParser(description="Prometheus metrics proxy with label injection")
parser.add_argument("--labels", default="/tmp/bench-metrics-labels.json",
help="Path to JSON file with labels to inject (default: /tmp/bench-metrics-labels.json)")
parser.add_argument("--upstream", default="http://127.0.0.1:9100/",
help="Upstream reth metrics URL (default: http://127.0.0.1:9100/)")
bind_group = parser.add_mutually_exclusive_group()
bind_group.add_argument("--bind", default=None,
help="Address to bind the proxy (default: 0.0.0.0)")
bind_group.add_argument("--subnet", default=None,
help="Auto-detect bind address from a local interface in this subnet (e.g. 10.10.0.0/24)")
parser.add_argument("--port", type=int, default=9090,
help="Port to bind the proxy (default: 9090)")
args = parser.parse_args()
if args.subnet:
bind_addr = resolve_bind_address(args.subnet)
elif args.bind:
bind_addr = args.bind
else:
bind_addr = "0.0.0.0"
server = HTTPServer((bind_addr, args.port), MetricsHandler)
server.upstream = args.upstream
server.labels_file = args.labels
print(f"bench-metrics-proxy listening on {bind_addr}:{args.port}")
print(f" upstream: {args.upstream}")
print(f" labels: {args.labels}")
sys.stdout.flush()
server.serve_forever()
if __name__ == "__main__":
main()

View File

@@ -22,6 +22,22 @@ MODE="$1"
SOURCE_DIR="$2"
COMMIT="$3"
# Tracy support: when BENCH_TRACY is "on" or "full", add Tracy cargo features
# and frame pointers for accurate stack traces.
EXTRA_FEATURES=""
EXTRA_RUSTFLAGS=""
if [ "${BENCH_TRACY:-off}" != "off" ]; then
EXTRA_FEATURES="tracy,tracy-client/ondemand"
EXTRA_RUSTFLAGS=" -C force-frame-pointers=yes"
fi
# Cache suffix: hash of features+rustflags so different build configs get separate cache entries
if [ -n "$EXTRA_FEATURES" ] || [ -n "$EXTRA_RUSTFLAGS" ]; then
BUILD_SUFFIX="-$(echo "${EXTRA_FEATURES}${EXTRA_RUSTFLAGS}" | sha256sum | cut -c1-12)"
else
BUILD_SUFFIX=""
fi
# Verify a cached reth binary was built from the expected commit.
# `reth --version` outputs "Commit SHA: <full-sha>" on its own line.
verify_binary() {
@@ -42,7 +58,7 @@ verify_binary() {
case "$MODE" in
baseline|main)
BUCKET="minio/reth-binaries/${COMMIT}"
BUCKET="minio/reth-binaries/${COMMIT}${BUILD_SUFFIX}"
mkdir -p "${SOURCE_DIR}/target/profiling"
CACHE_VALID=false
@@ -59,14 +75,23 @@ case "$MODE" in
if [ "$CACHE_VALID" = false ]; then
echo "Building baseline (${COMMIT}) from source..."
cd "${SOURCE_DIR}"
cargo build --profile profiling --bin reth
FEATURES_ARG=""
WORKSPACE_ARG=""
if [ -n "$EXTRA_FEATURES" ]; then
# --workspace is needed for cross-package feature syntax (tracy-client/ondemand)
FEATURES_ARG="--features ${EXTRA_FEATURES}"
WORKSPACE_ARG="--workspace"
fi
# shellcheck disable=SC2086
RUSTFLAGS="-C target-cpu=native${EXTRA_RUSTFLAGS}" \
cargo build --profile profiling --bin reth $WORKSPACE_ARG $FEATURES_ARG
$MC cp target/profiling/reth "${BUCKET}/reth"
fi
;;
feature|branch)
BRANCH_SHA="${4:-$COMMIT}"
BUCKET="minio/reth-binaries/${BRANCH_SHA}"
BUCKET="minio/reth-binaries/${BRANCH_SHA}${BUILD_SUFFIX}"
CACHE_VALID=false
if $MC stat "${BUCKET}/reth" &>/dev/null && $MC stat "${BUCKET}/reth-bench" &>/dev/null; then
@@ -85,7 +110,14 @@ case "$MODE" in
echo "Building feature (${COMMIT}) from source..."
cd "${SOURCE_DIR}"
rustup show active-toolchain || rustup default stable
make profiling
if [ -n "$EXTRA_FEATURES" ]; then
# Can't use `make profiling` when adding features; build explicitly
# --workspace is needed for cross-package feature syntax (tracy-client/ondemand)
RUSTFLAGS="-C target-cpu=native${EXTRA_RUSTFLAGS}" \
cargo build --profile profiling --workspace --bin reth --features "${EXTRA_FEATURES}"
else
make profiling
fi
make install-reth-bench
$MC cp target/profiling/reth "${BUCKET}/reth"
$MC cp "$(which reth-bench)" "${BUCKET}/reth-bench"

581
.github/scripts/bench-reth-local.sh vendored Executable file
View File

@@ -0,0 +1,581 @@
#!/usr/bin/env bash
#
# local-reth-bench.sh — Run the reth Engine API benchmark locally.
#
# Replicates the CI bench.yml workflow (build, snapshot, system tuning,
# interleaved B-F-F-B execution, summary, charts) without any GitHub
# Actions glue (no PR comments, no artifact upload, no Slack).
#
# Usage:
# local-reth-bench.sh <baseline-ref> <feature-ref> [options]
#
# Options:
# --blocks N Number of blocks to benchmark (default: 500)
# --warmup N Number of warmup blocks (default: 100)
# --cores N Limit reth to N CPU cores, 0 = all available (default: 0)
# --samply Enable samply profiling
# --tracy MODE Tracy profiling: off, on, full (default: off)
# --tracy-filter F Tracy tracing filter (default: debug)
# --no-tune Skip system tuning (useful on dev machines / macOS)
#
# Requires: the reth repo at RETH_REPO (default: ~/reth)
#
# Dependencies (install before first run):
# mc (MinIO client), schelk, cpupower, taskset, stdbuf, python3, curl,
# make, uv, pzstd, jq, Rust toolchain (cargo/rustup)
#
# The script delegates to the existing bench-reth-*.sh scripts in the reth
# repo for the actual build, snapshot, and run steps.
set -euo pipefail
# ── PATH ──────────────────────────────────────────────────────────────
# Ensure cargo and user-local bins (mc, uv) are visible
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
# ── Defaults ──────────────────────────────────────────────────────────
RETH_REPO="${RETH_REPO:-$HOME/reth}"
BLOCKS=500
WARMUP=100
CORES=0
SAMPLY=false
TRACY="off"
TRACY_FILTER="debug"
TUNE=true
BASELINE_REF=""
FEATURE_REF=""
# ── Parse arguments ──────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: $(basename "$0") <baseline-ref> <feature-ref> [options]
Options:
--blocks N Number of blocks to benchmark (default: 500)
--warmup N Number of warmup blocks (default: 100)
--cores N Limit reth to N CPU cores (default: 0 = all)
--samply Enable samply profiling
--tracy MODE Tracy profiling: off, on, full (default: off)
on = tracing only (lower overhead)
full = tracing + CPU sampling (higher overhead)
--tracy-filter F Tracy tracing filter (default: debug)
--no-tune Skip system tuning
EOF
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--blocks) BLOCKS="$2"; shift 2 ;;
--warmup) WARMUP="$2"; shift 2 ;;
--cores) CORES="$2"; shift 2 ;;
--samply) SAMPLY=true; shift ;;
--tracy) TRACY="$2"; shift 2 ;;
--tracy-filter) TRACY_FILTER="$2"; shift 2 ;;
--no-tune) TUNE=false; shift ;;
--help|-h) usage ;;
-*) echo "Unknown option: $1"; usage ;;
*)
if [ -z "$BASELINE_REF" ]; then
BASELINE_REF="$1"
elif [ -z "$FEATURE_REF" ]; then
FEATURE_REF="$1"
else
echo "Unexpected argument: $1"; usage
fi
shift
;;
esac
done
if [ -z "$BASELINE_REF" ] || [ -z "$FEATURE_REF" ]; then
echo "Error: both <baseline-ref> and <feature-ref> are required."
usage
fi
# Validate --tracy value
case "$TRACY" in
off|on|full) ;;
*) echo "Error: --tracy must be off, on, or full (got: $TRACY)"; usage ;;
esac
# Samply + tracy=full are mutually exclusive (both use perf sampling)
if [ "$SAMPLY" = "true" ] && [ "$TRACY" = "full" ]; then
echo "Warning: samply and tracy=full both use perf sampling; downgrading tracy to 'on'."
TRACY="on"
fi
# ── Check dependencies ───────────────────────────────────────────────
missing=()
for cmd in mc schelk cpupower taskset stdbuf python3 curl make uv pzstd jq cargo; do
command -v "$cmd" &>/dev/null || missing+=("$cmd")
done
if [ ${#missing[@]} -gt 0 ]; then
echo "Error: missing required tools: ${missing[*]}"
echo "See the CI 'Install dependencies' step in .github/workflows/bench.yml for install instructions."
exit 1
fi
if [ "$TRACY" != "off" ]; then
if ! command -v tracy-capture &>/dev/null; then
echo "Error: tracy-capture is required for --tracy $TRACY"
exit 1
fi
fi
# Ensure tools that run via sudo are in a sudo-visible path.
# The bench scripts use `sudo schelk` / `sudo samply` but cargo installs
# them to ~/.cargo/bin which sudo's secure_path doesn't include.
for cmd in schelk samply; do
if command -v "$cmd" &>/dev/null && ! sudo sh -c "command -v $cmd" &>/dev/null; then
echo "Installing $cmd to /usr/local/bin (needed for sudo)..."
sudo install "$(command -v "$cmd")" /usr/local/bin/
fi
done
if [ ! -d "$RETH_REPO/.git" ]; then
echo "Error: RETH_REPO=$RETH_REPO is not a git repository."
echo "Set RETH_REPO or clone reth to ~/reth"
exit 1
fi
# ── Resolve paths ────────────────────────────────────────────────────
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPTS_DIR="${RETH_REPO}/.github/scripts"
BENCH_WORK_DIR="${RETH_REPO}/../bench-work-$(date +%Y%m%d-%H%M%S)"
BASELINE_SRC="${RETH_REPO}/../reth-baseline"
FEATURE_SRC="${RETH_REPO}/../reth-feature"
mkdir -p "$BENCH_WORK_DIR"
BENCH_WORK_DIR="$(cd "$BENCH_WORK_DIR" && pwd)"
# ── Global cleanup trap (restores system tuning on any exit) ─────────
TUNING_APPLIED=false
CSTATE_PID=
METRICS_PROXY_PID=
cleanup_global() {
[ -n "$METRICS_PROXY_PID" ] && kill "$METRICS_PROXY_PID" 2>/dev/null || true
if [ "$TUNING_APPLIED" = true ]; then
echo
echo "▸ Restoring system settings..."
[ -n "$CSTATE_PID" ] && kill "$CSTATE_PID" 2>/dev/null || true
sudo systemctl start irqbalance cron atd 2>/dev/null || true
echo " System settings restored."
fi
}
trap cleanup_global EXIT
echo "═══════════════════════════════════════════════════════════"
echo " reth local benchmark"
echo "═══════════════════════════════════════════════════════════"
echo " Baseline ref : $BASELINE_REF"
echo " Feature ref : $FEATURE_REF"
echo " Blocks : $BLOCKS"
echo " Warmup : $WARMUP"
echo " Cores : $CORES"
echo " Samply : $SAMPLY"
echo " Tracy : $TRACY"
echo " Tracy filter : $TRACY_FILTER"
echo " System tune : $TUNE"
echo " Work dir : $BENCH_WORK_DIR"
echo " Reth repo : $RETH_REPO"
echo "═══════════════════════════════════════════════════════════"
echo
# Enable sccache if available (matches CI's RUSTC_WRAPPER=sccache)
if command -v sccache &>/dev/null; then
export RUSTC_WRAPPER="sccache"
fi
# Export env vars expected by the bench-reth-*.sh scripts
export BENCH_BLOCKS="$BLOCKS"
export BENCH_WARMUP_BLOCKS="$WARMUP"
export BENCH_CORES="$CORES"
export BENCH_SAMPLY="$SAMPLY"
export BENCH_TRACY="$TRACY"
export BENCH_TRACY_FILTER="$TRACY_FILTER"
export BENCH_WORK_DIR
export SCHELK_MOUNT="${SCHELK_MOUNT:-/reth-bench}"
export BENCH_RPC_URL="${BENCH_RPC_URL:-https://ethereum.reth.rs/rpc}"
export BENCH_METRICS_ADDR="127.0.0.1:9100"
# ── Step 1: Resolve refs to full SHAs ────────────────────────────────
echo "▸ Resolving git refs..."
cd "$RETH_REPO"
resolve_ref() {
local ref="$1"
git fetch origin "$ref" --quiet 2>/dev/null || true
git rev-parse "$ref" 2>/dev/null \
|| git rev-parse "origin/$ref" 2>/dev/null \
|| { echo "Error: cannot resolve ref '$ref'"; exit 1; }
}
BASELINE_SHA="$(resolve_ref "$BASELINE_REF")"
FEATURE_SHA="$(resolve_ref "$FEATURE_REF")"
echo " Baseline SHA : $BASELINE_SHA"
echo " Feature SHA : $FEATURE_SHA"
echo
# ── Step 2: Prepare source directories ───────────────────────────────
echo "▸ Preparing source directories..."
prepare_source() {
local src_dir="$1" ref="$2"
if [ -d "$src_dir" ]; then
git -C "$src_dir" fetch origin "$ref" 2>/dev/null || true
else
git clone --recurse-submodules "$RETH_REPO" "$src_dir"
fi
git -C "$src_dir" checkout "$ref" --force
git -C "$src_dir" submodule update --init --recursive
}
prepare_source "$BASELINE_SRC" "$BASELINE_SHA"
prepare_source "$FEATURE_SRC" "$FEATURE_SHA"
BASELINE_SRC="$(cd "$BASELINE_SRC" && pwd)"
FEATURE_SRC="$(cd "$FEATURE_SRC" && pwd)"
echo " Baseline src : $BASELINE_SRC"
echo " Feature src : $FEATURE_SRC"
echo
# ── Step 3: Check / download snapshot ────────────────────────────────
echo "▸ Checking snapshot..."
cd "$RETH_REPO"
SNAPSHOT_NEEDED=false
if ! "${SCRIPTS_DIR}/bench-reth-snapshot.sh" --check; then
SNAPSHOT_NEEDED=true
echo " Snapshot needs update."
else
echo " Snapshot is up-to-date."
fi
echo
# ── Step 4: Build binaries (+ snapshot download) in parallel ─────────
echo "▸ Building binaries (parallel)..."
cd "$RETH_REPO"
FAIL=0
"${SCRIPTS_DIR}/bench-reth-build.sh" baseline "$BASELINE_SRC" "$BASELINE_SHA" &
PID_BASELINE=$!
"${SCRIPTS_DIR}/bench-reth-build.sh" feature "$FEATURE_SRC" "$FEATURE_SHA" &
PID_FEATURE=$!
PID_SNAPSHOT=
if [ "$SNAPSHOT_NEEDED" = "true" ]; then
echo " Also downloading snapshot in parallel..."
"${SCRIPTS_DIR}/bench-reth-snapshot.sh" &
PID_SNAPSHOT=$!
fi
wait $PID_BASELINE || FAIL=1
wait $PID_FEATURE || FAIL=1
[ -n "$PID_SNAPSHOT" ] && { wait $PID_SNAPSHOT || FAIL=1; }
if [ $FAIL -ne 0 ]; then
echo "Error: one or more parallel tasks failed (builds / snapshot)"
exit 1
fi
echo " Binaries built successfully."
echo
# ── Step 5: System tuning (optional) ────────────────────────────────
if [ "$TUNE" = "true" ]; then
echo "▸ Applying system tuning..."
sudo cpupower frequency-set -g performance 2>/dev/null || true
# Disable turbo boost (Intel + AMD)
echo 1 | sudo tee /sys/devices/system/cpu/intel_pstate/no_turbo 2>/dev/null || true
echo 0 | sudo tee /sys/devices/system/cpu/cpufreq/boost 2>/dev/null || true
sudo swapoff -a 2>/dev/null || true
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space 2>/dev/null || true
# Disable SMT (hyperthreading)
for cpu in /sys/devices/system/cpu/cpu*/topology/thread_siblings_list; do
[ -f "$cpu" ] || continue
first=$(cut -d, -f1 < "$cpu" | cut -d- -f1)
current=$(echo "$cpu" | grep -o 'cpu[0-9]*' | grep -o '[0-9]*')
if [ "$current" != "$first" ]; then
echo 0 | sudo tee "/sys/devices/system/cpu/cpu${current}/online" 2>/dev/null || true
fi
done
echo " Online CPUs: $(nproc)"
# Disable transparent huge pages
for p in /sys/kernel/mm/transparent_hugepage /sys/kernel/mm/transparent_hugepages; do
if [ -d "$p" ]; then
echo never | sudo tee "$p/enabled" 2>/dev/null || true
echo never | sudo tee "$p/defrag" 2>/dev/null || true
break
fi
done
# Prevent deep C-states
sudo sh -c 'exec 3<>/dev/cpu_dma_latency; echo -ne "\x00\x00\x00\x00" >&3; sleep infinity' &
CSTATE_PID=$!
# Pin IRQs to core 0
for irq in /proc/irq/*/smp_affinity_list; do
echo 0 | sudo tee "$irq" 2>/dev/null || true
done
# Stop noisy background services
sudo systemctl stop irqbalance cron atd unattended-upgrades snapd 2>/dev/null || true
TUNING_APPLIED=true
# Log environment for reproducibility (matches CI)
echo " === Benchmark environment ==="
echo " Kernel : $(uname -r)"
lscpu | grep -E 'Model name|CPU\(s\)|MHz|NUMA' | sed 's/^/ /'
echo " Governor : $(cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 2>/dev/null || echo unknown)"
echo " Freq : $(cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq 2>/dev/null || echo unknown)"
echo " THP : $(cat /sys/kernel/mm/transparent_hugepage/enabled 2>/dev/null || cat /sys/kernel/mm/transparent_hugepages/enabled 2>/dev/null || echo unknown)"
free -h | sed 's/^/ /'
echo " System tuning applied."
echo
fi
# ── Step 5b: Tracefs mount (tracy=full only) ─────────────────────────
if [ "$TRACY" = "full" ] && [ "$(uname)" = "Linux" ]; then
echo "▸ Mounting tracefs for Tracy full mode..."
sudo mount -t tracefs tracefs /sys/kernel/tracing -o mode=755 2>/dev/null || true
fi
# ── Tracy upload & viewer helpers ────────────────────────────────────
TRACY_VIEWER_BASE="${TRACY_VIEWER_BASE:-}"
tracy_viewer_url() {
local profile_url="$1"
if [ -z "$TRACY_VIEWER_BASE" ]; then
echo ""
return
fi
local encoded
encoded=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$profile_url")
echo "${TRACY_VIEWER_BASE}?profile_url=${encoded}"
}
upload_tracy() {
local label="$1" output_dir="$2" sha="$3"
local tracy_file="$output_dir/tracy-profile.tracy"
if [ ! -f "$tracy_file" ]; then
echo " Tracy: no profile found, skipping upload."
return
fi
local timestamp short_sha remote_name bucket mc_alias
timestamp=$(date +%Y%m%d-%H%M%S)
short_sha="${sha:0:7}"
remote_name="${label}-${short_sha}-${timestamp}.tracy"
bucket="${TRACY_BUCKET:-tracy-profiles}"
mc_alias="${MC_ALIAS:-minio}"
local minio_base="${TRACY_MINIO_URL:-http://minio.minio.svc.cluster.local:9000}"
echo " Tracy: uploading profile..."
if mc cp "$tracy_file" "${mc_alias}/${bucket}/${remote_name}"; then
local url="${minio_base}/${bucket}/${remote_name}"
echo "$url" > "$output_dir/tracy_url.txt"
local viewer
viewer=$(tracy_viewer_url "$url")
if [ -n "$viewer" ]; then
echo "$viewer" > "$output_dir/tracy_viewer_url.txt"
echo " Tracy: uploaded → $viewer"
else
echo " Tracy: uploaded → $url"
fi
else
echo " Tracy: upload failed (non-fatal)."
fi
# Delete large profile to free disk
rm -f "$tracy_file"
}
# ── Step 6: Pre-flight cleanup ───────────────────────────────────────
echo "▸ Pre-flight cleanup..."
pkill -f bench-metrics-proxy 2>/dev/null || true
sudo pkill -9 reth 2>/dev/null || true
sleep 1
if mountpoint -q "$SCHELK_MOUNT" 2>/dev/null; then
sudo umount -l "$SCHELK_MOUNT" 2>/dev/null || true
sudo schelk recover -y 2>/dev/null || true
fi
echo
# ── Step 7: Interleaved benchmark runs (B-F-F-B) ────────────────────
# This ordering reduces systematic bias from thermal drift and cache warming.
BASELINE_BIN="${BASELINE_SRC}/target/profiling/reth"
FEATURE_BIN="${FEATURE_SRC}/target/profiling/reth"
# Start metrics proxy (reth → label injection → Prometheus)
LABELS_FILE="/tmp/bench-metrics-labels.json"
echo '{}' > "$LABELS_FILE"
METRICS_SUBNET="${METRICS_SUBNET:-10.10.0.0/24}"
METRICS_PORT="${METRICS_PORT:-9090}"
python3 "${SELF_DIR}/bench-metrics-proxy.py" \
--labels "$LABELS_FILE" \
--upstream "http://${BENCH_METRICS_ADDR}/" \
--subnet "$METRICS_SUBNET" \
--port "$METRICS_PORT" &
METRICS_PROXY_PID=$!
echo "▸ Metrics proxy started (PID $METRICS_PROXY_PID) on subnet ${METRICS_SUBNET}, port ${METRICS_PORT}"
# Unique benchmark ID: local-<timestamp> for local runs, ci-<run_id> for CI
BENCH_ID="local-$(basename "$BENCH_WORK_DIR" | sed 's/bench-work-//')"
# Reference epoch: shared time origin so all runs overlay in Grafana.
# The proxy maps each run's elapsed time onto this common origin.
BENCH_REFERENCE_EPOCH=$(date +%s)
write_labels() {
local run_label="$1" run_type="$2" ref="$3" sha="$4"
LAST_RUN_START=$(date +%s)
cat > "$LABELS_FILE" <<-EOF
{"benchmark_run":"${run_label}","run_type":"${run_type}","git_ref":"${ref}","bench_sha":"${sha}","benchmark_id":"${BENCH_ID}","run_start_epoch":"${LAST_RUN_START}","reference_epoch":"${BENCH_REFERENCE_EPOCH}"}
EOF
}
run_bench() {
local label="$1" binary="$2" output_dir="$3"
echo "▸ Running benchmark: ${label}..."
cd "$RETH_REPO"
if command -v taskset &>/dev/null; then
taskset -c 0 "${SCRIPTS_DIR}/bench-reth-run.sh" "$label" "$binary" "$output_dir"
else
"${SCRIPTS_DIR}/bench-reth-run.sh" "$label" "$binary" "$output_dir"
fi
echo "${label} complete."
echo
}
write_labels "baseline-1" "baseline" "$BASELINE_REF" "$BASELINE_SHA"
run_bench "baseline-1" "$BASELINE_BIN" "$BENCH_WORK_DIR/baseline-1"
write_labels "feature-1" "feature" "$FEATURE_REF" "$FEATURE_SHA"
run_bench "feature-1" "$FEATURE_BIN" "$BENCH_WORK_DIR/feature-1"
write_labels "feature-2" "feature" "$FEATURE_REF" "$FEATURE_SHA"
run_bench "feature-2" "$FEATURE_BIN" "$BENCH_WORK_DIR/feature-2"
write_labels "baseline-2" "baseline" "$BASELINE_REF" "$BASELINE_SHA"
run_bench "baseline-2" "$BASELINE_BIN" "$BENCH_WORK_DIR/baseline-2"
# ── Compute Grafana URL ──────────────────────────────────────────────
GRAFANA_BASE_URL="https://tempoxyz.grafana.net/d/reth-bench-ghr/reth-bench-ghr"
GRAFANA_DATASOURCE="ef57fux92e9z4e"
LAST_RUN_DURATION=$(( $(date +%s) - LAST_RUN_START ))
FROM_MS=$(( BENCH_REFERENCE_EPOCH * 1000 ))
TO_MS=$(( (BENCH_REFERENCE_EPOCH + LAST_RUN_DURATION) * 1000 ))
GRAFANA_URL="${GRAFANA_BASE_URL}?orgId=1&from=${FROM_MS}&to=${TO_MS}&timezone=browser&var-datasource=${GRAFANA_DATASOURCE}&var-job=reth-bench&var-benchmark_id=${BENCH_ID}&var-benchmark_run=\$__all"
# ── Step 8: Scan logs for errors ─────────────────────────────────────
echo "▸ Scanning logs for errors..."
ERRORS_FILE="$BENCH_WORK_DIR/errors.md"
found_errors=false
for run_dir in baseline-1 feature-1 feature-2 baseline-2; do
LOG="$BENCH_WORK_DIR/$run_dir/node.log"
[ -f "$LOG" ] || continue
panics=$(grep -c -E 'panicked at' "$LOG" 2>/dev/null || true)
errors=$(grep -c ' ERROR ' "$LOG" 2>/dev/null || true)
if [ "$panics" -gt 0 ] || [ "$errors" -gt 0 ]; then
if [ "$found_errors" = false ]; then
printf '### ⚠️ Node Errors\n\n' >> "$ERRORS_FILE"
found_errors=true
fi
printf '<details><summary><b>%s</b>: %d panic(s), %d error(s)</summary>\n\n' \
"$run_dir" "$panics" "$errors" >> "$ERRORS_FILE"
if [ "$panics" -gt 0 ]; then
printf '**Panics:**\n```\n' >> "$ERRORS_FILE"
grep -E 'panicked at' "$LOG" | head -10 >> "$ERRORS_FILE"
printf '```\n' >> "$ERRORS_FILE"
fi
if [ "$errors" -gt 0 ]; then
printf '**Errors (first 20):**\n```\n' >> "$ERRORS_FILE"
grep ' ERROR ' "$LOG" | head -20 >> "$ERRORS_FILE"
printf '```\n' >> "$ERRORS_FILE"
fi
printf '\n</details>\n\n' >> "$ERRORS_FILE"
fi
done
if [ "$found_errors" = true ]; then
echo " ⚠ Errors found — see $ERRORS_FILE"
else
echo " No errors found."
fi
echo
# ── Step 9: Parse results ───────────────────────────────────────────
echo "▸ Parsing results..."
cd "$RETH_REPO"
SUMMARY_ARGS=(
--output-summary "$BENCH_WORK_DIR/summary.json"
--output-markdown "$BENCH_WORK_DIR/comment.md"
--repo "paradigmxyz/reth"
--baseline-ref "$BASELINE_SHA"
--baseline-name "$BASELINE_REF"
--feature-name "$FEATURE_REF"
--feature-ref "$FEATURE_SHA"
--baseline-csv "$BENCH_WORK_DIR/baseline-1/combined_latency.csv" "$BENCH_WORK_DIR/baseline-2/combined_latency.csv"
--feature-csv "$BENCH_WORK_DIR/feature-1/combined_latency.csv" "$BENCH_WORK_DIR/feature-2/combined_latency.csv"
--gas-csv "$BENCH_WORK_DIR/feature-1/total_gas.csv"
--grafana-url "$GRAFANA_URL"
)
python3 "${SCRIPTS_DIR}/bench-reth-summary.py" "${SUMMARY_ARGS[@]}"
echo
# ── Step 10: Generate charts ─────────────────────────────────────────
echo "▸ Generating charts..."
CHART_ARGS=(
--output-dir "$BENCH_WORK_DIR/charts"
--feature "$BENCH_WORK_DIR/feature-1/combined_latency.csv" "$BENCH_WORK_DIR/feature-2/combined_latency.csv"
--baseline "$BENCH_WORK_DIR/baseline-1/combined_latency.csv" "$BENCH_WORK_DIR/baseline-2/combined_latency.csv"
--baseline-name "$BASELINE_REF"
--feature-name "$FEATURE_REF"
)
if python3 -c "import matplotlib" 2>/dev/null; then
python3 "${SCRIPTS_DIR}/bench-reth-charts.py" "${CHART_ARGS[@]}"
elif command -v uv &>/dev/null; then
uv run --with matplotlib python3 "${SCRIPTS_DIR}/bench-reth-charts.py" "${CHART_ARGS[@]}"
else
echo " Warning: matplotlib not available, skipping chart generation."
fi
echo
# ── Step 11: Upload Tracy profiles ────────────────────────────────────
if [ "$TRACY" != "off" ]; then
echo "▸ Uploading Tracy profiles..."
upload_tracy "baseline-1" "$BENCH_WORK_DIR/baseline-1" "$BASELINE_SHA"
upload_tracy "feature-1" "$BENCH_WORK_DIR/feature-1" "$FEATURE_SHA"
upload_tracy "feature-2" "$BENCH_WORK_DIR/feature-2" "$FEATURE_SHA"
upload_tracy "baseline-2" "$BENCH_WORK_DIR/baseline-2" "$BASELINE_SHA"
echo
fi
# ── Done (system restore happens via EXIT trap) ─────────────────────
echo "═══════════════════════════════════════════════════════════"
echo " Benchmark complete!"
echo "═══════════════════════════════════════════════════════════"
echo " Results : $BENCH_WORK_DIR/summary.json"
echo " Markdown : $BENCH_WORK_DIR/comment.md"
echo " Charts : $BENCH_WORK_DIR/charts/"
if [ -f "$ERRORS_FILE" ]; then
echo " Errors : $ERRORS_FILE"
fi
echo " Grafana : $GRAFANA_URL"
if [ "$TRACY" != "off" ]; then
echo " ─── Tracy Profiles ───"
for run_dir in baseline-1 feature-1 feature-2 baseline-2; do
url_file="$BENCH_WORK_DIR/$run_dir/tracy_viewer_url.txt"
if [ -f "$url_file" ]; then
echo " $run_dir : $(cat "$url_file")"
fi
done
fi
echo "═══════════════════════════════════════════════════════════"

View File

@@ -6,6 +6,13 @@
# Usage: bench-reth-run.sh <label> <binary> <output-dir>
#
# Required env: SCHELK_MOUNT, BENCH_RPC_URL, BENCH_BLOCKS, BENCH_WARMUP_BLOCKS
# Optional env: BENCH_BIG_BLOCKS (true/false), BENCH_WORK_DIR (for big blocks path)
# BENCH_RETH_NEW_PAYLOAD (true/false, default true)
# BENCH_WAIT_TIME (duration like 500ms, default empty)
# BENCH_BASELINE_ARGS (extra reth node args for baseline runs)
# BENCH_FEATURE_ARGS (extra reth node args for feature runs)
# BENCH_OTLP_TRACES_ENDPOINT (OTLP HTTP endpoint for traces, e.g. https://host/insert/opentelemetry/v1/traces)
# BENCH_OTLP_LOGS_ENDPOINT (OTLP HTTP endpoint for logs, e.g. https://host/insert/opentelemetry/v1/logs)
set -euo pipefail
LABEL="$1"
@@ -17,6 +24,24 @@ LOG="${OUTPUT_DIR}/node.log"
cleanup() {
kill "$TAIL_PID" 2>/dev/null || true
# Stop tracy-capture first (SIGINT makes it disconnect and flush to disk)
# Must happen before killing reth, otherwise reth keeps streaming data.
if [ -n "${TRACY_PID:-}" ] && kill -0 "$TRACY_PID" 2>/dev/null; then
echo "Stopping tracy-capture..."
kill -INT "$TRACY_PID" 2>/dev/null || true
for i in $(seq 1 30); do
kill -0 "$TRACY_PID" 2>/dev/null || break
if [ $((i % 10)) -eq 0 ]; then
echo "Waiting for tracy-capture to finish writing... (${i}s)"
fi
sleep 1
done
if kill -0 "$TRACY_PID" 2>/dev/null; then
echo "tracy-capture still running after 30s, killing..."
kill -9 "$TRACY_PID" 2>/dev/null || true
fi
wait "$TRACY_PID" 2>/dev/null || true
fi
if [ -n "${RETH_PID:-}" ] && sudo kill -0 "$RETH_PID" 2>/dev/null; then
if [ "${BENCH_SAMPLY:-false}" = "true" ]; then
# Send SIGINT to the inner reth process by exact name (not -f which
@@ -53,8 +78,14 @@ cleanup() {
fi
}
TAIL_PID=
TRACY_PID=
trap cleanup EXIT
# Clean up stale schelk state from a previous cancelled run.
# If schelk thinks it's still mounted (e.g. a cancelled run skipped cleanup),
# recover first to reset state.
sudo schelk recover -y -k || true
# Mount
sudo schelk mount -y
sync
@@ -73,6 +104,8 @@ if [ "${BENCH_CORES:-0}" -gt 0 ] && [ "$BENCH_CORES" -lt "$MAX_RETH" ]; then
fi
RETH_CPUS="1-${MAX_RETH}"
BIG_BLOCKS="${BENCH_BIG_BLOCKS:-false}"
RETH_ARGS=(
node
--datadir "$DATADIR"
@@ -87,16 +120,68 @@ RETH_ARGS=(
--no-persist-peers
)
# Big blocks mode requires the testing API and skip-invalid-transactions
if [ "$BIG_BLOCKS" = "true" ]; then
RETH_ARGS+=(--http.api eth,net,web3,reth,testing --testing.skip-invalid-transactions)
fi
# Append per-label extra node args (baseline or feature)
EXTRA_NODE_ARGS=""
case "$LABEL" in
baseline*) EXTRA_NODE_ARGS="${BENCH_BASELINE_ARGS:-}" ;;
feature*) EXTRA_NODE_ARGS="${BENCH_FEATURE_ARGS:-}" ;;
esac
if [ -n "$EXTRA_NODE_ARGS" ]; then
# Word-split the string into individual args
# shellcheck disable=SC2206
RETH_ARGS+=($EXTRA_NODE_ARGS)
fi
if [ -n "${BENCH_METRICS_ADDR:-}" ]; then
RETH_ARGS+=(--metrics "$BENCH_METRICS_ADDR")
fi
# OTLP traces and logs export
if [ -n "${BENCH_OTLP_TRACES_ENDPOINT:-}" ]; then
RETH_ARGS+=(--tracing-otlp="${BENCH_OTLP_TRACES_ENDPOINT}" --tracing-otlp.service-name=reth-bench)
fi
if [ -n "${BENCH_OTLP_LOGS_ENDPOINT:-}" ]; then
RETH_ARGS+=(--logs-otlp="${BENCH_OTLP_LOGS_ENDPOINT}" --logs-otlp.filter=debug)
fi
# Tracy profiling: add --log.tracy flags and set environment
if [ "${BENCH_TRACY:-off}" != "off" ]; then
RETH_ARGS+=(--log.tracy --log.tracy.filter "${BENCH_TRACY_FILTER:-debug}")
if [ "${BENCH_TRACY}" = "on" ]; then
export TRACY_NO_SYS_TRACE=1
elif [ "${BENCH_TRACY}" = "full" ]; then
export TRACY_SAMPLING_HZ="${BENCH_TRACY_SAMPLING_HZ:-1}"
fi
fi
SUDO_ENV=()
if [ -n "${OTEL_RESOURCE_ATTRIBUTES:-}" ]; then
SUDO_ENV+=("OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES}")
SUDO_ENV+=("OTEL_BSP_MAX_QUEUE_SIZE=65536" "OTEL_BLRP_MAX_QUEUE_SIZE=65536")
fi
# Limit reth memory to 95% of available RAM to prevent OOM kills
TOTAL_MEM_KB=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)
MEM_LIMIT=$(( TOTAL_MEM_KB * 95 / 100 * 1024 ))
echo "Memory limit: $(( MEM_LIMIT / 1024 / 1024 ))MB (95% of $(( TOTAL_MEM_KB / 1024 ))MB)"
if [ "${BENCH_SAMPLY:-false}" = "true" ]; then
RETH_ARGS+=(--log.samply)
SAMPLY="$(which samply)"
sudo taskset -c "$RETH_CPUS" nice -n -20 \
sudo systemd-run --scope -p MemoryMax="$MEM_LIMIT" -p AllowedCPUs="$RETH_CPUS" \
env "${SUDO_ENV[@]}" nice -n -20 \
"$SAMPLY" record --save-only --presymbolicate --rate 10000 \
--output "$OUTPUT_DIR/samply-profile.json.gz" \
-- "$BINARY" "${RETH_ARGS[@]}" \
> "$LOG" 2>&1 &
else
sudo taskset -c "$RETH_CPUS" nice -n -20 "$BINARY" "${RETH_ARGS[@]}" \
sudo systemd-run --scope -p MemoryMax="$MEM_LIMIT" -p AllowedCPUs="$RETH_CPUS" \
env "${SUDO_ENV[@]}" nice -n -20 "$BINARY" "${RETH_ARGS[@]}" \
> "$LOG" 2>&1 &
fi
@@ -124,21 +209,65 @@ done
# files are not root-owned (avoids EACCES on next checkout).
BENCH_NICE="sudo nice -n -20 sudo -u $(id -un)"
# Warmup
$BENCH_NICE "$RETH_BENCH" new-payload-fcu \
--rpc-url "$BENCH_RPC_URL" \
--engine-rpc-url http://127.0.0.1:8551 \
--jwt-secret "$DATADIR/jwt.hex" \
--advance "${BENCH_WARMUP_BLOCKS:-50}" \
--reth-new-payload 2>&1 | sed -u "s/^/[bench] /"
# Build optional flags
EXTRA_BENCH_ARGS=()
if [ "${BENCH_RETH_NEW_PAYLOAD:-true}" != "false" ]; then
EXTRA_BENCH_ARGS+=(--reth-new-payload)
fi
if [ -n "${BENCH_WAIT_TIME:-}" ]; then
EXTRA_BENCH_ARGS+=(--wait-time "$BENCH_WAIT_TIME")
fi
# Benchmark
$BENCH_NICE "$RETH_BENCH" new-payload-fcu \
--rpc-url "$BENCH_RPC_URL" \
--engine-rpc-url http://127.0.0.1:8551 \
--jwt-secret "$DATADIR/jwt.hex" \
--advance "$BENCH_BLOCKS" \
--reth-new-payload \
--output "$OUTPUT_DIR" 2>&1 | sed -u "s/^/[bench] /"
if [ "$BIG_BLOCKS" = "true" ]; then
# Big blocks mode: replay pre-generated payloads with gas ramp
BIG_BLOCKS_DIR="${BENCH_WORK_DIR}/big-blocks"
# Count gas ramp blocks for reporting
GAS_RAMP_COUNT=$(find "$BIG_BLOCKS_DIR/gas-ramp-dir" -name '*.json' | wc -l)
echo "$GAS_RAMP_COUNT" > "$OUTPUT_DIR/gas_ramp_blocks.txt"
echo "Gas ramp blocks: $GAS_RAMP_COUNT"
# Start tracy-capture so profile only covers the benchmark
if [ "${BENCH_TRACY:-off}" != "off" ]; then
echo "Starting tracy-capture..."
tracy-capture -f -o "$OUTPUT_DIR/tracy-profile.tracy" &
TRACY_PID=$!
sleep 0.5 # give tracy-capture time to connect
fi
echo "Running big blocks benchmark (replay-payloads)..."
$BENCH_NICE "$RETH_BENCH" replay-payloads \
"${EXTRA_BENCH_ARGS[@]}" \
--gas-ramp-dir "$BIG_BLOCKS_DIR/gas-ramp-dir" \
--payload-dir "$BIG_BLOCKS_DIR/payloads" \
--engine-rpc-url http://127.0.0.1:8551 \
--jwt-secret "$DATADIR/jwt.hex" \
--output "$OUTPUT_DIR" 2>&1 | sed -u "s/^/[bench] /"
else
# Standard mode: warmup + new-payload-fcu
# Warmup
$BENCH_NICE "$RETH_BENCH" new-payload-fcu \
--rpc-url "$BENCH_RPC_URL" \
--engine-rpc-url http://127.0.0.1:8551 \
--jwt-secret "$DATADIR/jwt.hex" \
--advance "${BENCH_WARMUP_BLOCKS:-50}" \
"${EXTRA_BENCH_ARGS[@]}" 2>&1 | sed -u "s/^/[bench] /"
# Start tracy-capture after warmup so profile only covers the benchmark
if [ "${BENCH_TRACY:-off}" != "off" ]; then
echo "Starting tracy-capture..."
tracy-capture -f -o "$OUTPUT_DIR/tracy-profile.tracy" &
TRACY_PID=$!
sleep 0.5 # give tracy-capture time to connect
fi
# Benchmark
$BENCH_NICE "$RETH_BENCH" new-payload-fcu \
--rpc-url "$BENCH_RPC_URL" \
--engine-rpc-url http://127.0.0.1:8551 \
--jwt-secret "$DATADIR/jwt.hex" \
--advance "$BENCH_BLOCKS" \
"${EXTRA_BENCH_ARGS[@]}" \
--output "$OUTPUT_DIR" 2>&1 | sed -u "s/^/[bench] /"
fi
# cleanup runs via trap

View File

@@ -350,6 +350,7 @@ def generate_comparison_table(
baseline_name: str,
feature_name: str,
feature_sha: str,
big_blocks: bool = False,
) -> str:
"""Generate a markdown comparison table between baseline and feature."""
n = paired["blocks"]
@@ -390,7 +391,7 @@ def generate_comparison_table(
f"| Mgas/s | {fmt_mgas(run1['mean_mgas_s'])} | {fmt_mgas(run2['mean_mgas_s'])} | {change_str(gas_pct, mgas_ci_pct, lower_is_better=False)} |",
f"| Wall Clock | {fmt_s(run1['wall_clock_s'])} | {fmt_s(run2['wall_clock_s'])} | {change_str(wall_pct, wall_ci_pct, lower_is_better=True)} |",
"",
f"*{n} blocks*",
f"*{n} {'big blocks' if big_blocks else 'blocks'}*",
]
return "\n".join(lines)
@@ -421,6 +422,7 @@ def generate_markdown(
summary: dict, comparison_table: str,
wait_time_tables: list[str] | None = None,
behind_baseline: int = 0, repo: str = "", baseline_ref: str = "", baseline_name: str = "",
grafana_url: str | None = None,
) -> str:
"""Generate a markdown comment body."""
lines = ["## Benchmark Results", ""]
@@ -440,6 +442,9 @@ def generate_markdown(
lines.append(table)
lines.append("")
lines.append("</details>")
if grafana_url:
lines.append("")
lines.append(f"**[Grafana Dashboard]({grafana_url})**")
return "\n".join(lines)
@@ -466,6 +471,9 @@ def main():
parser.add_argument("--feature-name", "--branch-name", default=None, help="Feature branch name")
parser.add_argument("--feature-ref", "--branch-sha", "--feature-sha", default=None, help="Feature commit SHA")
parser.add_argument("--behind-baseline", "--behind-main", type=int, default=0, help="Commits behind baseline")
parser.add_argument("--big-blocks", action="store_true", default=False, help="Big blocks mode")
parser.add_argument("--gas-ramp-blocks", type=int, default=0, help="Number of gas ramp blocks (big blocks mode)")
parser.add_argument("--grafana-url", default=None, help="Grafana dashboard URL for this benchmark run")
args = parser.parse_args()
if len(args.baseline_csv) != len(args.feature_csv):
@@ -514,6 +522,7 @@ def main():
baseline_name=baseline_name,
feature_name=feature_name,
feature_sha=feature_sha,
big_blocks=args.big_blocks,
)
print(f"Generated comparison ({paired_stats['n']} paired blocks, "
f"mean diff {paired_stats['mean_diff_ms']:+.3f}ms ± {paired_stats['ci_ms']:.3f}ms)")
@@ -544,6 +553,8 @@ def main():
summary = {
"blocks": paired_stats["blocks"],
"big_blocks": args.big_blocks,
"gas_ramp_blocks": args.gas_ramp_blocks,
"baseline": {
"name": baseline_name,
"ref": baseline_ref,
@@ -569,6 +580,7 @@ def main():
repo=args.repo,
baseline_ref=baseline_ref,
baseline_name=baseline_name,
grafana_url=args.grafana_url,
)
with open(args.output_markdown, "w") as f:

View File

@@ -7,6 +7,8 @@
// BENCH_PR PR number (may be empty)
// BENCH_ACTOR GitHub user who triggered the bench
// BENCH_JOB_URL URL to the Actions job page
// BENCH_BASELINE_ARGS Extra CLI args for the baseline reth node
// BENCH_FEATURE_ARGS Extra CLI args for the feature reth node
// BENCH_SAMPLY 'true' if samply profiling was enabled
//
// Usage from actions/github-script:
@@ -118,15 +120,27 @@ function buildSuccessBlocks({ summary, prNumber, actor, actorSlackId, jobUrl, re
if (fl1) featureLine += ` | <${fl1}|Samply 1>`;
if (fl2) featureLine += ` | <${fl2}|Samply 2>`;
const warmup = summary.warmup_blocks || process.env.BENCH_WARMUP_BLOCKS || '';
const cores = process.env.BENCH_CORES || '0';
const countsParts = [];
if (warmup) countsParts.push(`*Warmup:* ${warmup}`);
countsParts.push(`*Blocks:* ${summary.blocks}`);
if (summary.big_blocks) {
const gasRamp = summary.gas_ramp_blocks || 0;
if (gasRamp > 0) countsParts.push(`*Gas Ramp:* ${gasRamp}`);
countsParts.push(`*Big Blocks:* ${summary.blocks}`);
} else {
const warmup = summary.warmup_blocks || process.env.BENCH_WARMUP_BLOCKS || '';
if (warmup) countsParts.push(`*Warmup:* ${warmup}`);
countsParts.push(`*Blocks:* ${summary.blocks}`);
}
if (cores !== '0') countsParts.push(`*Cores:* ${cores}`);
const countsLine = countsParts.join(' | ');
const sectionText = [metaParts.join(' | '), '', baselineLine, featureLine, countsLine].join('\n');
const baselineArgs = process.env.BENCH_BASELINE_ARGS || '';
const featureArgs = process.env.BENCH_FEATURE_ARGS || '';
const argsLines = [];
if (baselineArgs) argsLines.push(`*Baseline Args:* \`${baselineArgs}\``);
if (featureArgs) argsLines.push(`*Feature Args:* \`${featureArgs}\``);
const sectionText = [metaParts.join(' | '), '', baselineLine, featureLine, ...argsLines, countsLine].join('\n');
// Action buttons
const diffUrl = `https://github.com/${repo}/compare/${summary.baseline.ref}...${summary.feature.ref}`;

414
.github/scripts/build_pgo_bolt.sh vendored Executable file
View File

@@ -0,0 +1,414 @@
#!/usr/bin/env bash
#
# Full PGO+BOLT optimized build for reth using real reth-bench workloads.
#
# Phases:
# 1. Build PGO-instrumented reth, run reth-bench → collect PGO profiles
# 2. Build BOLT-instrumented reth (with PGO), run reth-bench → collect BOLT profiles
# 3. Build final PGO+BOLT optimized binary
#
# Required environment variables:
# DATADIR - Path to reth datadir (must already contain chain data)
# RPC_URL - Source RPC URL for reth-bench to fetch payloads from
#
# Optional environment variables:
# PGO_BLOCKS - Number of blocks for PGO profiling (default: 20)
# BOLT_BLOCKS - Number of blocks for BOLT profiling (default: 20)
# SKIP_BOLT - Temporarily skip BOLT phases (default: false)
# STRIP_SYMBOLS - Strip debug symbols from output binary (default: true)
# COLLECT_PGO_ONLY - Stop after producing merged.profdata (default: false)
# PGO_PROFDATA - Path to pre-collected merged.profdata (optional)
# PROFILE - Cargo profile (default: maxperf-symbols)
# FEATURES - Cargo features (default: jemalloc,asm-keccak,min-debug-logs)
# TARGET - Target triple (default: auto-detected)
# EXTRA_RUSTFLAGS - Additional RUSTFLAGS (e.g. -C target-cpu=x86-64-v3)
#
# Output:
# target/$PROFILE_DIR/reth — final optimized binary
set -euo pipefail
gha_section_start() {
local title="$1"
if [ -n "${GITHUB_ACTIONS:-}" ]; then
echo "::group::$title"
else
echo ""
echo "=== $title ==="
fi
}
gha_section_end() {
if [ -n "${GITHUB_ACTIONS:-}" ]; then
echo "::endgroup::"
fi
}
cd "$(dirname "$0")/../.."
# ── Configuration ──────────────────────────────────────────────────────────────
PGO_BLOCKS="${PGO_BLOCKS:-20}"
BOLT_BLOCKS="${BOLT_BLOCKS:-20}"
SKIP_BOLT="${SKIP_BOLT:-false}"
STRIP_SYMBOLS="${STRIP_SYMBOLS:-true}"
COLLECT_PGO_ONLY="${COLLECT_PGO_ONLY:-false}"
PROFILE="${PROFILE:-maxperf-symbols}"
FEATURES="${FEATURES:-jemalloc,asm-keccak,min-debug-logs}"
TARGET="${TARGET:-$(rustc -Vv | grep host | cut -d' ' -f2)}"
BASE_RUSTFLAGS="${RUSTFLAGS:-}"
EXTRA_RUSTFLAGS="${EXTRA_RUSTFLAGS:-}"
COMBINED_RUSTFLAGS="$BASE_RUSTFLAGS $EXTRA_RUSTFLAGS"
PGO_PROFDATA="${PGO_PROFDATA:-}"
DATADIR="${DATADIR:-}"
RPC_URL="${RPC_URL:-}"
SKIP_BOLT_BOOL=false
if [[ "${SKIP_BOLT,,}" == "true" || "$SKIP_BOLT" == "1" ]]; then
SKIP_BOLT_BOOL=true
fi
STRIP_SYMBOLS_BOOL=false
if [[ "${STRIP_SYMBOLS,,}" == "true" || "$STRIP_SYMBOLS" == "1" ]]; then
STRIP_SYMBOLS_BOOL=true
fi
COLLECT_PGO_ONLY_BOOL=false
if [[ "${COLLECT_PGO_ONLY,,}" == "true" || "$COLLECT_PGO_ONLY" == "1" ]]; then
COLLECT_PGO_ONLY_BOOL=true
fi
USE_PRECOLLECTED_PGO=false
if [ -n "$PGO_PROFDATA" ]; then
if [ ! -f "$PGO_PROFDATA" ]; then
echo "error: PGO_PROFDATA points to a missing file: $PGO_PROFDATA"
exit 1
fi
USE_PRECOLLECTED_PGO=true
fi
NEEDS_BENCH_WORKLOAD=true
if [ "$USE_PRECOLLECTED_PGO" = true ] && [ "$SKIP_BOLT_BOOL" = true ]; then
NEEDS_BENCH_WORKLOAD=false
fi
if [ "$NEEDS_BENCH_WORKLOAD" = true ]; then
: "${DATADIR:?DATADIR must be set to the reth data directory}"
: "${RPC_URL:?RPC_URL must be set}"
fi
if [[ "$PROFILE" == dev ]]; then
PROFILE_DIR=debug
else
PROFILE_DIR=$PROFILE
fi
MANIFEST_PATH="bin/reth"
LLVM_VERSION=$(rustc -Vv | grep -oP 'LLVM version: \K\d+')
PGO_DIR="$PWD/target/pgo-profiles"
BOLT_DIR="$PWD/target/bolt-profiles"
CARGO_ARGS=(--profile "$PROFILE" --features "$FEATURES" --manifest-path "$MANIFEST_PATH/Cargo.toml" --bin "reth" --locked)
# Enable debug symbols for BOLT (requires symbols to reorder code).
# Strip them at the end.
PROFILE_UPPER=$(echo "$PROFILE" | tr '[:lower:]-' '[:upper:]_')
export "CARGO_PROFILE_${PROFILE_UPPER}_STRIP=debuginfo"
gha_section_start "Full PGO+BOLT Build"
echo "Binary: reth"
echo "Manifest: $MANIFEST_PATH"
echo "Target: $TARGET"
echo "Profile: $PROFILE"
echo "Features: $FEATURES"
echo "LLVM: $LLVM_VERSION"
echo "PGO blocks: $PGO_BLOCKS"
echo "BOLT blocks: $BOLT_BLOCKS"
echo "Skip BOLT: $SKIP_BOLT"
echo "Strip symbols: $STRIP_SYMBOLS"
echo "Collect only: $COLLECT_PGO_ONLY"
echo "PGO profdata: ${PGO_PROFDATA:-<collect with reth-bench>}"
echo "RUSTFLAGS: ${BASE_RUSTFLAGS:-<unset>}"
echo "EXTRA_RUSTFLAGS: ${EXTRA_RUSTFLAGS:-<unset>}"
if [ "$NEEDS_BENCH_WORKLOAD" = true ]; then
echo "Datadir: $DATADIR"
echo "RPC URL: $RPC_URL"
else
echo "Datadir: <not required>"
echo "RPC URL: <not required>"
fi
gha_section_end
# ── Prerequisites ──────────────────────────────────────────────────────────────
gha_section_start "Installing prerequisites"
rustup component add llvm-tools-preview
LLVM_PROFDATA=$(find "$(rustc --print sysroot)" -name llvm-profdata -type f | head -1)
if [ -z "$LLVM_PROFDATA" ]; then
echo "error: llvm-profdata not found"
exit 1
fi
install_bolt() {
if command -v llvm-bolt &>/dev/null; then
echo "BOLT already installed"
return
fi
echo "Installing BOLT from apt.llvm.org..."
wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc >/dev/null
CODENAME=$(lsb_release -cs)
echo "deb http://apt.llvm.org/$CODENAME/ llvm-toolchain-$CODENAME-$LLVM_VERSION main" | sudo tee /etc/apt/sources.list.d/llvm.list >/dev/null
sudo apt-get update -qq
sudo apt-get install -y -qq "bolt-$LLVM_VERSION"
sudo ln -sf "/usr/bin/llvm-bolt-$LLVM_VERSION" /usr/local/bin/llvm-bolt
sudo ln -sf "/usr/bin/merge-fdata-$LLVM_VERSION" /usr/local/bin/merge-fdata
}
if [ "$SKIP_BOLT_BOOL" = true ]; then
echo "Skipping BOLT installation (SKIP_BOLT=$SKIP_BOLT)"
else
install_bolt
fi
gha_section_end
if [ "$NEEDS_BENCH_WORKLOAD" = true ]; then
# Build reth-bench once (non-instrumented) — reused for both phases.
gha_section_start "Building reth-bench"
RUSTFLAGS="$COMBINED_RUSTFLAGS" \
cargo build --profile "$PROFILE" --features "$FEATURES" \
--manifest-path bin/reth-bench/Cargo.toml --bin reth-bench --locked
RETH_BENCH_BIN="$(find target -name reth-bench -type f -executable | head -1)"
echo "reth-bench: $RETH_BENCH_BIN"
gha_section_end
else
gha_section_start "Building reth-bench"
echo "Skipping reth-bench build (pre-collected PGO with SKIP_BOLT=true)"
gha_section_end
fi
# ── Helpers ────────────────────────────────────────────────────────────────────
RETH_PID=
cleanup() {
if [ -n "${RETH_PID:-}" ] && kill -0 "$RETH_PID" 2>/dev/null; then
echo "Stopping reth (pid $RETH_PID)..."
sudo kill "$RETH_PID" 2>/dev/null || true
for i in $(seq 1 60); do
sudo kill -0 "$RETH_PID" 2>/dev/null || break
if [ $((i % 10)) -eq 0 ]; then
echo " waiting... (${i}s)"
fi
sleep 1
done
sudo kill -9 "$RETH_PID" 2>/dev/null || true
fi
}
trap cleanup EXIT
# Start reth, wait for RPC, run reth-bench, then stop reth.
# Arguments: $1 = reth binary path, $2 = number of blocks, $3 = log label
run_bench_workload() {
local reth_bin="$1" blocks="$2" label="$3"
local http_port=8545 authrpc_port=8551
echo "--- Starting reth ($label) ---"
sudo "$reth_bin" node \
--datadir "$DATADIR" \
--log.file.directory "/tmp/reth-${label}-logs" \
--engine.accept-execution-requests-hash \
--http --http.port "$http_port" \
--authrpc.port "$authrpc_port" \
--disable-discovery --no-persist-peers \
> "/tmp/reth-${label}.log" 2>&1 &
RETH_PID=$!
echo "Waiting for reth RPC..."
for i in $(seq 1 120); do
if curl -sf "http://127.0.0.1:$http_port" -X POST \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \
> /dev/null 2>&1; then
echo "reth is ready after ${i}s"
break
fi
if [ "$i" -eq 120 ]; then
echo "error: reth failed to start within 120s"
cat "/tmp/reth-${label}.log"
exit 1
fi
sleep 1
done
echo "Running reth-bench ($blocks blocks)..."
"$RETH_BENCH_BIN" new-payload-fcu \
--rpc-url "$RPC_URL" \
--engine-rpc-url "http://127.0.0.1:$authrpc_port" \
--jwt-secret "$DATADIR/jwt.hex" \
--advance "$blocks" \
--reth-new-payload 2>&1 | sed -u "s/^/[$label] /"
echo "Stopping reth ($label)..."
sudo kill "$RETH_PID" 2>/dev/null || true
for i in $(seq 1 60); do
sudo kill -0 "$RETH_PID" 2>/dev/null || break
sleep 1
done
sudo kill -9 "$RETH_PID" 2>/dev/null || true
RETH_PID=
}
publish_binary() {
local source_bin="$1"
for out in "target/$TARGET/$PROFILE_DIR" "target/$PROFILE_DIR"; do
local destination="$out/reth"
mkdir -p "$out"
# Skip copying when source and destination resolve to the same inode.
if [ -e "$destination" ] && [ "$source_bin" -ef "$destination" ]; then
continue
fi
cp "$source_bin" "$destination"
done
}
if [ "$USE_PRECOLLECTED_PGO" = true ]; then
gha_section_start "Phase 1: Using Pre-Collected PGO Profile"
rm -rf "$PGO_DIR"
mkdir -p "$PGO_DIR"
cp "$PGO_PROFDATA" "$PGO_DIR/merged.profdata"
echo "Using pre-collected profile: $PGO_PROFDATA"
echo "PGO profile: $PGO_DIR/merged.profdata ($(ls -lh "$PGO_DIR/merged.profdata" | awk '{print $5}'))"
gha_section_end
else
# ── Phase 1: PGO profile collection ───────────────────────────────────────
gha_section_start "Phase 1: PGO Profile Collection"
rm -rf "$PGO_DIR"
mkdir -p "$PGO_DIR"
echo "Building PGO-instrumented binary..."
RUSTFLAGS="-Cprofile-generate=$PGO_DIR -Crelocation-model=pic $COMBINED_RUSTFLAGS" \
cargo build "${CARGO_ARGS[@]}" --target "$TARGET"
PGO_RETH_BIN="$PWD/target/$TARGET/$PROFILE_DIR/reth"
echo "Instrumented binary: $PGO_RETH_BIN ($(ls -lh "$PGO_RETH_BIN" | awk '{print $5}'))"
run_bench_workload "$PGO_RETH_BIN" "$PGO_BLOCKS" "pgo"
# Fix ownership if reth ran as root.
sudo chown -R "$(id -un):$(id -gn)" "$PGO_DIR" 2>/dev/null || true
# Merge PGO profiles.
echo "Merging PGO profiles..."
PROFRAW_COUNT=$(find "$PGO_DIR" -name '*.profraw' | wc -l)
echo "Found $PROFRAW_COUNT .profraw files"
if [ "$PROFRAW_COUNT" -eq 0 ]; then
echo "error: no .profraw files — instrumented binary did not produce profiles"
exit 1
fi
"$LLVM_PROFDATA" merge -o "$PGO_DIR/merged.profdata" "$PGO_DIR"/*.profraw
echo "PGO profile: $PGO_DIR/merged.profdata ($(ls -lh "$PGO_DIR/merged.profdata" | awk '{print $5}'))"
gha_section_end
fi
if [ "$COLLECT_PGO_ONLY_BOOL" = true ]; then
gha_section_start "PGO Collection Complete"
echo "COLLECT_PGO_ONLY=true, skipping PGO/BOLT optimized binary build"
echo "Profile: $PGO_DIR/merged.profdata"
gha_section_end
exit 0
fi
if [ "$SKIP_BOLT_BOOL" = true ]; then
gha_section_start "BOLT Phase Skipped"
echo "SKIP_BOLT=$SKIP_BOLT, building PGO-only binary"
echo "Building PGO-optimized binary..."
RUSTFLAGS="-Cprofile-use=$PGO_DIR/merged.profdata $COMBINED_RUSTFLAGS" \
cargo build "${CARGO_ARGS[@]}" --target "$TARGET"
BUILT_BIN="$PWD/target/$TARGET/$PROFILE_DIR/reth"
if [ "$STRIP_SYMBOLS_BOOL" = true ]; then
echo "Stripping debug symbols..."
strip "$BUILT_BIN"
else
echo "Skipping strip (STRIP_SYMBOLS=$STRIP_SYMBOLS)"
fi
publish_binary "$BUILT_BIN"
gha_section_end
else
# ── Phase 2: BOLT profile collection (with PGO) ──────────────────────────
gha_section_start "Phase 2: BOLT Profile Collection (with PGO)"
rm -rf "$BOLT_DIR"
mkdir -p "$BOLT_DIR"
echo "Building BOLT-instrumented binary with PGO..."
# --emit-relocs preserves relocation entries in the binary, required by llvm-bolt -instrument
RUSTFLAGS="-Cprofile-use=$PGO_DIR/merged.profdata -Clink-arg=-Wl,--emit-relocs $COMBINED_RUSTFLAGS" \
cargo build "${CARGO_ARGS[@]}" --target "$TARGET"
# Instrument with BOLT
BUILT_BIN="$PWD/target/$TARGET/$PROFILE_DIR/reth"
BOLT_INSTRUMENTED_BIN="$BUILT_BIN-bolt-instrumented"
echo "Instrumenting binary with BOLT..."
# --skip-funcs: skip compiler-generated drop_in_place functions that BOLT can't handle
# as split functions in relocation mode (triggered by --emit-relocs)
llvm-bolt "$BUILT_BIN" \
-instrument \
--instrumentation-file-append-pid \
--instrumentation-file="$BOLT_DIR/prof" \
--skip-funcs='.*drop_in_place.*' \
-o "$BOLT_INSTRUMENTED_BIN"
echo "BOLT-instrumented binary: $BOLT_INSTRUMENTED_BIN ($(ls -lh "$BOLT_INSTRUMENTED_BIN" | awk '{print $5}'))"
run_bench_workload "$BOLT_INSTRUMENTED_BIN" "$BOLT_BLOCKS" "bolt"
# Fix ownership for BOLT profiles
sudo chown -R "$(id -un):$(id -gn)" "$BOLT_DIR" 2>/dev/null || true
# Merge BOLT profiles
echo "Merging BOLT profiles..."
FDATA_COUNT=$(find "$BOLT_DIR" -name '*.fdata' | wc -l)
echo "Found $FDATA_COUNT .fdata files"
if [ "$FDATA_COUNT" -eq 0 ]; then
echo "error: no .fdata files — BOLT-instrumented binary did not produce profiles"
exit 1
fi
merge-fdata "$BOLT_DIR"/*.fdata > "$BOLT_DIR/merged.fdata"
echo "BOLT profile: $BOLT_DIR/merged.fdata ($(ls -lh "$BOLT_DIR/merged.fdata" | awk '{print $5}'))"
gha_section_end
# ── Phase 3: Final optimized build ───────────────────────────────────────
gha_section_start "Phase 3: Final PGO+BOLT Optimized Build"
echo "Building PGO-optimized binary..."
# --emit-relocs preserves relocation entries in the binary, required by llvm-bolt for code reordering
RUSTFLAGS="-Cprofile-use=$PGO_DIR/merged.profdata -Clink-arg=-Wl,--emit-relocs $COMBINED_RUSTFLAGS" \
cargo build "${CARGO_ARGS[@]}" --target "$TARGET"
BUILT_BIN="$PWD/target/$TARGET/$PROFILE_DIR/reth"
OPTIMIZED_BIN="$BUILT_BIN-bolt-optimized"
echo "Optimizing with BOLT..."
llvm-bolt "$BUILT_BIN" \
-o "$OPTIMIZED_BIN" \
--data "$BOLT_DIR/merged.fdata" \
-reorder-blocks=ext-tsp \
-reorder-functions=cdsort \
-split-functions \
-split-all-cold \
-dyno-stats \
-icf=1 \
-use-gnu-stack \
--skip-funcs='.*drop_in_place.*'
if [ "$STRIP_SYMBOLS_BOOL" = true ]; then
echo "Stripping debug symbols..."
strip "$OPTIMIZED_BIN"
else
echo "Skipping strip (STRIP_SYMBOLS=$STRIP_SYMBOLS)"
fi
publish_binary "$OPTIMIZED_BIN"
gha_section_end
fi
gha_section_start "Build Complete"
ls -lh "target/$PROFILE_DIR/reth"
echo "Output: target/$PROFILE_DIR/reth"
gha_section_end

View File

@@ -8,11 +8,11 @@
on:
issue_comment:
types: [created, edited]
types: [created]
workflow_dispatch:
inputs:
blocks:
description: "Number of blocks to benchmark"
description: "Number of blocks to benchmark (or 'big' for big blocks mode)"
required: false
default: "500"
type: string
@@ -31,16 +31,46 @@ on:
required: false
default: ""
type: string
wait_time:
description: "Fixed wait time between blocks (e.g. 500ms, 1s)"
required: false
default: ""
type: string
baseline_args:
description: "Extra CLI args for the baseline reth node"
required: false
default: ""
type: string
feature_args:
description: "Extra CLI args for the feature reth node"
required: false
default: ""
type: string
samply:
description: "Enable samply profiling"
required: false
default: "false"
type: boolean
reth_newPayload:
description: "Use reth_newPayload RPC (server-side timing)"
required: false
default: "true"
type: boolean
cores:
description: "Limit reth to N CPU cores (0 = all available)"
required: false
default: "0"
type: string
no_slack:
description: "Suppress Slack notifications for benchmark results"
required: false
default: "true"
type: boolean
abba:
description: "Run ABBA (BFFB) interleaved order; false = single AB pass"
required: false
default: "true"
type: boolean
env:
CARGO_TERM_COLOR: always
@@ -72,7 +102,14 @@ jobs:
baseline-name: ${{ steps.args.outputs.baseline-name }}
feature-name: ${{ steps.args.outputs.feature-name }}
samply: ${{ steps.args.outputs.samply }}
no-slack: ${{ steps.args.outputs.no-slack }}
cores: ${{ steps.args.outputs.cores }}
big-blocks: ${{ steps.args.outputs.big-blocks }}
reth-new-payload: ${{ steps.args.outputs.reth-new-payload }}
wait-time: ${{ steps.args.outputs.wait-time }}
baseline-args: ${{ steps.args.outputs.baseline-args }}
feature-args: ${{ steps.args.outputs.feature-args }}
abba: ${{ steps.args.outputs.abba }}
comment-id: ${{ steps.ack.outputs.comment-id }}
steps:
- name: Check org membership
@@ -100,7 +137,7 @@ jobs:
with:
github-token: ${{ secrets.DEREK_PAT }}
script: |
let pr, actor, blocks, warmup, baseline, feature, samply, cores;
let pr, actor, blocks, warmup, baseline, feature, samply, cores, bigBlocks;
if (context.eventName === 'workflow_dispatch') {
actor = '${{ github.actor }}';
@@ -109,7 +146,14 @@ jobs:
baseline = '${{ github.event.inputs.baseline }}';
feature = '${{ github.event.inputs.feature }}';
samply = '${{ github.event.inputs.samply }}' === 'true' ? 'true' : 'false';
var noSlack = '${{ github.event.inputs.no_slack }}' !== 'false' ? 'true' : 'false';
cores = '${{ github.event.inputs.cores }}' || '0';
bigBlocks = blocks === 'big' ? 'true' : 'false';
var rethNewPayload = '${{ github.event.inputs.reth_newPayload }}' !== 'false' ? 'true' : 'false';
var abba = '${{ github.event.inputs.abba }}' !== 'false' ? 'true' : 'false';
var waitTime = '${{ github.event.inputs.wait_time }}' || '';
var baselineNodeArgs = '${{ github.event.inputs.baseline_args }}' || '';
var featureNodeArgs = '${{ github.event.inputs.feature_args }}' || '';
// Find PR for the selected branch
const branch = '${{ github.ref_name }}';
@@ -129,37 +173,75 @@ jobs:
actor = context.payload.comment.user.login;
const body = context.payload.comment.body.trim();
const intArgs = new Set(['blocks', 'warmup', 'cores']);
const intArgs = new Set(['warmup', 'cores']);
const intOrKeywordArgs = new Map([['blocks', new Set(['big'])]]);
const refArgs = new Set(['baseline', 'feature']);
const boolArgs = new Set(['samply']);
const defaults = { blocks: '500', warmup: '100', baseline: '', feature: '', samply: 'false', cores: '0' };
const boolArgs = new Set(['samply', 'no-slack']);
const boolDefaultTrue = new Set(['reth_newPayload', 'abba']);
const durationArgs = new Set(['wait-time']);
const stringArgs = new Set(['baseline-args', 'feature-args']);
const defaults = { blocks: '500', warmup: '100', baseline: '', feature: '', samply: 'false', 'no-slack': 'false', cores: '0', reth_newPayload: 'true', abba: 'true', 'wait-time': '', 'baseline-args': '', 'feature-args': '' };
const unknown = [];
const invalid = [];
const args = body.replace(/^(?:@decofe|derek) bench\s*/, '');
for (const part of args.split(/\s+/).filter(Boolean)) {
// Parse args, handling quoted values like key="value with spaces"
const parts = [];
const argRegex = /(\S+?="[^"]*"|\S+?='[^']*'|\S+)/g;
let m;
while ((m = argRegex.exec(args)) !== null) parts.push(m[1]);
for (const part of parts) {
const eq = part.indexOf('=');
if (eq === -1) {
if (boolArgs.has(part)) {
defaults[part] = 'true';
} else if (boolDefaultTrue.has(part)) {
defaults[part] = 'true';
} else {
unknown.push(part);
}
continue;
}
const key = part.slice(0, eq);
const value = part.slice(eq + 1);
if (intArgs.has(key)) {
let value = part.slice(eq + 1);
// Strip surrounding quotes
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (boolDefaultTrue.has(key)) {
if (value === 'true' || value === 'false') {
defaults[key] = value;
} else {
invalid.push(`\`${key}=${value}\` (must be true or false)`);
}
} else if (durationArgs.has(key)) {
if (/^\d+(ms|s|m)$/.test(value)) {
defaults[key] = value;
} else {
invalid.push(`\`${key}=${value}\` (must be a duration like 500ms, 1s, 2m)`);
}
} else if (intArgs.has(key)) {
if (!/^\d+$/.test(value)) {
invalid.push(`\`${key}=${value}\` (must be a positive integer)`);
} else {
defaults[key] = value;
}
} else if (intOrKeywordArgs.has(key)) {
const keywords = intOrKeywordArgs.get(key);
if (keywords.has(value)) {
defaults[key] = value;
} else if (/^\d+$/.test(value)) {
defaults[key] = value;
} else {
invalid.push(`\`${key}=${value}\` (must be a positive integer or one of: ${[...keywords].join(', ')})`);
}
} else if (refArgs.has(key)) {
if (!value) {
invalid.push(`\`${key}=\` (must be a git ref)`);
} else {
defaults[key] = value;
}
} else if (stringArgs.has(key)) {
defaults[key] = value;
} else {
unknown.push(key);
}
@@ -168,7 +250,7 @@ jobs:
if (unknown.length) errors.push(`Unknown argument(s): \`${unknown.join('`, `')}\``);
if (invalid.length) errors.push(`Invalid value(s): ${invalid.join(', ')}`);
if (errors.length) {
const msg = `❌ **Invalid bench command**\n\n${errors.join('\n')}\n\n**Usage:** \`@decofe bench [blocks=N] [warmup=N] [baseline=REF] [feature=REF] [samply] [cores=N]\``;
const msg = `❌ **Invalid bench command**\n\n${errors.join('\n')}\n\n**Usage:** \`@decofe bench [blocks=N|big] [warmup=N] [baseline=REF] [feature=REF] [samply] [no-slack] [cores=N] [reth_newPayload=true|false] [abba=true|false] [wait-time=DURATION] [baseline-args="..."] [feature-args="..."]\``;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
@@ -183,7 +265,14 @@ jobs:
baseline = defaults.baseline;
feature = defaults.feature;
samply = defaults.samply;
var noSlack = defaults['no-slack'];
cores = defaults.cores;
bigBlocks = blocks === 'big' ? 'true' : 'false';
var rethNewPayload = defaults.reth_newPayload;
var abba = defaults.abba;
var waitTime = defaults['wait-time'];
var baselineNodeArgs = defaults['baseline-args'];
var featureNodeArgs = defaults['feature-args'];
}
// Resolve display names for baseline/feature
@@ -211,7 +300,14 @@ jobs:
core.setOutput('baseline-name', baselineName);
core.setOutput('feature-name', featureName);
core.setOutput('samply', samply);
core.setOutput('no-slack', noSlack);
core.setOutput('cores', cores);
core.setOutput('big-blocks', bigBlocks);
core.setOutput('reth-new-payload', rethNewPayload);
core.setOutput('wait-time', waitTime);
core.setOutput('baseline-args', baselineNodeArgs);
core.setOutput('feature-args', featureNodeArgs);
core.setOutput('abba', abba);
- name: Acknowledge request
id: ack
@@ -269,10 +365,24 @@ jobs:
const baseline = '${{ steps.args.outputs.baseline-name }}';
const feature = '${{ steps.args.outputs.feature-name }}';
const samply = '${{ steps.args.outputs.samply }}' === 'true';
const noSlack = '${{ steps.args.outputs.no-slack }}' === 'true';
const bigBlocks = '${{ steps.args.outputs.big-blocks }}' === 'true';
const samplyNote = samply ? ', samply: `enabled`' : '';
const noSlackNote = noSlack ? ', no-slack' : '';
const cores = '${{ steps.args.outputs.cores }}';
const coresNote = cores && cores !== '0' ? `, cores: \`${cores}\`` : '';
const config = `**Config:** ${blocks} blocks, ${warmup} warmup blocks, baseline: \`${baseline}\`, feature: \`${feature}\`${samplyNote}${coresNote}`;
const rethNP = '${{ steps.args.outputs.reth-new-payload }}' !== 'false';
const rethNPNote = !rethNP ? ', reth_newPayload: `disabled`' : '';
const abbaEnabled = '${{ steps.args.outputs.abba }}' !== 'false';
const abbaNote = !abbaEnabled ? ', abba: `disabled`' : '';
const waitTimeVal = '${{ steps.args.outputs.wait-time }}';
const waitTimeNote = waitTimeVal ? `, wait-time: \`${waitTimeVal}\`` : '';
const baselineArgsVal = '${{ steps.args.outputs.baseline-args }}';
const baselineArgsNote = baselineArgsVal ? `, baseline-args: \`${baselineArgsVal}\`` : '';
const featureArgsVal = '${{ steps.args.outputs.feature-args }}';
const featureArgsNote = featureArgsVal ? `, feature-args: \`${featureArgsVal}\`` : '';
const blocksDesc = bigBlocks ? 'blocks: `big`' : `${blocks} blocks, ${warmup} warmup blocks`;
const config = `**Config:** ${blocksDesc}, baseline: \`${baseline}\`, feature: \`${feature}\`${samplyNote}${noSlackNote}${coresNote}${rethNPNote}${abbaNote}${waitTimeNote}${baselineArgsNote}${featureArgsNote}`;
const { data: comment } = await github.rest.issues.createComment({
owner: context.repo.owner,
@@ -297,10 +407,24 @@ jobs:
const baseline = '${{ steps.args.outputs.baseline-name }}';
const feature = '${{ steps.args.outputs.feature-name }}';
const samply = '${{ steps.args.outputs.samply }}' === 'true';
const noSlack = '${{ steps.args.outputs.no-slack }}' === 'true';
const bigBlocks = '${{ steps.args.outputs.big-blocks }}' === 'true';
const samplyNote = samply ? ', samply: `enabled`' : '';
const noSlackNote = noSlack ? ', no-slack' : '';
const cores = '${{ steps.args.outputs.cores }}';
const coresNote = cores && cores !== '0' ? `, cores: \`${cores}\`` : '';
const config = `**Config:** ${blocks} blocks, ${warmup} warmup blocks, baseline: \`${baseline}\`, feature: \`${feature}\`${samplyNote}${coresNote}`;
const rethNP = '${{ steps.args.outputs.reth-new-payload }}' !== 'false';
const rethNPNote = !rethNP ? ', reth_newPayload: `disabled`' : '';
const abbaEnabled = '${{ steps.args.outputs.abba }}' !== 'false';
const abbaNote = !abbaEnabled ? ', abba: `disabled`' : '';
const waitTimeVal = '${{ steps.args.outputs.wait-time }}';
const waitTimeNote = waitTimeVal ? `, wait-time: \`${waitTimeVal}\`` : '';
const baselineArgsVal = '${{ steps.args.outputs.baseline-args }}';
const baselineArgsNote = baselineArgsVal ? `, baseline-args: \`${baselineArgsVal}\`` : '';
const featureArgsVal = '${{ steps.args.outputs.feature-args }}';
const featureArgsNote = featureArgsVal ? `, feature-args: \`${featureArgsVal}\`` : '';
const blocksDesc = bigBlocks ? 'blocks: `big`' : `${blocks} blocks, ${warmup} warmup blocks`;
const config = `**Config:** ${blocksDesc}, baseline: \`${baseline}\`, feature: \`${feature}\`${samplyNote}${noSlackNote}${coresNote}${rethNPNote}${abbaNote}${waitTimeNote}${baselineArgsNote}${featureArgsNote}`;
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const numRunners = parseInt(process.env.BENCH_RUNNERS) || 1;
@@ -352,7 +476,7 @@ jobs:
reth-bench:
needs: reth-bench-ack
name: reth-bench
runs-on: [self-hosted, Linux, X64]
runs-on: [self-hosted, Linux, X64, available]
timeout-minutes: 120
env:
BENCH_RPC_URL: https://ethereum.reth.rs/rpc
@@ -364,7 +488,17 @@ jobs:
BENCH_WARMUP_BLOCKS: ${{ needs.reth-bench-ack.outputs.warmup }}
BENCH_SAMPLY: ${{ needs.reth-bench-ack.outputs.samply }}
BENCH_CORES: ${{ needs.reth-bench-ack.outputs.cores }}
BENCH_BIG_BLOCKS: ${{ needs.reth-bench-ack.outputs.big-blocks }}
BENCH_RETH_NEW_PAYLOAD: ${{ needs.reth-bench-ack.outputs.reth-new-payload }}
BENCH_WAIT_TIME: ${{ needs.reth-bench-ack.outputs.wait-time }}
BENCH_BASELINE_ARGS: ${{ needs.reth-bench-ack.outputs.baseline-args }}
BENCH_FEATURE_ARGS: ${{ needs.reth-bench-ack.outputs.feature-args }}
BENCH_ABBA: ${{ needs.reth-bench-ack.outputs.abba }}
BENCH_COMMENT_ID: ${{ needs.reth-bench-ack.outputs.comment-id }}
BENCH_NO_SLACK: ${{ needs.reth-bench-ack.outputs.no-slack }}
BENCH_METRICS_ADDR: "127.0.0.1:9100"
BENCH_OTLP_TRACES_ENDPOINT: ${{ secrets.BENCH_OTLP_TRACES_ENDPOINT }}
BENCH_OTLP_LOGS_ENDPOINT: ${{ secrets.BENCH_OTLP_LOGS_ENDPOINT }}
steps:
- name: Clean up previous bench-work
run: sudo rm -rf "$BENCH_WORK_DIR" 2>/dev/null || true
@@ -383,13 +517,11 @@ jobs:
repo: context.repo.repo,
pull_number: parseInt(process.env.BENCH_PR),
});
// For closed/merged PRs, the merge ref doesn't exist — use head SHA
if (pr.state !== 'open') {
core.info(`PR #${process.env.BENCH_PR} is ${pr.state}, using head SHA ${pr.head.sha}`);
core.setOutput('ref', pr.head.sha);
} else {
core.setOutput('ref', `refs/pull/${process.env.BENCH_PR}/merge`);
}
// Always use head SHA — the merge ref (refs/pull/N/merge) may not
// exist if the PR has conflicts, was force-pushed, or was
// merged/closed between this step and checkout.
core.info(`PR #${process.env.BENCH_PR} (${pr.state}), using head SHA ${pr.head.sha}`);
core.setOutput('ref', pr.head.sha);
- uses: actions/checkout@v6
with:
@@ -417,10 +549,24 @@ jobs:
const baseline = '${{ needs.reth-bench-ack.outputs.baseline-name }}';
const feature = '${{ needs.reth-bench-ack.outputs.feature-name }}';
const samply = process.env.BENCH_SAMPLY === 'true';
const noSlack = process.env.BENCH_NO_SLACK === 'true';
const bigBlocks = process.env.BENCH_BIG_BLOCKS === 'true';
const samplyNote = samply ? ', samply: `enabled`' : '';
const noSlackNote = noSlack ? ', no-slack' : '';
const cores = process.env.BENCH_CORES || '0';
const coresNote = cores && cores !== '0' ? `, cores: \`${cores}\`` : '';
core.exportVariable('BENCH_CONFIG', `**Config:** ${blocks} blocks, ${warmup} warmup blocks, baseline: \`${baseline}\`, feature: \`${feature}\`${samplyNote}${coresNote}`);
const rethNP = (process.env.BENCH_RETH_NEW_PAYLOAD || 'true') !== 'false';
const rethNPNote = !rethNP ? ', reth_newPayload: `disabled`' : '';
const abbaEnabled = (process.env.BENCH_ABBA || 'true') !== 'false';
const abbaNote = !abbaEnabled ? ', abba: `disabled`' : '';
const waitTimeVal = process.env.BENCH_WAIT_TIME || '';
const waitTimeNote = waitTimeVal ? `, wait-time: \`${waitTimeVal}\`` : '';
const baselineArgsVal = process.env.BENCH_BASELINE_ARGS || '';
const baselineArgsNote = baselineArgsVal ? `, baseline-args: \`${baselineArgsVal}\`` : '';
const featureArgsVal = process.env.BENCH_FEATURE_ARGS || '';
const featureArgsNote = featureArgsVal ? `, feature-args: \`${featureArgsVal}\`` : '';
const blocksDesc = bigBlocks ? 'blocks: `big`' : `${blocks} blocks, ${warmup} warmup blocks`;
core.exportVariable('BENCH_CONFIG', `**Config:** ${blocksDesc}, baseline: \`${baseline}\`, feature: \`${feature}\`${samplyNote}${noSlackNote}${coresNote}${rethNPNote}${abbaNote}${waitTimeNote}${baselineArgsNote}${featureArgsNote}`);
const { buildBody } = require('./.github/scripts/bench-update-status.js');
await github.rest.issues.updateComment({
@@ -680,6 +826,45 @@ jobs:
rm -rf "$BENCH_WORK_DIR"
mkdir -p "$BENCH_WORK_DIR"
- name: Download big blocks
if: env.BENCH_BIG_BLOCKS == 'true'
run: |
set -euo pipefail
MC="mc --config-dir /home/ubuntu/.mc"
BUCKET="minio/reth-snapshots/reth-1-minimal-nightly-previous-big-blocks.tar.zst"
BIG_BLOCKS_DIR="${BENCH_WORK_DIR}/big-blocks"
rm -rf "$BIG_BLOCKS_DIR"; mkdir -p "$BIG_BLOCKS_DIR"
echo "Downloading big blocks from $BUCKET..."
$MC cat "$BUCKET" | pzstd -d -p 6 | tar -xf - -C "$BIG_BLOCKS_DIR"
echo "Big blocks downloaded to $BIG_BLOCKS_DIR"
# Verify expected directory structure
if [ ! -d "$BIG_BLOCKS_DIR/gas-ramp-dir" ] || [ ! -d "$BIG_BLOCKS_DIR/payloads" ]; then
echo "::error::Big blocks archive missing expected gas-ramp-dir/ or payloads/ directories"
ls -laR "$BIG_BLOCKS_DIR"
exit 1
fi
echo "Payload files: $(find "$BIG_BLOCKS_DIR/payloads" -name '*.json' | wc -l)"
- name: Start metrics proxy
run: |
BENCH_ID="ci-${{ github.run_id }}"
BENCH_REFERENCE_EPOCH=$(date +%s)
echo "BENCH_ID=${BENCH_ID}" >> "$GITHUB_ENV"
echo "BENCH_REFERENCE_EPOCH=${BENCH_REFERENCE_EPOCH}" >> "$GITHUB_ENV"
LABELS_FILE="/tmp/bench-metrics-labels.json"
echo '{}' > "$LABELS_FILE"
echo "BENCH_LABELS_FILE=${LABELS_FILE}" >> "$GITHUB_ENV"
python3 .github/scripts/bench-metrics-proxy.py \
--labels "$LABELS_FILE" \
--upstream "http://${BENCH_METRICS_ADDR}/" \
--subnet 10.10.0.0/24 \
--port 9090 &
PROXY_PID=$!
echo "BENCH_METRICS_PROXY_PID=${PROXY_PID}" >> "$GITHUB_ENV"
echo "Metrics proxy started (PID $PROXY_PID)"
- name: Update status (running benchmarks)
if: success() && env.BENCH_COMMENT_ID
uses: actions/github-script@v8
@@ -693,19 +878,64 @@ jobs:
# thermal drift and cache warming.
- name: "Run benchmark: baseline (1/2)"
id: run-baseline-1
run: taskset -c 0 .github/scripts/bench-reth-run.sh baseline ../reth-baseline/target/profiling/reth "$BENCH_WORK_DIR/baseline-1"
env:
BASELINE_REF: ${{ steps.refs.outputs.baseline-ref }}
OTEL_RESOURCE_ATTRIBUTES: "benchmark_id=${{ env.BENCH_ID }},benchmark_run=baseline-1,run_type=baseline,git_ref=${{ steps.refs.outputs.baseline-ref }}"
run: |
cat > "$BENCH_LABELS_FILE" <<LABELS
{"benchmark_run":"baseline-1","run_type":"baseline","git_ref":"${BASELINE_REF}","bench_sha":"${BASELINE_REF}","benchmark_id":"${BENCH_ID}","run_start_epoch":"$(date +%s)","reference_epoch":"${BENCH_REFERENCE_EPOCH}"}
LABELS
taskset -c 0 .github/scripts/bench-reth-run.sh baseline ../reth-baseline/target/profiling/reth "$BENCH_WORK_DIR/baseline-1"
- name: "Run benchmark: feature (1/2)"
id: run-feature-1
run: taskset -c 0 .github/scripts/bench-reth-run.sh feature ../reth-feature/target/profiling/reth "$BENCH_WORK_DIR/feature-1"
env:
FEATURE_REF: ${{ steps.refs.outputs.feature-ref }}
OTEL_RESOURCE_ATTRIBUTES: "benchmark_id=${{ env.BENCH_ID }},benchmark_run=feature-1,run_type=feature,git_ref=${{ steps.refs.outputs.feature-ref }}"
run: |
cat > "$BENCH_LABELS_FILE" <<LABELS
{"benchmark_run":"feature-1","run_type":"feature","git_ref":"${FEATURE_REF}","bench_sha":"${FEATURE_REF}","benchmark_id":"${BENCH_ID}","run_start_epoch":"$(date +%s)","reference_epoch":"${BENCH_REFERENCE_EPOCH}"}
LABELS
taskset -c 0 .github/scripts/bench-reth-run.sh feature ../reth-feature/target/profiling/reth "$BENCH_WORK_DIR/feature-1"
- name: "Run benchmark: feature (2/2)"
if: env.BENCH_ABBA != 'false'
id: run-feature-2
run: taskset -c 0 .github/scripts/bench-reth-run.sh feature ../reth-feature/target/profiling/reth "$BENCH_WORK_DIR/feature-2"
env:
FEATURE_REF: ${{ steps.refs.outputs.feature-ref }}
OTEL_RESOURCE_ATTRIBUTES: "benchmark_id=${{ env.BENCH_ID }},benchmark_run=feature-2,run_type=feature,git_ref=${{ steps.refs.outputs.feature-ref }}"
run: |
cat > "$BENCH_LABELS_FILE" <<LABELS
{"benchmark_run":"feature-2","run_type":"feature","git_ref":"${FEATURE_REF}","bench_sha":"${FEATURE_REF}","benchmark_id":"${BENCH_ID}","run_start_epoch":"$(date +%s)","reference_epoch":"${BENCH_REFERENCE_EPOCH}"}
LABELS
taskset -c 0 .github/scripts/bench-reth-run.sh feature ../reth-feature/target/profiling/reth "$BENCH_WORK_DIR/feature-2"
- name: "Run benchmark: baseline (2/2)"
if: env.BENCH_ABBA != 'false'
id: run-baseline-2
run: taskset -c 0 .github/scripts/bench-reth-run.sh baseline ../reth-baseline/target/profiling/reth "$BENCH_WORK_DIR/baseline-2"
env:
BASELINE_REF: ${{ steps.refs.outputs.baseline-ref }}
OTEL_RESOURCE_ATTRIBUTES: "benchmark_id=${{ env.BENCH_ID }},benchmark_run=baseline-2,run_type=baseline,git_ref=${{ steps.refs.outputs.baseline-ref }}"
run: |
LAST_RUN_START=$(date +%s)
echo "BENCH_LAST_RUN_START=${LAST_RUN_START}" >> "$GITHUB_ENV"
cat > "$BENCH_LABELS_FILE" <<LABELS
{"benchmark_run":"baseline-2","run_type":"baseline","git_ref":"${BASELINE_REF}","bench_sha":"${BASELINE_REF}","benchmark_id":"${BENCH_ID}","run_start_epoch":"${LAST_RUN_START}","reference_epoch":"${BENCH_REFERENCE_EPOCH}"}
LABELS
taskset -c 0 .github/scripts/bench-reth-run.sh baseline ../reth-baseline/target/profiling/reth "$BENCH_WORK_DIR/baseline-2"
- name: Stop metrics proxy & generate Grafana URL
id: metrics
if: "!cancelled()"
run: |
kill "$BENCH_METRICS_PROXY_PID" 2>/dev/null || true
LAST_RUN_DURATION=$(( $(date +%s) - BENCH_LAST_RUN_START ))
FROM_MS=$(( BENCH_REFERENCE_EPOCH * 1000 ))
TO_MS=$(( (BENCH_REFERENCE_EPOCH + LAST_RUN_DURATION) * 1000 ))
GRAFANA_URL="https://tempoxyz.grafana.net/d/reth-bench-ghr/reth-bench-ghr?orgId=1&from=${FROM_MS}&to=${TO_MS}&timezone=browser&var-datasource=ef57fux92e9z4e&var-job=reth-bench&var-benchmark_id=${BENCH_ID}&var-benchmark_run=\$__all"
echo "grafana-url=${GRAFANA_URL}" >> "$GITHUB_OUTPUT"
echo "Grafana URL: ${GRAFANA_URL}"
- name: Scan logs for errors
if: "!cancelled()"
@@ -807,12 +1037,30 @@ jobs:
SUMMARY_ARGS="$SUMMARY_ARGS --baseline-name ${BASELINE_NAME}"
SUMMARY_ARGS="$SUMMARY_ARGS --feature-name ${FEATURE_NAME}"
SUMMARY_ARGS="$SUMMARY_ARGS --feature-ref ${FEATURE_REF}"
SUMMARY_ARGS="$SUMMARY_ARGS --baseline-csv $BENCH_WORK_DIR/baseline-1/combined_latency.csv $BENCH_WORK_DIR/baseline-2/combined_latency.csv"
SUMMARY_ARGS="$SUMMARY_ARGS --feature-csv $BENCH_WORK_DIR/feature-1/combined_latency.csv $BENCH_WORK_DIR/feature-2/combined_latency.csv"
BASELINE_CSVS="$BENCH_WORK_DIR/baseline-1/combined_latency.csv"
FEATURE_CSVS="$BENCH_WORK_DIR/feature-1/combined_latency.csv"
if [ "${BENCH_ABBA:-true}" = "true" ]; then
BASELINE_CSVS="$BASELINE_CSVS $BENCH_WORK_DIR/baseline-2/combined_latency.csv"
FEATURE_CSVS="$FEATURE_CSVS $BENCH_WORK_DIR/feature-2/combined_latency.csv"
fi
SUMMARY_ARGS="$SUMMARY_ARGS --baseline-csv $BASELINE_CSVS"
SUMMARY_ARGS="$SUMMARY_ARGS --feature-csv $FEATURE_CSVS"
SUMMARY_ARGS="$SUMMARY_ARGS --gas-csv $BENCH_WORK_DIR/feature-1/total_gas.csv"
if [ "$BEHIND_BASELINE" -gt 0 ]; then
SUMMARY_ARGS="$SUMMARY_ARGS --behind-baseline $BEHIND_BASELINE"
fi
if [ "${BENCH_BIG_BLOCKS:-false}" = "true" ]; then
SUMMARY_ARGS="$SUMMARY_ARGS --big-blocks"
# Read gas ramp blocks count from first baseline run (same for all runs)
GAS_RAMP_FILE="$BENCH_WORK_DIR/baseline-1/gas_ramp_blocks.txt"
if [ -f "$GAS_RAMP_FILE" ]; then
SUMMARY_ARGS="$SUMMARY_ARGS --gas-ramp-blocks $(cat "$GAS_RAMP_FILE" | tr -d '[:space:]')"
fi
fi
GRAFANA_URL='${{ steps.metrics.outputs.grafana-url }}'
if [ -n "$GRAFANA_URL" ]; then
SUMMARY_ARGS="$SUMMARY_ARGS --grafana-url $GRAFANA_URL"
fi
# shellcheck disable=SC2086
python3 .github/scripts/bench-reth-summary.py $SUMMARY_ARGS
@@ -823,8 +1071,14 @@ jobs:
FEATURE_NAME: ${{ steps.refs.outputs.feature-name }}
run: |
CHART_ARGS="--output-dir $BENCH_WORK_DIR/charts"
CHART_ARGS="$CHART_ARGS --feature $BENCH_WORK_DIR/feature-1/combined_latency.csv $BENCH_WORK_DIR/feature-2/combined_latency.csv"
CHART_ARGS="$CHART_ARGS --baseline $BENCH_WORK_DIR/baseline-1/combined_latency.csv $BENCH_WORK_DIR/baseline-2/combined_latency.csv"
FEATURE_CSVS="$BENCH_WORK_DIR/feature-1/combined_latency.csv"
BASELINE_CSVS="$BENCH_WORK_DIR/baseline-1/combined_latency.csv"
if [ "${BENCH_ABBA:-true}" = "true" ]; then
FEATURE_CSVS="$FEATURE_CSVS $BENCH_WORK_DIR/feature-2/combined_latency.csv"
BASELINE_CSVS="$BASELINE_CSVS $BENCH_WORK_DIR/baseline-2/combined_latency.csv"
fi
CHART_ARGS="$CHART_ARGS --feature $FEATURE_CSVS"
CHART_ARGS="$CHART_ARGS --baseline $BASELINE_CSVS"
CHART_ARGS="$CHART_ARGS --baseline-name ${BASELINE_NAME}"
CHART_ARGS="$CHART_ARGS --feature-name ${FEATURE_NAME}"
# shellcheck disable=SC2086
@@ -832,7 +1086,7 @@ jobs:
- name: Upload results
if: "!cancelled()"
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: bench-reth-results
path: ${{ env.BENCH_WORK_DIR }}
@@ -864,7 +1118,7 @@ jobs:
rm -rf "${TMP_DIR}"
- name: Compare & comment
if: success()
if: success() && env.BENCH_COMMENT_ID
uses: actions/github-script@v8
with:
github-token: ${{ secrets.DEREK_PAT }}
@@ -900,7 +1154,8 @@ jobs:
// Samply profile links (URLs point directly to Firefox Profiler)
if (process.env.BENCH_SAMPLY === 'true') {
const runs = ['baseline-1', 'feature-1', 'feature-2', 'baseline-2'];
const abba = (process.env.BENCH_ABBA || 'true') !== 'false';
const runs = abba ? ['baseline-1', 'feature-1', 'feature-2', 'baseline-2'] : ['baseline-1', 'feature-1'];
const links = [];
for (const run of runs) {
try {
@@ -915,6 +1170,12 @@ jobs:
}
}
// Grafana dashboard link
const grafanaUrl = '${{ steps.metrics.outputs.grafana-url }}';
if (grafanaUrl) {
comment += `\n\n### Grafana Dashboard\n\n[View real-time metrics](${grafanaUrl})\n`;
}
// Node errors (panics / ERROR logs)
try {
const errors = fs.readFileSync(process.env.BENCH_WORK_DIR + '/errors.md', 'utf8');
@@ -940,7 +1201,7 @@ jobs:
}
- name: Send Slack notification (success)
if: success()
if: success() && env.BENCH_NO_SLACK != 'true'
uses: actions/github-script@v8
env:
SLACK_BENCH_BOT_TOKEN: ${{ secrets.SLACK_BENCH_BOT_TOKEN }}
@@ -956,12 +1217,13 @@ jobs:
with:
github-token: ${{ secrets.DEREK_PAT }}
script: |
const abba = (process.env.BENCH_ABBA || 'true') !== 'false';
const steps_status = [
['building binaries${{ steps.snapshot-check.outputs.needed == 'true' && ' & downloading snapshot' || '' }}', '${{ steps.build.outcome }}'],
['running baseline benchmark (1/2)', '${{ steps.run-baseline-1.outcome }}'],
['running feature benchmark (1/2)', '${{ steps.run-feature-1.outcome }}'],
['running feature benchmark (2/2)', '${{ steps.run-feature-2.outcome }}'],
['running baseline benchmark (2/2)', '${{ steps.run-baseline-2.outcome }}'],
...(abba ? [['running feature benchmark (2/2)', '${{ steps.run-feature-2.outcome }}']] : []),
...(abba ? [['running baseline benchmark (2/2)', '${{ steps.run-baseline-2.outcome }}']] : []),
];
const failed = steps_status.find(([, o]) => o === 'failure');
const failedStep = failed ? failed[0] : 'unknown step';
@@ -990,12 +1252,13 @@ jobs:
SLACK_BENCH_CHANNEL: ${{ secrets.SLACK_BENCH_CHANNEL }}
with:
script: |
const abba = (process.env.BENCH_ABBA || 'true') !== 'false';
const steps_status = [
['building binaries${{ steps.snapshot-check.outputs.needed == 'true' && ' & downloading snapshot' || '' }}', '${{ steps.build.outcome }}'],
['running baseline benchmark (1/2)', '${{ steps.run-baseline-1.outcome }}'],
['running feature benchmark (1/2)', '${{ steps.run-feature-1.outcome }}'],
['running feature benchmark (2/2)', '${{ steps.run-feature-2.outcome }}'],
['running baseline benchmark (2/2)', '${{ steps.run-baseline-2.outcome }}'],
...(abba ? [['running feature benchmark (2/2)', '${{ steps.run-feature-2.outcome }}']] : []),
...(abba ? [['running baseline benchmark (2/2)', '${{ steps.run-baseline-2.outcome }}']] : []),
];
const failed = steps_status.find(([, o]) => o === 'failure');
const failedStep = failed ? failed[0] : 'unknown step';

View File

@@ -6,7 +6,7 @@ on:
hive_target:
required: true
type: string
description: "Docker bake target to build (e.g. hive-stable, hive-edge)"
description: "Docker bake target to build (e.g. hive)"
artifact_name:
required: false
type: string
@@ -76,7 +76,7 @@ jobs:
*.dockerfile=Dockerfile
- name: Upload reth image
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: ${{ inputs.artifact_name }}
path: ./artifacts

View File

@@ -28,12 +28,30 @@ on:
required: false
type: boolean
default: false
pgo:
description: "Enable PGO profiling"
required: false
type: boolean
default: false
pgo_blocks:
description: "Number of blocks to execute for PGO profiling"
required: false
type: string
default: "20"
jobs:
collect-pgo-profile:
if: github.repository == 'paradigmxyz/reth' && github.event_name == 'workflow_dispatch' && inputs.pgo
uses: ./.github/workflows/pgo-profile.yml
with:
pgo_blocks: ${{ inputs.pgo_blocks || '20' }}
secrets: inherit
build:
if: github.repository == 'paradigmxyz/reth'
if: github.repository == 'paradigmxyz/reth' && !failure() && !cancelled()
name: Build Docker images
runs-on: ubuntu-24.04
needs: collect-pgo-profile
permissions:
packages: write
contents: read
@@ -45,7 +63,7 @@ jobs:
uses: depot/setup-action@v1
- name: Log in to GHCR
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -58,6 +76,30 @@ jobs:
echo "describe=$(git describe --always --tags)" >> "$GITHUB_OUTPUT"
echo "dirty=false" >> "$GITHUB_OUTPUT"
- name: Download pre-collected PGO profile
if: ${{ github.event_name == 'workflow_dispatch' && inputs.pgo }}
uses: actions/download-artifact@v7
with:
name: pgo-profdata
path: dist
- name: Configure PGO build args
id: pgo
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]] && [[ "${{ inputs.pgo }}" == "true" ]]; then
if [ ! -f dist/merged.profdata ]; then
echo "::error::Expected dist/merged.profdata from collect-pgo-profile job"
exit 1
fi
echo "use_pgo_bolt=true" >> "$GITHUB_OUTPUT"
echo "pgo_profdata=dist/merged.profdata" >> "$GITHUB_OUTPUT"
echo "Using pre-collected PGO profile from collect-pgo-profile job"
else
echo "use_pgo_bolt=false" >> "$GITHUB_OUTPUT"
echo "pgo_profdata=" >> "$GITHUB_OUTPUT"
echo "PGO disabled"
fi
- name: Determine build parameters
id: params
run: |
@@ -107,6 +149,9 @@ jobs:
push: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }}
set: |
${{ steps.params.outputs.ethereum_set }}
*.args.USE_PGO_BOLT=${{ steps.pgo.outputs.use_pgo_bolt }}
*.args.PGO_PROFDATA=${{ steps.pgo.outputs.pgo_profdata }}
*.args.STRIP_SYMBOLS=false
- name: Verify image architectures
env:

View File

@@ -63,6 +63,6 @@ jobs:
run: |
cargo nextest run \
--no-fail-fast \
--locked --features "edge" \
--locked \
-p reth-e2e-test-utils \
-E 'binary(rocksdb)'

View File

@@ -15,18 +15,11 @@ concurrency:
cancel-in-progress: true
jobs:
build-reth-stable:
build-reth:
uses: ./.github/workflows/docker-test.yml
with:
hive_target: hive-stable
artifact_name: "reth-stable"
secrets: inherit
build-reth-edge:
uses: ./.github/workflows/docker-test.yml
with:
hive_target: hive-edge
artifact_name: "reth-edge"
hive_target: hive
artifact_name: "reth"
secrets: inherit
prepare-hive:
@@ -75,7 +68,7 @@ jobs:
chmod +x hive
- name: Upload hive assets
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: hive_assets
path: ./hive_assets
@@ -84,7 +77,6 @@ jobs:
strategy:
fail-fast: false
matrix:
storage: [stable, edge]
# ethereum/rpc to be deprecated:
# https://github.com/ethereum/hive/pull/1117
scenario:
@@ -184,10 +176,9 @@ jobs:
- sim: ethereum/eels/consume-rlp
limit: .*tests/paris.*
needs:
- build-reth-stable
- build-reth-edge
- build-reth
- prepare-hive
name: ${{ matrix.storage }} / ${{ matrix.scenario.sim }}${{ matrix.scenario.limit && format(' - {0}', matrix.scenario.limit) }}
name: ${{ matrix.scenario.sim }}${{ matrix.scenario.limit && format(' - {0}', matrix.scenario.limit) }}
# Use larger runners for eels tests to avoid OOM runner crashes
runs-on: ${{ github.repository == 'paradigmxyz/reth' && (contains(matrix.scenario.sim, 'eels') && 'depot-ubuntu-latest-8' || 'depot-ubuntu-latest-4') || 'ubuntu-latest' }}
permissions:
@@ -198,15 +189,15 @@ jobs:
fetch-depth: 0
- name: Download hive assets
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: hive_assets
path: /tmp
- name: Download reth image
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: reth-${{ matrix.storage }}
name: reth
path: /tmp
- name: Load Docker images

View File

@@ -22,7 +22,7 @@ concurrency:
jobs:
test:
name: test / ${{ matrix.network }} / ${{ matrix.storage }}
name: test / ${{ matrix.network }}
if: github.event_name != 'schedule'
runs-on: ${{ github.repository == 'paradigmxyz/reth' && 'depot-ubuntu-latest-4' || 'ubuntu-latest' }}
env:
@@ -30,7 +30,6 @@ jobs:
strategy:
matrix:
network: ["ethereum"]
storage: ["stable", "edge"]
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
@@ -47,7 +46,7 @@ jobs:
run: |
cargo nextest run \
--no-fail-fast \
--locked --features "asm-keccak ${{ matrix.network }} ${{ matrix.storage == 'edge' && 'edge' || '' }}" \
--locked --features "asm-keccak ${{ matrix.network }}" \
--workspace --exclude ef-tests \
-E "kind(test) and not binary(e2e_testsuite)"

View File

@@ -40,7 +40,7 @@ jobs:
fetch-depth: 0
- name: Download reth image
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: artifacts
path: /tmp

107
.github/workflows/pgo-profile.yml vendored Normal file
View File

@@ -0,0 +1,107 @@
name: pgo-profile
on:
workflow_call:
inputs:
pgo_blocks:
description: "Number of blocks to execute for PGO profiling"
required: false
type: string
default: "20"
workflow_dispatch:
inputs:
pgo_blocks:
description: "Number of blocks to execute for PGO profiling"
required: false
type: string
default: "20"
jobs:
collect:
name: collect PGO profiles
runs-on: [self-hosted, Linux, X64]
timeout-minutes: 180
env:
SCHELK_MOUNT: /reth-bench
BENCH_RPC_URL: https://ethereum.reth.rs/rpc
RUSTC_WRAPPER: "sccache"
steps:
- uses: actions/checkout@v6
with:
submodules: true
- uses: rui314/setup-mold@v1
- uses: dtolnay/rust-toolchain@stable
with:
target: x86_64-unknown-linux-gnu
- uses: mozilla-actions/sccache-action@v0.0.9
continue-on-error: true
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- name: Install dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends \
dmsetup lsb-release wget linux-tools-"$(uname -r)" || \
sudo apt-get install -y --no-install-recommends linux-tools-generic
- name: Download snapshot if needed
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BENCH_REPO: ${{ github.repository }}
run: |
if ! .github/scripts/bench-reth-snapshot.sh --check; then
echo "Snapshot outdated or missing, downloading..."
.github/scripts/bench-reth-snapshot.sh
fi
- name: Mount snapshot
run: |
sudo pkill -9 reth || true
sleep 1
if mountpoint -q "$SCHELK_MOUNT"; then
sudo umount -l "$SCHELK_MOUNT" || true
sudo schelk recover -y || true
fi
sudo schelk mount -y
sync
sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
- name: Collect PGO profile
run: |
DATADIR="$SCHELK_MOUNT/datadir" \
RPC_URL="$BENCH_RPC_URL" \
PGO_BLOCKS="${{ inputs.pgo_blocks || '20' }}" \
BOLT_BLOCKS="${{ inputs.pgo_blocks || '20' }}" \
COLLECT_PGO_ONLY=true \
SKIP_BOLT=true \
PROFILE=maxperf-symbols \
FEATURES="jemalloc,asm-keccak,min-debug-logs" \
TARGET=x86_64-unknown-linux-gnu \
EXTRA_RUSTFLAGS="-C target-cpu=x86-64-v3 -C target-feature=+pclmulqdq" \
.github/scripts/build_pgo_bolt.sh
- name: Show PGO profile stats
run: |
LLVM_PROFDATA=$(find "$(rustc --print sysroot)" -name llvm-profdata -type f | head -1)
if [ -z "$LLVM_PROFDATA" ]; then
echo "::error::llvm-profdata not found in rust toolchain"
exit 1
fi
"$LLVM_PROFDATA" show --detailed-summary --topn=20 target/pgo-profiles/merged.profdata
- name: Upload PGO profile
uses: actions/upload-artifact@v7
with:
name: pgo-profdata
path: target/pgo-profiles/merged.profdata
retention-days: 1
- name: Recover snapshot
if: always()
run: |
if mountpoint -q "$SCHELK_MOUNT"; then
sudo umount -l "$SCHELK_MOUNT" || true
sudo schelk recover -y || true
fi

View File

@@ -52,7 +52,7 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}

View File

@@ -13,6 +13,14 @@ on:
description: "Enable dry run mode (builds artifacts but skips uploads and release creation)"
type: boolean
default: false
pgo:
description: "Enable PGO profiling"
type: boolean
default: false
pgo_blocks:
description: "Number of blocks to execute for PGO profiling on self-hosted runner"
type: string
default: "20"
env:
REPO_NAME: ${{ github.repository_owner }}/reth
@@ -69,11 +77,6 @@ jobs:
fail-fast: true
matrix:
configs:
- target: x86_64-unknown-linux-gnu
os: ubuntu-24.04
profile: maxperf
allow_fail: false
rustflags: "-C target-cpu=x86-64-v3 -C target-feature=+pclmulqdq"
- target: aarch64-unknown-linux-gnu
os: ubuntu-24.04-arm
profile: maxperf
@@ -142,23 +145,105 @@ jobs:
- name: Upload artifact
if: ${{ github.event.inputs.dry_run != 'true' }}
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.build.binary }}-${{ needs.extract-version.outputs.VERSION }}-${{ matrix.configs.target }}.tar.gz
path: ${{ matrix.build.binary }}-${{ needs.extract-version.outputs.VERSION }}-${{ matrix.configs.target }}.tar.gz
- name: Upload signature
if: ${{ github.event.inputs.dry_run != 'true' }}
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.build.binary }}-${{ needs.extract-version.outputs.VERSION }}-${{ matrix.configs.target }}.tar.gz.asc
path: ${{ matrix.build.binary }}-${{ needs.extract-version.outputs.VERSION }}-${{ matrix.configs.target }}.tar.gz.asc
collect-pgo-profile:
if: github.event_name == 'workflow_dispatch' && inputs.pgo
uses: ./.github/workflows/pgo-profile.yml
with:
pgo_blocks: ${{ inputs.pgo_blocks || '20' }}
secrets: inherit
build-pgo:
if: github.event_name == 'workflow_dispatch' && inputs.pgo
name: build release (x86_64-linux PGO+BOLT)
runs-on: [self-hosted, Linux, X64]
needs: [extract-version, collect-pgo-profile]
timeout-minutes: 120
env:
RUSTC_WRAPPER: "sccache"
steps:
- uses: actions/checkout@v6
with:
submodules: true
- uses: rui314/setup-mold@v1
- uses: dtolnay/rust-toolchain@stable
with:
target: x86_64-unknown-linux-gnu
- uses: mozilla-actions/sccache-action@v0.0.9
continue-on-error: true
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- name: Download pre-collected PGO profile
uses: actions/download-artifact@v7
with:
name: pgo-profdata
path: dist
- name: Verify PGO profile artifact
run: |
test -f dist/merged.profdata
ls -lh dist/merged.profdata
- name: Build Reth with PGO+BOLT
run: |
SKIP_BOLT=true \
PGO_PROFDATA="$PWD/dist/merged.profdata" \
PROFILE=maxperf-symbols \
FEATURES="jemalloc,asm-keccak,min-debug-logs" \
TARGET=x86_64-unknown-linux-gnu \
EXTRA_RUSTFLAGS="-C target-cpu=x86-64-v3 -C target-feature=+pclmulqdq" \
.github/scripts/build_pgo_bolt.sh
- name: Move binary
run: |
mkdir artifacts
mv target/maxperf-symbols/reth ./artifacts
- name: Configure GPG and create artifacts
env:
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGNING_KEY }}
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
run: |
export GPG_TTY=$(tty)
echo -n "$GPG_SIGNING_KEY" | base64 --decode | gpg --batch --import
cd artifacts
tar -czf reth-${{ needs.extract-version.outputs.VERSION }}-x86_64-unknown-linux-gnu.tar.gz reth*
echo "$GPG_PASSPHRASE" | gpg --passphrase-fd 0 --pinentry-mode loopback --batch -ab reth-${{ needs.extract-version.outputs.VERSION }}-x86_64-unknown-linux-gnu.tar.gz
mv *tar.gz* ..
shell: bash
- name: Upload artifact
if: ${{ github.event.inputs.dry_run != 'true' }}
uses: actions/upload-artifact@v6
with:
name: reth-${{ needs.extract-version.outputs.VERSION }}-x86_64-unknown-linux-gnu.tar.gz
path: reth-${{ needs.extract-version.outputs.VERSION }}-x86_64-unknown-linux-gnu.tar.gz
- name: Upload signature
if: ${{ github.event.inputs.dry_run != 'true' }}
uses: actions/upload-artifact@v6
with:
name: reth-${{ needs.extract-version.outputs.VERSION }}-x86_64-unknown-linux-gnu.tar.gz.asc
path: reth-${{ needs.extract-version.outputs.VERSION }}-x86_64-unknown-linux-gnu.tar.gz.asc
draft-release:
name: draft release
runs-on: ubuntu-latest
needs: [build, extract-version]
if: ${{ github.event.inputs.dry_run != 'true' }}
needs: [build, build-pgo, extract-version]
if: ${{ !failure() && !cancelled() && github.event.inputs.dry_run != 'true' }}
env:
VERSION: ${{ needs.extract-version.outputs.VERSION }}
permissions:
@@ -171,7 +256,7 @@ jobs:
with:
fetch-depth: 0
- name: Download artifacts
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
- name: Generate full changelog
id: changelog
run: |

View File

@@ -43,7 +43,7 @@ jobs:
echo "Binaries SHA256 on ${{ matrix.machine }}: $(cat checksum.sha256)"
- name: Upload the hash
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: checksum-${{ matrix.machine }}
path: |
@@ -56,12 +56,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Download artifacts from machine-1
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: checksum-machine-1
path: machine-1/
- name: Download artifacts from machine-2
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: checksum-machine-2
path: machine-2/

View File

@@ -51,15 +51,12 @@ jobs:
- name: Run execution stage
run: |
reth stage run execution --from ${{ env.FROM_BLOCK }} --to ${{ env.TO_BLOCK }} --commit --checkpoints
- name: Run account-hashing stage
run: |
reth stage run account-hashing --from ${{ env.FROM_BLOCK }} --to ${{ env.TO_BLOCK }} --commit --checkpoints
- name: Run storage hashing stage
run: |
reth stage run storage-hashing --from ${{ env.FROM_BLOCK }} --to ${{ env.TO_BLOCK }} --commit --checkpoints
- name: Run hashing stage
run: |
reth stage run hashing --from ${{ env.FROM_BLOCK }} --to ${{ env.TO_BLOCK }} --commit --checkpoints
# NOTE: account-hashing, storage-hashing, and hashing stages are omitted.
# With storage v2 (now default), these stages are no-ops because the
# execution stage writes directly to HashedAccounts/HashedStorages.
# Running them here is harmful: `stage run` unwinds before executing,
# and the unwind reverts the hashed state that execution wrote, but
# the no-op execute never restores it — causing merkle to fail.
- name: Run merkle stage
run: |
reth stage run merkle --from ${{ env.FROM_BLOCK }} --to ${{ env.TO_BLOCK }} --commit --checkpoints

View File

@@ -19,15 +19,13 @@ concurrency:
jobs:
test:
name: test / ${{ matrix.type }} / ${{ matrix.storage }}
name: test / ${{ matrix.type }}
runs-on: ${{ github.repository == 'paradigmxyz/reth' && 'depot-ubuntu-latest-4' || 'ubuntu-latest' }}
env:
RUST_BACKTRACE: 1
EDGE_FEATURES: ${{ matrix.storage == 'edge' && 'edge' || '' }}
strategy:
matrix:
type: [ethereum]
storage: [stable, edge]
include:
- type: ethereum
features: asm-keccak ethereum
@@ -50,14 +48,14 @@ jobs:
run: |
cargo nextest run \
--no-fail-fast \
--features "${{ matrix.features }} $EDGE_FEATURES" --locked \
--features "${{ matrix.features }}" --locked \
${{ matrix.exclude_args }} --workspace \
--exclude ef-tests --no-tests=warn \
-E "!kind(test) and not binary(e2e_testsuite)"
state:
name: Ethereum state tests
runs-on: ${{ github.repository == 'paradigmxyz/reth' && 'depot-ubuntu-latest-4' || 'ubuntu-latest' }}
runs-on: ${{ github.repository == 'paradigmxyz/reth' && 'depot-ubuntu-latest-8' || 'ubuntu-latest' }}
env:
RUST_LOG: info,sync=error
RUST_BACKTRACE: 1

1141
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
[workspace.package]
version = "1.11.1"
version = "1.11.3"
edition = "2024"
rust-version = "1.93"
license = "MIT OR Apache-2.0"
@@ -539,6 +539,8 @@ serde_json = { version = "1.0", default-features = false, features = ["alloc"] }
serde_with = { version = "3", default-features = false, features = ["macros"] }
sha2 = { version = "0.10", default-features = false }
shlex = "1.3"
# https://github.com/orlp/slotmap/pull/148
slotmap = { git = "https://github.com/DaniPopes/slotmap.git", branch = "dani/shrink-methods" }
smallvec = "1"
strum = { version = "0.27", default-features = false }
strum_macros = "0.27"

View File

@@ -1,8 +1,10 @@
# syntax=docker/dockerfile:1
# Dockerfile for reth, optimized for Depot builds
# Supports PGO+BOLT optimization for maximum performance
# Usage:
# reth: --build-arg BINARY=reth
# PGO+BOLT: --build-arg USE_PGO_BOLT=true (Linux x86_64/aarch64 only)
FROM rust:1.93 AS builder
WORKDIR /app
@@ -43,6 +45,18 @@ ENV VERGEN_GIT_SHA=$VERGEN_GIT_SHA
ENV VERGEN_GIT_DESCRIBE=$VERGEN_GIT_DESCRIBE
ENV VERGEN_GIT_DIRTY=$VERGEN_GIT_DIRTY
# Enable PGO+BOLT optimization (Linux only)
ARG USE_PGO_BOLT=false
ENV USE_PGO_BOLT=$USE_PGO_BOLT
# Optional path to a pre-collected merged.profdata file in build context.
ARG PGO_PROFDATA=""
ENV PGO_PROFDATA=$PGO_PROFDATA
# Whether to strip debug symbols from PGO-built binaries.
ARG STRIP_SYMBOLS=true
ENV STRIP_SYMBOLS=$STRIP_SYMBOLS
# Build application
# Platform-specific RUSTFLAGS: amd64 uses x86-64-v3 (Haswell+) with pclmulqdq for rocksdb
ARG TARGETPLATFORM
@@ -53,12 +67,21 @@ RUN --mount=type=secret,id=DEPOT_TOKEN,env=SCCACHE_WEBDAV_TOKEN \
--mount=type=cache,target=$SCCACHE_DIR,sharing=shared \
export RUSTC_WRAPPER=sccache SCCACHE_WEBDAV_ENDPOINT=https://cache.depot.dev SCCACHE_DIR=/sccache && \
sccache --start-server && \
if [ -n "$RUSTFLAGS" ]; then \
export RUSTFLAGS="$RUSTFLAGS"; \
elif [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
export RUSTFLAGS="-C target-cpu=x86-64-v3 -C target-feature=+pclmulqdq"; \
if [ "$USE_PGO_BOLT" = "true" ] && [ "$TARGETPLATFORM" = "linux/amd64" ] && [ -n "$PGO_PROFDATA" ] && [ -f "$PGO_PROFDATA" ]; then \
apt-get update && apt-get install -y -qq lsb-release wget sudo && \
BINARY="$BINARY" PROFILE="$BUILD_PROFILE" FEATURES="$FEATURES" SKIP_BOLT=true STRIP_SYMBOLS="$STRIP_SYMBOLS" PGO_PROFDATA="$PGO_PROFDATA" \
./.github/scripts/build_pgo_bolt.sh; \
else \
if [ "$USE_PGO_BOLT" = "true" ]; then \
echo "PGO requested but pre-collected profile missing at '${PGO_PROFDATA:-<unset>}' - falling back to non-PGO build"; \
fi; \
if [ -n "$RUSTFLAGS" ]; then \
export RUSTFLAGS="$RUSTFLAGS"; \
elif [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
export RUSTFLAGS="-C target-cpu=x86-64-v3 -C target-feature=+pclmulqdq"; \
fi && \
cargo build --profile $BUILD_PROFILE --features "$FEATURES" --locked --bin $BINARY --manifest-path $MANIFEST_PATH/Cargo.toml; \
fi && \
cargo build --profile $BUILD_PROFILE --features "$FEATURES" --locked --bin $BINARY --manifest-path $MANIFEST_PATH/Cargo.toml && \
sccache --show-stats
# Copy binary to a known location (ARG not resolved in COPY)

View File

@@ -7,7 +7,7 @@ use alloy_primitives::address;
use alloy_provider::{network::AnyNetwork, Provider, RootProvider};
use alloy_rpc_client::ClientBuilder;
use alloy_rpc_types_engine::JwtSecret;
use alloy_transport::layers::RetryBackoffLayer;
use alloy_transport::layers::{RateLimitRetryPolicy, RetryBackoffLayer};
use reqwest::Url;
use reth_node_core::args::BenchmarkArgs;
use tracing::info;
@@ -53,9 +53,15 @@ impl BenchContext {
}
}
// set up alloy client for blocks
// set up alloy client for blocks, retrying on 429/503 (default) and 502
let retry_policy =
RateLimitRetryPolicy::default().or(|err: &alloy_transport::TransportError| -> bool {
err.as_transport_err()
.and_then(|t| t.as_http_error())
.is_some_and(|e| e.status == 502)
});
let client = ClientBuilder::default()
.layer(RetryBackoffLayer::new(10, 800, u64::MAX))
.layer(RetryBackoffLayer::new_with_policy(10, 800, u64::MAX, retry_policy))
.http(rpc_url.parse()?);
let block_provider = RootProvider::<AnyNetwork>::new(client);

View File

@@ -24,7 +24,7 @@ pub(crate) struct GasRampPayloadFile {
/// Engine API version (1-5).
///
/// `None` indicates that `reth_newPayload` should be used.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) version: Option<u8>,
/// The block hash for FCU.
pub(crate) block_hash: B256,

View File

@@ -292,10 +292,6 @@ impl Command {
info!(target: "reth-bench", gas_ramp_payload = i + 1, "Gas ramp payload executed successfully");
if let Some(w) = &mut waiter {
w.on_block(payload.block_number).await?;
}
parent_hash = payload.file.block_hash;
}

View File

@@ -235,8 +235,8 @@ pub(crate) fn payload_to_new_payload(
))?,
)
} else {
// Extract actual Requests from RequestsOrHash
let requests = prague.requests.requests_hash();
// Preserve the original RequestsOrHash payload for engine_newPayloadV4.
let requests = prague.requests.clone();
(
version,
serde_json::to_value((

View File

@@ -89,7 +89,6 @@ default = [
"keccak-cache-global",
"asm-keccak",
"min-debug-logs",
"rocksdb",
]
otlp = [
@@ -191,8 +190,6 @@ min-trace-logs = [
]
trie-debug = ["reth-node-builder/trie-debug", "reth-node-core/trie-debug"]
rocksdb = ["reth-ethereum-cli/rocksdb", "reth-node-core/rocksdb"]
edge = ["rocksdb"]
[[bin]]
name = "reth"

View File

@@ -0,0 +1,69 @@
//! Execution timing statistics for detailed block logging.
//!
//! This module provides types for collecting and passing execution timing statistics
//! through the block processing pipeline, enabling unified detailed block logging after
//! database commit.
use std::time::Duration;
use alloy_primitives::B256;
/// Statistics collected during block execution for cross-client performance analysis.
///
/// These statistics are populated during block validation and carried through to
/// persistence, where they are used to emit a single unified log entry that includes
/// complete timing information (including commit time).
#[derive(Debug, Clone, Default)]
pub struct ExecutionTimingStats {
/// Block number
pub block_number: u64,
/// Block hash
pub block_hash: B256,
/// Total gas used by the block
pub gas_used: u64,
/// Number of transactions in the block
pub tx_count: usize,
/// Time spent executing transactions (includes state reads)
pub execution_duration: Duration,
/// Time spent fetching state during execution (subset of `execution_duration`, includes cache
/// hits)
pub state_read_duration: Duration,
/// Time spent computing state root hash
pub state_hash_duration: Duration,
/// Number of accounts read during execution
pub accounts_read: usize,
/// Number of storage slots read (SLOAD operations)
pub storage_read: usize,
/// Number of code reads (EXTCODE* operations)
pub code_read: usize,
/// Total bytes of code read
pub code_bytes_read: usize,
/// Number of accounts changed (balance/nonce updates)
pub accounts_changed: usize,
/// Number of accounts deleted (SELFDESTRUCT)
pub accounts_deleted: usize,
/// Number of storage slots changed (SSTORE operations)
pub storage_slots_changed: usize,
/// Number of storage slots deleted (set to zero)
pub storage_slots_deleted: usize,
/// Number of bytecodes created/changed (contract deployments)
pub bytecodes_changed: usize,
/// Total bytes of code written
pub code_bytes_written: usize,
/// Number of EIP-7702 delegations set
pub eip7702_delegations_set: usize,
/// Number of EIP-7702 delegations cleared
pub eip7702_delegations_cleared: usize,
/// Account cache hits
pub account_cache_hits: usize,
/// Account cache misses
pub account_cache_misses: usize,
/// Storage cache hits
pub storage_cache_hits: usize,
/// Storage cache misses
pub storage_cache_misses: usize,
/// Code cache hits
pub code_cache_hits: usize,
/// Code cache misses
pub code_cache_misses: usize,
}

View File

@@ -992,7 +992,7 @@ impl<N: NodePrimitives<SignedTx: SignedTransaction>> NewCanonicalChain<N> {
///
/// Returns the new tip for [`Self::Reorg`] and [`Self::Commit`] variants which commit at least
/// 1 new block.
pub fn tip(&self) -> &SealedBlock<N::Block> {
pub fn tip(&self) -> &RecoveredBlock<N::Block> {
match self {
Self::Commit { new } | Self::Reorg { new, .. } => {
new.last().expect("non empty blocks").recovered_block()

View File

@@ -8,6 +8,9 @@
#![cfg_attr(not(test), warn(unused_crate_dependencies))]
#![cfg_attr(docsrs, feature(doc_cfg))]
mod execution_stats;
pub use execution_stats::ExecutionTimingStats;
mod in_memory;
pub use in_memory::*;

View File

@@ -21,7 +21,7 @@ use crate::chainspec::ChainSpecParser;
///
/// This trait is supposed to be implemented by the main struct of the CLI.
///
/// It provides commonly used functionality for running commands and information about the CL, such
/// It provides commonly used functionality for running commands and information about the CLI, such
/// as the name and version.
pub trait RethCli: Sized {
/// The associated `ChainSpecParser` type

View File

@@ -110,7 +110,6 @@ reth-provider = { workspace = true, features = ["test-utils"] }
tempfile.workspace = true
[features]
default = []
arbitrary = [
"dep:proptest",
"dep:arbitrary",
@@ -135,6 +134,3 @@ arbitrary = [
"reth-primitives-traits/arbitrary",
"reth-ethereum-primitives/arbitrary",
]
rocksdb = ["reth-db-common/rocksdb", "reth-stages/rocksdb", "reth-provider/rocksdb", "reth-prune/rocksdb"]
edge = ["rocksdb"]

View File

@@ -73,17 +73,12 @@ pub struct EnvironmentArgs<C: ChainSpecParser> {
}
impl<C: ChainSpecParser> EnvironmentArgs<C> {
/// Returns the effective storage settings derived from `--storage.v2`.
/// Returns the storage settings for new database initialization.
///
/// The base storage mode is determined by `--storage.v2`:
/// - When `--storage.v2` is set: uses [`StorageSettings::v2()`] defaults
/// - Otherwise: uses [`StorageSettings::base()`] defaults
/// Always returns [`StorageSettings::v2()`] — v2 is the default for all new
/// databases. Existing databases use the settings persisted in their metadata.
pub fn storage_settings(&self) -> StorageSettings {
if self.storage.v2 {
StorageSettings::v2()
} else {
StorageSettings::base()
}
StorageSettings::v2()
}
/// Initializes environment according to [`AccessRights`] and returns an instance of

View File

@@ -21,7 +21,6 @@ use std::{
};
use tracing::{info, warn};
#[cfg(all(unix, feature = "rocksdb"))]
mod rocksdb;
/// Interval for logging progress during checksum computation.
@@ -73,7 +72,6 @@ enum Subcommand {
limit: Option<usize>,
},
/// Calculates the checksum of a RocksDB table
#[cfg(all(unix, feature = "rocksdb"))]
Rocksdb {
/// The RocksDB table
#[arg(value_enum)]
@@ -100,7 +98,6 @@ impl Command {
Subcommand::StaticFile { segment, start_block, end_block, limit } => {
checksum_static_file(tool, segment, start_block, end_block, limit)?;
}
#[cfg(all(unix, feature = "rocksdb"))]
Subcommand::Rocksdb { table, limit } => {
rocksdb::checksum_rocksdb(tool, table, limit)?;
}

View File

@@ -102,14 +102,14 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> Command<C>
let static_files_path = data_dir.static_files();
let exex_wal_path = data_dir.exex_wal();
// ensure the provided datadir exist
// ensure the provided datadir exists
eyre::ensure!(
data_dir.data_dir().is_dir(),
"Datadir does not exist: {:?}",
data_dir.data_dir()
);
// ensure the provided database exist
// ensure the provided database exists
eyre::ensure!(db_path.is_dir(), "Database does not exist: {:?}", db_path);
match self.command {

View File

@@ -19,7 +19,7 @@ use std::{
};
use tracing::info;
/// Log progress every 5 seconds
/// Log progress every 30 seconds
const LOG_INTERVAL: Duration = Duration::from_secs(30);
/// The arguments for the `reth db state` command

View File

@@ -290,6 +290,7 @@ mod tests {
storage_version: 2,
timestamp: 0,
base_url: None,
reth_version: None,
components: BTreeMap::new(),
}
}

View File

@@ -38,6 +38,9 @@ pub struct SnapshotManifest {
/// When omitted, downloaders should derive the base URL from the manifest URL.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
/// Reth version that produced this snapshot.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reth_version: Option<String>,
/// Available snapshot components.
pub components: BTreeMap<String, ComponentManifest>,
}
@@ -553,6 +556,7 @@ pub fn generate_manifest(
storage_version: 2,
timestamp,
base_url: base_url.map(str::to_owned),
reth_version: Some(reth_node_core::version::version_metadata().short_version.to_string()),
components,
})
}
@@ -834,6 +838,7 @@ mod tests {
storage_version: 2,
timestamp: 0,
base_url: Some("https://example.com".to_string()),
reth_version: None,
components,
}
}
@@ -884,6 +889,7 @@ mod tests {
storage_version: 2,
timestamp: 0,
base_url: Some("https://example.com".to_string()),
reth_version: None,
components,
};
@@ -953,6 +959,7 @@ mod tests {
storage_version: 2,
timestamp: 0,
base_url: Some("https://example.com".to_string()),
reth_version: None,
components,
};
let urls = m.archive_urls(SnapshotComponentType::StorageChangesets);
@@ -1028,6 +1035,7 @@ mod tests {
storage_version: 2,
timestamp: 0,
base_url: Some("https://example.com".to_string()),
reth_version: None,
components,
};

View File

@@ -7,7 +7,7 @@ use crate::common::EnvironmentArgs;
use blake3::Hasher;
use clap::Parser;
use config_gen::{config_for_selections, write_config};
use eyre::Result;
use eyre::{Result, WrapErr};
use futures::stream::{self, StreamExt};
use lz4::Decoder;
use manifest::{
@@ -17,6 +17,7 @@ use manifest::{
use reqwest::{blocking::Client as BlockingClient, header::RANGE, Client, StatusCode};
use reth_chainspec::{EthChainSpec, EthereumHardfork, EthereumHardforks};
use reth_cli::chainspec::ChainSpecParser;
use reth_cli_util::cancellation::CancellationToken;
use reth_db::{init_db, Database};
use reth_db_api::transaction::DbTx;
use reth_fs_util as fs;
@@ -42,7 +43,8 @@ use url::Url;
use zstd::stream::read::Decoder as ZstdDecoder;
const BYTE_UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
const MERKLE_BASE_URL: &str = "https://downloads.merkle.io";
const RETH_SNAPSHOTS_BASE_URL: &str = "https://snapshots-r2.reth.rs";
const RETH_SNAPSHOTS_API_URL: &str = "https://snapshots.reth.rs/api/snapshots";
const EXTENSION_TAR_LZ4: &str = ".tar.lz4";
const EXTENSION_TAR_ZSTD: &str = ".tar.zst";
const DOWNLOAD_CACHE_DIR: &str = ".download-cache";
@@ -97,14 +99,14 @@ impl DownloadDefaults {
DOWNLOAD_DEFAULTS.get_or_init(DownloadDefaults::default_download_defaults)
}
/// Default download configuration with defaults from merkle.io and publicnode
/// Default download configuration with defaults from snapshots.reth.rs and publicnode
pub fn default_download_defaults() -> Self {
Self {
available_snapshots: vec![
Cow::Borrowed("https://www.merkle.io/snapshots (default, mainnet archive)"),
Cow::Borrowed("https://snapshots.reth.rs (default)"),
Cow::Borrowed("https://publicnode.com/snapshots (full nodes & testnets)"),
],
default_base_url: Cow::Borrowed(MERKLE_BASE_URL),
default_base_url: Cow::Borrowed(RETH_SNAPSHOTS_BASE_URL),
default_chain_aware_base_url: None,
long_help: None,
}
@@ -120,7 +122,9 @@ impl DownloadDefaults {
}
let mut help = String::from(
"Specify a snapshot URL or let the command propose a default one.\n\nAvailable snapshot sources:\n",
"Specify a snapshot URL or let the command propose a default one.\n\n\
Browse available snapshots at https://snapshots.reth.rs\n\
or use --list-snapshots to see them from the CLI.\n\nAvailable snapshot sources:\n",
);
for source in &self.available_snapshots {
@@ -187,6 +191,7 @@ pub struct DownloadCommand<C: ChainSpecParser> {
/// Custom URL to download a single snapshot archive (legacy mode).
///
/// When provided, downloads and extracts a single archive without component selection.
/// Browse available snapshots at <https://snapshots.reth.rs> or use --list-snapshots.
#[arg(long, short, long_help = DownloadDefaults::get_global().long_help())]
url: Option<String>,
@@ -213,22 +218,30 @@ pub struct DownloadCommand<C: ChainSpecParser> {
#[arg(long, alias = "with-changesets", conflicts_with_all = ["minimal", "full", "archive"])]
with_state_history: bool,
/// Include transaction sender static files. Requires `--with-txs`.
#[arg(long, requires = "with_txs", conflicts_with_all = ["minimal", "full", "archive"])]
with_senders: bool,
/// Include RocksDB index files.
#[arg(long, conflicts_with_all = ["minimal", "full", "archive", "without_rocksdb"])]
with_rocksdb: bool,
/// Download all available components (archive node, no pruning).
#[arg(long, alias = "all", conflicts_with_all = ["with_txs", "with_receipts", "with_state_history", "minimal", "full"])]
#[arg(long, alias = "all", conflicts_with_all = ["with_txs", "with_receipts", "with_state_history", "with_senders", "with_rocksdb", "minimal", "full"])]
archive: bool,
/// Download the minimal component set (same default as --non-interactive).
#[arg(long, conflicts_with_all = ["with_txs", "with_receipts", "with_state_history", "archive", "full"])]
#[arg(long, conflicts_with_all = ["with_txs", "with_receipts", "with_state_history", "with_senders", "with_rocksdb", "archive", "full"])]
minimal: bool,
/// Download the full node component set (matches default full prune settings).
#[arg(long, conflicts_with_all = ["with_txs", "with_receipts", "with_state_history", "archive", "minimal"])]
#[arg(long, conflicts_with_all = ["with_txs", "with_receipts", "with_state_history", "with_senders", "with_rocksdb", "archive", "minimal"])]
full: bool,
/// Skip optional RocksDB indices even when archive components are selected.
///
/// This affects `--archive`/`--all` and TUI archive preset (`a`).
#[arg(long, conflicts_with = "url")]
#[arg(long, conflicts_with_all = ["url", "with_rocksdb"])]
without_rocksdb: bool,
/// Skip interactive component selection. Downloads the minimal set
@@ -247,6 +260,13 @@ pub struct DownloadCommand<C: ChainSpecParser> {
/// Maximum number of concurrent modular archive workers.
#[arg(long, default_value_t = MAX_CONCURRENT_DOWNLOADS)]
download_concurrency: usize,
/// List available snapshots from snapshots.reth.rs and exit.
///
/// Queries the snapshots API and prints all available snapshots for the selected chain,
/// including block number, size, and manifest URL.
#[arg(long, alias = "list-snapshots", conflicts_with_all = ["url", "manifest_url", "manifest_path"])]
list: bool,
}
impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> DownloadCommand<C> {
@@ -256,22 +276,39 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> DownloadCo
let data_dir = self.env.datadir.clone().resolve_datadir(chain);
fs::create_dir_all(&data_dir)?;
let cancel_token = CancellationToken::new();
let _cancel_guard = cancel_token.drop_guard();
// --list: print available snapshots and exit
if self.list {
let entries = fetch_snapshot_api_entries(chain_id).await?;
print_snapshot_listing(&entries, chain_id);
return Ok(());
}
// Legacy single-URL mode: download one archive and extract it
if let Some(url) = self.url {
if let Some(ref url) = self.url {
info!(target: "reth::cli",
dir = ?data_dir.data_dir(),
url = %url,
"Starting snapshot download and extraction"
);
stream_and_extract(&url, data_dir.data_dir(), None, self.resumable).await?;
stream_and_extract(
url,
data_dir.data_dir(),
None,
self.resumable,
cancel_token.clone(),
)
.await?;
info!(target: "reth::cli", "Snapshot downloaded and extracted successfully");
return Ok(());
}
// Modular download: fetch manifest and select components
let manifest_source = self.resolve_manifest_source(chain_id);
let manifest_source = self.resolve_manifest_source(chain_id).await?;
info!(target: "reth::cli", source = %manifest_source, "Fetching snapshot manifest");
let mut manifest = fetch_manifest_from_source(&manifest_source).await?;
@@ -365,7 +402,7 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> DownloadCo
"Downloading all archives"
);
let shared = SharedProgress::new(total_size, total_archives as u64);
let shared = SharedProgress::new(total_size, total_archives as u64, cancel_token.clone());
let progress_handle = spawn_progress_display(Arc::clone(&shared));
let target = target_dir.to_path_buf();
@@ -377,9 +414,17 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> DownloadCo
let dir = target.clone();
let cache = cache_dir.clone();
let sp = Arc::clone(&shared);
let ct = cancel_token.clone();
async move {
process_modular_archive(planned, &dir, cache.as_deref(), Some(sp), resumable)
.await?;
process_modular_archive(
planned,
&dir,
cache.as_deref(),
Some(sp),
resumable,
ct,
)
.await?;
Ok(())
}
})
@@ -467,7 +512,11 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> DownloadCo
});
}
let has_explicit_flags = self.with_txs || self.with_receipts || self.with_state_history;
let has_explicit_flags = self.with_txs ||
self.with_receipts ||
self.with_state_history ||
self.with_senders ||
self.with_rocksdb;
if has_explicit_flags {
let mut selections = BTreeMap::new();
@@ -494,6 +543,13 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> DownloadCo
.insert(SnapshotComponentType::StorageChangesets, ComponentSelection::All);
}
}
if self.with_senders && available(SnapshotComponentType::TransactionSenders) {
selections
.insert(SnapshotComponentType::TransactionSenders, ComponentSelection::All);
}
if self.with_rocksdb && available(SnapshotComponentType::RocksdbIndices) {
selections.insert(SnapshotComponentType::RocksdbIndices, ComponentSelection::All);
}
return Ok(ResolvedComponents { selections, preset: None });
}
@@ -602,17 +658,14 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> DownloadCo
}
}
fn resolve_manifest_source(&self, chain_id: u64) -> String {
async fn resolve_manifest_source(&self, chain_id: u64) -> Result<String> {
if let Some(path) = &self.manifest_path {
return path.display().to_string();
return Ok(path.display().to_string());
}
match &self.manifest_url {
Some(url) => url.clone(),
None => {
let base_url = get_base_url(chain_id);
format!("{base_url}/manifest.json")
}
Some(url) => Ok(url.clone()),
None => discover_manifest_url(chain_id).await,
}
}
}
@@ -800,19 +853,25 @@ struct SharedProgress {
total_archives: u64,
archives_done: AtomicU64,
done: AtomicBool,
cancel_token: CancellationToken,
}
impl SharedProgress {
fn new(total_size: u64, total_archives: u64) -> Arc<Self> {
fn new(total_size: u64, total_archives: u64, cancel_token: CancellationToken) -> Arc<Self> {
Arc::new(Self {
downloaded: AtomicU64::new(0),
total_size,
total_archives,
archives_done: AtomicU64::new(0),
done: AtomicBool::new(false),
cancel_token,
})
}
fn is_cancelled(&self) -> bool {
self.cancel_token.is_cancelled()
}
fn add(&self, bytes: u64) {
self.downloaded.fetch_add(bytes, Ordering::Relaxed);
}
@@ -901,16 +960,20 @@ fn spawn_progress_display(progress: Arc<SharedProgress>) -> tokio::task::JoinHan
struct ProgressReader<R> {
reader: R,
progress: DownloadProgress,
cancel_token: CancellationToken,
}
impl<R: Read> ProgressReader<R> {
fn new(reader: R, total_size: u64) -> Self {
Self { reader, progress: DownloadProgress::new(total_size) }
fn new(reader: R, total_size: u64, cancel_token: CancellationToken) -> Self {
Self { reader, progress: DownloadProgress::new(total_size), cancel_token }
}
}
impl<R: Read> Read for ProgressReader<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if self.cancel_token.is_cancelled() {
return Err(io::Error::new(io::ErrorKind::Interrupted, "download cancelled"));
}
let bytes = self.reader.read(buf)?;
if bytes > 0 &&
let Err(e) = self.progress.update(bytes as u64)
@@ -953,8 +1016,9 @@ fn extract_archive<R: Read>(
total_size: u64,
format: CompressionFormat,
target_dir: &Path,
cancel_token: CancellationToken,
) -> Result<()> {
let progress_reader = ProgressReader::new(reader, total_size);
let progress_reader = ProgressReader::new(reader, total_size, cancel_token);
match format {
CompressionFormat::Lz4 => {
@@ -998,7 +1062,7 @@ fn extract_from_file(path: &Path, format: CompressionFormat, target_dir: &Path)
"Extracting local archive"
);
let start = Instant::now();
extract_archive(file, total_size, format, target_dir)?;
extract_archive(file, total_size, format, target_dir, CancellationToken::new())?;
info!(target: "reth::cli",
file = %path.display(),
elapsed = %DownloadProgress::format_duration(start.elapsed()),
@@ -1015,10 +1079,14 @@ const RETRY_BACKOFF_SECS: u64 = 5;
struct ProgressWriter<W> {
inner: W,
progress: DownloadProgress,
cancel_token: CancellationToken,
}
impl<W: Write> Write for ProgressWriter<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if self.cancel_token.is_cancelled() {
return Err(io::Error::new(io::ErrorKind::Interrupted, "download cancelled"));
}
let n = self.inner.write(buf)?;
let _ = self.progress.update(n as u64);
Ok(n)
@@ -1038,6 +1106,9 @@ struct SharedProgressWriter<W> {
impl<W: Write> Write for SharedProgressWriter<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if self.progress.is_cancelled() {
return Err(io::Error::new(io::ErrorKind::Interrupted, "download cancelled"));
}
let n = self.inner.write(buf)?;
self.progress.add(n as u64);
Ok(n)
@@ -1057,6 +1128,9 @@ struct SharedProgressReader<R> {
impl<R: Read> Read for SharedProgressReader<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if self.progress.is_cancelled() {
return Err(io::Error::new(io::ErrorKind::Interrupted, "download cancelled"));
}
let n = self.inner.read(buf)?;
self.progress.add(n as u64);
Ok(n)
@@ -1073,6 +1147,7 @@ fn resumable_download(
url: &str,
target_dir: &Path,
shared: Option<&Arc<SharedProgress>>,
cancel_token: CancellationToken,
) -> Result<(PathBuf, u64)> {
let file_name = Url::parse(url)
.ok()
@@ -1196,7 +1271,11 @@ fn resumable_download(
// Legacy single-download path: local progress bar
let mut progress = DownloadProgress::new(current_total);
progress.downloaded = start_offset;
let mut writer = ProgressWriter { inner: BufWriter::new(file), progress };
let mut writer = ProgressWriter {
inner: BufWriter::new(file),
progress,
cancel_token: cancel_token.clone(),
};
copy_result = io::copy(&mut reader, &mut writer);
flush_result = writer.inner.flush();
println!();
@@ -1229,6 +1308,7 @@ fn streaming_download_and_extract(
format: CompressionFormat,
target_dir: &Path,
shared: Option<&Arc<SharedProgress>>,
cancel_token: CancellationToken,
) -> Result<()> {
let quiet = shared.is_some();
let mut last_error: Option<eyre::Error> = None;
@@ -1268,7 +1348,8 @@ fn streaming_download_and_extract(
let reader = SharedProgressReader { inner: response, progress: Arc::clone(sp) };
extract_archive_raw(reader, format, target_dir)
} else {
extract_archive_raw(response, format, target_dir)
let total_size = response.content_length().unwrap_or(0);
extract_archive(response, total_size, format, target_dir, cancel_token.clone())
};
match result {
@@ -1293,9 +1374,11 @@ fn download_and_extract(
format: CompressionFormat,
target_dir: &Path,
shared: Option<&Arc<SharedProgress>>,
cancel_token: CancellationToken,
) -> Result<()> {
let quiet = shared.is_some();
let (downloaded_path, total_size) = resumable_download(url, target_dir, shared)?;
let (downloaded_path, total_size) =
resumable_download(url, target_dir, shared, cancel_token.clone())?;
let file_name =
downloaded_path.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
@@ -1313,7 +1396,7 @@ fn download_and_extract(
// Skip progress tracking for extraction in parallel mode
extract_archive_raw(file, format, target_dir)?;
} else {
extract_archive(file, total_size, format, target_dir)?;
extract_archive(file, total_size, format, target_dir, cancel_token)?;
info!(target: "reth::cli",
file = %file_name,
"Extraction complete"
@@ -1339,6 +1422,7 @@ fn blocking_download_and_extract(
target_dir: &Path,
shared: Option<Arc<SharedProgress>>,
resumable: bool,
cancel_token: CancellationToken,
) -> Result<()> {
let format = CompressionFormat::from_url(url)?;
@@ -1356,9 +1440,10 @@ fn blocking_download_and_extract(
}
result
} else if resumable {
download_and_extract(url, format, target_dir, shared.as_ref())
download_and_extract(url, format, target_dir, shared.as_ref(), cancel_token)
} else {
let result = streaming_download_and_extract(url, format, target_dir, shared.as_ref());
let result =
streaming_download_and_extract(url, format, target_dir, shared.as_ref(), cancel_token);
if result.is_ok() &&
let Some(sp) = shared
{
@@ -1378,11 +1463,12 @@ async fn stream_and_extract(
target_dir: &Path,
shared: Option<Arc<SharedProgress>>,
resumable: bool,
cancel_token: CancellationToken,
) -> Result<()> {
let target_dir = target_dir.to_path_buf();
let url = url.to_string();
task::spawn_blocking(move || {
blocking_download_and_extract(&url, &target_dir, shared, resumable)
blocking_download_and_extract(&url, &target_dir, shared, resumable, cancel_token)
})
.await??;
@@ -1395,6 +1481,7 @@ async fn process_modular_archive(
cache_dir: Option<&Path>,
shared: Option<Arc<SharedProgress>>,
resumable: bool,
cancel_token: CancellationToken,
) -> Result<()> {
let target_dir = target_dir.to_path_buf();
let cache_dir = cache_dir.map(Path::to_path_buf);
@@ -1406,6 +1493,7 @@ async fn process_modular_archive(
cache_dir.as_deref(),
shared,
resumable,
cancel_token,
)
})
.await??;
@@ -1419,6 +1507,7 @@ fn blocking_process_modular_archive(
cache_dir: Option<&Path>,
shared: Option<Arc<SharedProgress>>,
resumable: bool,
cancel_token: CancellationToken,
) -> Result<()> {
let archive = &planned.archive;
if verify_output_files(target_dir, &archive.output_files)? {
@@ -1439,13 +1528,19 @@ fn blocking_process_modular_archive(
let archive_path = cache_dir.join(&archive.file_name);
let part_path = cache_dir.join(format!("{}.part", archive.file_name));
let (downloaded_path, _downloaded_size) =
resumable_download(&archive.url, cache_dir, shared.as_ref())?;
resumable_download(&archive.url, cache_dir, shared.as_ref(), cancel_token.clone())?;
let file = fs::open(&downloaded_path)?;
extract_archive_raw(file, format, target_dir)?;
let _ = fs::remove_file(&archive_path);
let _ = fs::remove_file(&part_path);
} else {
streaming_download_and_extract(&archive.url, format, target_dir, shared.as_ref())?;
streaming_download_and_extract(
&archive.url,
format,
target_dir,
shared.as_ref(),
cancel_token.clone(),
)?;
}
if verify_output_files(target_dir, &archive.output_files)? {
@@ -1511,20 +1606,149 @@ fn file_blake3_hex(path: &Path) -> Result<String> {
Ok(hasher.finalize().to_hex().to_string())
}
/// Builds the base URL for the given chain ID using configured defaults.
fn get_base_url(chain_id: u64) -> String {
let defaults = DownloadDefaults::get_global();
match &defaults.default_chain_aware_base_url {
Some(url) => format!("{url}/{chain_id}"),
None => defaults.default_base_url.to_string(),
/// Discovers the latest snapshot manifest URL for the given chain from the snapshots API.
///
/// Queries `snapshots.reth.rs/api/snapshots` and returns the manifest URL for the most
/// recent modular snapshot matching the requested chain.
async fn discover_manifest_url(chain_id: u64) -> Result<String> {
let api_url = RETH_SNAPSHOTS_API_URL;
info!(target: "reth::cli", %api_url, %chain_id, "Discovering latest snapshot manifest");
let entries = fetch_snapshot_api_entries(chain_id).await?;
let entry =
entries.iter().filter(|s| s.is_modular()).max_by_key(|s| s.block).ok_or_else(|| {
eyre::eyre!(
"No modular snapshot manifest found for chain \
{chain_id} at {api_url}\n\n\
You can provide a manifest URL directly with --manifest-url, or\n\
use a direct snapshot URL with -u from:\n\
\t- https://snapshots.reth.rs\n\n\
Use --list to see all available snapshots."
)
})?;
info!(target: "reth::cli",
block = entry.block,
url = %entry.metadata_url,
"Found latest snapshot manifest"
);
Ok(entry.metadata_url.clone())
}
/// Deserializes a JSON value that may be either a number or a string-encoded number.
fn deserialize_string_or_u64<'de, D>(deserializer: D) -> std::result::Result<u64, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
let value = serde_json::Value::deserialize(deserializer)?;
match &value {
serde_json::Value::Number(n) => {
n.as_u64().ok_or_else(|| serde::de::Error::custom("expected u64"))
}
serde_json::Value::String(s) => {
s.parse::<u64>().map_err(|_| serde::de::Error::custom("expected numeric string"))
}
_ => Err(serde::de::Error::custom("expected number or string")),
}
}
/// An entry from the `snapshots.reth.rs/api/snapshots` listing.
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct SnapshotApiEntry {
#[serde(deserialize_with = "deserialize_string_or_u64")]
chain_id: u64,
#[serde(deserialize_with = "deserialize_string_or_u64")]
block: u64,
#[serde(default)]
date: Option<String>,
#[serde(default)]
profile: Option<String>,
metadata_url: String,
#[serde(default)]
size: u64,
}
impl SnapshotApiEntry {
fn is_modular(&self) -> bool {
self.metadata_url.ends_with("manifest.json")
}
}
/// Fetches the full snapshot listing from the snapshots API, filtered by chain ID.
async fn fetch_snapshot_api_entries(chain_id: u64) -> Result<Vec<SnapshotApiEntry>> {
let api_url = RETH_SNAPSHOTS_API_URL;
let entries: Vec<SnapshotApiEntry> = Client::new()
.get(api_url)
.send()
.await
.and_then(|r| r.error_for_status())
.wrap_err_with(|| format!("Failed to fetch snapshot listing from {api_url}"))?
.json()
.await?;
Ok(entries.into_iter().filter(|e| e.chain_id == chain_id).collect())
}
/// Prints a formatted table of available modular snapshots.
fn print_snapshot_listing(entries: &[SnapshotApiEntry], chain_id: u64) {
let modular: Vec<_> = entries.iter().filter(|e| e.is_modular()).collect();
println!("Available snapshots for chain {chain_id} (https://snapshots.reth.rs):\n");
println!("{:<12} {:>10} {:<10} {:>10} MANIFEST URL", "DATE", "BLOCK", "PROFILE", "SIZE");
println!("{}", "-".repeat(100));
for entry in &modular {
let date = entry.date.as_deref().unwrap_or("-");
let profile = entry.profile.as_deref().unwrap_or("-");
let size = if entry.size > 0 {
DownloadProgress::format_size(entry.size)
} else {
"-".to_string()
};
println!(
"{date:<12} {:>10} {profile:<10} {size:>10} {}",
entry.block, entry.metadata_url
);
}
if modular.is_empty() {
println!(" (no modular snapshots found)");
}
println!(
"\nTo download a specific snapshot, copy its manifest URL and run:\n \
reth download --manifest-url <URL>"
);
}
async fn fetch_manifest_from_source(source: &str) -> Result<SnapshotManifest> {
if let Ok(parsed) = Url::parse(source) {
return match parsed.scheme() {
"http" | "https" => {
Ok(Client::new().get(source).send().await?.error_for_status()?.json().await?)
let response = Client::new()
.get(source)
.send()
.await
.and_then(|r| r.error_for_status())
.wrap_err_with(|| {
format!(
"Failed to fetch snapshot manifest from {source}\n\n\
The manifest endpoint may not be available for this snapshot source.\n\
You can use a direct snapshot URL instead:\n\n\
\treth download -u <snapshot-url>\n\n\
Available snapshot sources:\n\
\t- https://snapshots.reth.rs\n\
\t- https://publicnode.com/snapshots"
)
})?;
Ok(response.json().await?)
}
"file" => {
let path = parsed
@@ -1589,26 +1813,6 @@ fn resolve_manifest_base_url(manifest: &SnapshotManifest, source: &str) -> Resul
Ok(base)
}
/// Builds default URL for latest mainnet archive snapshot using configured defaults.
///
/// Used by the legacy single-archive download flow when no manifest is available.
#[allow(dead_code)]
async fn get_latest_snapshot_url(chain_id: u64) -> Result<String> {
let base_url = get_base_url(chain_id);
let latest_url = format!("{base_url}/latest.txt");
let filename = Client::new()
.get(latest_url)
.send()
.await?
.error_for_status()?
.text()
.await?
.trim()
.to_string();
Ok(format!("{base_url}/{filename}"))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1641,6 +1845,7 @@ mod tests {
storage_version: 2,
timestamp: 0,
base_url: Some("https://example.com".to_string()),
reth_version: None,
components,
}
}
@@ -1672,7 +1877,7 @@ mod tests {
let help = defaults.long_help();
assert!(help.contains("Available snapshot sources:"));
assert!(help.contains("merkle.io"));
assert!(help.contains("snapshots.reth.rs"));
assert!(help.contains("publicnode.com"));
assert!(help.contains("file://"));
}

View File

@@ -19,11 +19,12 @@ use reth_node_api::BlockTy;
use reth_node_events::node::NodeEvent;
use reth_provider::{
providers::ProviderNodeTypes, BlockNumReader, HeaderProvider, ProviderError, ProviderFactory,
StageCheckpointReader,
RocksDBProviderFactory, StageCheckpointReader,
};
use reth_prune::PruneModes;
use reth_stages::{prelude::*, ControlFlow, Pipeline, StageId, StageSet};
use reth_static_file::StaticFileProducer;
use reth_storage_api::StorageSettingsCache;
use std::{path::Path, sync::Arc};
use tokio::sync::watch;
use tracing::{debug, error, info, warn};
@@ -108,7 +109,11 @@ where
let provider = provider_factory.provider()?;
let init_blocks = provider.tx_ref().entries::<tables::HeaderNumbers>()?;
let init_txns = provider.tx_ref().entries::<tables::TransactionHashNumbers>()?;
let init_txns = if provider_factory.cached_storage_settings().storage_v2 {
provider_factory.rocksdb_provider().iter::<tables::TransactionHashNumbers>()?.count()
} else {
provider.tx_ref().entries::<tables::TransactionHashNumbers>()?
};
drop(provider);
let mut total_decoded_blocks = 0;
@@ -215,8 +220,12 @@ where
let provider = provider_factory.provider()?;
let total_imported_blocks = provider.tx_ref().entries::<tables::HeaderNumbers>()? - init_blocks;
let total_imported_txns =
provider.tx_ref().entries::<tables::TransactionHashNumbers>()? - init_txns;
let current_txns = if provider_factory.cached_storage_settings().storage_v2 {
provider_factory.rocksdb_provider().iter::<tables::TransactionHashNumbers>()?.count()
} else {
provider.tx_ref().entries::<tables::TransactionHashNumbers>()?
};
let total_imported_txns = current_txns - init_txns;
let result = ImportResult {
total_decoded_blocks,

View File

@@ -47,7 +47,7 @@ pub struct InitStateCommand<C: ChainSpecParser> {
/// Specifies whether to initialize the state without relying on EVM historical data.
///
/// When enabled, and before inserting the state, it creates a dummy chain up to the last EVM
/// block specified. It then, appends the first block provided block.
/// block specified. It then appends the first provided block.
///
/// - **Note**: **Do not** import receipts and blocks beforehand, or this will fail or be
/// ignored.

View File

@@ -125,12 +125,12 @@ pub struct NodeCommand<C: ChainSpecParser, Ext: clap::Args + fmt::Debug = NoArgs
}
impl<C: ChainSpecParser> NodeCommand<C> {
/// Parsers only the default CLI arguments
/// Parses only the default CLI arguments
pub fn parse_args() -> Self {
Self::parse()
}
/// Parsers only the default [`NodeCommand`] arguments from the given iterator
/// Parses only the default [`NodeCommand`] arguments from the given iterator
pub fn try_parse_args_from<I, T>(itr: I) -> Result<Self, clap::error::Error>
where
I: IntoIterator<Item = T>,

View File

@@ -193,7 +193,10 @@ impl<C: ChainSpecParser> DownloadArgs<C> {
let default_secret_key_path = data_dir.p2p_secret();
let p2p_secret_key = self.network.secret_key(default_secret_key_path)?;
let rlpx_socket = (self.network.addr, self.network.port).into();
let boot_nodes = self.chain.bootnodes().unwrap_or_default();
let boot_nodes = self
.network
.resolved_bootnodes()
.unwrap_or_else(|| self.chain.bootnodes().unwrap_or_default());
let net =
NetworkConfigBuilder::<N::NetworkPrimitives>::new(p2p_secret_key, Runtime::test())

View File

@@ -12,7 +12,6 @@ use reth_node_metrics::{
server::{MetricServer, MetricServerConfig},
version::VersionInfo,
};
#[cfg(all(unix, feature = "rocksdb"))]
use reth_provider::RocksDBProviderFactory;
use reth_prune::PrunerBuilder;
use reth_static_file::StaticFileProducer;
@@ -122,7 +121,6 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> PruneComma
}
// Flush and compact RocksDB to reclaim disk space after pruning
#[cfg(all(unix, feature = "rocksdb"))]
{
info!(target: "reth::cli", "Flushing and compacting RocksDB...");
provider_factory.rocksdb_provider().flush_and_compact()?;

View File

@@ -107,7 +107,7 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + Hardforks + EthereumHardforks>
Comp: CliNodeComponents<N>,
F: FnOnce(Arc<C::ChainSpec>) -> Comp,
{
// Quit early if the stages requires a commit and `--commit` is not provided.
// Quit early if the stage requires a commit and `--commit` is not provided.
if self.requires_commit() && !self.commit {
return Err(eyre::eyre!(
"The stage {} requires overwriting existing static files and must commit, but `--commit` was not provided. Please pass `--commit` and try again.",

View File

@@ -71,7 +71,12 @@ impl CliRunner {
) -> Result<(), E>
where
F: Future<Output = Result<(), E>>,
E: Send + Sync + From<std::io::Error> + From<reth_tasks::PanickedTaskError> + 'static,
E: Send
+ Sync
+ std::fmt::Display
+ From<std::io::Error>
+ From<reth_tasks::PanickedTaskError>
+ 'static,
{
let (context, task_manager_handle) = cli_context(&self.runtime);
@@ -81,8 +86,8 @@ impl CliRunner {
run_until_ctrl_c(command(context)),
));
if command_res.is_err() {
error!(target: "reth::cli", "shutting down due to error");
if let Err(err) = &command_res {
error!(target: "reth::cli", %err, "shutting down due to error");
} else {
debug!(target: "reth::cli", "shutting down gracefully");
// after the command has finished or exit signal was received we shutdown the
@@ -105,7 +110,12 @@ impl CliRunner {
) -> Result<(), E>
where
F: Future<Output = Result<(), E>> + Send + 'static,
E: Send + Sync + From<std::io::Error> + From<reth_tasks::PanickedTaskError> + 'static,
E: Send
+ Sync
+ std::fmt::Display
+ From<std::io::Error>
+ From<reth_tasks::PanickedTaskError>
+ 'static,
{
let (context, task_manager_handle) = cli_context(&self.runtime);
@@ -122,8 +132,8 @@ impl CliRunner {
),
));
if command_res.is_err() {
error!(target: "reth::cli", "shutting down due to error");
if let Err(err) = &command_res {
error!(target: "reth::cli", %err, "shutting down due to error");
} else {
debug!(target: "reth::cli", "shutting down gracefully");
self.runtime.graceful_shutdown_with_timeout(self.config.graceful_shutdown_timeout);

View File

@@ -75,8 +75,3 @@ path = "tests/e2e-testsuite/main.rs"
[[test]]
name = "rocksdb"
path = "tests/rocksdb/main.rs"
required-features = ["rocksdb"]
[features]
rocksdb = ["reth-node-core/rocksdb", "reth-provider/rocksdb", "reth-cli-commands/rocksdb"]
edge = ["rocksdb"]

View File

@@ -1,7 +1,5 @@
//! E2E tests for `RocksDB` provider functionality.
#![cfg(all(feature = "rocksdb", unix))]
use alloy_consensus::BlockHeader;
use alloy_primitives::B256;
use alloy_rpc_types_eth::{Transaction, TransactionReceipt};

View File

@@ -39,6 +39,7 @@ thiserror.workspace = true
[features]
default = ["std"]
trie-debug = []
std = [
"reth-execution-types/std",
"reth-ethereum-primitives/std",

View File

@@ -26,10 +26,15 @@ pub const DEFAULT_RESERVED_CPU_CORES: usize = 1;
/// Depth 4 means we keep roughly 16^4 = 65536 potential branch paths at most.
pub const DEFAULT_SPARSE_TRIE_PRUNE_DEPTH: usize = 4;
/// Default maximum number of storage tries to keep after pruning.
/// Default LFU hot-slot capacity for sparse trie pruning.
///
/// Storage tries beyond this limit are cleared (but allocations preserved).
pub const DEFAULT_SPARSE_TRIE_MAX_STORAGE_TRIES: usize = 100;
/// Limits the number of `(address, slot)` pairs retained across prune cycles.
pub const DEFAULT_SPARSE_TRIE_MAX_HOT_SLOTS: usize = 1500;
/// Default LFU hot-account capacity for sparse trie pruning.
///
/// Limits the number of account addresses retained across prune cycles.
pub const DEFAULT_SPARSE_TRIE_MAX_HOT_ACCOUNTS: usize = 1000;
/// Default timeout for the state root task before spawning a sequential fallback.
pub const DEFAULT_STATE_ROOT_TASK_TIMEOUT: Duration = Duration::from_secs(1);
@@ -131,15 +136,28 @@ pub struct TreeConfig {
disable_cache_metrics: bool,
/// Depth for sparse trie pruning after state root computation.
sparse_trie_prune_depth: usize,
/// Maximum number of storage tries to retain after pruning.
sparse_trie_max_storage_tries: usize,
/// LFU hot-slot capacity: max `(address, slot)` pairs retained across prune cycles.
sparse_trie_max_hot_slots: usize,
/// LFU hot-account capacity: max account addresses retained across prune cycles.
sparse_trie_max_hot_accounts: usize,
/// When set, blocks whose total processing time (execution + state reads + state root +
/// DB commit) exceeds this duration trigger a structured `warn!` log with detailed timing,
/// state-operation counts, and cache hit-rate metrics. `Duration::ZERO` logs every block.
slow_block_threshold: Option<Duration>,
/// Whether to fully disable sparse trie cache pruning between blocks.
disable_sparse_trie_cache_pruning: bool,
/// Whether to use the arena-based sparse trie implementation.
enable_arena_sparse_trie: bool,
/// Timeout for the state root task before spawning a sequential fallback computation.
/// If `Some`, after waiting this duration for the state root task, a sequential state root
/// computation is spawned in parallel and whichever finishes first is used.
/// If `None`, the timeout fallback is disabled.
state_root_task_timeout: Option<Duration>,
/// Maximum random jitter applied before each proof computation (trie-debug only).
/// When set, each proof worker sleeps for a random duration up to this value
/// before starting a proof calculation.
#[cfg(feature = "trie-debug")]
proof_jitter: Option<Duration>,
}
impl Default for TreeConfig {
@@ -165,9 +183,14 @@ impl Default for TreeConfig {
allow_unwind_canonical_header: false,
disable_cache_metrics: false,
sparse_trie_prune_depth: DEFAULT_SPARSE_TRIE_PRUNE_DEPTH,
sparse_trie_max_storage_tries: DEFAULT_SPARSE_TRIE_MAX_STORAGE_TRIES,
sparse_trie_max_hot_slots: DEFAULT_SPARSE_TRIE_MAX_HOT_SLOTS,
sparse_trie_max_hot_accounts: DEFAULT_SPARSE_TRIE_MAX_HOT_ACCOUNTS,
slow_block_threshold: None,
disable_sparse_trie_cache_pruning: false,
enable_arena_sparse_trie: false,
state_root_task_timeout: Some(DEFAULT_STATE_ROOT_TASK_TIMEOUT),
#[cfg(feature = "trie-debug")]
proof_jitter: None,
}
}
}
@@ -196,7 +219,9 @@ impl TreeConfig {
allow_unwind_canonical_header: bool,
disable_cache_metrics: bool,
sparse_trie_prune_depth: usize,
sparse_trie_max_storage_tries: usize,
sparse_trie_max_hot_slots: usize,
sparse_trie_max_hot_accounts: usize,
slow_block_threshold: Option<Duration>,
state_root_task_timeout: Option<Duration>,
) -> Self {
Self {
@@ -220,9 +245,14 @@ impl TreeConfig {
allow_unwind_canonical_header,
disable_cache_metrics,
sparse_trie_prune_depth,
sparse_trie_max_storage_tries,
sparse_trie_max_hot_slots,
sparse_trie_max_hot_accounts,
slow_block_threshold,
disable_sparse_trie_cache_pruning: false,
enable_arena_sparse_trie: false,
state_root_task_timeout,
#[cfg(feature = "trie-debug")]
proof_jitter: None,
}
}
@@ -471,14 +501,43 @@ impl TreeConfig {
self
}
/// Returns the maximum number of storage tries to retain after pruning.
pub const fn sparse_trie_max_storage_tries(&self) -> usize {
self.sparse_trie_max_storage_tries
/// Returns the LFU hot-slot capacity for sparse trie pruning.
pub const fn sparse_trie_max_hot_slots(&self) -> usize {
self.sparse_trie_max_hot_slots
}
/// Setter for maximum storage tries to retain.
pub const fn with_sparse_trie_max_storage_tries(mut self, max_tries: usize) -> Self {
self.sparse_trie_max_storage_tries = max_tries;
/// Setter for LFU hot-slot capacity.
pub const fn with_sparse_trie_max_hot_slots(mut self, max_hot_slots: usize) -> Self {
self.sparse_trie_max_hot_slots = max_hot_slots;
self
}
/// Returns the LFU hot-account capacity for sparse trie pruning.
pub const fn sparse_trie_max_hot_accounts(&self) -> usize {
self.sparse_trie_max_hot_accounts
}
/// Setter for LFU hot-account capacity.
pub const fn with_sparse_trie_max_hot_accounts(mut self, max_hot_accounts: usize) -> Self {
self.sparse_trie_max_hot_accounts = max_hot_accounts;
self
}
/// Returns the slow block threshold, if configured.
///
/// When `Some`, blocks whose total processing time exceeds this duration emit a structured
/// warning with timing, state-operation, and cache-hit-rate details. `Duration::ZERO` logs
/// every block.
pub const fn slow_block_threshold(&self) -> Option<Duration> {
self.slow_block_threshold
}
/// Setter for slow block threshold.
pub const fn with_slow_block_threshold(
mut self,
slow_block_threshold: Option<Duration>,
) -> Self {
self.slow_block_threshold = slow_block_threshold;
self
}
@@ -493,6 +552,17 @@ impl TreeConfig {
self
}
/// Returns whether the arena-based sparse trie is enabled.
pub const fn enable_arena_sparse_trie(&self) -> bool {
self.enable_arena_sparse_trie
}
/// Setter for whether to enable the arena-based sparse trie.
pub const fn with_enable_arena_sparse_trie(mut self, value: bool) -> Self {
self.enable_arena_sparse_trie = value;
self
}
/// Returns the state root task timeout.
pub const fn state_root_task_timeout(&self) -> Option<Duration> {
self.state_root_task_timeout
@@ -503,4 +573,17 @@ impl TreeConfig {
self.state_root_task_timeout = timeout;
self
}
/// Returns the proof jitter duration, if configured (trie-debug only).
#[cfg(feature = "trie-debug")]
pub const fn proof_jitter(&self) -> Option<Duration> {
self.proof_jitter
}
/// Setter for proof jitter (trie-debug only).
#[cfg(feature = "trie-debug")]
pub const fn with_proof_jitter(mut self, proof_jitter: Option<Duration>) -> Self {
self.proof_jitter = proof_jitter;
self
}
}

View File

@@ -9,7 +9,7 @@ use core::{
fmt::{Display, Formatter, Result},
time::Duration,
};
use reth_chain_state::ExecutedBlock;
use reth_chain_state::{ExecutedBlock, ExecutionTimingStats};
use reth_ethereum_primitives::EthPrimitives;
use reth_primitives_traits::{NodePrimitives, SealedBlock, SealedHeader};
@@ -32,6 +32,8 @@ pub enum ConsensusEngineEvent<N: NodePrimitives = EthPrimitives> {
CanonicalChainCommitted(Box<SealedHeader<N::BlockHeader>>, Duration),
/// The consensus engine processed an invalid block.
InvalidBlock(Box<SealedBlock<N::Block>>),
/// A slow block was detected after persistence, with its timing statistics.
SlowBlock(SlowBlockInfo),
}
impl<N: NodePrimitives> ConsensusEngineEvent<N> {
@@ -73,6 +75,25 @@ where
Self::BlockReceived(num_hash) => {
write!(f, "BlockReceived({num_hash:?})")
}
Self::SlowBlock(info) => {
write!(
f,
"SlowBlock(block={}, total={:?})",
info.stats.block_number, info.total_duration
)
}
}
}
}
/// Information about a slow block detected after persistence.
#[derive(Clone, Debug)]
pub struct SlowBlockInfo {
/// The timing statistics for the slow block.
pub stats: Box<ExecutionTimingStats>,
/// The commit duration for the batch containing this block.
pub commit_duration: Duration,
/// The total duration (execution + `state_root` + commit).
/// Note: `state_read` is a subset of execution and is not added separately.
pub total_duration: Duration,
}

View File

@@ -100,7 +100,6 @@ revm-state.workspace = true
assert_matches.workspace = true
eyre.workspace = true
serde_json.workspace = true
crossbeam-channel.workspace = true
proptest.workspace = true
rand.workspace = true
rand_08.workspace = true
@@ -134,14 +133,12 @@ test-utils = [
"reth-evm-ethereum/test-utils",
"reth-tasks/test-utils",
]
trie-debug = ["reth-trie-sparse/trie-debug", "dep:serde_json"]
rocksdb = [
"reth-provider/rocksdb",
"reth-prune/rocksdb",
"reth-stages?/rocksdb",
"reth-e2e-test-utils/rocksdb",
trie-debug = [
"reth-trie-sparse/trie-debug",
"reth-trie-parallel/trie-debug",
"reth-engine-primitives/trie-debug",
"dep:serde_json",
]
edge = ["rocksdb"]
[[test]]
name = "e2e_testsuite"

View File

@@ -18,10 +18,20 @@ use std::{
Arc,
},
thread::JoinHandle,
time::Duration,
};
use thiserror::Error;
use tracing::{debug, error, instrument};
/// Unified result of any persistence operation.
#[derive(Debug)]
pub struct PersistenceResult {
/// The last block that was persisted, if any.
pub last_block: Option<BlockNumHash>,
/// The commit duration, only available for save-blocks operations.
pub commit_duration: Option<Duration>,
}
/// Writes parts of reth's in memory tree state to the database and static files.
///
/// This is meant to be a spawned service that listens for various incoming persistence operations,
@@ -86,18 +96,16 @@ where
while let Ok(action) = self.incoming.recv() {
match action {
PersistenceAction::RemoveBlocksAbove(new_tip_num, sender) => {
let result = self.on_remove_blocks_above(new_tip_num)?;
let last_block = self.on_remove_blocks_above(new_tip_num)?;
// send new sync metrics based on removed blocks
let _ =
self.sync_metrics_tx.send(MetricEvent::SyncHeight { height: new_tip_num });
// we ignore the error because the caller may or may not care about the result
let _ = sender.send(result);
let _ = sender.send(PersistenceResult { last_block, commit_duration: None });
}
PersistenceAction::SaveBlocks(blocks, sender) => {
let result = self.on_save_blocks(blocks)?;
let result_number = result.map(|r| r.number);
let result_number = result.last_block.map(|b| b.number);
// we ignore the error because the caller may or may not care about the result
let _ = sender.send(result);
if let Some(block_number) = result_number {
@@ -140,7 +148,7 @@ where
fn on_save_blocks(
&mut self,
blocks: Vec<ExecutedBlock<N::Primitives>>,
) -> Result<Option<BlockNumHash>, PersistenceError> {
) -> Result<PersistenceResult, PersistenceError> {
let first_block = blocks.first().map(|b| b.recovered_block.num_hash());
let last_block = blocks.last().map(|b| b.recovered_block.num_hash());
let block_count = blocks.len();
@@ -157,8 +165,6 @@ where
provider_rw.save_blocks(blocks, SaveBlocksMode::Full)?;
if let Some(finalized) = pending_finalized {
// Clamp to the highest persisted block so that on restart
// `last_finalized_block_number` never points past available state.
provider_rw.save_finalized_block_number(finalized.min(last.number))?;
if finalized > last.number {
self.pending_finalized_block = Some(finalized);
@@ -183,10 +189,11 @@ where
debug!(target: "engine::persistence", first=?first_block, last=?last_block, "Saved range of blocks");
let elapsed = start_time.elapsed();
self.metrics.save_blocks_batch_size.record(block_count as f64);
self.metrics.save_blocks_duration_seconds.record(start_time.elapsed());
self.metrics.save_blocks_duration_seconds.record(elapsed);
Ok(last_block)
Ok(PersistenceResult { last_block, commit_duration: Some(elapsed) })
}
}
@@ -210,13 +217,13 @@ pub enum PersistenceAction<N: NodePrimitives = EthPrimitives> {
///
/// First, header, transaction, and receipt-related data should be written to static files.
/// Then the execution history-related data will be written to the database.
SaveBlocks(Vec<ExecutedBlock<N>>, CrossbeamSender<Option<BlockNumHash>>),
SaveBlocks(Vec<ExecutedBlock<N>>, CrossbeamSender<PersistenceResult>),
/// Removes block data above the given block number from the database.
///
/// This will first update checkpoints from the database, then remove actual block data from
/// static files.
RemoveBlocksAbove(u64, CrossbeamSender<Option<BlockNumHash>>),
RemoveBlocksAbove(u64, CrossbeamSender<PersistenceResult>),
/// Update the persisted finalized block on disk
SaveFinalizedBlock(u64),
@@ -295,7 +302,7 @@ impl<T: NodePrimitives> PersistenceHandle<T> {
pub fn save_blocks(
&self,
blocks: Vec<ExecutedBlock<T>>,
tx: CrossbeamSender<Option<BlockNumHash>>,
tx: CrossbeamSender<PersistenceResult>,
) -> Result<(), SendError<PersistenceAction<T>>> {
self.send_action(PersistenceAction::SaveBlocks(blocks, tx))
}
@@ -330,7 +337,7 @@ impl<T: NodePrimitives> PersistenceHandle<T> {
pub fn remove_blocks_above(
&self,
block_num: u64,
tx: CrossbeamSender<Option<BlockNumHash>>,
tx: CrossbeamSender<PersistenceResult>,
) -> Result<(), SendError<PersistenceAction<T>>> {
self.send_action(PersistenceAction::RemoveBlocksAbove(block_num, tx))
}
@@ -390,8 +397,8 @@ mod tests {
handle.save_blocks(blocks, tx).unwrap();
let hash = rx.recv().unwrap();
assert_eq!(hash, None);
let result = rx.recv().unwrap();
assert!(result.last_block.is_none());
}
#[test]
@@ -409,12 +416,9 @@ mod tests {
handle.save_blocks(blocks, tx).unwrap();
let BlockNumHash { hash: actual_hash, number: _ } = rx
.recv_timeout(std::time::Duration::from_secs(10))
.expect("test timed out")
.expect("no hash returned");
let result = rx.recv_timeout(std::time::Duration::from_secs(10)).expect("test timed out");
assert_eq!(block_hash, actual_hash);
assert_eq!(block_hash, result.last_block.unwrap().hash);
}
#[test]
@@ -428,8 +432,8 @@ mod tests {
let (tx, rx) = crossbeam_channel::bounded(1);
handle.save_blocks(blocks, tx).unwrap();
let BlockNumHash { hash: actual_hash, number: _ } = rx.recv().unwrap().unwrap();
assert_eq!(last_hash, actual_hash);
let result = rx.recv().unwrap();
assert_eq!(last_hash, result.last_block.unwrap().hash);
}
#[test]
@@ -446,8 +450,8 @@ mod tests {
handle.save_blocks(blocks, tx).unwrap();
let BlockNumHash { hash: actual_hash, number: _ } = rx.recv().unwrap().unwrap();
assert_eq!(last_hash, actual_hash);
let result = rx.recv().unwrap();
assert_eq!(last_hash, result.last_block.unwrap().hash);
}
}
}

View File

@@ -18,9 +18,7 @@ use reth_trie::{
updates::TrieUpdates, AccountProof, HashedPostState, HashedStorage, MultiProof,
MultiProofTargets, StorageMultiProof, StorageProof, TrieInput,
};
use revm_primitives::eip7907::MAX_CODE_SIZE;
use std::{
mem::size_of,
sync::{
atomic::{AtomicU64, AtomicUsize, Ordering},
Arc,
@@ -56,8 +54,17 @@ const fn fixed_cache_key_size_with_value<K>(value: usize) -> usize {
raw_size.div_ceil(FIXED_CACHE_ALIGNMENT) * FIXED_CACHE_ALIGNMENT
}
/// Size in bytes of a single code cache entry.
const CODE_CACHE_ENTRY_SIZE: usize = fixed_cache_key_size_with_value::<Address>(MAX_CODE_SIZE);
/// Estimated average bytecode size for cache budget calculation.
///
/// The fixed-cache stores `Option<Bytecode>` inline (pointer-sized), but each cached contract
/// also holds bytecode on the heap. For budget estimation we use 8 KiB, which is close to the
/// observed mainnet average (~7 KiB). Using `MAX_CODE_SIZE` (48 KiB) overestimates by ~7x,
/// yielding only 4096 entries for a 228 MB code-cache budget when 16384 fit comfortably.
const ESTIMATED_AVG_CODE_SIZE: usize = 8 * 1024;
/// Size in bytes of a single code cache entry (inline metadata + estimated heap).
const CODE_CACHE_ENTRY_SIZE: usize =
fixed_cache_key_size_with_value::<Address>(ESTIMATED_AVG_CODE_SIZE);
/// Size in bytes of a single storage cache entry.
const STORAGE_CACHE_ENTRY_SIZE: usize =
@@ -94,6 +101,10 @@ pub struct CachedStateProvider<S, const PREWARM: bool = false> {
/// Metrics for the cached state provider
metrics: CachedStateMetrics,
/// Optional cache statistics for detailed block logging. Only tracked when slow block
/// threshold is configured.
cache_stats: Option<Arc<CacheStats>>,
}
impl<S> CachedStateProvider<S> {
@@ -104,7 +115,7 @@ impl<S> CachedStateProvider<S> {
caches: ExecutionCache,
metrics: CachedStateMetrics,
) -> Self {
Self { state_provider, caches, metrics }
Self { state_provider, caches, metrics, cache_stats: None }
}
}
@@ -115,14 +126,28 @@ impl<S> CachedStateProvider<S, true> {
caches: ExecutionCache,
metrics: CachedStateMetrics,
) -> Self {
Self { state_provider, caches, metrics }
Self { state_provider, caches, metrics, cache_stats: None }
}
}
/// Metrics for the cached state provider, showing hits / misses / size for each cache.
///
/// This struct combines both the provider-level metrics (hits/misses tracked by the provider)
/// and the fixed-cache internal stats (collisions, size, capacity).
impl<S, const PREWARM: bool> CachedStateProvider<S, PREWARM> {
/// Enables cache statistics tracking for detailed block logging.
pub fn with_cache_stats(mut self, stats: Option<Arc<CacheStats>>) -> Self {
self.cache_stats = stats;
self
}
}
/// Represents the status of a key in the cache.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CachedStatus<T> {
/// The key is not in the cache (or was invalidated). The value was recalculated.
NotCached(T),
/// The key exists in cache and has a specific value.
Cached(T),
}
/// Metrics for the cached state provider, showing hits / misses for each cache
#[derive(Metrics, Clone)]
#[metrics(scope = "sync.caching")]
pub struct CachedStateMetrics {
@@ -211,6 +236,73 @@ impl CachedStateMetrics {
}
}
/// Cache hit/miss statistics for detailed block logging.
#[derive(Debug, Default)]
pub struct CacheStats {
/// Account cache hits
account_hits: AtomicUsize,
/// Account cache misses
account_misses: AtomicUsize,
/// Storage cache hits
storage_hits: AtomicUsize,
/// Storage cache misses
storage_misses: AtomicUsize,
/// Code cache hits
code_hits: AtomicUsize,
/// Code cache misses
code_misses: AtomicUsize,
}
impl CacheStats {
pub(crate) fn record_account_hit(&self) {
self.account_hits.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn record_account_miss(&self) {
self.account_misses.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn account_hits(&self) -> usize {
self.account_hits.load(Ordering::Relaxed)
}
pub(crate) fn account_misses(&self) -> usize {
self.account_misses.load(Ordering::Relaxed)
}
pub(crate) fn record_storage_hit(&self) {
self.storage_hits.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn record_storage_miss(&self) {
self.storage_misses.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn storage_hits(&self) -> usize {
self.storage_hits.load(Ordering::Relaxed)
}
pub(crate) fn storage_misses(&self) -> usize {
self.storage_misses.load(Ordering::Relaxed)
}
pub(crate) fn record_code_hit(&self) {
self.code_hits.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn record_code_miss(&self) {
self.code_misses.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn code_hits(&self) -> usize {
self.code_hits.load(Ordering::Relaxed)
}
pub(crate) fn code_misses(&self) -> usize {
self.code_misses.load(Ordering::Relaxed)
}
}
/// A stats handler for fixed-cache that tracks collisions and size.
///
/// Note: Hits and misses are tracked directly by the [`CachedStateProvider`] via
@@ -306,27 +398,36 @@ impl<S: AccountReader, const PREWARM: bool> AccountReader for CachedStateProvide
match self.caches.get_or_try_insert_account_with(*address, || {
self.state_provider.basic_account(address)
})? {
CachedStatus::NotCached(value) | CachedStatus::Cached(value) => Ok(value),
// During prewarm we only record stats (not prometheus metrics)
CachedStatus::NotCached(value) => {
if let Some(stats) = &self.cache_stats {
stats.record_account_miss();
}
Ok(value)
}
CachedStatus::Cached(value) => {
if let Some(stats) = &self.cache_stats {
stats.record_account_hit();
}
Ok(value)
}
}
} else if let Some(account) = self.caches.0.account_cache.get(address) {
self.metrics.account_cache_hits.increment(1);
if let Some(stats) = &self.cache_stats {
stats.record_account_hit();
}
Ok(account)
} else {
self.metrics.account_cache_misses.increment(1);
if let Some(stats) = &self.cache_stats {
stats.record_account_miss();
}
self.state_provider.basic_account(address)
}
}
}
/// Represents the status of a key in the cache.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CachedStatus<T> {
/// The key is not in the cache (or was invalidated). The value was recalculated.
NotCached(T),
/// The key exists in cache and has a specific value.
Cached(T),
}
impl<S: StateProvider, const PREWARM: bool> StateProvider for CachedStateProvider<S, PREWARM> {
fn storage(
&self,
@@ -337,17 +438,31 @@ impl<S: StateProvider, const PREWARM: bool> StateProvider for CachedStateProvide
match self.caches.get_or_try_insert_storage_with(account, storage_key, || {
self.state_provider.storage(account, storage_key).map(Option::unwrap_or_default)
})? {
CachedStatus::NotCached(value) | CachedStatus::Cached(value) => {
// The slot that was never written to is indistinguishable from a slot
// explicitly set to zero. We return `None` in both cases.
// During prewarm we only record stats (not prometheus metrics)
CachedStatus::NotCached(value) => {
if let Some(stats) = &self.cache_stats {
stats.record_storage_miss();
}
Ok(Some(value).filter(|v| !v.is_zero()))
}
CachedStatus::Cached(value) => {
if let Some(stats) = &self.cache_stats {
stats.record_storage_hit();
}
Ok(Some(value).filter(|v| !v.is_zero()))
}
}
} else if let Some(value) = self.caches.0.storage_cache.get(&(account, storage_key)) {
self.metrics.storage_cache_hits.increment(1);
if let Some(stats) = &self.cache_stats {
stats.record_storage_hit();
}
Ok(Some(value).filter(|v| !v.is_zero()))
} else {
self.metrics.storage_cache_misses.increment(1);
if let Some(stats) = &self.cache_stats {
stats.record_storage_miss();
}
self.state_provider.storage(account, storage_key)
}
}
@@ -359,13 +474,31 @@ impl<S: BytecodeReader, const PREWARM: bool> BytecodeReader for CachedStateProvi
match self.caches.get_or_try_insert_code_with(*code_hash, || {
self.state_provider.bytecode_by_hash(code_hash)
})? {
CachedStatus::NotCached(code) | CachedStatus::Cached(code) => Ok(code),
// During prewarm we only record stats (not prometheus metrics)
CachedStatus::NotCached(code) => {
if let Some(stats) = &self.cache_stats {
stats.record_code_miss();
}
Ok(code)
}
CachedStatus::Cached(code) => {
if let Some(stats) = &self.cache_stats {
stats.record_code_hit();
}
Ok(code)
}
}
} else if let Some(code) = self.caches.0.code_cache.get(code_hash) {
self.metrics.code_cache_hits.increment(1);
if let Some(stats) = &self.cache_stats {
stats.record_code_hit();
}
Ok(code)
} else {
self.metrics.code_cache_misses.increment(1);
if let Some(stats) = &self.cache_stats {
stats.record_code_miss();
}
self.state_provider.bytecode_by_hash(code_hash)
}
}
@@ -707,7 +840,8 @@ impl ExecutionCache {
}
self.0.account_cache.remove(addr);
continue
self.0.account_stats.decrement_size();
continue;
}
// If we have an account that was modified, but it has a `None` account info, some wild
@@ -837,8 +971,10 @@ impl SavedCache {
self.caches.update_metrics(&self.metrics);
}
/// Clears all caches, resetting them to empty state.
pub(crate) fn clear(&self) {
/// Clears all caches, resetting them to empty state,
/// and updates the hash of the block this cache belongs to.
pub(crate) fn clear_with_hash(&mut self, hash: B256) {
self.hash = hash;
self.caches.clear();
}
}
@@ -1085,4 +1221,20 @@ mod tests {
assert!(caches.0.account_cache.get(&addr1).is_none());
assert!(caches.0.account_cache.get(&addr2).is_some());
}
#[test]
fn test_code_cache_capacity_with_default_budget() {
// Default cross-block cache is 4 GB; code gets 5.56% = ~228 MB.
let total_cache_size = 4 * 1024 * 1024 * 1024; // 4 GB
let code_budget = (total_cache_size * 556) / 10000; // 228 MB
let capacity = ExecutionCache::bytes_to_entries(code_budget, CODE_CACHE_ENTRY_SIZE);
// With ESTIMATED_AVG_CODE_SIZE (8 KiB) we expect 16384 entries.
// If someone accidentally reverts to MAX_CODE_SIZE (48 KiB), this would drop to 4096.
assert_eq!(
capacity, 16384,
"code cache should have 16384 entries with default 4 GB budget"
);
}
}

View File

@@ -13,7 +13,10 @@ use reth_trie::{
MultiProofTargets, StorageMultiProof, StorageProof, TrieInput,
};
use std::{
sync::atomic::{AtomicU64, Ordering},
sync::{
atomic::{AtomicU64, AtomicUsize, Ordering},
Arc,
},
time::Duration,
};
@@ -33,11 +36,6 @@ pub(crate) struct AtomicDuration {
}
impl AtomicDuration {
/// Returns a zero duration.
pub(crate) const fn zero() -> Self {
Self { nanos: AtomicU64::new(0) }
}
/// Returns the duration as a [`Duration`]
pub(crate) fn duration(&self) -> Duration {
let nanos = self.nanos.load(Ordering::Relaxed);
@@ -63,18 +61,10 @@ impl AtomicDuration {
pub struct InstrumentedStateProvider<S> {
/// The state provider
state_provider: S,
/// Metrics for the instrumented state provider
/// Prometheus metrics for the instrumented state provider
metrics: StateProviderMetrics,
/// The total time we spend fetching storage over the lifetime of this state provider
total_storage_fetch_latency: AtomicDuration,
/// The total time we spend fetching code over the lifetime of this state provider
total_code_fetch_latency: AtomicDuration,
/// The total time we spend fetching accounts over the lifetime of this state provider
total_account_fetch_latency: AtomicDuration,
/// Shared fetch statistics, readable after the provider is consumed.
stats: Arc<StateProviderStats>,
}
impl<S> InstrumentedStateProvider<S>
@@ -87,58 +77,33 @@ where
Self {
state_provider,
metrics: StateProviderMetrics::new_with_labels(&[("source", source)]),
total_storage_fetch_latency: AtomicDuration::zero(),
total_code_fetch_latency: AtomicDuration::zero(),
total_account_fetch_latency: AtomicDuration::zero(),
stats: Arc::new(StateProviderStats::default()),
}
}
}
impl<S> InstrumentedStateProvider<S> {
/// Records the latency for a storage fetch, and increments the duration counter for the storage
/// fetch.
fn record_storage_fetch(&self, latency: Duration) {
self.metrics.storage_fetch_latency.record(latency);
self.total_storage_fetch_latency.add_duration(latency);
}
/// Records the latency for a code fetch, and increments the duration counter for the code
/// fetch.
fn record_code_fetch(&self, latency: Duration) {
self.metrics.code_fetch_latency.record(latency);
self.total_code_fetch_latency.add_duration(latency);
}
/// Records the latency for an account fetch, and increments the duration counter for the
/// account fetch.
fn record_account_fetch(&self, latency: Duration) {
self.metrics.account_fetch_latency.record(latency);
self.total_account_fetch_latency.add_duration(latency);
}
/// Records the total latencies into their respective gauges and histograms.
pub(crate) fn record_total_latency(&self) {
let total_storage_fetch_latency = self.total_storage_fetch_latency.duration();
self.metrics.total_storage_fetch_latency.record(total_storage_fetch_latency);
self.metrics
.total_storage_fetch_latency_gauge
.set(total_storage_fetch_latency.as_secs_f64());
let total_code_fetch_latency = self.total_code_fetch_latency.duration();
self.metrics.total_code_fetch_latency.record(total_code_fetch_latency);
self.metrics.total_code_fetch_latency_gauge.set(total_code_fetch_latency.as_secs_f64());
let total_account_fetch_latency = self.total_account_fetch_latency.duration();
self.metrics.total_account_fetch_latency.record(total_account_fetch_latency);
self.metrics
.total_account_fetch_latency_gauge
.set(total_account_fetch_latency.as_secs_f64());
/// Returns a shared reference to the accumulated fetch statistics.
pub fn stats(&self) -> Arc<StateProviderStats> {
Arc::clone(&self.stats)
}
}
impl<S> Drop for InstrumentedStateProvider<S> {
fn drop(&mut self) {
self.record_total_latency();
let total_storage_fetch_latency = self.stats.total_storage_fetch_latency.duration();
self.metrics.total_storage_fetch_latency.record(total_storage_fetch_latency);
self.metrics
.total_storage_fetch_latency_gauge
.set(total_storage_fetch_latency.as_secs_f64());
let total_code_fetch_latency = self.stats.total_code_fetch_latency.duration();
self.metrics.total_code_fetch_latency.record(total_code_fetch_latency);
self.metrics.total_code_fetch_latency_gauge.set(total_code_fetch_latency.as_secs_f64());
let total_account_fetch_latency = self.stats.total_account_fetch_latency.duration();
self.metrics.total_account_fetch_latency.record(total_account_fetch_latency);
self.metrics
.total_account_fetch_latency_gauge
.set(total_account_fetch_latency.as_secs_f64());
}
}
@@ -183,7 +148,10 @@ impl<S: AccountReader> AccountReader for InstrumentedStateProvider<S> {
fn basic_account(&self, address: &Address) -> ProviderResult<Option<Account>> {
let start = Instant::now();
let res = self.state_provider.basic_account(address);
self.record_account_fetch(start.elapsed());
let elapsed = start.elapsed();
self.metrics.account_fetch_latency.record(elapsed);
self.stats.total_account_fetches.fetch_add(1, Ordering::Relaxed);
self.stats.total_account_fetch_latency.add_duration(elapsed);
res
}
}
@@ -196,7 +164,10 @@ impl<S: StateProvider> StateProvider for InstrumentedStateProvider<S> {
) -> ProviderResult<Option<StorageValue>> {
let start = Instant::now();
let res = self.state_provider.storage(account, storage_key);
self.record_storage_fetch(start.elapsed());
let elapsed = start.elapsed();
self.metrics.storage_fetch_latency.record(elapsed);
self.stats.total_storage_fetches.fetch_add(1, Ordering::Relaxed);
self.stats.total_storage_fetch_latency.add_duration(elapsed);
res
}
}
@@ -205,7 +176,17 @@ impl<S: BytecodeReader> BytecodeReader for InstrumentedStateProvider<S> {
fn bytecode_by_hash(&self, code_hash: &B256) -> ProviderResult<Option<Bytecode>> {
let start = Instant::now();
let res = self.state_provider.bytecode_by_hash(code_hash);
self.record_code_fetch(start.elapsed());
let elapsed = start.elapsed();
self.metrics.code_fetch_latency.record(elapsed);
self.stats.total_code_fetches.fetch_add(1, Ordering::Relaxed);
self.stats.total_code_fetch_latency.add_duration(elapsed);
self.stats.total_code_fetched_bytes.fetch_add(
res.as_ref()
.ok()
.and_then(|code| code.as_ref().map(|code| code.len()))
.unwrap_or_default(),
Ordering::Relaxed,
);
res
}
}
@@ -308,3 +289,56 @@ impl<S: HashedPostStateProvider> HashedPostStateProvider for InstrumentedStatePr
self.state_provider.hashed_post_state(bundle_state)
}
}
/// Accumulated fetch statistics from an [`InstrumentedStateProvider`].
///
/// Shared via `Arc` so statistics can be read after the provider is consumed.
#[derive(Debug, Default)]
pub struct StateProviderStats {
total_storage_fetches: AtomicUsize,
total_storage_fetch_latency: AtomicDuration,
total_code_fetches: AtomicUsize,
total_code_fetch_latency: AtomicDuration,
total_code_fetched_bytes: AtomicUsize,
total_account_fetches: AtomicUsize,
total_account_fetch_latency: AtomicDuration,
}
impl StateProviderStats {
/// Returns total number of storage fetches.
pub fn total_storage_fetches(&self) -> usize {
self.total_storage_fetches.load(Ordering::Relaxed)
}
/// Returns total time spent on storage fetches.
pub fn total_storage_fetch_latency(&self) -> Duration {
self.total_storage_fetch_latency.duration()
}
/// Returns total number of code fetches.
pub fn total_code_fetches(&self) -> usize {
self.total_code_fetches.load(Ordering::Relaxed)
}
/// Returns total time spent on code fetches.
pub fn total_code_fetch_latency(&self) -> Duration {
self.total_code_fetch_latency.duration()
}
/// Returns total amount of code fetched, in bytes.
pub fn total_code_fetched_bytes(&self) -> usize {
self.total_code_fetched_bytes.load(Ordering::Relaxed)
}
/// Returns total number of account fetches.
pub fn total_account_fetches(&self) -> usize {
self.total_account_fetches.load(Ordering::Relaxed)
}
/// Returns total time spent on account fetches.
pub fn total_account_fetch_latency(&self) -> Duration {
self.total_account_fetch_latency.duration()
}
}

View File

@@ -13,13 +13,13 @@ use alloy_rpc_types_engine::{
};
use error::{InsertBlockError, InsertBlockFatalError};
use reth_chain_state::{
CanonicalInMemoryState, ComputedTrieData, ExecutedBlock, MemoryOverlayStateProvider,
NewCanonicalChain,
CanonicalInMemoryState, ComputedTrieData, ExecutedBlock, ExecutionTimingStats,
MemoryOverlayStateProvider, NewCanonicalChain,
};
use reth_consensus::{Consensus, FullConsensus};
use reth_engine_primitives::{
BeaconEngineMessage, BeaconOnNewPayloadError, ConsensusEngineEvent, ExecutionPayload,
ForkchoiceStateTracker, NewPayloadTimings, OnForkChoiceUpdated,
ForkchoiceStateTracker, NewPayloadTimings, OnForkChoiceUpdated, SlowBlockInfo,
};
use reth_errors::{ConsensusError, ProviderResult};
use reth_evm::ConfigureEvm;
@@ -42,7 +42,7 @@ use reth_tasks::{spawn_os_thread, utils::increase_thread_priority};
use reth_trie_db::ChangesetCache;
use revm::interpreter::debug_unreachable;
use state::TreeState;
use std::{fmt::Debug, ops, sync::Arc, time::Duration};
use std::{collections::HashMap, fmt::Debug, ops, sync::Arc, time::Duration};
use crossbeam_channel::{Receiver, Sender};
use tokio::sync::{
@@ -65,7 +65,7 @@ pub mod precompile_cache;
mod tests;
mod trie_updates;
use crate::tree::error::AdvancePersistenceError;
use crate::{persistence::PersistenceResult, tree::error::AdvancePersistenceError};
pub use block_buffer::BlockBuffer;
pub use cached_state::{CachedStateMetrics, CachedStateProvider, ExecutionCache, SavedCache};
pub use invalid_headers::InvalidHeaderCache;
@@ -273,6 +273,10 @@ where
evm_config: C,
/// Changeset cache for in-memory trie changesets
changeset_cache: ChangesetCache,
/// Timing statistics for executed blocks, keyed by block hash.
/// Stored here (not in `ExecutedBlock`) to avoid leaking observability concerns into the block
/// type. Entries are removed when blocks are persisted or invalidated.
execution_timing_stats: HashMap<B256, Box<ExecutionTimingStats>>,
/// Whether the node uses hashed state as canonical storage (v2 mode).
/// Cached at construction to avoid threading `StorageSettingsCache` bounds everywhere.
use_hashed_state: bool,
@@ -303,6 +307,7 @@ where
.field("engine_kind", &self.engine_kind)
.field("evm_config", &self.evm_config)
.field("changeset_cache", &self.changeset_cache)
.field("execution_timing_stats", &self.execution_timing_stats.len())
.field("use_hashed_state", &self.use_hashed_state)
.field("runtime", &self.runtime)
.finish()
@@ -367,6 +372,7 @@ where
engine_kind,
evm_config,
changeset_cache,
execution_timing_stats: HashMap::new(),
use_hashed_state,
runtime,
}
@@ -503,8 +509,8 @@ where
recv(persistence_rx) -> result => {
// Don't put it back - consumed (oneshot-like behavior)
match result {
Ok(value) => LoopEvent::PersistenceComplete {
result: value,
Ok(result) => LoopEvent::PersistenceComplete {
result,
start_time,
},
Err(_) => LoopEvent::Disconnected,
@@ -1369,15 +1375,16 @@ where
/// Handles a completed persistence task.
fn on_persistence_complete(
&mut self,
last_persisted_hash_num: Option<BlockNumHash>,
result: PersistenceResult,
start_time: Instant,
) -> Result<(), AdvancePersistenceError> {
self.metrics.engine.persistence_duration.record(start_time.elapsed());
let commit_duration = result.commit_duration;
let Some(BlockNumHash {
hash: last_persisted_block_hash,
number: last_persisted_block_number,
}) = last_persisted_hash_num
}) = result.last_block
else {
// if this happened, then we persisted no blocks because we sent an empty vec of blocks
warn!(target: "engine::tree", "Persistence task completed but did not persist any blocks");
@@ -1423,6 +1430,8 @@ where
});
}
self.purge_timing_stats(last_persisted_block_number, commit_duration);
Ok(())
}
@@ -1728,6 +1737,7 @@ where
// remove all buffered blocks below the backfill height
self.state.buffer.remove_old_blocks(backfill_height);
self.purge_timing_stats(backfill_height, None);
// we remove all entries because now we're synced to the backfill target and consider this
// the canonical chain
self.canonical_in_memory_state.clear_state();
@@ -1861,6 +1871,43 @@ where
Ok(())
}
/// Removes timing stats for blocks at or below `below_number`.
///
/// No-op when detailed block logging is disabled (no stats are recorded in that case).
/// When `commit_duration` is provided and a slow block threshold is configured, checks
/// each removed block against the threshold and emits a [`ConsensusEngineEvent::SlowBlock`]
/// event for blocks that exceed it.
fn purge_timing_stats(&mut self, below_number: u64, commit_duration: Option<Duration>) {
let threshold = self.config.slow_block_threshold();
let check_slow = commit_duration.is_some() && threshold.is_some();
// Two-pass: collect keys first because emit_event borrows &mut self.
let keys_to_remove: Vec<B256> = self
.execution_timing_stats
.iter()
.filter(|(_, stats)| stats.block_number <= below_number)
.map(|(k, _)| *k)
.collect();
for key in keys_to_remove {
let stats = self.execution_timing_stats.remove(&key).expect("key just found");
if check_slow {
let commit_dur = commit_duration.expect("checked above");
// state_read_duration is already included in execution_duration
let total_duration =
stats.execution_duration + stats.state_hash_duration + commit_dur;
if total_duration > threshold.expect("checked above") {
self.emit_event(ConsensusEngineEvent::SlowBlock(SlowBlockInfo {
stats,
commit_duration: commit_dur,
total_duration,
}));
}
}
}
}
/// Emits an outgoing event to the engine.
fn emit_event(&mut self, event: impl Into<EngineApiEvent<N>>) {
let event = event.into();
@@ -2173,18 +2220,26 @@ where
/// Finds any invalid ancestor for the given payload.
///
/// This function walks up the chain of buffered ancestors from the payload's block
/// hash and checks if any ancestor is marked as invalid in the tree state.
/// This function first checks if the block itself is in the invalid headers cache (to
/// avoid re-executing a known-invalid block). Then it walks up the chain of buffered
/// ancestors and checks if any ancestor is marked as invalid.
///
/// The check works by:
/// 1. Finding the lowest buffered ancestor for the given block hash
/// 2. If the ancestor is the same as the block hash itself, using the parent hash instead
/// 3. Checking if this ancestor is in the `invalid_headers` map
/// 1. Checking if the block hash itself is in the `invalid_headers` map
/// 2. Finding the lowest buffered ancestor for the given block hash
/// 3. If the ancestor is the same as the block hash itself, using the parent hash instead
/// 4. Checking if this ancestor is in the `invalid_headers` map
///
/// Returns the invalid ancestor block info if found, or None if no invalid ancestor exists.
fn find_invalid_ancestor(&mut self, payload: &T::ExecutionData) -> Option<BlockWithParent> {
let parent_hash = payload.parent_hash();
let block_hash = payload.block_hash();
// Check if the block itself is already known to be invalid, avoiding re-execution
if let Some(entry) = self.state.invalid_headers.get(&block_hash) {
return Some(entry);
}
let mut lowest_buffered_ancestor = self.lowest_buffered_ancestor_or(block_hash);
if lowest_buffered_ancestor == block_hash {
lowest_buffered_ancestor = parent_hash;
@@ -2736,7 +2791,12 @@ where
&mut self,
block_id: BlockWithParent,
input: Input,
execute: impl FnOnce(&mut V, Input, TreeCtx<'_, N>) -> Result<ExecutedBlock<N>, Err>,
execute: impl FnOnce(
&mut V,
Input,
TreeCtx<'_, N>,
)
-> Result<(ExecutedBlock<N>, Option<Box<ExecutionTimingStats>>), Err>,
convert_to_block: impl FnOnce(&mut Self, Input) -> Result<SealedBlock<N::Block>, Err>,
) -> Result<InsertPayloadOk, Err>
where
@@ -2806,7 +2866,12 @@ where
let start = Instant::now();
let executed = execute(&mut self.payload_validator, input, ctx)?;
let (executed, timing_stats) = execute(&mut self.payload_validator, input, ctx)?;
// Store timing stats for detailed block logging after persistence
if let Some(stats) = timing_stats {
self.execution_timing_stats.insert(executed.recovered_block().hash(), stats);
}
// if the parent is the canonical head, we can insert the block as the pending block
if self.state.tree_state.canonical_block_hash() == executed.recovered_block().parent_hash()
@@ -3128,8 +3193,8 @@ where
EngineMessage(FromEngine<EngineApiRequest<T, N>, N::Block>),
/// A persistence task completed.
PersistenceComplete {
/// The result of the persistence operation.
result: Option<BlockNumHash>,
/// The unified result of the persistence operation.
result: PersistenceResult,
/// When the persistence operation started.
start_time: Instant,
},

View File

@@ -38,9 +38,7 @@ use reth_trie_parallel::{
proof_task::{ProofTaskCtx, ProofWorkerHandle},
root::ParallelStateRootError,
};
use reth_trie_sparse::{
ParallelSparseTrie, ParallelismThresholds, RevealableSparseTrie, SparseStateTrie,
};
use reth_trie_sparse::ParallelismThresholds;
use std::{
ops::Not,
sync::{
@@ -54,19 +52,22 @@ use tracing::{debug, debug_span, instrument, warn, Span};
pub mod bal;
pub mod multiproof;
pub mod post_exec;
mod preserved_sparse_trie;
pub mod prewarm;
pub mod receipt_root_task;
pub mod sparse_trie;
use preserved_sparse_trie::{PreservedSparseTrie, SharedPreservedSparseTrie};
pub use preserved_sparse_trie::{
PayloadSparseTrieCache, PayloadSparseTrieKind, PayloadSparseTrieStoreOutcome,
SparseTrieCheckout,
};
/// Default parallelism thresholds to use with the [`ParallelSparseTrie`].
///
/// These values were determined by performing benchmarks using gradually increasing values to judge
/// the affects. Below 100 throughput would generally be equal or slightly less, while above 150 it
/// the effects. Below 100 throughput would generally be equal or slightly less, while above 150 it
/// would deteriorate to the point where PST might as well not be used.
pub const PARALLEL_SPARSE_TRIE_PARALLELISM_THRESHOLDS: ParallelismThresholds =
const PARALLEL_SPARSE_TRIE_PARALLELISM_THRESHOLDS: ParallelismThresholds =
ParallelismThresholds { min_revealed_nodes: 100, min_updated_nodes: 100 };
/// Default node capacity for shrinking the sparse trie. This is used to limit the number of trie
@@ -103,6 +104,52 @@ type IteratorPayloadHandle<Evm, I, N> = PayloadHandle<
<N as NodePrimitives>::Receipt,
>;
/// Shared cache handles that can be exported to engine consumers and downstream payload builders.
#[derive(Debug, Clone)]
pub struct EngineSharedCaches<Evm: ConfigureEvm> {
execution_cache: PayloadExecutionCache,
sparse_trie_cache: PayloadSparseTrieCache,
precompile_cache_map: PrecompileCacheMap<SpecFor<Evm>>,
}
impl<Evm> Default for EngineSharedCaches<Evm>
where
Evm: ConfigureEvm,
{
fn default() -> Self {
Self::with_sparse_trie_kind(PayloadSparseTrieKind::default())
}
}
impl<Evm> EngineSharedCaches<Evm>
where
Evm: ConfigureEvm,
{
/// Creates shared caches backed by the requested sparse trie implementation.
pub fn with_sparse_trie_kind(sparse_trie_kind: PayloadSparseTrieKind) -> Self {
Self {
execution_cache: Default::default(),
sparse_trie_cache: PayloadSparseTrieCache::new(sparse_trie_kind),
precompile_cache_map: Default::default(),
}
}
/// Returns the shared execution cache handle for engine-internal use.
pub(crate) fn execution_cache(&self) -> PayloadExecutionCache {
self.execution_cache.clone()
}
/// Returns the shared sparse trie cache handle.
pub fn sparse_trie_cache(&self) -> PayloadSparseTrieCache {
self.sparse_trie_cache.clone()
}
/// Returns the shared precompile cache map.
pub fn precompile_cache_map(&self) -> PrecompileCacheMap<SpecFor<Evm>> {
self.precompile_cache_map.clone()
}
}
/// Entrypoint for executing the payload.
#[derive(Debug)]
pub struct PayloadProcessor<Evm>
@@ -111,8 +158,8 @@ where
{
/// The executor used by to spawn tasks.
executor: Runtime,
/// The most recent cache used for execution.
execution_cache: PayloadExecutionCache,
/// Shared caches reused across payload processing.
shared_caches: EngineSharedCaches<Evm>,
/// Metrics for trie operations
trie_metrics: MultiProofTaskMetrics,
/// Cross-block cache size in bytes.
@@ -125,16 +172,10 @@ where
evm_config: Evm,
/// Whether precompile cache should be disabled.
precompile_cache_disabled: bool,
/// Precompile cache map.
precompile_cache_map: PrecompileCacheMap<SpecFor<Evm>>,
/// A pruned `SparseStateTrie`, kept around as a cache of already revealed trie nodes and to
/// re-use allocated memory. Stored with the block hash it was computed for to enable trie
/// preservation across sequential payload validations.
sparse_state_trie: SharedPreservedSparseTrie,
/// Sparse trie prune depth.
sparse_trie_prune_depth: usize,
/// Maximum storage tries to retain after pruning.
sparse_trie_max_storage_tries: usize,
/// LFU hot-slot capacity: max storage slots retained across prune cycles.
sparse_trie_max_hot_slots: usize,
/// LFU hot-account capacity: max account addresses retained across prune cycles.
sparse_trie_max_hot_accounts: usize,
/// Whether sparse trie cache pruning is fully disabled.
disable_sparse_trie_cache_pruning: bool,
/// Whether to disable cache metrics recording.
@@ -156,31 +197,23 @@ where
executor: Runtime,
evm_config: Evm,
config: &TreeConfig,
precompile_cache_map: PrecompileCacheMap<SpecFor<Evm>>,
shared_caches: EngineSharedCaches<Evm>,
) -> Self {
Self {
executor,
execution_cache: Default::default(),
shared_caches,
trie_metrics: Default::default(),
cross_block_cache_size: config.cross_block_cache_size(),
disable_transaction_prewarming: config.disable_prewarming(),
evm_config,
disable_state_cache: config.disable_state_cache(),
precompile_cache_disabled: config.precompile_cache_disabled(),
precompile_cache_map,
sparse_state_trie: SharedPreservedSparseTrie::default(),
sparse_trie_prune_depth: config.sparse_trie_prune_depth(),
sparse_trie_max_storage_tries: config.sparse_trie_max_storage_tries(),
sparse_trie_max_hot_slots: config.sparse_trie_max_hot_slots(),
sparse_trie_max_hot_accounts: config.sparse_trie_max_hot_accounts(),
disable_sparse_trie_cache_pruning: config.disable_sparse_trie_cache_pruning(),
disable_cache_metrics: config.disable_cache_metrics(),
}
}
/// Creates a new post-execution handle for a block, immediately spawning the
/// single event-driven post-exec background worker.
pub fn post_exec_handle(&self, receipts_len: usize) -> post_exec::PostExecHandle<N::Receipt> {
post_exec::PostExecHandle::new(&self.executor, receipts_len)
}
}
impl<Evm> WaitForCaches for PayloadProcessor<Evm>
@@ -191,8 +224,8 @@ where
debug!(target: "engine::tree::payload_processor", "Waiting for execution cache and sparse trie locks");
// Wait for both caches in parallel using std threads
let execution_cache = self.execution_cache.clone();
let sparse_trie = self.sparse_state_trie.clone();
let execution_cache = self.shared_caches.execution_cache();
let sparse_trie = self.shared_caches.sparse_trie_cache();
// Use channels and spawn_blocking instead of std::thread::spawn
let (execution_tx, execution_rx) = std::sync::mpsc::channel();
@@ -357,6 +390,8 @@ where
let (to_multi_proof, from_multi_proof) = crossbeam_channel::unbounded();
let task_ctx = ProofTaskCtx::new(multiproof_provider_factory);
#[cfg(feature = "trie-debug")]
let task_ctx = task_ctx.with_proof_jitter(config.proof_jitter());
let halve_workers = env.transaction_count <= Self::SMALL_BLOCK_PROOF_WORKER_TX_THRESHOLD;
let proof_handle = ProofWorkerHandle::new(&self.executor, task_ctx, halve_workers);
@@ -504,12 +539,12 @@ where
terminate_execution: Arc::new(AtomicBool::new(false)),
executed_tx_index: Arc::clone(&executed_tx_index),
precompile_cache_disabled: self.precompile_cache_disabled,
precompile_cache_map: self.precompile_cache_map.clone(),
precompile_cache_map: self.shared_caches.precompile_cache_map(),
};
let (prewarm_task, to_prewarm_task) = PrewarmCacheTask::new(
self.executor.clone(),
self.execution_cache.clone(),
self.shared_caches.execution_cache(),
prewarm_ctx,
to_multi_proof,
);
@@ -537,7 +572,7 @@ where
/// instance.
#[instrument(level = "debug", target = "engine::caching", skip(self))]
fn cache_for(&self, parent_hash: B256) -> SavedCache {
if let Some(cache) = self.execution_cache.get_cache_for(parent_hash) {
if let Some(cache) = self.shared_caches.execution_cache().get_cache_for(parent_hash) {
debug!("reusing execution cache");
cache
} else {
@@ -562,54 +597,33 @@ where
parent_state_root: B256,
chunk_size: usize,
) {
let preserved_sparse_trie = self.sparse_state_trie.clone();
let sparse_trie_cache = self.shared_caches.sparse_trie_cache();
let trie_metrics = self.trie_metrics.clone();
let prune_depth = self.sparse_trie_prune_depth;
let max_storage_tries = self.sparse_trie_max_storage_tries;
let max_hot_slots = self.sparse_trie_max_hot_slots;
let max_hot_accounts = self.sparse_trie_max_hot_accounts;
let disable_cache_pruning = self.disable_sparse_trie_cache_pruning;
let executor = self.executor.clone();
let parent_span = Span::current();
self.executor.spawn_blocking_named("sparse-trie", move || {
reth_tasks::once!(increase_thread_priority());
reth_tasks::once!(increase_thread_priority);
let _enter = debug_span!(target: "engine::tree::payload_processor", parent: parent_span, "sparse_trie_task")
.entered();
// Reuse a stored SparseStateTrie if available, applying continuation logic.
// If this payload's parent state root matches the preserved trie's anchor,
// we can reuse the pruned trie structure. Otherwise, we clear the trie but
// keep allocations.
let start = Instant::now();
let preserved = preserved_sparse_trie.take();
let mut checkout = sparse_trie_cache.take_or_create_for(parent_state_root);
trie_metrics
.sparse_trie_cache_wait_duration_histogram
.record(start.elapsed().as_secs_f64());
checkout.set_hot_cache_capacities(max_hot_slots, max_hot_accounts);
let sparse_state_trie = preserved
.map(|preserved| preserved.into_trie_for(parent_state_root))
.unwrap_or_else(|| {
debug!(
target: "engine::tree::payload_processor",
"Creating new sparse trie - no preserved trie available"
);
let default_trie = RevealableSparseTrie::blind_from(
ParallelSparseTrie::default().with_parallelism_thresholds(
PARALLEL_SPARSE_TRIE_PARALLELISM_THRESHOLDS,
),
);
SparseStateTrie::new()
.with_accounts_trie(default_trie.clone())
.with_default_storage_trie(default_trie)
.with_updates(true)
});
let mut task = SparseTrieCacheTask::new_with_trie(
let mut task = SparseTrieCacheTask::new_with_checkout(
&executor,
from_multi_proof,
proof_worker_handle,
trie_metrics.clone(),
sparse_state_trie,
checkout,
chunk_size,
);
@@ -620,7 +634,7 @@ where
// causing take() to return None and forcing it to create a new empty trie
// instead of reusing the preserved one. Holding the guard ensures the next
// block's take() blocks until we've stored the trie for reuse.
let mut guard = preserved_sparse_trie.lock();
let mut guard = sparse_trie_cache.lock();
let task_result = result.as_ref().ok().cloned();
// Send state root computation result - next block may start but will block on take()
@@ -635,10 +649,9 @@ where
SPARSE_TRIE_MAX_NODES_SHRINK_CAPACITY,
SPARSE_TRIE_MAX_VALUES_SHRINK_CAPACITY,
);
guard.store(PreservedSparseTrie::cleared(trie));
// Drop guard before deferred to release lock before expensive deallocations
trie.store_prepared_cleared_with_guard(&mut guard);
drop(guard);
drop(deferred);
executor.spawn_drop(deferred);
return;
}
@@ -649,8 +662,8 @@ where
let deferred = if let Some(result) = task_result {
let start = Instant::now();
let (trie, deferred) = task.into_trie_for_reuse(
prune_depth,
max_storage_tries,
max_hot_slots,
max_hot_accounts,
SPARSE_TRIE_MAX_NODES_SHRINK_CAPACITY,
SPARSE_TRIE_MAX_VALUES_SHRINK_CAPACITY,
disable_cache_pruning,
@@ -665,7 +678,7 @@ where
trie_metrics
.sparse_trie_retained_storage_tries
.set(trie.retained_storage_tries_count() as f64);
guard.store(PreservedSparseTrie::anchored(trie, result.state_root));
trie.store_anchored_with_guard(&mut guard, result.state_root);
deferred
} else {
debug!(
@@ -676,12 +689,11 @@ where
SPARSE_TRIE_MAX_NODES_SHRINK_CAPACITY,
SPARSE_TRIE_MAX_VALUES_SHRINK_CAPACITY,
);
guard.store(PreservedSparseTrie::cleared(trie));
trie.store_prepared_cleared_with_guard(&mut guard);
deferred
};
// Drop guard before deferred to release lock before expensive deallocations
drop(guard);
drop(deferred);
executor.spawn_drop(deferred);
});
}
@@ -698,7 +710,7 @@ where
bundle_state: &BundleState,
) {
let disable_cache_metrics = self.disable_cache_metrics;
self.execution_cache.update_with_guard(|cached| {
self.shared_caches.execution_cache().update_with_guard(|cached| {
if cached.as_ref().is_some_and(|c| c.executed_block_hash() != block_with_parent.parent) {
debug!(
target: "engine::caching",
@@ -1002,7 +1014,7 @@ impl<R> Drop for CacheTaskHandle<R> {
/// - Prepares data for state root proof computation
/// - Runs concurrently but must not interfere with cache saves
#[derive(Clone, Debug, Default)]
pub struct PayloadExecutionCache {
pub(crate) struct PayloadExecutionCache {
/// Guarded cloneable cache identified by a block hash.
inner: Arc<RwLock<Option<SavedCache>>>,
/// Metrics for cache operations.
@@ -1010,15 +1022,15 @@ pub struct PayloadExecutionCache {
}
impl PayloadExecutionCache {
/// Returns the cache for `parent_hash` if it's available for use.
/// Returns the cache backing store for `parent_hash` if it's available for reuse.
///
/// A cache is considered available when:
/// - It exists and matches the requested parent hash
/// - No other tasks are currently using it (checked via Arc reference count)
/// If the tracked cache is available but keyed to a different parent hash, the cache is
/// cleared and returned so callers can reuse the underlying allocations without carrying over
/// stale state.
#[instrument(level = "debug", target = "engine::tree::payload_processor", skip(self))]
pub(crate) fn get_cache_for(&self, parent_hash: B256) -> Option<SavedCache> {
let start = Instant::now();
let cache = self.inner.read();
let mut cache = self.inner.write();
let elapsed = start.elapsed();
self.metrics.execution_cache_wait_duration.record(elapsed.as_secs_f64());
@@ -1026,7 +1038,7 @@ impl PayloadExecutionCache {
warn!(blocked_for=?elapsed, "Blocked waiting for execution cache mutex");
}
if let Some(c) = cache.as_ref() {
if let Some(c) = cache.as_mut() {
let cached_hash = c.executed_block_hash();
// Check that the cache hash matches the parent hash of the current block. It won't
// match in case it's a fork block.
@@ -1047,13 +1059,13 @@ impl PayloadExecutionCache {
);
if available {
// If the has is available (no other threads are using it), but has a mismatching
// parent hash, we can just clear it and keep using without re-creating from
// scratch.
if !hash_matches {
c.clear();
// Fork block: clear and update the hash on the ORIGINAL before cloning.
// This prevents the canonical chain from matching on the stale hash
// and picking up polluted data if the fork block fails.
c.clear_with_hash(parent_hash);
}
return Some(c.clone())
return Some(c.clone());
} else if hash_matches {
self.metrics.execution_cache_in_use.increment(1);
}
@@ -1064,19 +1076,13 @@ impl PayloadExecutionCache {
None
}
/// Clears the tracked cache
#[expect(unused)]
pub(crate) fn clear(&self) {
self.inner.write().take();
}
/// Waits until the execution cache becomes available for use.
///
/// This acquires a write lock to ensure exclusive access, then immediately releases it.
/// This is useful for synchronization before starting payload processing.
///
/// Returns the time spent waiting for the lock.
pub fn wait_for_availability(&self) -> Duration {
pub(crate) fn wait_for_availability(&self) -> Duration {
let start = Instant::now();
// Acquire write lock to wait for any current holders to finish
let _guard = self.inner.write();
@@ -1104,7 +1110,7 @@ impl PayloadExecutionCache {
///
/// Violating this requirement can result in cache corruption, incorrect state data,
/// and potential consensus failures.
pub fn update_with_guard<F>(&self, update_fn: F)
pub(crate) fn update_with_guard<F>(&self, update_fn: F)
where
F: FnOnce(&mut Option<SavedCache>),
{
@@ -1173,8 +1179,9 @@ mod tests {
use super::PayloadExecutionCache;
use crate::tree::{
cached_state::{CachedStateMetrics, ExecutionCache, SavedCache},
payload_processor::{evm_state_to_hashed_post_state, ExecutionEnv, PayloadProcessor},
precompile_cache::PrecompileCacheMap,
payload_processor::{
evm_state_to_hashed_post_state, EngineSharedCaches, ExecutionEnv, PayloadProcessor,
},
StateProviderBuilder, TreeConfig,
};
use alloy_eips::eip1898::{BlockNumHash, BlockWithParent};
@@ -1247,10 +1254,18 @@ mod tests {
execution_cache.update_with_guard(|slot| *slot = Some(make_saved_cache(hash)));
// When the parent hash doesn't match, the cache is cleared and returned for reuse
// When the parent hash doesn't match (fork block), the cache is cleared,
// hash updated on the original, and clone returned for reuse
let different_hash = B256::from([4u8; 32]);
let cache = execution_cache.get_cache_for(different_hash);
assert!(cache.is_some(), "cache should be returned for reuse after clearing")
assert!(cache.is_some(), "cache should be returned for reuse after clearing");
drop(cache);
// The stored cache now has the fork block's parent hash.
// Canonical chain looking for original hash sees a mismatch → clears and reuses.
let original = execution_cache.get_cache_for(hash);
assert!(original.is_some(), "canonical chain gets cache back via mismatch+clear");
}
#[test]
@@ -1278,7 +1293,7 @@ mod tests {
reth_tasks::Runtime::test(),
EthEvmConfig::new(Arc::new(ChainSpec::default())),
&TreeConfig::default(),
PrecompileCacheMap::default(),
EngineSharedCaches::default(),
);
let parent_hash = B256::from([1u8; 32]);
@@ -1290,13 +1305,17 @@ mod tests {
let bundle_state = BundleState::default();
// Cache should be empty initially
assert!(payload_processor.execution_cache.get_cache_for(block_hash).is_none());
assert!(payload_processor
.shared_caches
.execution_cache()
.get_cache_for(block_hash)
.is_none());
// Update cache with inserted block
payload_processor.on_inserted_executed_block(block_with_parent, &bundle_state);
// Cache should now exist for the block hash
let cached = payload_processor.execution_cache.get_cache_for(block_hash);
let cached = payload_processor.shared_caches.execution_cache().get_cache_for(block_hash);
assert!(cached.is_some());
assert_eq!(cached.unwrap().executed_block_hash(), block_hash);
}
@@ -1307,13 +1326,14 @@ mod tests {
reth_tasks::Runtime::test(),
EthEvmConfig::new(Arc::new(ChainSpec::default())),
&TreeConfig::default(),
PrecompileCacheMap::default(),
EngineSharedCaches::default(),
);
// Setup: populate cache with block 1
let block1_hash = B256::from([1u8; 32]);
payload_processor
.execution_cache
.shared_caches
.execution_cache()
.update_with_guard(|slot| *slot = Some(make_saved_cache(block1_hash)));
// Try to insert block 3 with wrong parent (should skip and keep block 1's cache)
@@ -1328,11 +1348,11 @@ mod tests {
payload_processor.on_inserted_executed_block(block_with_parent, &bundle_state);
// Cache should still be for block 1 (unchanged)
let cached = payload_processor.execution_cache.get_cache_for(block1_hash);
let cached = payload_processor.shared_caches.execution_cache().get_cache_for(block1_hash);
assert!(cached.is_some(), "Original cache should be preserved");
// Cache for block 3 should not exist
let cached3 = payload_processor.execution_cache.get_cache_for(block3_hash);
let cached3 = payload_processor.shared_caches.execution_cache().get_cache_for(block3_hash);
assert!(cached3.is_none(), "New block cache should not be created on mismatch");
}
@@ -1442,7 +1462,7 @@ mod tests {
reth_tasks::Runtime::test(),
EthEvmConfig::new(factory.chain_spec()),
&TreeConfig::default(),
PrecompileCacheMap::default(),
EngineSharedCaches::default(),
);
let provider_factory = BlockchainProvider::new(factory).unwrap();
@@ -1474,4 +1494,61 @@ mod tests {
"State root mismatch: task={root_from_task}, base={root_from_regular}"
);
}
/// Tests the full prewarm lifecycle for a fork block:
///
/// 1. Cache is at canonical block 4.
/// 2. Fork block (parent = block 2) checks out the cache via `get_cache_for`, simulating what
/// `PrewarmCacheTask` does when it receives a `SavedCache`.
/// 3. Prewarm populates the shared cache with fork-specific state.
/// 4. While the prewarm clone is alive, the cache is unavailable (`usage_guard` > 1).
/// 5. Prewarm drops without calling `save_cache` (fork block was invalid).
/// 6. Canonical block 5 (parent = block 4) must get a cache with correct hash and no stale fork
/// data.
#[test]
fn fork_prewarm_dropped_without_save_does_not_corrupt_cache() {
let execution_cache = PayloadExecutionCache::default();
// Canonical chain at block 4.
let block4_hash = B256::from([4u8; 32]);
execution_cache.update_with_guard(|slot| *slot = Some(make_saved_cache(block4_hash)));
// Fork block arrives with parent = block 2. Prewarm task checks out the cache.
// This simulates PrewarmCacheTask receiving a SavedCache clone from get_cache_for.
let fork_parent = B256::from([2u8; 32]);
let prewarm_cache = execution_cache.get_cache_for(fork_parent);
assert!(prewarm_cache.is_some(), "prewarm should obtain cache for fork block");
let prewarm_cache = prewarm_cache.unwrap();
assert_eq!(prewarm_cache.executed_block_hash(), fork_parent);
// Prewarm populates cache with fork-specific state (ancestor data for block 2).
// Since ExecutionCache uses Arc<Inner>, this data is shared with the stored original.
let fork_addr = Address::from([0xBB; 20]);
let fork_key = B256::from([0xCC; 32]);
prewarm_cache.cache().insert_storage(fork_addr, fork_key, Some(U256::from(999)));
// While prewarm holds the clone, the usage_guard count > 1 → cache is in use.
let during_prewarm = execution_cache.get_cache_for(block4_hash);
assert!(
during_prewarm.is_none(),
"cache must be unavailable while prewarm holds a reference"
);
// Fork block fails — prewarm task drops without calling save_cache/update_with_guard.
drop(prewarm_cache);
// Canonical block 5 arrives (parent = block 4).
// Stored hash = fork_parent (our fix), so get_cache_for sees a mismatch,
// clears the stale fork data, and returns a cache with hash = block4_hash.
let block5_cache = execution_cache.get_cache_for(block4_hash);
assert!(
block5_cache.is_some(),
"canonical chain must get cache after fork prewarm is dropped"
);
assert_eq!(
block5_cache.as_ref().unwrap().executed_block_hash(),
block4_hash,
"cache must carry the canonical parent hash, not the fork parent"
);
}
}

View File

@@ -47,16 +47,6 @@ pub enum MultiProofMessage {
PrefetchProofs(MultiProofTargetsV2),
/// New state update from transaction execution with its source
StateUpdate(Source, EvmState),
/// State update that can be applied to the sparse trie without any new proofs.
///
/// It can be the case when all accounts and storage slots from the state update were already
/// fetched and revealed.
EmptyProof {
/// The index of this proof in the sequence of state updates
sequence_number: u64,
/// The state update that was used to calculate the proof
state: HashedPostState,
},
/// Pre-hashed state update from BAL conversion that can be applied directly without proofs.
HashedStateUpdate(HashedPostState),
/// Block Access List (EIP-7928; BAL) containing complete state changes for the block.
@@ -128,41 +118,6 @@ pub(crate) fn evm_state_to_hashed_post_state(update: EvmState) -> HashedPostStat
#[derive(Metrics, Clone)]
#[metrics(scope = "tree.root")]
pub(crate) struct MultiProofTaskMetrics {
/// Histogram of active storage workers processing proofs.
pub active_storage_workers_histogram: Histogram,
/// Histogram of active account workers processing proofs.
pub active_account_workers_histogram: Histogram,
/// Gauge for the maximum number of storage workers in the pool.
pub max_storage_workers: Gauge,
/// Gauge for the maximum number of account workers in the pool.
pub max_account_workers: Gauge,
/// Histogram of pending storage multiproofs in the queue.
pub pending_storage_multiproofs_histogram: Histogram,
/// Histogram of pending account multiproofs in the queue.
pub pending_account_multiproofs_histogram: Histogram,
/// Histogram of the number of prefetch proof target accounts.
pub prefetch_proof_targets_accounts_histogram: Histogram,
/// Histogram of the number of prefetch proof target storages.
pub prefetch_proof_targets_storages_histogram: Histogram,
/// Histogram of the number of prefetch proof target chunks.
pub prefetch_proof_chunks_histogram: Histogram,
/// Histogram of the number of state update proof target accounts.
pub state_update_proof_targets_accounts_histogram: Histogram,
/// Histogram of the number of state update proof target storages.
pub state_update_proof_targets_storages_histogram: Histogram,
/// Histogram of the number of state update proof target chunks.
pub state_update_proof_chunks_histogram: Histogram,
/// Histogram of prefetch proof batch sizes (number of messages merged).
pub prefetch_batch_size_histogram: Histogram,
/// Histogram of proof calculation durations.
pub proof_calculation_duration_histogram: Histogram,
/// Histogram of sparse trie update durations.
pub sparse_trie_update_duration_histogram: Histogram,
/// Histogram of durations spent revealing multiproof results into the sparse trie.
pub sparse_trie_reveal_multiproof_duration_histogram: Histogram,
/// Histogram of durations spent coalescing multiple proof results from the channel.
@@ -175,17 +130,6 @@ pub(crate) struct MultiProofTaskMetrics {
pub sparse_trie_final_update_duration_histogram: Histogram,
/// Histogram of sparse trie total durations.
pub sparse_trie_total_duration_histogram: Histogram,
/// Histogram of state updates received.
pub state_updates_received_histogram: Histogram,
/// Histogram of proofs processed.
pub proofs_processed_histogram: Histogram,
/// Histogram of total time spent in the multiproof task.
pub multiproof_task_total_duration_histogram: Histogram,
/// Total time spent waiting for the first state update or prefetch request.
pub first_update_wait_time_histogram: Histogram,
/// Total time spent waiting for the last proof result.
pub last_proof_wait_time_histogram: Histogram,
/// Time spent preparing the sparse trie for reuse after state root computation.
pub into_trie_for_reuse_duration_histogram: Histogram,
/// Time spent waiting for preserved sparse trie cache to become available.

View File

@@ -1,483 +0,0 @@
//! Per-block post-execution handle for background post-execution artifact computation.
//!
//! This module provides [`PostExecHandle`], a block-scoped facade that coordinates
//! background tasks:
//!
//! 1. **Receipt root worker** — spawned at construction via [`Runtime::spawn_blocking_named`].
//! Receipts are streamed incrementally during execution; when the channel closes the worker
//! finalizes the receipt trie root and aggregated bloom.
//!
//! 2. **Hashed post-state task** — spawned by [`PostExecHandle::finish`] so it starts immediately
//! after execution, running in parallel with receipt-root finalization.
//!
//! 3. **Transaction root task** — spawned by [`PostExecHandle::finish`] for payload blocks,
//! computing the transaction trie root in parallel with the other tasks.
//!
//! Results are accessed via blocking accessors that wait for the background tasks to complete.
use alloy_eips::Encodable2718;
use alloy_primitives::{Bloom, B256};
use crossbeam_channel::Sender as CrossbeamSender;
use reth_primitives_traits::Receipt;
use reth_tasks::{LazyHandle, Runtime};
use reth_trie::HashedPostState;
use reth_trie_common::ordered_root::OrderedTrieRootEncodedBuilder;
use std::sync::{Arc, OnceLock};
use tracing::error;
/// Receipt with index, ready to be sent to the background task for encoding and trie building.
#[derive(Debug, Clone)]
pub struct IndexedReceipt<R> {
/// The transaction index within the block.
pub index: usize,
/// The receipt.
pub receipt: R,
}
impl<R> IndexedReceipt<R> {
/// Creates a new indexed receipt.
#[inline]
pub const fn new(index: usize, receipt: R) -> Self {
Self { index, receipt }
}
}
/// Block-scoped handle for post-execution background tasks.
///
/// Created once per block via [`PostExecHandle::new`], which immediately spawns a
/// receipt-root background worker. During transaction execution, receipts are streamed
/// via [`push_receipt`](Self::push_receipt). After execution completes, call
/// [`finish`](Self::finish) to close the receipt channel and spawn hashed-post-state
/// and (optionally) transaction-root computation in parallel.
#[must_use]
pub struct PostExecHandle<R> {
tx: Option<CrossbeamSender<IndexedReceipt<R>>>,
receipt_root_bloom: Arc<OnceLock<Option<(B256, Bloom)>>>,
hashed_post_state: Option<LazyHandle<HashedPostState>>,
transaction_root: Option<LazyHandle<B256>>,
executor: Runtime,
}
impl<R> core::fmt::Debug for PostExecHandle<R> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("PostExecHandle").field("finished", &self.tx.is_none()).finish()
}
}
impl<R: Receipt + 'static> PostExecHandle<R> {
/// Creates a new handle and immediately spawns the receipt-root background worker.
///
/// The worker begins waiting for receipts via the crossbeam channel and builds the
/// receipt trie incrementally as they arrive. When the channel closes (via
/// [`finish`](Self::finish) or handle drop), the worker finalizes the receipt root.
pub fn new(executor: &Runtime, receipts_len: usize) -> Self {
let (tx, rx) = crossbeam_channel::unbounded();
let receipt_root_bloom = Arc::new(OnceLock::new());
let receipt_root_bloom_worker = receipt_root_bloom.clone();
// Use spawn_blocking_named for consistent thread naming; ignore the LazyHandle<()> return.
let _ = executor.spawn_blocking_named("receipt-root", move || {
run_receipt_root_worker(rx, receipt_root_bloom_worker, receipts_len);
});
Self {
tx: Some(tx),
receipt_root_bloom,
hashed_post_state: None,
transaction_root: None,
executor: executor.clone(),
}
}
/// Streams one receipt to the background worker.
#[inline]
pub fn push_receipt(&self, index: usize, receipt: R) {
if let Some(tx) = self.tx.as_ref() &&
tx.send(IndexedReceipt::new(index, receipt)).is_err()
{
error!(
target: "engine::tree::payload_processor",
index,
"receipt-root worker dropped before receipt event",
);
}
}
/// Closes the receipt channel and spawns hashed-post-state and (optionally)
/// transaction-root computation.
///
/// Dropping the channel sender signals the receipt-root worker to finalize.
/// The hashed-post-state and transaction-root closures are each spawned on
/// separate threads, running in parallel with receipt-root finalization.
///
/// Pass `None` for `tx_root_fn` when the block is not a payload (no tx root needed).
///
/// Must be called after all receipts have been pushed.
pub fn finish(
&mut self,
hashed_state_fn: impl FnOnce() -> HashedPostState + Send + 'static,
tx_root_fn: Option<impl FnOnce() -> B256 + Send + 'static>,
) {
// Drop receipt channel sender — signals worker to finalize receipt root.
self.tx.take();
// Spawn hashed-post-state computation immediately on a separate thread.
self.hashed_post_state =
Some(self.executor.spawn_blocking_named("hash-post-state", hashed_state_fn));
// Spawn transaction-root computation if this is a payload block.
self.transaction_root =
tx_root_fn.map(|f| self.executor.spawn_blocking_named("payload-tx-root", f));
}
/// Returns the computed receipt root and aggregated logs bloom.
///
/// Blocks until the receipt-root worker completes. Returns `None` if the receipt
/// stream was incomplete (e.g., execution was aborted).
pub fn receipt_root_bloom(&self) -> Option<(B256, Bloom)> {
*self.receipt_root_bloom.wait()
}
/// Returns the computed transaction root, if this was a payload block.
///
/// Blocks until the transaction-root task completes. Returns `None` for non-payload
/// blocks where tx root computation was not requested.
pub fn transaction_root(&self) -> Option<B256> {
self.transaction_root.as_ref().map(|h| *h.get())
}
/// Returns a reference to the computed hashed post state.
///
/// Blocks until the background task completes.
///
/// # Panics
///
/// Panics if [`finish`](Self::finish) was not called before this method.
pub fn hashed_post_state(&self) -> &HashedPostState {
self.hashed_post_state
.as_ref()
.expect("finish() must be called before hashed_post_state()")
.get()
}
/// Extracts the [`LazyHandle<HashedPostState>`] from this handle.
///
/// # Panics
///
/// Panics if [`finish`](Self::finish) was not called.
pub fn into_lazy_hashed_state(&mut self) -> LazyHandle<HashedPostState> {
self.hashed_post_state
.take()
.expect("finish() must be called before into_lazy_hashed_state()")
}
}
impl<R> Drop for PostExecHandle<R> {
fn drop(&mut self) {
// Drop the channel sender if finish() was never called, so the receipt-root
// worker can observe channel closure and terminate.
self.tx.take();
}
}
/// Runs the receipt-root background worker.
///
/// Receives indexed receipts from the channel, incrementally builds the receipt trie,
/// and aggregates the logs bloom. When the channel closes, it finalizes the root and
/// stores the result in the shared [`OnceLock`].
fn run_receipt_root_worker<R: Receipt>(
rx: crossbeam_channel::Receiver<IndexedReceipt<R>>,
receipt_root_bloom: Arc<OnceLock<Option<(B256, Bloom)>>>,
receipts_len: usize,
) {
// RAII guard ensures the OnceLock is set to None if we return early / panic.
struct AbortGuard<'a> {
lock: &'a OnceLock<Option<(B256, Bloom)>>,
disarmed: bool,
}
impl Drop for AbortGuard<'_> {
fn drop(&mut self) {
if !self.disarmed {
let _ = self.lock.set(None);
}
}
}
let mut guard = AbortGuard { lock: &receipt_root_bloom, disarmed: false };
let mut builder = OrderedTrieRootEncodedBuilder::new(receipts_len);
let mut aggregated_bloom = Bloom::ZERO;
let mut encode_buf = Vec::new();
let mut received_count = 0usize;
for indexed_receipt in &rx {
let receipt_with_bloom = indexed_receipt.receipt.with_bloom_ref();
encode_buf.clear();
receipt_with_bloom.encode_2718(&mut encode_buf);
match builder.push(indexed_receipt.index, &encode_buf) {
Ok(()) => {
received_count += 1;
aggregated_bloom |= *receipt_with_bloom.bloom_ref();
}
Err(err) => {
error!(
target: "engine::tree::payload_processor",
index = indexed_receipt.index,
?err,
"Receipt root worker received invalid receipt index, skipping"
);
}
}
}
// Finalize receipt root.
match builder.finalize() {
Ok(root) => {
let _ = receipt_root_bloom.set(Some((root, aggregated_bloom)));
}
Err(_) => {
error!(
target: "engine::tree::payload_processor",
expected = receipts_len,
received = received_count,
"Receipt-root worker received incomplete receipts, execution likely aborted"
);
let _ = receipt_root_bloom.set(None);
return;
}
}
guard.disarmed = true;
}
#[cfg(test)]
mod tests {
use super::*;
use alloy_consensus::{proofs::calculate_receipt_root, TxReceipt};
use alloy_primitives::{Address, Bytes, Log, B256};
use reth_ethereum_primitives::{Receipt, TxType};
fn test_runtime() -> Runtime {
Runtime::test()
}
fn sample_receipts() -> Vec<Receipt> {
vec![
Receipt {
tx_type: TxType::Legacy,
cumulative_gas_used: 21_000,
success: true,
logs: vec![],
},
Receipt {
tx_type: TxType::Eip1559,
cumulative_gas_used: 42_000,
success: true,
logs: vec![Log {
address: Address::ZERO,
data: alloy_primitives::LogData::new_unchecked(vec![B256::ZERO], Bytes::new()),
}],
},
Receipt {
tx_type: TxType::Eip2930,
cumulative_gas_used: 63_000,
success: false,
logs: vec![],
},
]
}
fn expected_root_bloom(receipts: &[Receipt]) -> (B256, Bloom) {
let receipts_with_bloom: Vec<_> = receipts.iter().map(|r| r.with_bloom_ref()).collect();
let root = calculate_receipt_root(&receipts_with_bloom);
let bloom =
receipts_with_bloom.iter().fold(Bloom::ZERO, |acc, receipt| acc | *receipt.bloom_ref());
(root, bloom)
}
#[test]
fn post_exec_handle_computes_receipt_root_and_bloom() {
let rt = test_runtime();
let receipts = sample_receipts();
let (expected_root, expected_bloom) = expected_root_bloom(&receipts);
let mut handle = PostExecHandle::<Receipt>::new(&rt, receipts.len());
for (index, receipt) in receipts.into_iter().enumerate() {
handle.push_receipt(index, receipt);
}
handle.finish(HashedPostState::default, None::<fn() -> B256>);
let (root, bloom) = handle.receipt_root_bloom().unwrap();
assert_eq!(root, expected_root);
assert_eq!(bloom, expected_bloom);
}
#[test]
fn post_exec_handle_handles_out_of_order_receipts() {
let rt = test_runtime();
let receipts = sample_receipts();
let (expected_root, expected_bloom) = expected_root_bloom(&receipts);
let mut handle = PostExecHandle::<Receipt>::new(&rt, receipts.len());
for (index, receipt) in receipts.into_iter().enumerate().rev() {
handle.push_receipt(index, receipt);
}
handle.finish(HashedPostState::default, None::<fn() -> B256>);
let (root, bloom) = handle.receipt_root_bloom().unwrap();
assert_eq!(root, expected_root);
assert_eq!(bloom, expected_bloom);
}
#[test]
fn post_exec_handle_ignores_invalid_index_for_bloom_aggregation() {
let rt = test_runtime();
let valid = Receipt::default();
let invalid = Receipt {
tx_type: TxType::Legacy,
cumulative_gas_used: 21_000,
success: true,
logs: vec![Log {
address: Address::ZERO,
data: alloy_primitives::LogData::new_unchecked(vec![B256::ZERO], Bytes::new()),
}],
};
let expected = expected_root_bloom(core::slice::from_ref(&valid));
let mut handle = PostExecHandle::<Receipt>::new(&rt, 1);
handle.push_receipt(0, valid);
handle.push_receipt(999, invalid);
handle.finish(HashedPostState::default, None::<fn() -> B256>);
assert_eq!(handle.receipt_root_bloom(), Some(expected));
}
#[test]
fn post_exec_handle_returns_none_for_incomplete_stream() {
let rt = test_runtime();
let mut handle = PostExecHandle::<Receipt>::new(&rt, 2);
handle.push_receipt(0, Receipt::default());
// Finish with only 1 of 2 receipts — root should be None.
handle.finish(HashedPostState::default, None::<fn() -> B256>);
assert!(handle.receipt_root_bloom().is_none());
}
#[test]
fn post_exec_handle_with_hashed_post_state() {
let rt = test_runtime();
let mut handle = PostExecHandle::<Receipt>::new(&rt, 0);
let expected = HashedPostState::default();
handle.finish(HashedPostState::default, None::<fn() -> B256>);
assert_eq!(handle.hashed_post_state(), &expected);
}
#[test]
fn post_exec_handle_with_transaction_root() {
let rt = test_runtime();
let expected_root = B256::repeat_byte(0x42);
let mut handle = PostExecHandle::<Receipt>::new(&rt, 0);
handle.finish(HashedPostState::default, Some(move || expected_root));
assert_eq!(handle.transaction_root(), Some(expected_root));
}
#[test]
fn post_exec_handle_without_transaction_root() {
let rt = test_runtime();
let mut handle = PostExecHandle::<Receipt>::new(&rt, 0);
handle.finish(HashedPostState::default, None::<fn() -> B256>);
assert_eq!(handle.transaction_root(), None);
}
#[test]
fn post_exec_handle_parallel_blocks() {
let rt = test_runtime();
let receipts_a = sample_receipts();
let (expected_root_a, expected_bloom_a) = expected_root_bloom(&receipts_a);
let receipts_b = vec![Receipt::default(); 2];
let (expected_root_b, expected_bloom_b) = expected_root_bloom(&receipts_b);
let mut handle_a = PostExecHandle::<Receipt>::new(&rt, receipts_a.len());
let mut handle_b = PostExecHandle::<Receipt>::new(&rt, receipts_b.len());
for (index, receipt) in receipts_a.into_iter().enumerate() {
handle_a.push_receipt(index, receipt);
}
for (index, receipt) in receipts_b.into_iter().enumerate() {
handle_b.push_receipt(index, receipt);
}
handle_a.finish(HashedPostState::default, None::<fn() -> B256>);
handle_b.finish(HashedPostState::default, None::<fn() -> B256>);
let (root_a, bloom_a) = handle_a.receipt_root_bloom().unwrap();
let (root_b, bloom_b) = handle_b.receipt_root_bloom().unwrap();
assert_eq!(root_a, expected_root_a);
assert_eq!(bloom_a, expected_bloom_a);
assert_eq!(root_b, expected_root_b);
assert_eq!(bloom_b, expected_bloom_b);
}
#[test]
fn post_exec_handle_aborted_block_then_next_succeeds() {
let rt = test_runtime();
// First block: aborted (dropped without finishing all receipts)
let handle = PostExecHandle::<Receipt>::new(&rt, 2);
handle.push_receipt(0, Receipt::default());
drop(handle);
// Second block: succeeds
let mut handle = PostExecHandle::<Receipt>::new(&rt, 1);
handle.push_receipt(0, Receipt::default());
handle.finish(HashedPostState::default, None::<fn() -> B256>);
assert!(handle.receipt_root_bloom().is_some());
}
#[test]
fn lazy_hashed_post_state_get_and_try_into_inner() {
let rt = test_runtime();
let mut handle = PostExecHandle::<Receipt>::new(&rt, 0);
handle.finish(HashedPostState::default, None::<fn() -> B256>);
let lazy = handle.into_lazy_hashed_state();
// handle is partially consumed but Drop is safe (hashed_post_state is now None)
drop(handle);
assert_eq!(lazy.get(), &HashedPostState::default());
let inner = lazy.try_into_inner().unwrap();
assert_eq!(inner, HashedPostState::default());
}
#[test]
fn lazy_hashed_post_state_clone_prevents_try_into_inner() {
let rt = test_runtime();
let mut handle = PostExecHandle::<Receipt>::new(&rt, 0);
handle.finish(HashedPostState::default, None::<fn() -> B256>);
let lazy = handle.into_lazy_hashed_state();
drop(handle);
let _clone = lazy.clone();
// try_into_inner fails because there are multiple Arc references.
let lazy = lazy.try_into_inner().unwrap_err();
assert_eq!(lazy.get(), &HashedPostState::default());
}
}

View File

@@ -1,44 +1,128 @@
//! Preserved sparse trie for reuse across payload validations.
use super::{
PARALLEL_SPARSE_TRIE_PARALLELISM_THRESHOLDS, SPARSE_TRIE_MAX_NODES_SHRINK_CAPACITY,
SPARSE_TRIE_MAX_VALUES_SHRINK_CAPACITY,
};
use alloy_primitives::B256;
use parking_lot::Mutex;
use reth_trie_sparse::SparseStateTrie;
use std::{sync::Arc, time::Instant};
use reth_trie_sparse::{
ArenaParallelSparseTrie, ConfigurableSparseTrie, ParallelSparseTrie, RevealableSparseTrie,
SparseStateTrie,
};
use std::{
ops::{Deref, DerefMut},
sync::Arc,
time::{Duration, Instant},
};
use tracing::debug;
/// Type alias for the sparse trie type used in preservation.
pub(super) type SparseTrie = SparseStateTrie;
type SparseTrie = SparseStateTrie<ConfigurableSparseTrie, ConfigurableSparseTrie>;
/// Shared handle to a preserved sparse trie that can be reused across payload validations.
/// Sparse trie implementation used by [`PayloadSparseTrieCache`].
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum PayloadSparseTrieKind {
/// Back sparse trie storage with hash maps.
#[default]
HashMap,
/// Back sparse trie storage with arena allocations.
Arena,
}
impl From<bool> for PayloadSparseTrieKind {
fn from(enable_arena_sparse_trie: bool) -> Self {
if enable_arena_sparse_trie {
Self::Arena
} else {
Self::HashMap
}
}
}
#[derive(Debug, Default)]
struct PayloadSparseTrieState {
latest_checkout_id: u64,
preserved: Option<PreservedSparseTrie>,
}
/// Outcome of storing a checked-out sparse trie back into the shared cache.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PayloadSparseTrieStoreOutcome {
/// The checkout was the most recent lease and the trie was stored.
Stored,
/// A newer checkout had already been issued, so this stale lease was ignored.
IgnoredStaleCheckout,
}
/// Shared sparse trie cache that can be reused across payload validations.
///
/// This is stored in [`PayloadProcessor`](super::PayloadProcessor) and cloned to pass to
/// [`SparseTrieCacheTask`](super::sparse_trie::SparseTrieCacheTask) for trie reuse.
#[derive(Debug, Default, Clone)]
pub(super) struct SharedPreservedSparseTrie(Arc<Mutex<Option<PreservedSparseTrie>>>);
/// This is the public sparse-trie SDK surface exposed through
/// [`EngineSharedCaches`](super::EngineSharedCaches). Callers take or create a trie, use it for
/// payload work, then store it back either anchored to the resulting state root or cleared for
/// allocation reuse.
#[derive(Debug, Clone)]
pub struct PayloadSparseTrieCache {
kind: PayloadSparseTrieKind,
state: Arc<Mutex<PayloadSparseTrieState>>,
}
impl SharedPreservedSparseTrie {
/// Takes the preserved trie if present, leaving `None` in its place.
pub(super) fn take(&self) -> Option<PreservedSparseTrie> {
self.0.lock().take()
impl Default for PayloadSparseTrieCache {
fn default() -> Self {
Self::new(PayloadSparseTrieKind::default())
}
}
impl PayloadSparseTrieCache {
/// Creates a sparse trie cache backed by the requested trie implementation.
pub fn new(kind: PayloadSparseTrieKind) -> Self {
Self { kind, state: Arc::new(Mutex::new(PayloadSparseTrieState::default())) }
}
/// Acquires a guard that blocks `take()` until dropped.
/// Use this before sending the state root result to ensure the next block
/// waits for the trie to be stored.
pub(super) fn lock(&self) -> PreservedTrieGuard<'_> {
PreservedTrieGuard(self.0.lock())
/// Returns the sparse trie implementation used when the cache needs to create a new trie.
pub const fn kind(&self) -> PayloadSparseTrieKind {
self.kind
}
/// Takes a preserved trie for `parent_state_root` or creates a new trie if the cache is empty.
pub fn take_or_create_for(&self, parent_state_root: B256) -> SparseTrieCheckout {
let start = Instant::now();
let mut state = self.state.lock();
state.latest_checkout_id += 1;
let checkout_id = state.latest_checkout_id;
let trie = state
.preserved
.take()
.map(|preserved| preserved.into_trie_for(parent_state_root))
.unwrap_or_else(|| {
debug!(
target: "engine::tree::payload_processor",
%parent_state_root,
kind = ?self.kind,
"Creating new sparse trie - no preserved trie available"
);
new_sparse_trie(self.kind)
});
drop(state);
let elapsed = start.elapsed();
if elapsed.as_millis() > 5 {
debug!(
target: "engine::tree::payload_processor",
blocked_for=?elapsed,
"Waited for preserved sparse trie checkout"
);
}
SparseTrieCheckout { trie: Some(trie), cache: self.clone(), checkout_id }
}
/// Waits until the sparse trie lock becomes available.
///
/// This acquires and immediately releases the lock, ensuring that any
/// ongoing operations complete before returning. Useful for synchronization
/// before starting payload processing.
///
/// Returns the time spent waiting for the lock.
pub(super) fn wait_for_availability(&self) -> std::time::Duration {
pub fn wait_for_availability(&self) -> Duration {
let start = Instant::now();
let _guard = self.0.lock();
let _guard = self.state.lock();
let elapsed = start.elapsed();
if elapsed.as_millis() > 5 {
debug!(
@@ -49,27 +133,142 @@ impl SharedPreservedSparseTrie {
}
elapsed
}
/// Acquires a guard that blocks cache mutation until dropped.
///
/// Engine-internal code uses this before making the state-root result visible so the next
/// payload cannot observe an empty cache between send and store.
pub(super) fn lock(&self) -> PreservedTrieGuard<'_> {
PreservedTrieGuard { state: self.state.lock() }
}
}
/// A checked-out sparse trie lease.
///
/// This dereferences to [`SparseStateTrie`] so callers can reuse the trie directly. If the lease is
/// dropped without being stored back, a cleared trie is returned to the shared cache unless a newer
/// checkout has already superseded it.
#[derive(Debug)]
pub struct SparseTrieCheckout {
trie: Option<SparseTrie>,
cache: PayloadSparseTrieCache,
checkout_id: u64,
}
impl SparseTrieCheckout {
/// Stores the trie back into the shared cache anchored to the given state root.
pub fn store_anchored(self, state_root: B256) -> PayloadSparseTrieStoreOutcome {
let cache = self.cache.clone();
let mut guard = cache.lock();
self.store_anchored_with_guard(&mut guard, state_root)
}
/// Stores the trie back into the shared cache in a cleared state.
pub fn store_cleared(mut self) -> PayloadSparseTrieStoreOutcome {
let cache = self.cache.clone();
let mut trie = self.take_trie();
prepare_cleared_trie(&mut trie);
let deferred = trie.take_deferred_drops();
let mut guard = cache.lock();
let outcome = guard.store(self.checkout_id, PreservedSparseTrie::cleared(trie));
drop(guard);
drop(deferred);
outcome
}
/// Stores the trie back into the shared cache anchored to the given state root while the
/// caller is already holding the preservation lock.
pub(super) fn store_anchored_with_guard(
mut self,
guard: &mut PreservedTrieGuard<'_>,
state_root: B256,
) -> PayloadSparseTrieStoreOutcome {
guard.store(self.checkout_id, PreservedSparseTrie::anchored(self.take_trie(), state_root))
}
/// Stores an already-cleared trie back into the shared cache while the caller is already
/// holding the preservation lock.
pub(super) fn store_prepared_cleared_with_guard(
mut self,
guard: &mut PreservedTrieGuard<'_>,
) -> PayloadSparseTrieStoreOutcome {
guard.store(self.checkout_id, PreservedSparseTrie::cleared(self.take_trie()))
}
fn take_trie(&mut self) -> SparseTrie {
self.trie.take().expect("sparse trie checkout must hold a trie until it is stored")
}
}
impl Deref for SparseTrieCheckout {
type Target = SparseTrie;
fn deref(&self) -> &Self::Target {
self.trie.as_ref().expect("sparse trie checkout must hold a trie until it is stored")
}
}
impl DerefMut for SparseTrieCheckout {
fn deref_mut(&mut self) -> &mut Self::Target {
self.trie.as_mut().expect("sparse trie checkout must hold a trie until it is stored")
}
}
impl Drop for SparseTrieCheckout {
fn drop(&mut self) {
let Some(mut trie) = self.trie.take() else { return };
debug!(
target: "engine::tree::payload_processor",
checkout_id = self.checkout_id,
"Sparse trie checkout dropped before store, returning cleared trie to cache"
);
prepare_cleared_trie(&mut trie);
let deferred = trie.take_deferred_drops();
let mut guard = self.cache.lock();
let _ = guard.store(self.checkout_id, PreservedSparseTrie::cleared(trie));
drop(guard);
drop(deferred);
}
}
/// Guard that holds the lock on the preserved trie.
/// While held, `take()` will block. Call `store()` to save the trie before dropping.
pub(super) struct PreservedTrieGuard<'a>(parking_lot::MutexGuard<'a, Option<PreservedSparseTrie>>);
/// While held, take-or-create calls will block. Call `store()` to save the trie before dropping.
pub(super) struct PreservedTrieGuard<'a> {
state: parking_lot::MutexGuard<'a, PayloadSparseTrieState>,
}
impl PreservedTrieGuard<'_> {
/// Stores a preserved trie for later reuse.
pub(super) fn store(&mut self, trie: PreservedSparseTrie) {
self.0.replace(trie);
/// Stores a preserved trie for later reuse if the checkout is still current.
fn store(
&mut self,
checkout_id: u64,
trie: PreservedSparseTrie,
) -> PayloadSparseTrieStoreOutcome {
if checkout_id != self.state.latest_checkout_id {
debug!(
target: "engine::tree::payload_processor",
checkout_id,
latest_checkout_id = self.state.latest_checkout_id,
"Ignoring stale sparse trie checkout"
);
return PayloadSparseTrieStoreOutcome::IgnoredStaleCheckout;
}
self.state.preserved.replace(trie);
PayloadSparseTrieStoreOutcome::Stored
}
}
/// A preserved sparse trie that can be reused across payload validations.
///
/// The trie exists in one of two states:
/// - **Anchored**: Has a computed state root and can be reused for payloads whose parent state root
/// matches the anchor.
/// - **Anchored**: Has a computed state root and can be reused for payloads whose parent state
/// root matches the anchor.
/// - **Cleared**: Trie data has been cleared but allocations are preserved for reuse.
#[derive(Debug)]
pub(super) enum PreservedSparseTrie {
enum PreservedSparseTrie {
/// Trie with a computed state root that can be reused for continuation payloads.
Anchored {
/// The sparse state trie (pruned after root computation).
@@ -87,24 +286,17 @@ pub(super) enum PreservedSparseTrie {
impl PreservedSparseTrie {
/// Creates a new anchored preserved trie.
///
/// The `state_root` is the computed state root from the trie, which becomes the
/// anchor for determining if subsequent payloads can reuse this trie.
pub(super) const fn anchored(trie: SparseTrie, state_root: B256) -> Self {
const fn anchored(trie: SparseTrie, state_root: B256) -> Self {
Self::Anchored { trie, state_root }
}
/// Creates a cleared preserved trie (allocations preserved, data cleared).
pub(super) const fn cleared(trie: SparseTrie) -> Self {
const fn cleared(trie: SparseTrie) -> Self {
Self::Cleared { trie }
}
/// Consumes self and returns the trie for reuse.
///
/// If the preserved trie is anchored and the parent state root matches, the pruned
/// trie structure is reused directly. Otherwise, the trie is cleared but allocations
/// are preserved to reduce memory overhead.
pub(super) fn into_trie_for(self, parent_state_root: B256) -> SparseTrie {
fn into_trie_for(self, parent_state_root: B256) -> SparseTrie {
match self {
Self::Anchored { trie, state_root } if state_root == parent_state_root => {
debug!(
@@ -135,3 +327,111 @@ impl PreservedSparseTrie {
}
}
}
fn new_sparse_trie(kind: PayloadSparseTrieKind) -> SparseTrie {
let default_trie = match kind {
PayloadSparseTrieKind::HashMap => {
RevealableSparseTrie::blind_from(ConfigurableSparseTrie::HashMap(
ParallelSparseTrie::default()
.with_parallelism_thresholds(PARALLEL_SPARSE_TRIE_PARALLELISM_THRESHOLDS),
))
}
PayloadSparseTrieKind::Arena => RevealableSparseTrie::blind_from(
ConfigurableSparseTrie::Arena(ArenaParallelSparseTrie::default()),
),
};
SparseStateTrie::default()
.with_accounts_trie(default_trie.clone())
.with_default_storage_trie(default_trie)
.with_updates(true)
}
fn prepare_cleared_trie(trie: &mut SparseTrie) {
trie.clear();
trie.shrink_to(SPARSE_TRIE_MAX_NODES_SHRINK_CAPACITY, SPARSE_TRIE_MAX_VALUES_SHRINK_CAPACITY);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn take_or_create_reuses_matching_anchor() {
let cache = PayloadSparseTrieCache::default();
let state_root = B256::with_last_byte(1);
assert_eq!(
cache.take_or_create_for(state_root).store_anchored(state_root),
PayloadSparseTrieStoreOutcome::Stored
);
match cache.state.lock().preserved.as_ref() {
Some(PreservedSparseTrie::Anchored { state_root: anchored, .. }) => {
assert_eq!(*anchored, state_root);
}
other => panic!("expected anchored trie, got {other:?}"),
}
}
#[test]
fn drop_restores_cleared_trie() {
let cache = PayloadSparseTrieCache::default();
let state_root = B256::with_last_byte(2);
let mut checkout = cache.take_or_create_for(state_root);
checkout.set_updates(true);
drop(checkout);
match cache.state.lock().preserved.as_ref() {
Some(PreservedSparseTrie::Cleared { .. }) => {}
other => panic!("expected cleared trie, got {other:?}"),
}
}
#[test]
fn stale_checkout_does_not_overwrite_newer_store() {
let cache = PayloadSparseTrieCache::default();
let parent_state_root = B256::with_last_byte(3);
let anchored_state_root = B256::with_last_byte(4);
let stale = cache.take_or_create_for(parent_state_root);
let fresh = cache.take_or_create_for(parent_state_root);
assert_eq!(
fresh.store_anchored(anchored_state_root),
PayloadSparseTrieStoreOutcome::Stored
);
assert_eq!(stale.store_cleared(), PayloadSparseTrieStoreOutcome::IgnoredStaleCheckout);
match cache.state.lock().preserved.as_ref() {
Some(PreservedSparseTrie::Anchored { state_root, .. }) => {
assert_eq!(*state_root, anchored_state_root);
}
other => panic!("expected anchored trie to survive stale checkout, got {other:?}"),
}
}
#[test]
fn stale_checkout_drop_does_not_overwrite_newer_store() {
let cache = PayloadSparseTrieCache::default();
let parent_state_root = B256::with_last_byte(5);
let anchored_state_root = B256::with_last_byte(6);
let stale = cache.take_or_create_for(parent_state_root);
let fresh = cache.take_or_create_for(parent_state_root);
assert_eq!(
fresh.store_anchored(anchored_state_root),
PayloadSparseTrieStoreOutcome::Stored
);
drop(stale);
match cache.state.lock().preserved.as_ref() {
Some(PreservedSparseTrie::Anchored { state_root, .. }) => {
assert_eq!(*state_root, anchored_state_root);
}
other => panic!("expected anchored trie to survive stale checkout drop, got {other:?}"),
}
}
}

View File

@@ -84,7 +84,7 @@ where
Evm: ConfigureEvm<Primitives = N> + 'static,
{
/// Initializes the task with the given transactions pending execution
pub fn new(
pub(crate) fn new(
executor: Runtime,
execution_cache: PayloadExecutionCache,
ctx: PrewarmContext<N, P, Evm>,

View File

@@ -0,0 +1,281 @@
//! Receipt root computation in a background task.
//!
//! This module provides a streaming receipt root builder that computes the receipt trie root
//! in a background thread. Receipts are sent via a channel with their index, and for each
//! receipt received, the builder incrementally flushes leaves to the underlying
//! [`OrderedTrieRootEncodedBuilder`] when possible. When the channel closes, the task returns the
//! computed root.
use alloy_eips::Encodable2718;
use alloy_primitives::{Bloom, B256};
use crossbeam_channel::Receiver;
use reth_primitives_traits::Receipt;
use reth_trie_common::ordered_root::OrderedTrieRootEncodedBuilder;
use tokio::sync::oneshot;
use tracing::debug_span;
/// Receipt with index, ready to be sent to the background task for encoding and trie building.
#[derive(Debug, Clone)]
pub struct IndexedReceipt<R> {
/// The transaction index within the block.
pub index: usize,
/// The receipt.
pub receipt: R,
}
impl<R> IndexedReceipt<R> {
/// Creates a new indexed receipt.
#[inline]
pub const fn new(index: usize, receipt: R) -> Self {
Self { index, receipt }
}
}
/// Handle for running the receipt root computation in a background task.
///
/// This struct holds the channels needed to receive receipts and send the result.
/// Use [`Self::run`] to execute the computation (typically in a spawned blocking task).
#[derive(Debug)]
pub struct ReceiptRootTaskHandle<R> {
/// Receiver for indexed receipts.
receipt_rx: Receiver<IndexedReceipt<R>>,
/// Sender for the computed result.
result_tx: oneshot::Sender<(B256, Bloom)>,
}
impl<R: Receipt> ReceiptRootTaskHandle<R> {
/// Creates a new handle from the receipt receiver and result sender channels.
pub const fn new(
receipt_rx: Receiver<IndexedReceipt<R>>,
result_tx: oneshot::Sender<(B256, Bloom)>,
) -> Self {
Self { receipt_rx, result_tx }
}
/// Runs the receipt root computation, consuming the handle.
///
/// This method receives indexed receipts from the channel, encodes them,
/// and builds the trie incrementally. When all receipts have been received
/// (channel closed), it sends the result through the oneshot channel.
///
/// This is designed to be called inside a blocking task (e.g., via
/// `executor.spawn_blocking(move || handle.run(receipts_len))`).
///
/// # Arguments
///
/// * `receipts_len` - The total number of receipts expected. This is needed to correctly order
/// the trie keys according to RLP encoding rules.
pub fn run(self, receipts_len: usize) {
let _span = debug_span!(
target: "engine::tree::payload_processor",
"receipt_root",
receipts_len,
)
.entered();
let mut builder = OrderedTrieRootEncodedBuilder::new(receipts_len);
let mut aggregated_bloom = Bloom::ZERO;
let mut encode_buf = Vec::new();
let mut received_count = 0usize;
for indexed_receipt in self.receipt_rx {
let receipt_with_bloom = indexed_receipt.receipt.with_bloom_ref();
encode_buf.clear();
receipt_with_bloom.encode_2718(&mut encode_buf);
aggregated_bloom |= *receipt_with_bloom.bloom_ref();
match builder.push(indexed_receipt.index, &encode_buf) {
Ok(()) => {
received_count += 1;
}
Err(err) => {
// If a duplicate or out-of-bounds index is streamed, skip it and
// fall back to computing the receipt root from the full receipts
// vector later.
tracing::error!(
target: "engine::tree::payload_processor",
index = indexed_receipt.index,
?err,
"Receipt root task received invalid receipt index, skipping"
);
}
}
}
let Ok(root) = builder.finalize() else {
// Finalize fails if we didn't receive exactly `receipts_len` receipts. This can
// happen if execution was aborted early (e.g., invalid transaction encountered).
// We return without sending a result, allowing the caller to handle the abort.
tracing::error!(
target: "engine::tree::payload_processor",
expected = receipts_len,
received = received_count,
"Receipt root task received incomplete receipts, execution likely aborted"
);
return;
};
let _ = self.result_tx.send((root, aggregated_bloom));
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloy_consensus::{proofs::calculate_receipt_root, TxReceipt};
use alloy_primitives::{b256, hex, Address, Bytes, Log};
use crossbeam_channel::bounded;
use reth_ethereum_primitives::{Receipt, TxType};
#[tokio::test]
async fn test_receipt_root_task_empty() {
let (_tx, rx) = bounded::<IndexedReceipt<Receipt>>(1);
let (result_tx, result_rx) = oneshot::channel();
drop(_tx);
let handle = ReceiptRootTaskHandle::new(rx, result_tx);
tokio::task::spawn_blocking(move || handle.run(0)).await.unwrap();
let (root, bloom) = result_rx.await.unwrap();
// Empty trie root
assert_eq!(root, reth_trie_common::EMPTY_ROOT_HASH);
assert_eq!(bloom, Bloom::ZERO);
}
#[tokio::test]
async fn test_receipt_root_task_single_receipt() {
let receipts: Vec<Receipt> = vec![Receipt::default()];
let (tx, rx) = bounded(1);
let (result_tx, result_rx) = oneshot::channel();
let receipts_len = receipts.len();
let handle = ReceiptRootTaskHandle::new(rx, result_tx);
let join_handle = tokio::task::spawn_blocking(move || handle.run(receipts_len));
for (i, receipt) in receipts.clone().into_iter().enumerate() {
tx.send(IndexedReceipt::new(i, receipt)).unwrap();
}
drop(tx);
join_handle.await.unwrap();
let (root, _bloom) = result_rx.await.unwrap();
// Verify against the standard calculation
let receipts_with_bloom: Vec<_> = receipts.iter().map(|r| r.with_bloom_ref()).collect();
let expected_root = calculate_receipt_root(&receipts_with_bloom);
assert_eq!(root, expected_root);
}
#[tokio::test]
async fn test_receipt_root_task_multiple_receipts() {
let receipts: Vec<Receipt> = vec![Receipt::default(); 5];
let (tx, rx) = bounded(4);
let (result_tx, result_rx) = oneshot::channel();
let receipts_len = receipts.len();
let handle = ReceiptRootTaskHandle::new(rx, result_tx);
let join_handle = tokio::task::spawn_blocking(move || handle.run(receipts_len));
for (i, receipt) in receipts.into_iter().enumerate() {
tx.send(IndexedReceipt::new(i, receipt)).unwrap();
}
drop(tx);
join_handle.await.unwrap();
let (root, bloom) = result_rx.await.unwrap();
// Verify against expected values from existing test
assert_eq!(
root,
b256!("0x61353b4fb714dc1fccacbf7eafc4273e62f3d1eed716fe41b2a0cd2e12c63ebc")
);
assert_eq!(
bloom,
Bloom::from(hex!("00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"))
);
}
#[tokio::test]
async fn test_receipt_root_matches_standard_calculation() {
// Create some receipts with actual data
let receipts = vec![
Receipt {
tx_type: TxType::Legacy,
cumulative_gas_used: 21000,
success: true,
logs: vec![],
},
Receipt {
tx_type: TxType::Eip1559,
cumulative_gas_used: 42000,
success: true,
logs: vec![Log {
address: Address::ZERO,
data: alloy_primitives::LogData::new_unchecked(vec![B256::ZERO], Bytes::new()),
}],
},
Receipt {
tx_type: TxType::Eip2930,
cumulative_gas_used: 63000,
success: false,
logs: vec![],
},
];
// Calculate expected values first (before we move receipts)
let receipts_with_bloom: Vec<_> = receipts.iter().map(|r| r.with_bloom_ref()).collect();
let expected_root = calculate_receipt_root(&receipts_with_bloom);
let expected_bloom =
receipts_with_bloom.iter().fold(Bloom::ZERO, |bloom, r| bloom | r.bloom_ref());
// Calculate using the task
let (tx, rx) = bounded(4);
let (result_tx, result_rx) = oneshot::channel();
let receipts_len = receipts.len();
let handle = ReceiptRootTaskHandle::new(rx, result_tx);
let join_handle = tokio::task::spawn_blocking(move || handle.run(receipts_len));
for (i, receipt) in receipts.into_iter().enumerate() {
tx.send(IndexedReceipt::new(i, receipt)).unwrap();
}
drop(tx);
join_handle.await.unwrap();
let (task_root, task_bloom) = result_rx.await.unwrap();
assert_eq!(task_root, expected_root);
assert_eq!(task_bloom, expected_bloom);
}
#[tokio::test]
async fn test_receipt_root_task_out_of_order() {
let receipts: Vec<Receipt> = vec![Receipt::default(); 5];
// Calculate expected values first (before we move receipts)
let receipts_with_bloom: Vec<_> = receipts.iter().map(|r| r.with_bloom_ref()).collect();
let expected_root = calculate_receipt_root(&receipts_with_bloom);
let (tx, rx) = bounded(4);
let (result_tx, result_rx) = oneshot::channel();
let receipts_len = receipts.len();
let handle = ReceiptRootTaskHandle::new(rx, result_tx);
let join_handle = tokio::task::spawn_blocking(move || handle.run(receipts_len));
// Send in reverse order to test out-of-order handling
for (i, receipt) in receipts.into_iter().enumerate().rev() {
tx.send(IndexedReceipt::new(i, receipt)).unwrap();
}
drop(tx);
join_handle.await.unwrap();
let (root, _bloom) = result_rx.await.unwrap();
assert_eq!(root, expected_root);
}
}

View File

@@ -7,13 +7,13 @@ use crate::tree::{
dispatch_with_chunking, evm_state_to_hashed_post_state, MultiProofMessage,
DEFAULT_MAX_TARGETS_FOR_CHUNKING,
},
payload_processor::multiproof::MultiProofTaskMetrics,
payload_processor::{multiproof::MultiProofTaskMetrics, SparseTrieCheckout},
};
use alloy_primitives::B256;
use alloy_rlp::{Decodable, Encodable};
use crossbeam_channel::{Receiver as CrossbeamReceiver, Sender as CrossbeamSender};
use rayon::iter::ParallelIterator;
use reth_primitives_traits::{Account, FastInstant as Instant, ParallelBridgeBuffered};
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use reth_primitives_traits::{Account, FastInstant as Instant};
use reth_tasks::Runtime;
use reth_trie::{
updates::TrieUpdates, DecodedMultiProofV2, HashedPostState, TrieAccount, EMPTY_ROOT_HASH,
@@ -29,8 +29,8 @@ use reth_trie_parallel::{
#[cfg(feature = "trie-debug")]
use reth_trie_sparse::debug_recorder::TrieDebugRecorder;
use reth_trie_sparse::{
errors::SparseTrieResult, DeferredDrops, LeafUpdate, ParallelSparseTrie, SparseStateTrie,
SparseTrie,
errors::SparseTrieResult, ConfigurableSparseTrie, DeferredDrops, LeafUpdate,
RevealableSparseTrie,
};
use revm_primitives::{hash_map::Entry, B256Map};
use tracing::{debug, debug_span, error, instrument, trace_span};
@@ -39,7 +39,7 @@ use tracing::{debug, debug_span, error, instrument, trace_span};
const MAX_PENDING_UPDATES: usize = 100;
/// Sparse trie task implementation that uses in-memory sparse trie data to schedule proof fetching.
pub(super) struct SparseTrieCacheTask<A = ParallelSparseTrie, S = ParallelSparseTrie> {
pub(super) struct SparseTrieCacheTask {
/// Sender for proof results.
proof_result_tx: CrossbeamSender<ProofResultMessage>,
/// Receiver for proof results directly from workers.
@@ -47,7 +47,7 @@ pub(super) struct SparseTrieCacheTask<A = ParallelSparseTrie, S = ParallelSparse
/// Receives updates from execution and prewarming.
updates: CrossbeamReceiver<SparseTrieTaskMessage>,
/// `SparseStateTrie` used for computing the state root.
trie: SparseStateTrie<A, S>,
trie: SparseTrieCheckout,
/// Handle to the proof worker pools (storage and account).
proof_worker_handle: ProofWorkerHandle,
@@ -110,18 +110,14 @@ pub(super) struct SparseTrieCacheTask<A = ParallelSparseTrie, S = ParallelSparse
metrics: MultiProofTaskMetrics,
}
impl<A, S> SparseTrieCacheTask<A, S>
where
A: SparseTrie + Default,
S: SparseTrie + Default + Clone,
{
impl SparseTrieCacheTask {
/// Creates a new sparse trie, pre-populating with an existing [`SparseStateTrie`].
pub(super) fn new_with_trie(
pub(super) fn new_with_checkout(
executor: &Runtime,
updates: CrossbeamReceiver<MultiProofMessage>,
proof_worker_handle: ProofWorkerHandle,
metrics: MultiProofTaskMetrics,
trie: SparseStateTrie<A, S>,
trie: SparseTrieCheckout,
chunk_size: usize,
) -> Self {
let (proof_result_tx, proof_result_rx) = crossbeam_channel::unbounded();
@@ -179,9 +175,7 @@ where
MultiProofMessage::FinishedStateUpdates => {
SparseTrieTaskMessage::FinishedStateUpdates
}
MultiProofMessage::EmptyProof { .. } | MultiProofMessage::BlockAccessList(_) => {
continue
}
MultiProofMessage::BlockAccessList(_) => continue,
MultiProofMessage::HashedStateUpdate(state) => {
SparseTrieTaskMessage::HashedState(state)
}
@@ -201,17 +195,17 @@ where
/// benchmarking purposes.
pub(super) fn into_trie_for_reuse(
self,
prune_depth: usize,
max_storage_tries: usize,
max_hot_slots: usize,
max_hot_accounts: usize,
max_nodes_capacity: usize,
max_values_capacity: usize,
disable_pruning: bool,
updates: &TrieUpdates,
) -> (SparseStateTrie<A, S>, DeferredDrops) {
) -> (SparseTrieCheckout, DeferredDrops) {
let Self { mut trie, .. } = self;
trie.commit_updates(updates);
if !disable_pruning {
trie.prune(prune_depth, max_storage_tries);
trie.prune(max_hot_slots, max_hot_accounts);
trie.shrink_to(max_nodes_capacity, max_values_capacity);
}
let deferred = trie.take_deferred_drops();
@@ -226,7 +220,7 @@ where
self,
max_nodes_capacity: usize,
max_values_capacity: usize,
) -> (SparseStateTrie<A, S>, DeferredDrops) {
) -> (SparseTrieCheckout, DeferredDrops) {
let Self { mut trie, .. } = self;
trie.clear();
trie.shrink_to(max_nodes_capacity, max_values_capacity);
@@ -308,9 +302,9 @@ where
self.promote_pending_account_updates()?;
self.metrics.sparse_trie_process_updates_duration_histogram.record(t.elapsed());
if self.finished_state_updates &&
self.account_updates.is_empty() &&
self.storage_updates.iter().all(|(_, updates)| updates.is_empty())
if self.finished_state_updates
&& self.account_updates.is_empty()
&& self.storage_updates.iter().all(|(_, updates)| updates.is_empty())
{
break;
}
@@ -384,13 +378,13 @@ where
}
for (address, slots) in targets.storage_targets {
for slot in slots {
// Only touch storages that are not yet present in the updates set.
self.new_storage_updates
.entry(address)
.or_default()
.entry(slot.key())
.or_insert(LeafUpdate::Touched);
if !slots.is_empty() {
// Look up outer map once per address instead of once per slot.
let new_updates = self.new_storage_updates.entry(address).or_default();
for slot in slots {
// Only touch storages that are not yet present in the updates set.
new_updates.entry(slot.key()).or_insert(LeafUpdate::Touched);
}
}
// Touch corresponding account leaf to make sure its revealed in accounts trie for
@@ -407,19 +401,26 @@ where
)]
fn on_hashed_state_update(&mut self, hashed_state_update: HashedPostState) {
for (address, storage) in hashed_state_update.storages {
for (slot, value) in storage.storage {
let encoded = if value.is_zero() {
Vec::new()
} else {
alloy_rlp::encode_fixed_size(&value).to_vec()
};
self.new_storage_updates
.entry(address)
.or_default()
.insert(slot, LeafUpdate::Changed(encoded));
if !storage.storage.is_empty() {
// Look up outer maps once per address instead of once per slot.
let new_updates = self.new_storage_updates.entry(address).or_default();
let mut existing_updates = self.storage_updates.get_mut(&address);
// Remove an existing storage update if it exists.
self.storage_updates.get_mut(&address).and_then(|updates| updates.remove(&slot));
for (slot, value) in storage.storage {
self.trie.record_slot_touch(address, slot);
let encoded = if value.is_zero() {
Vec::new()
} else {
alloy_rlp::encode_fixed_size(&value).to_vec()
};
new_updates.insert(slot, LeafUpdate::Changed(encoded));
// Remove an existing storage update if it exists.
if let Some(ref mut existing) = existing_updates {
existing.remove(&slot);
}
}
}
// Make sure account is tracked in `account_updates` so that it is revealed in accounts
@@ -432,6 +433,8 @@ where
}
for (address, account) in hashed_state_update.accounts {
self.trie.record_account_touch(address);
// Track account as touched.
//
// This might overwrite an existing update, which is fine, because storage root from it
@@ -593,6 +596,59 @@ where
Ok(updates_len_after < updates_len_before)
}
/// Computes storage roots for accounts whose storage updates are fully drained.
///
/// For each storage trie T that:
/// 1. was modified in the current block,
/// 2. all the storage updates are fully drained,
/// 3. but the storage root hasn't been updated yet,
///
/// we trigger state root computation on a rayon pool.
#[instrument(
level = "debug",
target = "engine::tree::payload_processor::sparse_trie",
skip_all
)]
fn compute_drained_storage_roots(&mut self) {
let addresses_to_compute_roots: Vec<_> = self
.storage_updates
.iter()
.filter_map(|(address, updates)| updates.is_empty().then_some(*address))
.collect();
struct SendStorageTriePtr(*mut RevealableSparseTrie<ConfigurableSparseTrie>);
// SAFETY: this wrapper only forwards the pointer across rayon; deref invariants are
// documented at the use site below.
unsafe impl Send for SendStorageTriePtr {}
let mut tries_to_compute_roots: Vec<(B256, SendStorageTriePtr)> =
Vec::with_capacity(addresses_to_compute_roots.len());
for address in addresses_to_compute_roots {
if let Some(trie) = self.trie.storage_tries_mut().get_mut(&address)
&& !trie.is_root_cached()
{
tries_to_compute_roots.push((address, SendStorageTriePtr(trie)));
}
}
let parent_span = tracing::Span::current();
tries_to_compute_roots.into_par_iter().for_each(|(address, SendStorageTriePtr(trie))| {
let _enter = debug_span!(
target: "engine::tree::payload_processor::sparse_trie",
parent: &parent_span,
"storage_root",
?address
)
.entered();
// SAFETY:
// - pointers are created from `storage_tries_mut().get_mut(address)` above;
// - `addresses_to_compute_roots` comes from map iteration, so addresses are unique;
// - we do not insert/remove entries between pointer collection and use, so pointers
// stay valid and map reallocation cannot occur;
// - each pointer is consumed by at most one rayon task, so no aliasing mutable access.
unsafe { (*trie).root().expect("updates are drained, trie should be revealed by now") };
});
}
/// Iterates through all storage tries for which all updates were processed, computes their
/// storage roots, and promotes corresponding pending account updates into proper leaf updates
@@ -609,21 +665,7 @@ where
return Ok(());
}
let span = debug_span!("compute_storage_roots").entered();
self
.trie
.storage_tries_mut()
.iter_mut()
.filter(|(address, trie)| {
self.storage_updates.get(*address).is_some_and(|updates| updates.is_empty()) &&
!trie.is_root_cached()
})
.par_bridge_buffered()
.for_each(|(address, trie)| {
let _enter = debug_span!(target: "engine::tree::payload_processor::sparse_trie", parent: &span, "storage_root", ?address).entered();
trie.root().expect("updates are drained, trie should be revealed by now");
});
drop(span);
self.compute_drained_storage_roots();
loop {
let span = debug_span!("promote_updates", promoted = tracing::field::Empty).entered();
@@ -682,7 +724,7 @@ where
// We need to keep iterating if any updates are being drained because that might
// indicate that more pending account updates can be promoted.
if num_promoted == 0 || !self.process_account_leaf_updates(false)? {
break
break;
}
}
@@ -803,7 +845,6 @@ pub struct StateRootComputeOutcome {
mod tests {
use super::*;
use alloy_primitives::{keccak256, Address, B256, U256};
use reth_trie_sparse::ParallelSparseTrie;
#[test]
fn test_run_hashing_task_hashed_state_update_forwards() {
@@ -826,10 +867,7 @@ mod tests {
let expected_state = hashed_state.clone();
let handle = std::thread::spawn(move || {
SparseTrieCacheTask::<ParallelSparseTrie, ParallelSparseTrie>::run_hashing_task(
updates_rx,
hashed_state_tx,
);
SparseTrieCacheTask::run_hashing_task(updates_rx, hashed_state_tx);
});
updates_tx.send(MultiProofMessage::HashedStateUpdate(hashed_state)).unwrap();

View File

@@ -1,10 +1,10 @@
//! Types and traits for validating blocks and payloads.
use crate::tree::{
cached_state::CachedStateProvider,
cached_state::{CacheStats, CachedStateProvider},
error::{InsertBlockError, InsertBlockErrorKind, InsertPayloadError},
instrumented_state::InstrumentedStateProvider,
payload_processor::PayloadProcessor,
instrumented_state::{InstrumentedStateProvider, StateProviderStats},
payload_processor::{EngineSharedCaches, PayloadProcessor},
precompile_cache::{CachedPrecompile, CachedPrecompileMetrics, PrecompileCacheMap},
sparse_trie::StateRootComputeOutcome,
CacheWaitDurations, EngineApiMetrics, EngineApiTreeState, ExecutionEnv, PayloadHandle,
@@ -14,12 +14,14 @@ use alloy_consensus::transaction::{Either, TxHashRef};
use alloy_eip7928::BlockAccessList;
use alloy_eips::{eip1898::BlockWithParent, eip4895::Withdrawal, NumHash};
use alloy_evm::Evm;
use alloy_primitives::B256;
use alloy_primitives::{map::B256Set, B256};
#[cfg(feature = "trie-debug")]
use reth_trie_sparse::debug_recorder::TrieDebugRecorder;
use crate::tree::payload_processor::post_exec::PostExecHandle;
use reth_chain_state::{CanonicalInMemoryState, DeferredTrieData, ExecutedBlock, LazyOverlay};
use crate::tree::payload_processor::receipt_root_task::{IndexedReceipt, ReceiptRootTaskHandle};
use reth_chain_state::{
CanonicalInMemoryState, DeferredTrieData, ExecutedBlock, ExecutionTimingStats, LazyOverlay,
};
use reth_consensus::{ConsensusError, FullConsensus, ReceiptRootBloom};
use reth_engine_primitives::{
ConfigureEngineEvm, ExecutableTxIterator, ExecutionPayload, InvalidBlockHook, PayloadValidator,
@@ -42,11 +44,11 @@ use reth_provider::{
ProviderError, PruneCheckpointReader, StageCheckpointReader, StateProvider,
StateProviderFactory, StateReader, StorageChangeSetReader, StorageSettingsCache,
};
use reth_revm::db::{states::bundle_state::BundleRetention, State};
use reth_revm::db::{states::bundle_state::BundleRetention, BundleAccount, State};
use reth_trie::{trie_cursor::TrieCursorFactory, updates::TrieUpdates, HashedPostState, StateRoot};
use reth_trie_db::ChangesetCache;
use reth_trie_parallel::root::{ParallelStateRoot, ParallelStateRootError};
use revm_primitives::Address;
use revm_primitives::{Address, KECCAK_EMPTY};
use std::{
collections::HashMap,
panic::{self, AssertUnwindSafe},
@@ -55,12 +57,23 @@ use std::{
mpsc::RecvTimeoutError,
Arc,
},
time::Duration,
};
use tracing::{debug, debug_span, error, info, instrument, trace, warn, Span};
/// Output of block or payload validation.
pub type ValidationOutcome<N, E = InsertPayloadError<BlockTy<N>>> =
Result<(ExecutedBlock<N>, Option<Box<ExecutionTimingStats>>), E>;
/// Handle to a [`HashedPostState`] computed on a background thread.
type LazyHashedPostState = reth_tasks::LazyHandle<HashedPostState>;
/// Result type for block validation with optional timing stats.
type InsertPayloadResult<N> = Result<
(ExecutedBlock<N>, Option<Box<ExecutionTimingStats>>),
InsertPayloadError<<N as NodePrimitives>::Block>,
>;
/// Context providing access to tree state during validation.
///
/// This context is provided to the [`EngineValidator`] and includes the state of the tree's
@@ -89,7 +102,9 @@ impl<'a, N: NodePrimitives> TreeCtx<'a, N> {
) -> Self {
Self { state, canonical_in_memory_state }
}
}
impl<'a, N: NodePrimitives> TreeCtx<'a, N> {
/// Returns a reference to the engine tree state
pub const fn state(&self) -> &EngineApiTreeState<N> {
&*self.state
@@ -175,16 +190,13 @@ where
validator: V,
config: TreeConfig,
invalid_block_hook: Box<dyn InvalidBlockHook<N>>,
shared_caches: EngineSharedCaches<Evm>,
changeset_cache: ChangesetCache,
runtime: reth_tasks::Runtime,
) -> Self {
let precompile_cache_map = PrecompileCacheMap::default();
let payload_processor = PayloadProcessor::new(
runtime.clone(),
evm_config.clone(),
&config,
precompile_cache_map.clone(),
);
let precompile_cache_map = shared_caches.precompile_cache_map();
let payload_processor =
PayloadProcessor::new(runtime.clone(), evm_config.clone(), &config, shared_caches);
Self {
provider,
consensus,
@@ -280,7 +292,7 @@ where
input: BlockOrPayload<T>,
execution_err: InsertBlockErrorKind,
parent_block: &SealedHeader<N::BlockHeader>,
) -> Result<ExecutedBlock<N>, InsertPayloadError<N::Block>>
) -> InsertPayloadResult<N>
where
V: PayloadValidator<T, Block = N::Block>,
{
@@ -298,7 +310,7 @@ where
// Validate block consensus rules which includes header validation
if let Err(consensus_err) = self.validate_block_inner(&block, None) {
// Header validation error takes precedence over execution error
return Err(InsertBlockError::new(block, consensus_err.into()).into())
return Err(InsertBlockError::new(block, consensus_err.into()).into());
}
// Also validate against the parent
@@ -306,7 +318,7 @@ where
self.consensus.validate_header_against_parent(block.sealed_header(), parent_block)
{
// Parent validation error takes precedence over execution error
return Err(InsertBlockError::new(block, consensus_err.into()).into())
return Err(InsertBlockError::new(block, consensus_err.into()).into());
}
// No header validation errors, return the original execution error
@@ -333,7 +345,7 @@ where
&mut self,
input: BlockOrPayload<T>,
mut ctx: TreeCtx<'_, N>,
) -> ValidationOutcome<N, InsertPayloadError<N::Block>>
) -> InsertPayloadResult<N>
where
V: PayloadValidator<T, Block = N::Block> + Clone,
Evm: ConfigureEngineEvm<T::ExecutionData, Primitives = N>,
@@ -381,7 +393,7 @@ where
Ok(val) => val,
Err(e) => {
let block = convert_to_block(input)?;
return Err(InsertBlockError::new(block, e.into()).into())
return Err(InsertBlockError::new(block, e.into()).into());
}
}
};
@@ -414,7 +426,7 @@ where
convert_to_block(input)?,
ProviderError::HeaderNotFound(parent_hash.into()).into(),
)
.into())
.into());
};
let mut state_provider = ensure_ok!(provider_builder.build());
drop(_enter);
@@ -427,7 +439,7 @@ where
convert_to_block(input)?,
ProviderError::HeaderNotFound(parent_hash.into()).into(),
)
.into())
.into());
};
let evm_env = debug_span!(target: "engine::tree::payload_validator", "evm_env")
@@ -485,25 +497,39 @@ where
block_access_list,
));
// Create optional cache stats for detailed block logging
let slow_block_enabled = self.config.slow_block_threshold().is_some();
let cache_stats = slow_block_enabled.then(|| Arc::new(CacheStats::default()));
// Use cached state provider before executing, used in execution after prewarming threads
// complete
if let Some((caches, cache_metrics)) = handle.caches().zip(handle.cache_metrics()) {
state_provider =
Box::new(CachedStateProvider::new(state_provider, caches, cache_metrics));
state_provider = Box::new(
CachedStateProvider::new(state_provider, caches, cache_metrics)
.with_cache_stats(cache_stats.clone()),
);
};
if self.config.state_provider_metrics() {
state_provider = Box::new(InstrumentedStateProvider::new(state_provider, "engine"));
}
let state_provider_stats = if slow_block_enabled || self.config.state_provider_metrics() {
let instrumented_state_provider =
InstrumentedStateProvider::new(state_provider, "engine");
let stats = slow_block_enabled.then(|| instrumented_state_provider.stats());
state_provider = Box::new(instrumented_state_provider);
stats
} else {
None
};
// Execute the block and handle any execution errors.
// The post-exec handle manages receipt root computation in a background worker,
// receiving receipts incrementally as transactions complete.
let (output, senders, mut post_exec) =
// The receipt root task is spawned before execution and receives receipts incrementally
// as transactions complete, allowing parallel computation during execution.
let execute_block_start = Instant::now();
let (output, senders, receipt_root_rx) =
match self.execute_block(state_provider, env, &input, &mut handle) {
Ok(output) => output,
Err(err) => return self.handle_execution_error(input, err, &parent_block),
};
let execution_duration = execute_block_start.elapsed();
// After executing the block we can stop prewarming transactions
handle.stop_prewarming_execution();
@@ -517,40 +543,59 @@ where
// needed. This frees up resources while state root computation continues.
let valid_block_tx = handle.terminate_caching(Some(output.clone()));
// Spawn hashed post state and (for payloads) transaction root on separate threads,
// in parallel with receipt-root finalization. Dropping the channel closes the receipt
// stream.
// Spawn hashed post state computation in background so it runs concurrently with
// block conversion and receipt root computation. This is a pure CPU-bound task
// (keccak256 hashing of all changed addresses and storage slots).
let hashed_state_output = output.clone();
let hashed_state_provider = self.provider.clone();
let block = convert_to_block(input)?;
let tx_root_fn = is_payload.then(|| {
let block = block.clone();
let parent_span = Span::current();
let num_hash = block.num_hash();
move || {
let _span =
debug_span!(target: "engine::tree::payload_validator", parent: parent_span, "payload_tx_root", block = ?num_hash)
.entered();
block.body().calculate_tx_root()
}
});
post_exec.finish(
move || {
let hashed_state: LazyHashedPostState =
self.payload_processor.executor().spawn_blocking_named("hash-post-state", move || {
let _span = debug_span!(
target: "engine::tree::payload_validator",
"hashed_post_state",
)
.entered();
hashed_state_provider.hashed_post_state(&hashed_state_output.state)
},
tx_root_fn,
);
});
let block = convert_to_block(input)?;
let transaction_root = is_payload.then(|| {
let block = block.clone();
let parent_span = Span::current();
let num_hash = block.num_hash();
self.payload_processor.executor().spawn_blocking_named("payload-tx-root", move || {
let _span =
debug_span!(target: "engine::tree::payload_validator", parent: parent_span, "payload_tx_root", block = ?num_hash)
.entered();
block.body().calculate_tx_root()
})
});
let block = block.with_senders(senders);
let receipt_root_bloom = post_exec.receipt_root_bloom();
let transaction_root = post_exec.transaction_root();
let hashed_state: LazyHashedPostState = post_exec.into_lazy_hashed_state();
// Wait for the receipt root computation to complete.
let receipt_root_bloom = {
let _enter = debug_span!(
target: "engine::tree::payload_validator",
"wait_receipt_root",
)
.entered();
receipt_root_rx
.blocking_recv()
.inspect_err(|_| {
tracing::error!(
target: "engine::tree::payload_validator",
"Receipt root task dropped sender without result, receipt root calculation likely aborted"
);
})
.ok()
};
let transaction_root = transaction_root.map(|handle| {
let _span =
debug_span!(target: "engine::tree::payload_validator", "wait_payload_tx_root")
.entered();
handle.try_into_inner().expect("sole handle")
});
let hashed_state = ensure_ok_post_block!(
self.validate_post_execution(
@@ -714,9 +759,20 @@ where
)
.into(),
)
.into())
.into());
}
let timing_stats = state_provider_stats.map(|stats| {
self.calculate_timing_stats(
&block,
stats,
cache_stats,
&output,
execution_duration,
root_elapsed,
)
});
if let Some(valid_block_tx) = valid_block_tx {
let _ = valid_block_tx.send(());
}
@@ -728,14 +784,15 @@ where
let changeset_provider =
ensure_ok_post_block!(overlay_factory.database_provider_ro(), block);
Ok(self.spawn_deferred_trie_task(
let executed_block = self.spawn_deferred_trie_task(
block,
output,
&ctx,
hashed_state,
trie_output,
changeset_provider,
))
);
Ok((executed_block, timing_stats))
}
/// Return sealed block header from database or in-memory state by hash.
@@ -764,14 +821,14 @@ where
) -> Result<(), ConsensusError> {
if let Err(e) = self.consensus.validate_header(block.sealed_header()) {
error!(target: "engine::tree::payload_validator", ?block, "Failed to validate header {}: {e}", block.hash());
return Err(e)
return Err(e);
}
if let Err(e) =
self.consensus.validate_block_pre_execution_with_tx_root(block, transaction_root)
{
error!(target: "engine::tree::payload_validator", ?block, "Failed to validate block {}: {e}", block.hash());
return Err(e)
return Err(e);
}
Ok(())
@@ -793,7 +850,11 @@ where
input: &BlockOrPayload<T>,
handle: &mut PayloadHandle<impl ExecutableTxFor<Evm>, Err, N::Receipt>,
) -> Result<
(BlockExecutionOutput<N::Receipt>, Vec<Address>, PostExecHandle<N::Receipt>),
(
BlockExecutionOutput<N::Receipt>,
Vec<Address>,
tokio::sync::oneshot::Receiver<(B256, alloy_primitives::Bloom)>,
),
InsertBlockErrorKind,
>
where
@@ -842,10 +903,15 @@ where
);
}
// Create a unified post-exec handle that manages receipt root, hashed post state,
// and transaction root computation in parallel background tasks.
// Spawn background task to compute receipt root and logs bloom incrementally.
// Unbounded channel is used since tx count bounds capacity anyway (max ~30k txs per block).
let receipts_len = input.transaction_count();
let post_exec = self.payload_processor.post_exec_handle(receipts_len);
let (receipt_tx, receipt_rx) = crossbeam_channel::unbounded();
let (result_tx, result_rx) = tokio::sync::oneshot::channel();
let task_handle = ReceiptRootTaskHandle::new(receipt_rx, result_tx);
self.payload_processor
.executor()
.spawn_blocking_named("receipt-root", move || task_handle.run(receipts_len));
let transaction_count = input.transaction_count();
let executed_tx_index = Arc::clone(handle.executed_tx_index());
@@ -860,9 +926,10 @@ where
executor,
transaction_count,
handle.iter_transactions(),
&post_exec,
&receipt_tx,
&executed_tx_index,
)?;
drop(receipt_tx);
// Finish execution and get the result
let post_exec_start = Instant::now();
@@ -880,12 +947,12 @@ where
let execution_duration = execution_start.elapsed();
self.metrics.record_block_execution(&output, execution_duration);
self.metrics.record_block_execution_gas_bucket(output.result.gas_used, execution_duration);
debug!(target: "engine::tree::payload_validator", elapsed = ?execution_duration, "Executed block");
Ok((output, senders, post_exec))
Ok((output, senders, result_rx))
}
/// Executes transactions and collects senders, streaming receipts to the post-exec worker.
/// Executes transactions and collects senders, streaming receipts to a background task.
///
/// This method handles:
/// - Applying pre-execution changes (e.g., beacon root updates)
@@ -899,7 +966,7 @@ where
mut executor: E,
transaction_count: usize,
transactions: impl Iterator<Item = Result<Tx, Err>>,
post_exec: &PostExecHandle<N::Receipt>,
receipt_tx: &crossbeam_channel::Sender<IndexedReceipt<N::Receipt>>,
executed_tx_index: &AtomicUsize,
) -> Result<(E, Vec<Address>), BlockExecutionError>
where
@@ -939,6 +1006,7 @@ where
let _enter = debug_span!(
target: "engine::tree",
"execute tx",
tx_index = senders.len() - 1,
)
.entered();
trace!(target: "engine::tree", "Executing transaction");
@@ -953,11 +1021,10 @@ where
let current_len = executor.receipts().len();
if current_len > last_sent_len {
last_sent_len = current_len;
// Stream the latest receipt to the post-exec worker for incremental root
// computation.
// Send the latest receipt to the background task for incremental root computation.
if let Some(receipt) = executor.receipts().last() {
let tx_index = current_len - 1;
post_exec.push_receipt(tx_index, receipt.clone());
let _ = receipt_tx.send(IndexedReceipt::new(tx_index, receipt.clone()));
}
}
}
@@ -1253,7 +1320,7 @@ where
trace!(target: "engine::tree::payload_validator", block=?block.num_hash(), "Validating block consensus");
// validate block consensus rules
if let Err(e) = self.validate_block_inner(block, transaction_root) {
return Err(e.into())
return Err(e.into());
}
// now validate against the parent
@@ -1262,7 +1329,7 @@ where
self.consensus.validate_header_against_parent(block.sealed_header(), parent_block)
{
warn!(target: "engine::tree::payload_validator", ?block, "Failed to validate header {} against parent: {e}", block.hash());
return Err(e.into())
return Err(e.into());
}
drop(_enter);
@@ -1275,7 +1342,7 @@ where
{
// call post-block hook
self.on_invalid_block(parent_block, block, output, None, ctx.state_mut());
return Err(err.into())
return Err(err.into());
}
drop(_enter);
@@ -1291,7 +1358,7 @@ where
{
// call post-block hook
self.on_invalid_block(parent_block, block, output, None, ctx.state_mut());
return Err(err.into())
return Err(err.into());
}
// record post-execution validation duration
@@ -1399,7 +1466,7 @@ where
self.provider.clone(),
historical,
Some(blocks),
)))
)));
}
// Check if the block is persisted
@@ -1407,7 +1474,7 @@ where
debug!(target: "engine::tree::payload_validator", %hash, number = %header.number(), "found canonical state for block in database, creating provider builder");
// For persisted blocks, we create a builder that will fetch state directly from the
// database
return Ok(Some(StateProviderBuilder::new(self.provider.clone(), hash, None)))
return Ok(Some(StateProviderBuilder::new(self.provider.clone(), hash, None)));
}
debug!(target: "engine::tree::payload_validator", %hash, "no canonical state found for block");
@@ -1439,7 +1506,7 @@ where
) {
if state.invalid_headers.get(&block.hash()).is_some() {
// we already marked this block as invalid
return
return;
}
self.invalid_block_hook.on_invalid_block(parent_header, block, output, trie_updates);
}
@@ -1630,10 +1697,142 @@ where
deferred_trie_data,
)
}
}
/// Output of block or payload validation.
pub type ValidationOutcome<N, E = InsertPayloadError<BlockTy<N>>> = Result<ExecutedBlock<N>, E>;
fn calculate_timing_stats(
&self,
block: &RecoveredBlock<N::Block>,
provider_stats: Arc<StateProviderStats>,
cache_stats: Option<Arc<CacheStats>>,
output: &BlockExecutionOutput<N::Receipt>,
execution_duration: Duration,
state_hash_duration: Duration,
) -> Box<ExecutionTimingStats> {
let accounts_read = provider_stats.total_account_fetches();
let storage_read = provider_stats.total_storage_fetches();
let code_read = provider_stats.total_code_fetches();
let code_bytes_read = provider_stats.total_code_fetched_bytes();
// Write stats from BundleState (final state changes)
let accounts_changed = output.state.state.len();
let accounts_deleted =
output.state.state.values().filter(|acc| acc.was_destroyed()).count();
let storage_slots_changed =
output.state.state.values().map(|account| account.storage.len()).sum::<usize>();
let storage_slots_deleted = output
.state
.state
.values()
.flat_map(|account| account.storage.values())
.filter(|slot| {
slot.present_value.is_zero() && !slot.previous_or_original_value.is_zero()
})
.count();
// Helper: check if account represents a new contract deployment
let is_new_deployment = |acc: &BundleAccount| -> bool {
let has_code_now = acc.info.as_ref().is_some_and(|info| info.code_hash != KECCAK_EMPTY);
let had_no_code_before = acc
.original_info
.as_ref()
.map(|info| info.code_hash == KECCAK_EMPTY)
.unwrap_or(true);
has_code_now && had_no_code_before
};
let bytecodes_changed =
output.state.state.values().filter(|acc| is_new_deployment(acc)).count();
// Unique new code hashes to count actual bytes persisted (deduplicated)
let unique_new_code_hashes: B256Set = output
.state
.state
.values()
.filter(|acc| is_new_deployment(acc))
.filter_map(|acc| acc.info.as_ref().map(|info| info.code_hash))
.collect();
let code_bytes_written: usize = unique_new_code_hashes
.iter()
.filter_map(|hash| {
output.state.contracts.get(hash).map(|bytecode| bytecode.original_bytes().len())
})
.sum();
// Total time spent fetching state during execution
let state_read_duration = provider_stats.total_account_fetch_latency() +
provider_stats.total_storage_fetch_latency() +
provider_stats.total_code_fetch_latency();
// EIP-7702 delegation tracking from bytecode changes
// Count new EIP-7702 bytecodes as delegations set
let eip7702_delegations_set =
output.state.contracts.values().filter(|bytecode| bytecode.is_eip7702()).count();
// Delegations cleared: accounts where bytecode changed FROM EIP-7702 TO empty
// This detects when an EIP-7702 delegation is removed by setting code to empty
// Note: Clearing a delegation does NOT destroy the account - it just empties the
// bytecode
let eip7702_delegations_cleared = output
.state
.state
.values()
.filter(|acc| {
// Check if original bytecode was EIP-7702
let original_was_eip7702 = acc
.original_info
.as_ref()
.and_then(|info| info.code.as_ref())
.map(|bytecode| bytecode.is_eip7702())
.unwrap_or(false);
// Check if current code is empty (delegation cleared)
let code_now_empty =
acc.info.as_ref().map(|info| info.code_hash == KECCAK_EMPTY).unwrap_or(false);
original_was_eip7702 && code_now_empty
})
.count();
// Get cache statistics for detailed block logging
let (account_cache_hits, account_cache_misses) = cache_stats
.as_ref()
.map(|s| (s.account_hits(), s.account_misses()))
.unwrap_or_default();
let (storage_cache_hits, storage_cache_misses) = cache_stats
.as_ref()
.map(|s| (s.storage_hits(), s.storage_misses()))
.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();
// Build execution timing stats for detailed block logging
Box::new(ExecutionTimingStats {
block_number: block.number(),
block_hash: block.hash(),
gas_used: output.result.gas_used,
tx_count: block.transaction_count(),
execution_duration,
state_read_duration,
state_hash_duration,
accounts_read,
storage_read,
code_read,
code_bytes_read,
accounts_changed,
accounts_deleted,
storage_slots_changed,
storage_slots_deleted,
bytecodes_changed,
code_bytes_written,
eip7702_delegations_set,
eip7702_delegations_cleared,
account_cache_hits,
account_cache_misses,
storage_cache_hits,
storage_cache_misses,
code_cache_hits,
code_cache_misses,
})
}
}
/// Strategy describing how to compute the state root.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

View File

@@ -20,6 +20,7 @@
//! The [`PersistenceState`] tracks ongoing persistence operations and coordinates
//! between the main execution thread and background persistence workers.
use crate::persistence::PersistenceResult;
use alloy_eips::BlockNumHash;
use alloy_primitives::B256;
use crossbeam_channel::Receiver as CrossbeamReceiver;
@@ -36,7 +37,7 @@ pub struct PersistenceState {
/// Receiver end of channel where the result of the persistence task will be
/// sent when done. A None value means there's no persistence task in progress.
pub(crate) rx:
Option<(CrossbeamReceiver<Option<BlockNumHash>>, Instant, CurrentPersistenceAction)>,
Option<(CrossbeamReceiver<PersistenceResult>, Instant, CurrentPersistenceAction)>,
}
impl PersistenceState {
@@ -50,7 +51,7 @@ impl PersistenceState {
pub(crate) fn start_remove(
&mut self,
new_tip_num: u64,
rx: CrossbeamReceiver<Option<BlockNumHash>>,
rx: CrossbeamReceiver<PersistenceResult>,
) {
self.rx =
Some((rx, Instant::now(), CurrentPersistenceAction::RemovingBlocks { new_tip_num }));
@@ -60,7 +61,7 @@ impl PersistenceState {
pub(crate) fn start_save(
&mut self,
highest: BlockNumHash,
rx: CrossbeamReceiver<Option<BlockNumHash>>,
rx: CrossbeamReceiver<PersistenceResult>,
) {
self.rx = Some((rx, Instant::now(), CurrentPersistenceAction::SavingBlocks { highest }));
}

View File

@@ -169,11 +169,11 @@ where
}
fn call(&self, input: PrecompileInput<'_>) -> PrecompileResult {
if let Some(entry) = &self.cache.get(input.data, self.spec_id.clone()) {
if let Some(entry) = &self.cache.get(input.data, self.spec_id.clone()) &&
input.gas >= entry.gas_used()
{
self.increment_by_one_precompile_cache_hits();
if input.gas >= entry.gas_used() {
return entry.to_precompile_result()
}
return entry.to_precompile_result()
}
let calldata = input.data;

View File

@@ -36,6 +36,7 @@ use std::{
mpsc::{Receiver, Sender},
Arc,
},
time::Duration,
};
use tokio::sync::oneshot;
@@ -202,6 +203,7 @@ impl TestHarness {
payload_validator,
TreeConfig::default(),
Box::new(NoopInvalidBlockHook::default()),
EngineSharedCaches::default(),
changeset_cache.clone(),
reth_tasks::Runtime::test(),
);
@@ -406,6 +408,7 @@ impl ValidatorTestHarness {
payload_validator,
TreeConfig::default(),
Box::new(NoopInvalidBlockHook::default()),
EngineSharedCaches::default(),
changeset_cache,
reth_tasks::Runtime::test(),
);
@@ -415,14 +418,13 @@ impl ValidatorTestHarness {
/// Configure `PersistenceState` for specific persistence scenarios
fn start_persistence_operation(&mut self, action: CurrentPersistenceAction) {
// Create a dummy receiver for testing - it will never receive a value
let (_tx, rx) = crossbeam_channel::bounded(1);
match action {
CurrentPersistenceAction::SavingBlocks { highest } => {
let (_tx, rx) = crossbeam_channel::bounded(1);
self.harness.tree.persistence_state.start_save(highest, rx);
}
CurrentPersistenceAction::RemovingBlocks { new_tip_num } => {
let (_tx, rx) = crossbeam_channel::bounded(1);
self.harness.tree.persistence_state.start_remove(new_tip_num, rx);
}
}
@@ -759,7 +761,12 @@ async fn test_tree_state_on_new_head_reorg() {
assert_eq!(saved_blocks, vec![blocks[0].clone(), blocks[1].clone()]);
// send the response so we can advance again
sender.send(Some(blocks[1].recovered_block().num_hash())).unwrap();
sender
.send(PersistenceResult {
last_block: Some(blocks[1].recovered_block().num_hash()),
commit_duration: Some(Duration::ZERO),
})
.unwrap();
// we should be persisting blocks[1] because we threw out the prev action
let current_action = test_harness.tree.persistence_state.current_action().cloned();
@@ -1582,6 +1589,39 @@ mod check_invalid_ancestors_tests {
}
}
/// Test that `find_invalid_ancestor` detects the block itself in the invalid cache
#[test]
fn test_find_invalid_ancestor_detects_block_itself() {
reth_tracing::init_test_tracing();
let mut test_harness = TestHarness::new(HOLESKY.clone());
// Read block 1
let s1 = include_str!("../../test-data/holesky/1.rlp");
let data1 = Bytes::from_str(s1).unwrap();
let block1 = Block::decode(&mut data1.as_ref()).unwrap();
let sealed1 = block1.seal_slow();
let hash1 = sealed1.hash();
let parent1 = sealed1.parent_hash();
// Mark block 1 itself as invalid (simulates a block that failed execution)
test_harness
.tree
.state
.invalid_headers
.insert(BlockWithParent { block: sealed1.num_hash(), parent: parent1 });
// Create payload for block 1 (same block, sent again by CL)
let payload1 = ExecutionData {
payload: ExecutionPayloadV1::from_block_unchecked(hash1, &sealed1.into_block()).into(),
sidecar: ExecutionPayloadSidecar::none(),
};
// find_invalid_ancestor should detect the block itself without re-execution
let result = test_harness.tree.find_invalid_ancestor(&payload1);
assert!(result.is_some(), "Should detect block itself in invalid headers cache");
}
/// Helper function to create a malformed payload that descends from a given parent
fn create_malformed_payload_descending_from(parent_hash: B256) -> ExecutionData {
// Create a block with invalid hash (mismatch between computed and provided hash)
@@ -2034,7 +2074,12 @@ mod forkchoice_updated_tests {
if let Some(last) = saved_blocks.last() {
last_persisted_number = last.recovered_block().number;
}
sender.send(saved_blocks.last().map(|b| b.recovered_block().num_hash())).unwrap();
sender
.send(PersistenceResult {
last_block: saved_blocks.last().map(|b| b.recovered_block().num_hash()),
commit_duration: Some(Duration::ZERO),
})
.unwrap();
}
}

View File

@@ -0,0 +1,27 @@
//! SDK smoke tests for `EngineSharedCaches`.
use alloy_primitives::B256;
use reth_engine_tree::tree::{
EngineSharedCaches, PayloadSparseTrieKind, PayloadSparseTrieStoreOutcome,
};
use reth_evm_ethereum::EthEvmConfig;
#[test]
fn engine_shared_caches_exposes_public_sparse_trie_sdk() {
let caches =
EngineSharedCaches::<EthEvmConfig>::with_sparse_trie_kind(PayloadSparseTrieKind::Arena);
let _precompile_cache_map = caches.precompile_cache_map();
let sparse_trie_cache = caches.sparse_trie_cache();
assert_eq!(sparse_trie_cache.kind(), PayloadSparseTrieKind::Arena);
let state_root = B256::with_last_byte(1);
assert_eq!(
sparse_trie_cache.take_or_create_for(state_root).store_anchored(state_root),
PayloadSparseTrieStoreOutcome::Stored
);
let checkout = sparse_trie_cache.take_or_create_for(state_root);
assert!(checkout.memory_size() > 0 || checkout.retained_storage_tries_count() == 0);
}

View File

@@ -4,6 +4,7 @@ use eyre::{eyre, OptionExt};
use futures_util::{stream::StreamExt, Stream, TryStreamExt};
use reqwest::{Client, IntoUrl, Url};
use reth_era::common::file_ops::EraFileType;
use reth_fs_util::FsPathError;
use sha2::{Digest, Sha256};
use std::{future::Future, path::Path, str::FromStr};
use tokio::{
@@ -136,7 +137,7 @@ impl<Http: HttpClient + Clone> EraClient<Http> {
let Some(number) = self.file_name_to_number(name) &&
(number < index || number >= last)
{
reth_fs_util::remove_file(entry.path())?;
remove_file_ignore_not_found(entry.path())?;
}
}
}
@@ -321,6 +322,16 @@ impl<Http: HttpClient + Clone> EraClient<Http> {
}
}
fn remove_file_ignore_not_found(path: impl AsRef<Path>) -> eyre::Result<()> {
match reth_fs_util::remove_file(path) {
Ok(()) => Ok(()),
Err(FsPathError::RemoveFile { source, .. }) if source.kind() == io::ErrorKind::NotFound => {
Ok(())
}
Err(err) => Err(err.into()),
}
}
async fn checksum(mut reader: impl AsyncRead + Unpin) -> eyre::Result<Vec<u8>> {
let mut hasher = Sha256::new();
@@ -367,4 +378,25 @@ mod tests {
assert_eq!(actual_number, expected_number);
}
#[test]
fn test_remove_file_ignore_not_found() {
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("missing.era1");
assert!(remove_file_ignore_not_found(&path).is_ok());
}
#[test]
fn test_remove_file_ignore_not_found_preserves_other_errors() {
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("dir");
std::fs::create_dir_all(&path).unwrap();
let err = remove_file_ignore_not_found(&path).unwrap_err();
assert!(matches!(
err.downcast_ref::<FsPathError>(),
Some(FsPathError::RemoveFile { source, .. }) if source.kind() != io::ErrorKind::NotFound
));
}
}

View File

@@ -36,8 +36,6 @@ tracing.workspace = true
tempfile.workspace = true
[features]
default = []
otlp = ["reth-tracing/otlp", "reth-node-core/otlp"]
otlp-logs = ["reth-tracing/otlp-logs", "reth-node-core/otlp-logs"]
@@ -89,6 +87,3 @@ min-trace-logs = [
"tracing/release_max_level_trace",
"reth-node-core/min-trace-logs",
]
rocksdb = ["reth-cli-commands/rocksdb"]
edge = ["rocksdb"]

View File

@@ -21,7 +21,7 @@ use reth_node_core::{
args::{LogArgs, OtlpInitStatus, OtlpLogsStatus, TraceArgs},
version::version_metadata,
};
use reth_rpc_server_types::{DefaultRpcModuleValidator, RpcModuleValidator};
use reth_rpc_server_types::{DefaultRpcModuleValidator, RethRpcModule, RpcModuleValidator};
use reth_tracing::{FileWorkerGuard, Layers};
use std::{ffi::OsString, fmt, future::Future, marker::PhantomData, sync::Arc};
use tracing::{info, warn};
@@ -223,7 +223,9 @@ impl<
let otlp_status = runner.block_on(self.traces.init_otlp_tracing(&mut layers))?;
let otlp_logs_status = runner.block_on(self.traces.init_otlp_logs(&mut layers))?;
let guard = self.logs.init_tracing_with_layers(layers)?;
// Enable reload support if debug RPC namespace is available
let enable_reload = self.command.debug_namespace_enabled();
let file_guard = self.logs.init_tracing_with_layers(layers, enable_reload)?;
info!(target: "reth::cli", "Initialized tracing, debug log directory: {}", self.logs.log_file_directory);
match otlp_status {
@@ -246,7 +248,7 @@ impl<
OtlpLogsStatus::Disabled => {}
}
Ok(guard)
Ok(file_guard)
}
}
@@ -349,6 +351,16 @@ impl<C: ChainSpecParser, Ext: clap::Args + fmt::Debug, SubCmd: Subcommand + fmt:
Self::Ext(_) => None,
}
}
/// Returns `true` if this is a node command with debug RPC namespace enabled.
///
/// This is used to determine whether to enable runtime log level changes.
pub fn debug_namespace_enabled(&self) -> bool {
match self {
Self::Node(cmd) => cmd.rpc.is_namespace_enabled(RethRpcModule::Debug),
_ => false,
}
}
}
#[cfg(test)]

View File

@@ -273,7 +273,7 @@ async fn test_sparse_trie_reuse_across_blocks() -> eyre::Result<()> {
let tree_config = TreeConfig::default()
.with_legacy_state_root(false)
.with_sparse_trie_prune_depth(2)
.with_sparse_trie_max_storage_tries(100);
.with_sparse_trie_max_hot_slots(100);
let (mut nodes, _wallet) = setup_engine::<EthereumNode>(
1,

View File

@@ -78,3 +78,43 @@ async fn test_simulate_v1_with_max_fee_per_blob_gas_only() -> eyre::Result<()> {
Ok(())
}
#[tokio::test]
async fn test_simulate_v1_too_many_blocks_error() -> eyre::Result<()> {
reth_tracing::init_test_tracing();
let chain_spec = Arc::new(
ChainSpecBuilder::default()
.chain(MAINNET.chain)
.genesis(serde_json::from_str(include_str!("../assets/genesis.json")).unwrap())
.cancun_activated()
.build(),
);
let (mut nodes, wallet) = setup_engine::<EthereumNode>(
1,
chain_spec,
false,
Default::default(),
eth_payload_attributes,
)
.await?;
let node = nodes.pop().unwrap();
let provider = ProviderBuilder::new()
.wallet(EthereumWallet::new(wallet.wallet_gen().swap_remove(0)))
.connect_http(node.rpc_url());
let payload: SimulatePayload<TransactionRequest> =
(0..257).fold(SimulatePayload::default(), |payload, _| payload.extend(SimBlock::default()));
let err = provider
.raw_request::<_, Vec<SimulatedBlock>>("eth_simulateV1".into(), (&payload, "latest"))
.await
.unwrap_err();
let err = err.as_error_resp().expect("expected JSON-RPC error response");
assert_eq!(err.code, -38026);
assert_eq!(err.message, "too many blocks");
Ok(())
}

View File

@@ -367,7 +367,7 @@ where
.is_prague_active_at_timestamp(attributes.timestamp)
.then_some(execution_result.requests);
let sealed_block = Arc::new(block.sealed_block().clone());
let sealed_block = Arc::new(block.into_sealed_block());
debug!(target: "payload_builder", id=%attributes.id, sealed_block_header = ?sealed_block.sealed_header(), "sealed built block");
if is_osaka && sealed_block.rlp_length() > MAX_RLP_BLOCK_SIZE {

View File

@@ -85,4 +85,7 @@ serde = [
"alloy-rpc-types-eth?/serde",
"rand/serde",
]
rpc = ["dep:alloy-rpc-types-eth"]
rpc = [
"dep:alloy-rpc-types-eth",
"alloy-rpc-types-eth?/serde",
]

View File

@@ -66,8 +66,6 @@ secp256k1.workspace = true
tempfile.workspace = true
[features]
default = []
edge = ["reth-provider/edge"]
serde = [
"reth-exex-types/serde",
"reth-revm/serde",

View File

@@ -3,7 +3,7 @@ use alloy_eips::BlockHashOrNumber;
use alloy_primitives::{BlockHash, BlockNumber, Sealable, B256};
use async_compression::tokio::bufread::GzipDecoder;
use futures::Future;
use itertools::Either;
use itertools::{Either, Itertools};
use reth_consensus::{Consensus, ConsensusError};
use reth_network_p2p::{
bodies::client::{BodiesClient, BodiesFut},
@@ -163,17 +163,9 @@ impl<B: FullBlock> FileClient<B> {
if self.headers.is_empty() {
return true
}
let mut nums = self.headers.keys().copied().collect::<Vec<_>>();
nums.sort_unstable();
let mut iter = nums.into_iter();
let mut lowest = iter.next().expect("not empty");
for next in iter {
if next != lowest + 1 {
return false
}
lowest = next;
}
true
let (min, max) = self.headers.keys().minmax().into_option().expect("not empty");
// Contiguous range from min to max means no gaps
*max - *min + 1 == self.headers.len() as u64
}
/// Use the provided bodies as the file client's block body buffer.

View File

@@ -25,6 +25,7 @@ use reth_primitives_traits::{Block, SignedTransaction};
RlpDecodableWrapper,
Default,
Deref,
DerefMut,
IntoIterator,
)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
@@ -238,7 +239,7 @@ impl NewPooledTransactionHashes {
/// the rest. If `len` is greater than the number of hashes, this has no effect.
pub fn truncate(&mut self, len: usize) {
match self {
Self::Eth66(msg) => msg.0.truncate(len),
Self::Eth66(msg) => msg.truncate(len),
Self::Eth68(msg) => {
msg.types.truncate(len);
msg.sizes.truncate(len);
@@ -263,7 +264,7 @@ impl NewPooledTransactionHashes {
}
}
/// Returns an immutable reference to the inner type if this an eth68 announcement.
/// Returns an immutable reference to the inner type if this is an eth68 announcement.
pub const fn as_eth68(&self) -> Option<&NewPooledTransactionHashes68> {
match self {
Self::Eth66(_) => None,
@@ -271,7 +272,7 @@ impl NewPooledTransactionHashes {
}
}
/// Returns a mutable reference to the inner type if this an eth68 announcement.
/// Returns a mutable reference to the inner type if this is an eth68 announcement.
pub const fn as_eth68_mut(&mut self) -> Option<&mut NewPooledTransactionHashes68> {
match self {
Self::Eth66(_) => None,
@@ -279,7 +280,7 @@ impl NewPooledTransactionHashes {
}
}
/// Returns a mutable reference to the inner type if this an eth66 announcement.
/// Returns a mutable reference to the inner type if this is an eth66 announcement.
pub const fn as_eth66_mut(&mut self) -> Option<&mut NewPooledTransactionHashes66> {
match self {
Self::Eth66(msg) => Some(msg),
@@ -287,7 +288,7 @@ impl NewPooledTransactionHashes {
}
}
/// Returns the inner type if this an eth68 announcement.
/// Returns the inner type if this is an eth68 announcement.
pub fn take_eth68(&mut self) -> Option<NewPooledTransactionHashes68> {
match self {
Self::Eth66(_) => None,
@@ -295,7 +296,7 @@ impl NewPooledTransactionHashes {
}
}
/// Returns the inner type if this an eth66 announcement.
/// Returns the inner type if this is an eth66 announcement.
pub fn take_eth66(&mut self) -> Option<NewPooledTransactionHashes66> {
match self {
Self::Eth66(msg) => Some(mem::take(msg)),
@@ -336,6 +337,7 @@ impl From<NewPooledTransactionHashes68> for NewPooledTransactionHashes {
RlpDecodableWrapper,
Default,
Deref,
DerefMut,
IntoIterator,
)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
@@ -865,8 +867,8 @@ mod tests {
let latest = blocks.latest().unwrap();
assert_eq!(latest.number, 0);
blocks.0.push(BlockHashNumber { hash: B256::random(), number: 100 });
blocks.0.push(BlockHashNumber { hash: B256::random(), number: 2 });
blocks.push(BlockHashNumber { hash: B256::random(), number: 100 });
blocks.push(BlockHashNumber { hash: B256::random(), number: 2 });
let latest = blocks.latest().unwrap();
assert_eq!(latest.number, 100);
}

View File

@@ -63,6 +63,28 @@ impl EthStreamError {
}
}
/// Returns whether this error indicates a protocol breach on the receive side.
///
/// These are errors caused by the remote peer sending invalid or malformed data
/// that warrant disconnecting with [`DisconnectReason::ProtocolBreach`].
pub const fn is_protocol_breach(&self) -> bool {
matches!(
self,
Self::InvalidMessage(_) |
Self::MessageTooBig(_) |
Self::TransactionHashesInvalidLenOfFields { .. } |
Self::UnsupportedMessage { .. } |
Self::P2PStreamError(
P2PStreamError::Rlp(_) |
P2PStreamError::Snap(_) |
P2PStreamError::MessageTooBig { .. } |
P2PStreamError::UnknownReservedMessageId(_) |
P2PStreamError::EmptyProtocolMessage |
P2PStreamError::UnknownDisconnectReason(_)
)
)
}
/// Returns the [`io::Error`] if it was caused by IO
pub const fn as_io(&self) -> Option<&io::Error> {
if let Self::P2PStreamError(P2PStreamError::Io(io)) = self {

View File

@@ -262,12 +262,12 @@ pub enum Direction {
}
impl Direction {
/// Returns `true` if this an incoming connection.
/// Returns `true` if this is an incoming connection.
pub const fn is_incoming(&self) -> bool {
matches!(self, Self::Incoming)
}
/// Returns `true` if this an outgoing connection.
/// Returns `true` if this is an outgoing connection.
pub const fn is_outgoing(&self) -> bool {
matches!(self, Self::Outgoing(_))
}

View File

@@ -21,7 +21,7 @@ alloy-eip2124.workspace = true
# misc
serde = { workspace = true, optional = true }
humantime-serde = { workspace = true, optional = true }
serde_json = { workspace = true, features = ["std"] }
serde_json = { workspace = true, features = ["std"], optional = true }
# misc
tracing.workspace = true
@@ -30,6 +30,7 @@ tracing.workspace = true
serde = [
"dep:serde",
"dep:humantime-serde",
"dep:serde_json",
"alloy-eip2124/serde",
]
test-utils = []

View File

@@ -1,15 +1,9 @@
//! Configuration for peering.
use std::{
collections::HashSet,
io::{self, ErrorKind},
path::Path,
time::Duration,
};
use std::{collections::HashSet, time::Duration};
use reth_net_banlist::{BanList, IpFilter};
use reth_network_peers::{NodeRecord, TrustedPeer};
use tracing::info;
use crate::{peers::PersistedPeerInfo, BackoffKind, ReputationChangeWeights};
@@ -311,16 +305,16 @@ impl PeersConfig {
#[cfg(feature = "serde")]
pub fn with_basic_nodes_from_file(
mut self,
optional_file: Option<impl AsRef<Path>>,
) -> Result<Self, io::Error> {
optional_file: Option<impl AsRef<std::path::Path>>,
) -> Result<Self, std::io::Error> {
let Some(file_path) = optional_file else { return Ok(self) };
let raw = match std::fs::read_to_string(file_path.as_ref()) {
Ok(contents) => contents,
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(self),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(self),
Err(e) => return Err(e),
};
info!(target: "net::peers", file = %file_path.as_ref().display(), "Loading saved peers");
tracing::info!(target: "net::peers", file = %file_path.as_ref().display(), "Loading saved peers");
// Try the new format first, fall back to legacy Vec<NodeRecord>
let peers: Vec<PersistedPeerInfo> = serde_json::from_str(&raw)
@@ -330,9 +324,9 @@ impl PeersConfig {
nodes.into_iter().map(PersistedPeerInfo::from_node_record).collect(),
)
})
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
info!(target: "net::peers", count = peers.len(), "Loaded persisted peers");
tracing::info!(target: "net::peers", count = peers.len(), "Loaded persisted peers");
self.persisted_peers = peers;
Ok(self)
}

View File

@@ -637,7 +637,7 @@ impl<N: NetworkPrimitives> NetworkManager<N> {
PeerMessage::NewBlockHashes(hashes) => {
self.within_pow_or_disconnect(peer_id, |this| {
// update peer's state, to track what blocks this peer has seen
this.swarm.state_mut().on_new_block_hashes(peer_id, hashes.0.clone());
this.swarm.state_mut().on_new_block_hashes(peer_id, hashes.to_vec());
// start block import process for the hashes
this.block_import.on_new_block(peer_id, NewBlockEvent::Hashes(hashes));
})

View File

@@ -65,6 +65,36 @@ pub enum PeerMessage<N: NetworkPrimitives = EthNetworkPrimitives> {
Other(RawCapabilityMessage),
}
impl<N: NetworkPrimitives> PeerMessage<N> {
/// Returns a static string identifying the message variant for logging.
pub const fn message_kind(&self) -> &'static str {
match self {
Self::NewBlockHashes(_) => "NewBlockHashes",
Self::NewBlock(_) => "NewBlock",
Self::ReceivedTransaction(_) => "ReceivedTransaction",
Self::SendTransactions(_) => "SendTransactions",
Self::PooledTransactions(_) => "PooledTransactions",
Self::EthRequest(_) => "EthRequest",
Self::BlockRangeUpdated(_) => "BlockRangeUpdated",
Self::Other(_) => "Other",
}
}
/// Returns the number of items in the message payload, if applicable.
pub fn message_item_count(&self) -> usize {
match self {
Self::NewBlockHashes(msg) => msg.len(),
Self::ReceivedTransaction(msg) => msg.len(),
Self::SendTransactions(msg) => msg.len(),
Self::PooledTransactions(msg) => msg.len(),
Self::NewBlock(_) |
Self::EthRequest(_) |
Self::BlockRangeUpdated(_) |
Self::Other(_) => 1,
}
}
}
/// Request Variants that only target block related data.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BlockRequest {

Some files were not shown because too many files have changed in this diff Show More