Compare commits

...

124 Commits

Author SHA1 Message Date
klkvr
ae9042a5fb wip 2026-04-23 21:25:48 +04:00
Emma Jamieson-Hoare
62d99888d2 fix(db): move unix deps section after strum in Cargo.toml (#23697)
Co-authored-by: Amp <amp@ampcode.com>
2026-04-23 14:08:15 +00:00
dependabot[bot]
73f5d77b51 chore(deps): bump actions/setup-python from 5 to 6 (#23689)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 11:42:47 +00:00
figtracer
b62f71977a fix(era): align ERA1 export with spec (#23693) 2026-04-23 11:09:13 +00:00
JOJO
ad27be67be fix(net): track unknown tx types in announcement metrics (#23688)
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
2026-04-23 10:44:22 +00:00
Veronica Hayes
63f80907cc fix(cli): use TxTy and ReceiptTy for static-file db get (#23692) 2026-04-23 10:29:00 +00:00
Soubhik Singha Mahapatra
a57930481c chore: add DecodedBal in ExecutionEnv (#23675) 2026-04-23 09:48:50 +00:00
Matthias Seitz
bbcfe354a1 fix(rpc): clean up eth state cache reorg entries (#23683) 2026-04-23 07:03:57 +00:00
Arsenii Kulikov
7839f3d876 perf: avoid reopening .csoff on every changeset lookup (#23687) 2026-04-22 23:24:35 +00:00
Matthias Seitz
e89b4611e4 fix(engine): configure invalid header cache hit eviction (#23670) 2026-04-22 20:20:41 +00:00
Emma Jamieson-Hoare
2b7d4b54d4 feat(p2p): Discv5 is enabled by default (#23686) 2026-04-22 17:49:52 +00:00
Emma Jamieson-Hoare
fe7a4c80b6 feat(db): detect and warn about ZFS (#23685) 2026-04-22 16:07:06 +00:00
Brian Picciano
122c5b322b fix(bench): require local benchmark data (#23679)
Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-22 12:16:26 +00:00
Alexey Shekhirin
f1ed5f0ade fix(re-execute): disable read tx timeout (#23680) 2026-04-22 11:13:21 +00:00
Alexey Shekhirin
6364fb87d0 deps: bump rustls-webpki (#23681) 2026-04-22 10:52:11 +00:00
Matthias Seitz
d55458479d chore(deps): bump alloy crates to 2.0.1 (#23677) 2026-04-22 09:15:39 +02:00
AJStonewee
42f49132b7 test: remove unsafe env::set_var(RUST_LOG) from tests (#23672) 2026-04-22 07:04:36 +00:00
Matthias Seitz
f39c47bd11 feat(net): add snap/2 wire helpers and messages (#23611) 2026-04-22 09:03:29 +02:00
Dan Cline
b1ac264107 fix(ci): use second most recent snapshot as previous (#23671) 2026-04-21 19:13:57 +00:00
Ishika Choudhury
0195da5b84 chore(BAL): added parallelization and batch io flags (#23663) 2026-04-21 17:59:59 +00:00
joshieDo
b964195ef8 fix(engine): let consensus impls control which errors are transient (#23668)
Co-authored-by: Amp <amp@ampcode.com>
2026-04-21 17:58:27 +00:00
Matthias Seitz
252fe42c54 refactor(consensus): unify opaque error helpers (#23669) 2026-04-21 15:59:02 +00:00
Brian Picciano
3edb271183 refactor(trie): remove TrieNodeProvider (#23658)
Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-21 12:36:51 +00:00
Karl Yu
165a80441b feat(bal): scaffold BAL store abstraction (#23596) 2026-04-21 10:10:52 +00:00
Ishika Choudhury
981e32d4d9 chore(BAL): enabled bal building in ethereum payload (#23597) 2026-04-21 09:23:32 +00:00
Karl Yu
d7522904a0 feat(p2p): optionally fetch BAL with full blocks (#23629)
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
2026-04-21 09:16:38 +00:00
Arsenii Kulikov
e92af360ae refactor: encapsulate state fetching in db provider (#23656) 2026-04-20 16:38:22 +00:00
joshieDo
408ef4657d feat(engine): suppress persistence during payload building (#23618)
Co-authored-by: Amp <amp@ampcode.com>
2026-04-20 16:28:53 +00:00
Soubhik Singha Mahapatra
3574ecaaa0 chore(bench): add cli flag to fetch bal by default (#23655) 2026-04-20 16:10:08 +00:00
DaniPopes
d58c6e3d07 chore(docs): normalize Grafana dashboard JSON formatting and tags (#23266) 2026-04-20 13:42:53 +00:00
Matthias Seitz
d577814eb1 fix(engine): align Amsterdam endpoint validation (#23625) 2026-04-20 13:34:46 +00:00
Emma Jamieson-Hoare
8b46f1a6d0 chore: release 2.1.0 (#23641)
Co-authored-by: Amp <amp@ampcode.com>
2026-04-20 13:27:51 +00:00
Brian Picciano
c527c2e7d6 fix(engine): revert #23541 and #23578 (#23646)
Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-20 13:23:24 +00:00
Derek Cofausper
14570f325a perf(txpool): replace BTreeMap with imbl::OrdMap in BestTransactions (#23621)
Co-authored-by: DaniPopes <57450786+DaniPopes@users.noreply.github.com>
2026-04-20 12:56:04 +00:00
Emma Jamieson-Hoare
41fe41f2f2 perf(re-execute): relax executor reset thresholds (#23617)
Co-authored-by: Amp <amp@ampcode.com>
2026-04-20 09:39:06 +00:00
Tim
27bfddeada feat: add fetch-grafana-dashboard workflow (#23585) 2026-04-20 08:02:06 +00:00
github-actions[bot]
981a7ef99b chore(deps): weekly cargo update (#23628)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
2026-04-19 08:38:10 +00:00
Matthias Seitz
8c826a5cd0 fix: address nightly clippy warnings (#23630) 2026-04-19 10:13:27 +02:00
Derek Cofausper
6465997ea1 refactor(tasks): make WorkerPool lazy by default (#23627)
Co-authored-by: DaniPopes <57450786+DaniPopes@users.noreply.github.com>
2026-04-18 18:59:13 +00:00
Derek Cofausper
03a308da63 feat(cli): add reth db migrate-v2 for v1→v2 storage migration (#23422)
Co-authored-by: Arsenii Kulikov <62447812+klkvr@users.noreply.github.com>
Co-authored-by: klkvr <klkvrr@gmail.com>
Co-authored-by: Alexey Shekhirin <github@shekhirin.com>
2026-04-18 18:12:28 +00:00
Soubhik Singha Mahapatra
af84b982c3 chore: add slotnum to payload (#23626) 2026-04-18 15:51:44 +00:00
Ishika Choudhury
77c3e86ec6 chore: added gas limit to BlockOrPayload (#23624) 2026-04-18 14:28:07 +00:00
Arsenii Kulikov
98ebc3454f fix: don't cache stateful precompiles (#23619) 2026-04-17 18:42:47 +00:00
Derek Cofausper
c8979d0a1d fix(txpool,rpc): skip tx gas limit cap enforcement when EIP-8037 is active (#23612)
Co-authored-by: Arsenii Kulikov <62447812+klkvr@users.noreply.github.com>
Co-authored-by: klkvr <klkvrr@gmail.com>
2026-04-17 14:35:34 +00:00
Alexey Shekhirin
742a7e7a18 ci: use reth 2.0 banner image in release draft (#23404) 2026-04-17 14:31:41 +00:00
Derek Cofausper
99bf7a17c0 refactor(rpc): accept BlockId in block_access_list_raw (#23615)
Co-authored-by: Matthias Seitz <19890894+mattsse@users.noreply.github.com>
2026-04-17 14:10:56 +00:00
Ishika Choudhury
24436ca9f9 chore(BAL): added changes for slotnum (#23605)
Co-authored-by: klkvr <klkvrr@gmail.com>
2026-04-17 13:17:52 +00:00
Derek Cofausper
c26ec53d7d fix(bench): use previous snapshot to avoid block fetch failures (#23608)
Co-authored-by: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com>
2026-04-17 11:22:14 +00:00
Dan Cline
3a136fc8c3 fix(db): use sync=true for rocksdb WriteOptions (#23603) 2026-04-17 11:17:02 +00:00
Soubhik Singha Mahapatra
d215d16a7d chore(BAL): add eth bal rpc methods to EngineEth (#23609) 2026-04-17 10:39:50 +00:00
Soubhik Singha Mahapatra
b36fff0ab8 feat(BAL): use new engine-api methods in bench (#23517)
Co-authored-by: Ishika Choudhury <117741714+Rimeeeeee@users.noreply.github.com>
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
2026-04-17 10:36:30 +00:00
Derek Cofausper
e4d4ba30cb refactor(provider): simplify get_overlay cache miss tracking (#23584)
Co-authored-by: Sergei Shulepov <2205845+pepyakin@users.noreply.github.com>
2026-04-17 08:15:44 +00:00
John Chase
7c219fa955 fix(cli): open stage dump environment read-write (#23602) 2026-04-17 08:02:35 +00:00
Derek Cofausper
0ac36468c6 feat(cli): add RocksDB support to reth db get (#23032)
Co-authored-by: Arsenii Kulikov <62447812+klkvr@users.noreply.github.com>
Co-authored-by: Dan Cline <6798349+Rjected@users.noreply.github.com>
Co-authored-by: Arsenii Kulikov <klkvrr@gmail.com>
2026-04-16 20:26:04 +00:00
Derek Cofausper
93b2201c76 fix(engine): include backpressure in newPayload prometheus latency (#23578)
Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com>
2026-04-16 18:26:00 +00:00
Dan Cline
9990670990 fix(cli): error on non-mainnet when no download url provided (#23570)
Co-authored-by: Derek Cofausper <256792747+decofe@users.noreply.github.com>
2026-04-16 18:07:26 +00:00
Derek Cofausper
1b69c9bb42 fix(ci): use proper Slack mention for AI bot in bench failure alerts (#23591)
Co-authored-by: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com>
2026-04-16 17:29:14 +00:00
Derek Cofausper
c2e649fc90 perf: parallel segmented snapshot downloads (#23028)
Co-authored-by: Dan Cline <6798349+Rjected@users.noreply.github.com>
Co-authored-by: Alexey Shekhirin <github@shekhirin.com>
Co-authored-by: Emma Jamieson-Hoare <21029500+emmajam@users.noreply.github.com>
Co-authored-by: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com>
2026-04-16 16:57:22 +00:00
Alexey Shekhirin
cff41bb9c2 feat(miner): add --dev.payload-wait-time to LocalMiner (#23598) 2026-04-16 16:04:56 +00:00
Brian Picciano
0a9af7907f fix(ci): clean up bench cpu dma latency helper (#23594)
Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-16 14:02:29 +00:00
figtracer
815d8407ce chore(examples): add custom auth HTTP middleware example (#23586) 2026-04-16 12:53:01 +00:00
cui
6cf6378e36 perf(eth-wire-types): encode DisconnectReason without heap allocation (#23479) 2026-04-16 10:31:52 +00:00
figtracer
39f078e40f feat(rpc): expose auth HTTP transport middleware (#23579) 2026-04-16 10:14:29 +00:00
dependabot[bot]
37a23ae169 chore(deps): bump actions/upload-pages-artifact from 4 to 5 (#23572)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-16 10:00:02 +00:00
dependabot[bot]
8da8f3e4bc chore(deps): bump actions/github-script from 8 to 9 (#23571)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-16 09:59:49 +00:00
Derek Cofausper
f97947b5a5 bench: bump defaults to 200 warmup, 500 blocks (#23580)
Co-authored-by: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com>
2026-04-16 08:49:45 +00:00
Alexey Shekhirin
199b7460a9 refactor: decouple CachedStateMetrics from SavedCache (#23552) 2026-04-15 21:30:15 +00:00
Derek Cofausper
41592ef1f8 fix(download): respect --datadir.static-files during extraction (#23445)
Co-authored-by: Matthias Seitz <19890894+mattsse@users.noreply.github.com>
Co-authored-by: Emma Jamieson-Hoare <21029500+emmajam@users.noreply.github.com>
Co-authored-by: Dan Cline <6798349+Rjected@users.noreply.github.com>
2026-04-15 21:19:42 +00:00
Arsenii Kulikov
bdbb8df17e fix: validate against executor output gas used (#23569) 2026-04-15 20:37:14 +00:00
Dan Cline
f451ad5380 feat(cli): add reth download config options (#23513) 2026-04-15 20:23:08 +00:00
AJStonewee
6e4009eed4 fix(rpc): prevent panic in log subscription on broadcast lag (#23561) 2026-04-15 19:23:33 +00:00
Brian Picciano
cf29b3fffe perf(engine): include backpressure in newPayload latency metric (#23541)
Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-15 17:14:35 +00:00
Matthias Seitz
7fe76a83d1 fix(net): encode block access lists as raw BAL RLP (#23536)
Co-authored-by: Arsenii Kulikov <klkvrr@gmail.com>
2026-04-15 12:42:16 +00:00
Ishika Choudhury
b1cff500ad chore(BAL): remove debug_get_block_access_list (#23534) 2026-04-15 12:33:37 +00:00
figtracer
0b33057414 fix(init-state): write accounts directly with chunked commits (#23469) 2026-04-15 10:52:53 +00:00
Soubhik Singha Mahapatra
3891092ee9 chore: add amsterdam time to chainspec (#23526) 2026-04-15 10:29:14 +00:00
Emma Jamieson-Hoare
8784aa45fc chore: bump revm to v37 (EIP-8037 state gas) (#23191)
Co-authored-by: Federico Gimenez <federico.gimenez@gmail.com>
Co-authored-by: Federico Gimenez <fgimenez@users.noreply.github.com>
Co-authored-by: klkvr <klkvrr@gmail.com>
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
2026-04-15 10:08:12 +00:00
Brian Picciano
f1d90612e3 feat(ci): add slack=on-win mode to bench workflows (#23522)
Co-authored-by: Amp <amp@ampcode.com>
2026-04-15 09:45:37 +00:00
Derek Cofausper
03d69f59a5 chore(ci): add @ai investigate to bench failure alerts (#23520)
Co-authored-by: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com>
2026-04-15 09:19:14 +00:00
Ishika Choudhury
d372c8f5a9 chore(BAL): added gas limit fn to ExecutionPayload (#23518) 2026-04-15 09:01:35 +00:00
Arsenii Kulikov
dbb8495be1 fix: allow adding peers without overriding kind (#23516) 2026-04-14 21:00:39 +00:00
Ishika Choudhury
044db3ec95 feat: implement try into v6 (#23497)
Co-authored-by: Soubhik Singha Mahapatra <soubhiksmp2004@gmail.com>
Co-authored-by: Soubhik Singha Mahapatra <160333583+Soubhik-10@users.noreply.github.com>
2026-04-14 20:04:21 +00:00
Matthias Seitz
13217d5517 feat(discv4): add AddBootNode command (#23515) 2026-04-14 19:32:38 +00:00
Matthias Seitz
0165569bc1 feat(net): add discv4/discv5 getters to NetworkHandle (#23514) 2026-04-14 19:25:54 +00:00
Brian Picciano
84c14fe0a8 ci(bench): replace no_slack boolean with slack dropdown (always/on-error/never) (#23501)
Co-authored-by: Amp <amp@ampcode.com>
2026-04-14 15:57:20 +00:00
Tim
5b4af55017 feat: add reqwest-rustls to support otlp endpoints with https (#23495) 2026-04-14 14:09:09 +00:00
Dan Cline
b8ab2c628e chore(cli): add binary name and chain detection in tempo download log (#23356)
Co-authored-by: Derek Cofausper <256792747+decofe@users.noreply.github.com>
Co-authored-by: Emma Jamieson-Hoare <21029500+emmajam@users.noreply.github.com>
2026-04-14 13:23:29 +00:00
Brian Picciano
766f4317a6 chore(bench): reduce default blocks to 200, warmup to 20 for big-blocks (#23494)
Co-authored-by: mediocregopher <mediocregopher@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-14 13:13:21 +00:00
Derek Cofausper
c20d897efe fix(node): downgrade prune config log from warn to info (#23493)
Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com>
2026-04-14 11:43:43 +00:00
Brian Picciano
ad1e8f2cea feat(bench): BAL capture, replay, and inline payload decoding (#23434)
Co-authored-by: Soubhik Singha Mahapatra <soubhiksmp2004@gmail.com>
Co-authored-by: Soubhik Singha Mahapatra <160333583+Soubhik-10@users.noreply.github.com>
Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: steven <corderosteven6@gmail.com>
Co-authored-by: Derek Cofausper <256792747+decofe@users.noreply.github.com>
2026-04-14 11:23:13 +00:00
Derek Cofausper
51309ff55c fix(bench): retry on all transport errors (#23491)
Co-authored-by: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com>
Co-authored-by: Alexey Shekhirin <github@shekhirin.com>
2026-04-14 10:42:05 +00:00
Soubhik Singha Mahapatra
e0aac5015f chore(BAL): added newPayloadV5 and getPayloadV6 (#23486)
Co-authored-by: Ishika Choudhury <117741714+Rimeeeeee@users.noreply.github.com>
2026-04-14 08:57:42 +00:00
Ishika Choudhury
3b8290439a chore(BAL): added helper functions for building (#23490) 2026-04-14 08:49:49 +00:00
yottaes
1a2836ff53 feat(rpc): support transactionReceipts subscription in eth_subscribe (#23485)
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
2026-04-14 08:42:01 +00:00
MagicJoshh
bce7368a82 fix(engine): use IndexSet for deterministic block buffer child ordering (#22676) 2026-04-13 19:18:23 +00:00
MagicJoshh
1e461ef281 fix(trie): terminate depth-first iterator on database error (#22709) 2026-04-13 17:48:07 +00:00
Ishika Choudhury
a5113622fd chore(BAL): added fcuv4 and EngineApiMessageVersion6 (#23480)
Co-authored-by: Soubhik Singha Mahapatra <soubhiksmp2004@gmail.com>
2026-04-13 14:47:28 +00:00
stevencartavia
bfb7ab72f7 chore: bump alloy to 2.0.0 (#23407)
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
2026-04-13 13:51:19 +00:00
Matthias Seitz
3d5c29c179 feat(net): add enforce_enr_fork_id to DefaultNetworkArgs (#23477) 2026-04-13 13:00:59 +00:00
Ignacio Hagopian
a05960ab07 feat(stateless): make witness generation conform to the draft specs (#22289)
Signed-off-by: jsign <jsign.uy@gmail.com>
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
Co-authored-by: Brian Picciano <me@mediocregopher.com>
2026-04-13 13:34:00 +02:00
John Chase
6b499151d8 perf(txpool): use FxHashMap/FxHashSet for TxHash-heavy containers (#23037) 2026-04-13 11:22:21 +02:00
Matthias Seitz
a9bd38a43e perf(trie): parallelize merge_ancestors_into_overlay (#21473)
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: DaniPopes <57450786+DaniPopes@users.noreply.github.com>
2026-04-12 15:27:30 +02:00
github-actions[bot]
a544d244d8 chore(deps): weekly cargo update (#23464)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-04-12 12:25:36 +02:00
Derek Cofausper
a550b7a7d3 perf(tracing): also disable target attribute in OTLP spans (#23462)
Co-authored-by: Arsenii Kulikov <62447812+klkvr@users.noreply.github.com>
Co-authored-by: Arsenii Kulikov <klkvrr@gmail.com>
2026-04-11 16:54:06 +00:00
Derek Cofausper
7035bbcf3a refactor(examples): replace mev-share-sse with reqwest bytes_stream in beacon-api-sse (#23458)
Co-authored-by: Matthias Seitz <19890894+mattsse@users.noreply.github.com>
2026-04-11 10:33:34 +00:00
Tamjid Ahmed
0c278f5fab feat(eth-wire): introduce configurable maximum ETH message size acros… (#22668)
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
2026-04-11 06:23:12 +00:00
Arsh
03dd1c3ae2 fix(txpool): do not mark ExceedsFeeCap as a bad transaction (#23450) 2026-04-11 05:13:05 +00:00
Dan Cline
6aa2234d9a chore(cli): make --resumable default (#23451) 2026-04-11 04:49:16 +00:00
Derek Cofausper
5ae8f0bc54 perf(engine): downgrade sparse trie spans to trace (#23448)
Co-authored-by: Arsenii Kulikov <62447812+klkvr@users.noreply.github.com>
2026-04-11 00:24:25 +00:00
Derek Cofausper
e3536f768e perf(tracing): disable location and inactivity tracking in OTLP span export (#23447)
Co-authored-by: Arsenii Kulikov <62447812+klkvr@users.noreply.github.com>
2026-04-10 19:23:34 +00:00
Derek Cofausper
ff1a78e1ce ci: remove PGO from CI workflows (#23405)
Co-authored-by: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com>
2026-04-10 19:17:42 +00:00
Matthias Seitz
fc3f465321 fix(net): seed peer range from handshake status (#23446)
Co-authored-by: Weixie Cui <cuiweixie@gmail.com>
2026-04-10 14:06:10 +00:00
cui
b0956b12ae fix(rpc): paginate ots_getBlockTransactions in block order (#23442)
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
2026-04-10 13:32:28 +00:00
cui
a774920b78 fix(provider): size block_range buffer for inclusive span (#23443) 2026-04-10 13:07:59 +00:00
cui
77d5f86b42 fix(consensus): always validate minimum gas limit (#23441) 2026-04-10 12:55:11 +00:00
Hwangjae Lee
e118963b8f fix(rpc): preserve nested bundle structure in mev_simBundle logs (#20565)
Signed-off-by: Hwangjae Lee <meetrick@gmail.com>
Co-authored-by: Derek Cofausper <256792747+decofe@users.noreply.github.com>
Co-authored-by: Matthias Seitz <19890894+mattsse@users.noreply.github.com>
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
2026-04-10 12:54:27 +00:00
Derek Cofausper
64f6117dc0 docs(trie): replace stale MultiProofTask references with SparseTrieCacheTask (#22780)
Co-authored-by: Matthias Seitz <19890894+mattsse@users.noreply.github.com>
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
2026-04-10 11:30:42 +00:00
Emma Jamieson-Hoare
53fe0a077a bench: add release regression mode (#23416)
Co-authored-by: Amp <amp@ampcode.com>
2026-04-10 11:13:24 +00:00
Brian Picciano
828965c39d perf(engine): improve BAL prewarm sparse-trie streaming (#23423)
Co-authored-by: Derek Cofausper <256792747+decofe@users.noreply.github.com>
Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-10 10:11:16 +00:00
Emma Jamieson-Hoare
53e1ec81b3 docs: update README for Reth 2.0 (#23424)
Co-authored-by: Amp <amp@ampcode.com>
2026-04-10 09:25:31 +00:00
dependabot[bot]
608c96791f chore(deps): bump tokio from 1.51.0 to 1.51.1 in the cargo-weekly group (#23410)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 07:22:23 +00:00
Derek Cofausper
13ae241a0d ci: bump MSRV job runner to depot-ubuntu-latest-8 (#23432)
Co-authored-by: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com>
2026-04-10 06:45:30 +00:00
Alexey Shekhirin
cecbb4cc8c fix(cli): use recent speed instead of all-time average for download ETA (#23425) 2026-04-10 00:29:54 +00:00
Alexey Shekhirin
0ed4739482 fix(download): show error on retry and reset counter on progress (#23426) 2026-04-10 00:29:45 +00:00
281 changed files with 23239 additions and 16210 deletions

View File

@@ -20,11 +20,6 @@
# 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

@@ -1,24 +1,21 @@
#!/usr/bin/env bash
#
# Builds (or fetches from cache) reth binaries for benchmarking.
# Builds reth binaries for benchmarking from local source only.
#
# Usage: bench-reth-build.sh <baseline|feature> <source-dir> <commit> [branch-sha]
# Usage: bench-reth-build.sh <baseline|feature> <source-dir> <commit>
#
# baseline — build/fetch the baseline binary at <commit> (merge-base)
# baseline — build the baseline binary at <commit> (merge-base)
# source-dir must be checked out at <commit>
# feature — build/fetch the candidate binary + reth-bench at <commit>
# feature — build the candidate binary + reth-bench at <commit>
# source-dir must be checked out at <commit>
# optional branch-sha is the PR head commit for cache key
#
# Outputs:
# baseline: <source-dir>/target/profiling/reth (or reth-bb if BENCH_BIG_BLOCKS=true)
# feature: <source-dir>/target/profiling/reth (or reth-bb), reth-bench installed to cargo bin
#
# Required: mc (MinIO client) with a configured alias
# Optional env: BENCH_BIG_BLOCKS (true/false) — build reth-bb instead of reth
set -euxo pipefail
MC="mc"
MODE="$1"
SOURCE_DIR="$2"
COMMIT="$3"
@@ -42,103 +39,38 @@ if [ "${BENCH_TRACY:-off}" != "off" ]; then
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
# Build the requested node binary with the benchmark profile.
build_node_binary() {
local features_arg=""
local workspace_arg=""
# Verify a cached reth binary was built from the expected commit.
# `reth --version` outputs "Commit SHA: <full-sha>" on its own line.
verify_binary() {
local binary="$1" expected_commit="$2"
local version binary_sha
version=$("$binary" --version 2>/dev/null) || return 1
binary_sha=$(echo "$version" | sed -n 's/^Commit SHA: *//p')
if [ -z "$binary_sha" ]; then
echo "Warning: could not extract commit SHA from version output"
return 1
cd "$SOURCE_DIR"
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
if [ "$binary_sha" = "$expected_commit" ]; then
return 0
fi
echo "Cache mismatch: binary built from ${binary_sha} but expected ${expected_commit}"
return 1
# shellcheck disable=SC2086
RUSTFLAGS="-C target-cpu=native${EXTRA_RUSTFLAGS}" \
cargo build --profile profiling $NODE_PKG $workspace_arg $features_arg
}
case "$MODE" in
baseline|main)
BUCKET="minio/reth-binaries/${COMMIT}${BUILD_SUFFIX}"
mkdir -p "${SOURCE_DIR}/target/profiling"
CACHE_VALID=false
if $MC stat --no-list "${BUCKET}/${NODE_BIN}" &>/dev/null; then
echo "Cache hit for baseline (${COMMIT}), downloading ${NODE_BIN}..."
if $MC cp "${BUCKET}/${NODE_BIN}" "${SOURCE_DIR}/target/profiling/${NODE_BIN}" && \
chmod +x "${SOURCE_DIR}/target/profiling/${NODE_BIN}" && \
verify_binary "${SOURCE_DIR}/target/profiling/${NODE_BIN}" "${COMMIT}"; then
CACHE_VALID=true
else
echo "Cached baseline binary is stale or download failed, rebuilding..."
fi
fi
if [ "$CACHE_VALID" = false ]; then
echo "Building baseline ${NODE_BIN} (${COMMIT}) from source..."
cd "${SOURCE_DIR}"
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 $NODE_PKG $WORKSPACE_ARG $FEATURES_ARG
$MC cp "target/profiling/${NODE_BIN}" "${BUCKET}/${NODE_BIN}"
fi
echo "Building baseline ${NODE_BIN} (${COMMIT}) from source..."
build_node_binary
;;
feature|branch)
BRANCH_SHA="${4:-$COMMIT}"
BUCKET="minio/reth-binaries/${BRANCH_SHA}${BUILD_SUFFIX}"
CACHE_VALID=false
if $MC stat --no-list "${BUCKET}/${NODE_BIN}" &>/dev/null && $MC stat --no-list "${BUCKET}/reth-bench" &>/dev/null; then
echo "Cache hit for ${BRANCH_SHA}, downloading binaries..."
mkdir -p "${SOURCE_DIR}/target/profiling"
if $MC cp "${BUCKET}/${NODE_BIN}" "${SOURCE_DIR}/target/profiling/${NODE_BIN}" && \
$MC cp "${BUCKET}/reth-bench" /home/ubuntu/.cargo/bin/reth-bench && \
chmod +x "${SOURCE_DIR}/target/profiling/${NODE_BIN}" /home/ubuntu/.cargo/bin/reth-bench && \
verify_binary "${SOURCE_DIR}/target/profiling/${NODE_BIN}" "${COMMIT}"; then
CACHE_VALID=true
else
echo "Cached feature binary is stale or download failed, rebuilding..."
fi
fi
if [ "$CACHE_VALID" = false ]; then
echo "Building feature ${NODE_BIN} (${COMMIT}) from source..."
cd "${SOURCE_DIR}"
rustup show active-toolchain || rustup default stable
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 $NODE_PKG --features "${EXTRA_FEATURES}"
else
# shellcheck disable=SC2086
RUSTFLAGS="-C target-cpu=native${EXTRA_RUSTFLAGS}" \
cargo build --profile profiling $NODE_PKG
fi
make install-reth-bench
$MC cp "target/profiling/${NODE_BIN}" "${BUCKET}/${NODE_BIN}"
$MC cp "$(which reth-bench)" "${BUCKET}/reth-bench"
fi
echo "Building feature ${NODE_BIN} (${COMMIT}) from source..."
rustup show active-toolchain || rustup default stable
build_node_binary
make -C "$SOURCE_DIR" install-reth-bench
;;
*)
echo "Usage: $0 <baseline|feature> <source-dir> <commit> [branch-sha]"
echo "Usage: $0 <baseline|feature> <source-dir> <commit>"
exit 1
;;
esac

View File

@@ -2,7 +2,7 @@
#
# local-reth-bench.sh — Run the reth Engine API benchmark locally.
#
# Replicates the CI bench.yml workflow (build, snapshot, system tuning,
# Replicates the CI bench.yml workflow (build, local snapshot validation, system tuning,
# interleaved B-F-F-B execution, summary, charts) without any GitHub
# Actions glue (no PR comments, no artifact upload, no Slack).
#
@@ -21,15 +21,17 @@
# 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)
# schelk, cpupower, taskset, stdbuf, python3, curl,
# make, uv, jq, Rust toolchain (cargo/rustup)
# Optional:
# mc for Tracy profile upload
#
# The script delegates to the existing bench-reth-*.sh scripts in the reth
# repo for the actual build, snapshot, and run steps.
set -euxo pipefail
# ── PATH ──────────────────────────────────────────────────────────────
# Ensure cargo and user-local bins (mc, uv) are visible
# Ensure cargo and user-local bins (uv) are visible
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
# ── Defaults ──────────────────────────────────────────────────────────
@@ -106,7 +108,7 @@ fi
# ── Check dependencies ───────────────────────────────────────────────
missing=()
for cmd in mc schelk cpupower taskset stdbuf python3 curl make uv pzstd jq cargo; do
for cmd in schelk cpupower taskset stdbuf python3 curl make uv jq cargo; do
command -v "$cmd" &>/dev/null || missing+=("$cmd")
done
if [ ${#missing[@]} -gt 0 ]; then
@@ -238,19 +240,14 @@ echo " Baseline src : $BASELINE_SRC"
echo " Feature src : $FEATURE_SRC"
echo
# ── Step 3: Check / download snapshot ────────────────────────────────
echo "▸ Checking snapshot..."
# ── Step 3: Validate local snapshot ──────────────────────────────────
echo "▸ Validating local 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
"${SCRIPTS_DIR}/bench-reth-snapshot.sh"
echo " Snapshot is ready."
echo
# ── Step 4: Build binaries (+ snapshot download) in parallel ─────────
# ── Step 4: Build binaries in parallel ───────────────────────────────
echo "▸ Building binaries (parallel)..."
cd "$RETH_REPO"
@@ -262,19 +259,11 @@ 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)"
echo "Error: one or more build tasks failed"
exit 1
fi
echo " Binaries built successfully."

View File

@@ -7,6 +7,7 @@
#
# 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_BAL (false/true/feature/baseline; only used with big blocks)
# 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)
@@ -87,10 +88,16 @@ trap cleanup EXIT
# Stop any leftover reth process in the scope, then recover schelk state.
sudo systemctl stop "$RETH_SCOPE" 2>/dev/null || true
sudo systemctl reset-failed "$RETH_SCOPE" 2>/dev/null || true
sudo schelk recover -y --kill || true
sudo schelk recover -y --kill || sudo schelk full-recover -y || true
# Mount
sudo schelk mount -y
sudo schelk mount -y || true
if [ ! -d "$DATADIR/db" ] || [ ! -d "$DATADIR/static_files" ]; then
echo "::error::Failed to mount benchmark datadir at ${DATADIR}"
ls -la "$SCHELK_MOUNT" || true
ls -la "$DATADIR" || true
exit 1
fi
sync
sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
echo "=== Cache state after drop ==="
@@ -249,11 +256,33 @@ fi
if [ "$BIG_BLOCKS" = "true" ]; then
# Big blocks mode: replay pre-generated payloads
BIG_BLOCKS_DIR="${BENCH_BIG_BLOCKS_DIR:-${BENCH_WORK_DIR}/big-blocks}"
BENCH_BAL_MODE="${BENCH_BAL:-false}"
BB_BENCH_ARGS=(--reth-new-payload)
if [ -n "${BENCH_WAIT_TIME:-}" ]; then
BB_BENCH_ARGS+=(--wait-time "$BENCH_WAIT_TIME")
fi
case "$BENCH_BAL_MODE" in
false)
;;
true)
BB_BENCH_ARGS+=(--bal)
;;
baseline)
if [[ "$LABEL" == baseline* ]]; then
BB_BENCH_ARGS+=(--bal)
fi
;;
feature)
if [[ "$LABEL" == feature* ]]; then
BB_BENCH_ARGS+=(--bal)
fi
;;
*)
echo "::error::Unknown BENCH_BAL value: $BENCH_BAL_MODE"
exit 1
;;
esac
# Warmup
WARMUP="${BENCH_WARMUP_BLOCKS:-50}"

View File

@@ -1,129 +1,56 @@
#!/usr/bin/env bash
#
# Downloads the latest snapshot into the schelk volume using
# `reth download` with progress reporting to the GitHub PR comment.
#
# Skips the download if the manifest content hasn't changed since
# the last successful download (checked via SHA-256 of the manifest).
# Validates that the benchmark snapshot has already been populated into the
# local schelk volume.
#
# Usage: bench-reth-snapshot.sh [--check]
# --check Only check if a download is needed; exits 0 if up-to-date, 10 if not.
# --check Exit 0 if the local snapshot is ready, 10 if it is missing.
#
# Required env:
# SCHELK_MOUNT schelk mount point (e.g. /reth-bench)
# BENCH_RETH_BINARY path to the reth binary
# GITHUB_TOKEN token for GitHub API calls (only for download)
# BENCH_COMMENT_ID PR comment ID to update (optional)
# BENCH_REPO owner/repo (e.g. paradigmxyz/reth)
# BENCH_JOB_URL link to the Actions job
# BENCH_ACTOR user who triggered the benchmark
# BENCH_CONFIG config summary line
# SCHELK_MOUNT schelk mount point (e.g. /reth-bench)
# Optional env:
# BENCH_BIG_BLOCKS true when validating the big-blocks snapshot datadir
# BENCH_SNAPSHOT_NAME expected snapshot label for log/error output
set -euxo pipefail
MC="mc"
BUCKET="minio/reth-snapshots"
# Allow overriding the snapshot name (e.g. for big-blocks mode where the
# big-blocks manifest specifies which base snapshot to use).
SNAPSHOT_NAME="${BENCH_SNAPSHOT_NAME:-reth-1-minimal-stable}"
MANIFEST_PATH="${SNAPSHOT_NAME}/manifest.json"
: "${SCHELK_MOUNT:?SCHELK_MOUNT must be set}"
DATADIR_NAME="datadir"
HASH_MODE_SUFFIX=""
if [ "${BENCH_BIG_BLOCKS:-false}" = "true" ]; then
DATADIR_NAME="datadir-big-blocks"
HASH_MODE_SUFFIX="-big-blocks"
fi
DATADIR="$SCHELK_MOUNT/$DATADIR_NAME"
HASH_FILE="$HOME/.reth-bench-snapshot-hash${HASH_MODE_SUFFIX}"
# Fetch manifest and compute content hash for reliable freshness check
MANIFEST_CONTENT=$($MC cat "${BUCKET}/${MANIFEST_PATH}" 2>/dev/null) || {
echo "::error::Failed to fetch snapshot manifest from ${BUCKET}/${MANIFEST_PATH}"
exit 2
describe_snapshot() {
if [ -n "${BENCH_SNAPSHOT_NAME:-}" ]; then
printf '%s' "${BENCH_SNAPSHOT_NAME}"
elif [ "${BENCH_BIG_BLOCKS:-false}" = "true" ]; then
printf '%s' 'big-block weekly snapshot'
else
printf '%s' 'benchmark snapshot'
fi
}
REMOTE_HASH=$(echo "$MANIFEST_CONTENT" | sha256sum | awk '{print $1}')
LOCAL_HASH=""
[ -f "$HASH_FILE" ] && LOCAL_HASH=$(cat "$HASH_FILE")
snapshot_ready() {
[ -d "$DATADIR/db" ] && [ -d "$DATADIR/static_files" ]
}
if [ "$REMOTE_HASH" = "$LOCAL_HASH" ]; then
echo "Snapshot is up-to-date (manifest hash: ${REMOTE_HASH:0:16}…)"
EXPECTED_SNAPSHOT="$(describe_snapshot)"
sudo schelk recover -y --kill || sudo schelk full-recover -y || true
sudo schelk mount -y || true
if snapshot_ready; then
echo "Found local ${EXPECTED_SNAPSHOT} at ${DATADIR}"
exit 0
fi
echo "Snapshot needs update (local: ${LOCAL_HASH:+${LOCAL_HASH:0:16}}${LOCAL_HASH:-<none>}, remote: ${REMOTE_HASH:0:16}…)"
echo "::error::Missing local ${EXPECTED_SNAPSHOT} at ${DATADIR}. Benchmarks no longer download snapshots; pre-populate the local schelk data first."
ls -la "$SCHELK_MOUNT" || true
ls -la "$DATADIR" || true
if [ "${1:-}" = "--check" ]; then
exit 10
fi
RETH="${BENCH_RETH_BINARY:?BENCH_RETH_BINARY must be set}"
if [ ! -x "$RETH" ]; then
echo "::error::reth binary not found or not executable at $RETH"
exit 1
fi
# Resolve the MinIO HTTP endpoint from the mc alias so reth can
# fetch archives over HTTP (the manifest's embedded base_url points
# to the cluster-internal address which is unreachable from runners).
MINIO_ENDPOINT=$($MC alias list minio --json 2>/dev/null | jq -r '.URL // empty') || true
if [ -z "$MINIO_ENDPOINT" ]; then
echo "::error::Failed to resolve MinIO endpoint from mc alias 'minio'"
exit 1
fi
BASE_URL="${MINIO_ENDPOINT}/reth-snapshots/${SNAPSHOT_NAME}"
# Rewrite manifest's base_url with the runner-reachable endpoint
MANIFEST_TMP=$(mktemp --suffix=.json)
trap 'rm -f -- "$MANIFEST_TMP"' EXIT
echo "$MANIFEST_CONTENT" \
| jq --arg base "$BASE_URL" '.base_url = $base' > "$MANIFEST_TMP"
# Prepare mount. If a previous run left the volume mounted, recover first.
sudo schelk recover -y --kill || true
sudo schelk mount -y
sudo rm -rf "$DATADIR"
sudo mkdir -p "$DATADIR"
# reth download runs as current user (not root), needs write access
sudo chown -R "$(id -u):$(id -g)" "$DATADIR"
update_comment() {
local status="$1"
[ -z "${BENCH_COMMENT_ID:-}" ] && return 0
local body
body="$(printf 'cc @%s\n\n🚀 Benchmark started! [View job](%s)\n\n⏳ **Status:** %s\n\n%s' \
"$BENCH_ACTOR" "$BENCH_JOB_URL" "$status" "$BENCH_CONFIG")"
curl -sf -X PATCH \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${BENCH_REPO}/issues/comments/${BENCH_COMMENT_ID}" \
-d "$(jq -nc --arg body "$body" '{body: $body}')" \
> /dev/null 2>&1 || true
}
update_comment "Downloading snapshot…"
# Download using reth download (manifest-path with rewritten base_url)
"$RETH" download \
--manifest-path "$MANIFEST_TMP" \
-y \
--minimal \
--datadir "$DATADIR"
update_comment "Downloading snapshot… done"
echo "Snapshot download complete"
# Sanity check: verify expected directories exist
if [ ! -d "$DATADIR/db" ] || [ ! -d "$DATADIR/static_files" ]; then
echo "::error::Snapshot download did not produce expected directory layout (missing db/ or static_files/)"
ls -la "$DATADIR" || true
exit 1
fi
# Promote the new snapshot to become the schelk baseline (virgin volume).
# This copies changed blocks from scratch → virgin so that future
# `schelk recover` calls restore to this new state.
sync
sudo schelk promote -y
# Save manifest hash
echo "$REMOTE_HASH" > "$HASH_FILE"
echo "Snapshot promoted to schelk baseline (manifest hash: ${REMOTE_HASH:0:16}…)"
exit 1

View File

@@ -111,6 +111,14 @@ def compute_stats(combined: list[dict]) -> dict:
wall_clock_s = sum(total_latencies_ms) / 1_000
mean_total_lat_ms = sum(total_latencies_ms) / n
# Persistence wait mean (for main table)
persist_values_ms = []
for r in combined:
v = r.get("persistence_wait_us")
if v is not None:
persist_values_ms.append(v / 1_000)
mean_persist_ms = sum(persist_values_ms) / len(persist_values_ms) if persist_values_ms else 0.0
return {
"n": n,
"mean_ms": mean_lat,
@@ -121,6 +129,7 @@ def compute_stats(combined: list[dict]) -> dict:
"mean_mgas_s": mean_mgas_s,
"wall_clock_s": wall_clock_s,
"mean_total_lat_ms": mean_total_lat_ms,
"mean_persist_ms": mean_persist_ms,
}
@@ -145,7 +154,7 @@ def compute_wait_stats(combined: list[dict], field: str) -> dict:
def _paired_data(
baseline: list[dict], feature: list[dict]
) -> tuple[list[tuple[float, float]], list[float], list[float], list[float]]:
) -> tuple[list[tuple[float, float]], list[float], list[float], list[float], list[float]]:
"""Match blocks and return paired latencies and per-block diffs.
Returns:
@@ -153,6 +162,7 @@ def _paired_data(
lat_diffs_ms: list of feature baseline latency diffs in ms
mgas_diffs: list of feature baseline Mgas/s diffs
total_lat_diffs_ms: list of feature baseline total latency diffs in ms
persist_diffs_ms: list of feature baseline persistence wait diffs in ms
"""
baseline_by_block = {r["block_number"]: r for r in baseline}
feature_by_block = {r["block_number"]: r for r in feature}
@@ -162,6 +172,7 @@ def _paired_data(
lat_diffs_ms = []
mgas_diffs = []
total_lat_diffs_ms = []
persist_diffs_ms = []
for bn in common_blocks:
b = baseline_by_block[bn]
f = feature_by_block[bn]
@@ -179,7 +190,10 @@ def _paired_data(
total_lat_diffs_ms.append(
f["total_latency_us"] / 1_000 - b["total_latency_us"] / 1_000
)
return pairs, lat_diffs_ms, mgas_diffs, total_lat_diffs_ms
b_persist = (b.get("persistence_wait_us") or 0) / 1_000
f_persist = (f.get("persistence_wait_us") or 0) / 1_000
persist_diffs_ms.append(f_persist - b_persist)
return pairs, lat_diffs_ms, mgas_diffs, total_lat_diffs_ms, persist_diffs_ms
def compute_paired_stats(
@@ -195,13 +209,15 @@ def compute_paired_stats(
all_lat_diffs = []
all_mgas_diffs = []
all_total_lat_diffs = []
all_persist_diffs = []
blocks_per_pair = []
for baseline, feature in zip(baseline_runs, feature_runs):
pairs, lat_diffs, mgas_diffs, total_lat_diffs = _paired_data(baseline, feature)
pairs, lat_diffs, mgas_diffs, total_lat_diffs, persist_diffs = _paired_data(baseline, feature)
all_pairs.extend(pairs)
all_lat_diffs.extend(lat_diffs)
all_mgas_diffs.extend(mgas_diffs)
all_total_lat_diffs.extend(total_lat_diffs)
all_persist_diffs.extend(persist_diffs)
blocks_per_pair.append(len(pairs))
if not all_lat_diffs:
@@ -245,6 +261,11 @@ def compute_paired_stats(
total_se = std_total_diff / math.sqrt(len(all_total_lat_diffs)) if all_total_lat_diffs else 0.0
wall_clock_ci_ms = T_CRITICAL * total_se
mean_persist_diff = sum(all_persist_diffs) / len(all_persist_diffs) if all_persist_diffs else 0.0
std_persist_diff = stddev(all_persist_diffs, mean_persist_diff) if len(all_persist_diffs) > 1 else 0.0
persist_se = std_persist_diff / math.sqrt(len(all_persist_diffs)) if all_persist_diffs else 0.0
persist_ci_ms = T_CRITICAL * persist_se
return {
"n": n,
"mean_diff_ms": mean_diff,
@@ -258,6 +279,7 @@ def compute_paired_stats(
"mean_mgas_diff": mean_mgas_diff,
"mgas_ci": mgas_ci,
"wall_clock_ci_ms": wall_clock_ci_ms,
"persist_ci_ms": persist_ci_ms,
"blocks": max(blocks_per_pair),
}
@@ -290,6 +312,14 @@ def fmt_s(v: float) -> str:
return f"{v:.2f}s"
def display_bal_mode(bal_mode: str | None) -> str | None:
if not bal_mode or bal_mode == "false":
return None
if bal_mode == "both":
return "true"
return bal_mode
def significance(pct: float, ci_pct: float, lower_is_better: bool) -> str:
"""Return significance label: 'good', 'bad', or 'neutral'."""
significant = abs(pct) > ci_pct
@@ -328,6 +358,7 @@ def compute_changes(
("p99", "p99_ms", "p99_ci_ms", "p99_ms", True),
("mgas_s", "mean_mgas_s", "mgas_ci", "mean_mgas_s", False),
("wall_clock", "wall_clock_s", "wall_clock_ci_ms", "mean_total_lat_ms", True),
("persist_wait", "mean_persist_ms", "persist_ci_ms", "mean_persist_ms", True),
]
changes = {}
for name, stat_key, ci_key, base_key, lower_is_better in metrics:
@@ -353,6 +384,7 @@ def generate_comparison_table(
big_blocks: bool = False,
warmup_blocks: str | None = None,
wait_time: str | None = None,
bal_mode: str | None = None,
) -> str:
"""Generate a markdown comparison table between baseline and feature."""
n = paired["blocks"]
@@ -368,6 +400,8 @@ def generate_comparison_table(
p90_pct = pct(run1["p90_ms"], run2["p90_ms"])
p99_pct = pct(run1["p99_ms"], run2["p99_ms"])
persist_pct = pct(run1["mean_persist_ms"], run2["mean_persist_ms"])
# Bootstrap CIs as % of baseline percentile
p50_ci_pct = paired["p50_ci_ms"] / run1["p50_ms"] * 100.0 if run1["p50_ms"] > 0 else 0.0
p90_ci_pct = paired["p90_ci_ms"] / run1["p90_ms"] * 100.0 if run1["p90_ms"] > 0 else 0.0
@@ -377,6 +411,7 @@ def generate_comparison_table(
lat_ci_pct = paired["ci_ms"] / run1["mean_ms"] * 100.0 if run1["mean_ms"] > 0 else 0.0
mgas_ci_pct = paired["mgas_ci"] / run1["mean_mgas_s"] * 100.0 if run1["mean_mgas_s"] > 0 else 0.0
wall_ci_pct = paired["wall_clock_ci_ms"] / run1["mean_total_lat_ms"] * 100.0 if run1["mean_total_lat_ms"] > 0 else 0.0
persist_ci_pct = paired["persist_ci_ms"] / run1["mean_persist_ms"] * 100.0 if run1["mean_persist_ms"] > 0 else 0.0
base_url = f"https://github.com/{repo}/commit"
baseline_label = f"[`{baseline_name}`]({base_url}/{baseline_ref})"
@@ -392,6 +427,7 @@ def generate_comparison_table(
f"| P99 | {fmt_ms(run1['p99_ms'])} | {fmt_ms(run2['p99_ms'])} | {change_str(p99_pct, p99_ci_pct, lower_is_better=True)} |",
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"| Persist Wait | {fmt_ms(run1['mean_persist_ms'])} | {fmt_ms(run2['mean_persist_ms'])} | {change_str(persist_pct, persist_ci_pct, lower_is_better=True)} |",
"",
]
meta_parts = [f"{n} {'big blocks' if big_blocks else 'blocks'}"]
@@ -399,6 +435,9 @@ def generate_comparison_table(
meta_parts.append(f"{warmup_blocks} warmup")
if wait_time:
meta_parts.append(f"wait time: {wait_time}")
display_mode = display_bal_mode(bal_mode)
if big_blocks and display_mode:
meta_parts.append(f"BAL: {display_mode}")
lines.append(f"*{', '.join(meta_parts)}*")
return "\n".join(lines)
@@ -481,6 +520,7 @@ def main():
parser.add_argument("--big-blocks", action="store_true", default=False, help="Big blocks mode")
parser.add_argument("--warmup-blocks", default=None, help="Number of warmup blocks")
parser.add_argument("--wait-time", default=None, help="Wait time interval used between blocks")
parser.add_argument("--bal-mode", default=None, help="BAL mode (true, feature, baseline)")
parser.add_argument("--grafana-url", default=None, help="Grafana dashboard URL for this benchmark run")
args = parser.parse_args()
@@ -520,6 +560,7 @@ def main():
baseline_name = args.baseline_name or "baseline"
feature_name = args.feature_name or "feature"
feature_sha = args.feature_ref or "unknown"
bal_mode = display_bal_mode(args.bal_mode)
comparison_table = generate_comparison_table(
baseline_stats,
@@ -533,6 +574,7 @@ def main():
big_blocks=args.big_blocks,
warmup_blocks=args.warmup_blocks,
wait_time=args.wait_time,
bal_mode=bal_mode,
)
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)")
@@ -566,6 +608,7 @@ def main():
"big_blocks": args.big_blocks,
"warmup_blocks": args.warmup_blocks,
"wait_time": args.wait_time,
"bal_mode": bal_mode,
"baseline": {
"name": baseline_name,
"ref": baseline_ref,

View File

@@ -2,17 +2,20 @@
#
# Resolves baseline and feature refs for scheduled benchmark runs.
#
# Supports two modes:
# Supports three modes:
# nightly — Queries the latest successful scheduled docker.yml run via
# GitHub API to find the nightly Docker image commit. Compares
# with the last successful feature ref to detect staleness.
# hourly — Compares origin/main HEAD against the last successfully
# benchmarked commit (falls back to HEAD~1 on first run).
# Checks for in-progress sibling runs to avoid overlap.
# release — Compares the latest GitHub release tag against the current
# nightly Docker build. Baseline is the release tag commit,
# feature is the nightly commit.
#
# Usage: bench-scheduled-refs.sh <force> <mode>
# force — "true" to run even if no new commit (bypass skip logic)
# mode — "nightly" or "hourly"
# mode — "nightly", "hourly", or "release"
#
# Outputs (via GITHUB_OUTPUT):
# baseline-ref — commit SHA for baseline
@@ -21,10 +24,12 @@
# is-stale — "true" if latest nightly build is >24h old (nightly only)
# stale-age-hours — age of the nightly build in hours (nightly only)
# nightly-created — ISO timestamp of the nightly build (nightly only)
# release-tag — release tag name (release mode only, e.g. "v2.0.0")
#
# Reads:
# state/nightly-last-feature-ref (nightly, from decofe/reth-bench-charts repo)
# state/hourly-last-feature-ref (hourly, from decofe/reth-bench-charts repo)
# state/release-last-feature-ref (release, from decofe/reth-bench-charts repo)
#
# Requires: gh (GitHub CLI), jq, date, git (hourly mode), curl, DEREK_TOKEN env
set -euxo pipefail
@@ -121,6 +126,106 @@ if [ "$MODE" = "hourly" ]; then
exit 0
fi
# ==========================================================================
# Release mode: compare latest GitHub release tag vs current nightly build
# ==========================================================================
if [ "$MODE" = "release" ]; then
# --- Step 1: Resolve feature ref from latest nightly Docker build ---
echo "::group::Querying latest nightly docker build"
RUNS_JSON=$(gh run list \
-R "$REPO" \
--workflow=docker.yml \
--event=schedule \
--status=completed \
--limit 5 \
--json headSha,createdAt,conclusion)
LATEST=$(echo "$RUNS_JSON" | jq -r '[.[] | select(.conclusion == "success")] | first // empty')
if [ -z "$LATEST" ]; then
echo "::error::No successful scheduled docker.yml run found in the last 5 runs"
exit 1
fi
FEATURE_REF=$(echo "$LATEST" | jq -r '.headSha')
echo "Nightly commit (feature): $FEATURE_REF"
echo "::endgroup::"
# --- Step 2: Resolve baseline ref from latest GitHub release ---
echo "::group::Resolving latest release tag"
RELEASE_JSON=$(gh release view --repo "$REPO" --json tagName,targetCommitish,publishedAt 2>/dev/null || echo "{}")
RELEASE_TAG=$(echo "$RELEASE_JSON" | jq -r '.tagName // empty')
if [ -z "$RELEASE_TAG" ]; then
echo "::error::No release found on $REPO"
exit 1
fi
# Resolve the tag to a commit SHA
BASELINE_REF=$(gh api "repos/$REPO/git/ref/tags/$RELEASE_TAG" --jq '.object.sha' 2>/dev/null || true)
# If tag points to an annotated tag object, dereference to the commit
if [ -n "$BASELINE_REF" ]; then
OBJ_TYPE=$(gh api "repos/$REPO/git/tags/$BASELINE_REF" --jq '.object.type' 2>/dev/null || echo "commit")
if [ "$OBJ_TYPE" = "commit" ]; then
BASELINE_REF=$(gh api "repos/$REPO/git/tags/$BASELINE_REF" --jq '.object.sha' 2>/dev/null || echo "$BASELINE_REF")
fi
fi
if [ -z "$BASELINE_REF" ]; then
echo "::error::Could not resolve release tag $RELEASE_TAG to a commit"
exit 1
fi
echo "Release tag: $RELEASE_TAG"
echo "Release commit (baseline): $BASELINE_REF"
echo "::endgroup::"
# --- Step 3: Read last successful feature ref from charts repo ---
echo "::group::Reading persisted state"
LAST_FEATURE_REF=""
STATE_URL="https://raw.githubusercontent.com/decofe/reth-bench-charts/state/state/release-last-feature-ref"
if RAW=$(curl -sfL -H "Authorization: token ${DEREK_TOKEN}" "$STATE_URL"); then
LAST_FEATURE_REF=$(echo "$RAW" | tr -d '[:space:]')
echo "Previous feature ref: $LAST_FEATURE_REF"
else
echo "No persisted state found (first run)"
fi
echo "::endgroup::"
# --- Step 4: Skip logic ---
echo "::group::Resolving skip logic"
SHOULD_SKIP="false"
if [ -n "$LAST_FEATURE_REF" ] && [ "$LAST_FEATURE_REF" = "$FEATURE_REF" ]; then
if [ "$FORCE" = "true" ] || [ "$FORCE" = "--force" ]; then
echo "No new nightly, but force=true — running anyway"
else
SHOULD_SKIP="true"
echo "No new nightly since last release regression run — will skip"
fi
else
echo "New nightly detected or first run"
fi
echo "Baseline: $BASELINE_REF ($RELEASE_TAG)"
echo "Feature: $FEATURE_REF"
echo "Skip: $SHOULD_SKIP"
echo "::endgroup::"
# --- Step 5: Write outputs ---
{
echo "baseline-ref=$BASELINE_REF"
echo "feature-ref=$FEATURE_REF"
echo "should-skip=$SHOULD_SKIP"
echo "is-stale=false"
echo "stale-age-hours=0"
echo "nightly-created="
echo "long-running=false"
echo "release-tag=$RELEASE_TAG"
} >> "$GITHUB_OUTPUT"
exit 0
fi
# ==========================================================================
# Nightly mode: query latest Docker nightly build (original logic)
# ==========================================================================

View File

@@ -250,6 +250,8 @@ async function success({ core, context }) {
}
}
const slackMode = process.env.BENCH_SLACK || 'always';
// Post to public channel if any metric shows significant improvement or regression
const channel = process.env.SLACK_BENCH_CHANNEL;
let postedToChannel = false;
@@ -264,6 +266,14 @@ async function success({ core, context }) {
}
}
// In on-win mode, only notify on improvement — skip DM fallback entirely
if (slackMode === 'on-win') {
if (!postedToChannel) {
core.info('on-win mode: no improvement detected, skipping all notifications');
}
return;
}
// DM the actor only when results were not posted to the public channel
if (!postedToChannel) {
if (actorSlackId) {

View File

@@ -39,10 +39,25 @@ function loadSamplyUrls(workDir) {
return urls;
}
function balModeLabel(mode) {
switch (mode) {
case 'true':
case 'feature':
case 'baseline':
return mode;
case 'both':
return 'true';
default:
return '';
}
}
function blocksLabel(summary) {
const parts = [];
if (summary.big_blocks) {
parts.push({ key: 'Big Blocks', value: summary.blocks });
const balMode = balModeLabel(summary.bal_mode || summary.bal || process.env.BENCH_BAL || 'false');
if (balMode) parts.push({ key: 'BAL', value: balMode });
} else {
const warmup = summary.warmup_blocks || process.env.BENCH_WARMUP_BLOCKS || '';
if (warmup) parts.push({ key: 'Warmup', value: warmup });
@@ -68,6 +83,7 @@ function metricRows(summary) {
{ label: 'P99', baseline: fmtMs(b.p99_ms), feature: fmtMs(f.p99_ms), change: fmtChange(c.p99) },
{ label: 'Mgas/s', baseline: fmtMgas(b.mean_mgas_s), feature: fmtMgas(f.mean_mgas_s), change: fmtChange(c.mgas_s) },
{ label: 'Wall Clock', baseline: fmtS(b.wall_clock_s), feature: fmtS(f.wall_clock_s), change: fmtChange(c.wall_clock) },
{ label: 'Persist Wait', baseline: fmtMs(b.mean_persist_ms || 0), feature: fmtMs(f.mean_persist_ms || 0), change: fmtChange(c.persist_wait) },
];
}

View File

@@ -1,414 +0,0 @@
#!/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 -euxo 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

@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""
Fetch a Grafana dashboard and convert it to the portable import format.
Fetches the dashboard via API, replaces internal datasource/variable references
with template variables, and adds __inputs/__requires/__elements so the JSON is
importable on any Grafana instance.
Usage:
export FETCH_GRAFANA_DASHBOARD_URL=https://<NAMESPACE>.grafana.net
export FETCH_GRAFANA_DASHBOARD_TOKEN=glsa_...
python3 .github/scripts/fetch-grafana-dashboard.py <dashboard-uid> > output.json
"""
import json
import os
import sys
import urllib.request
PANEL_TYPE_NAMES = {
"bargauge": "Bar gauge",
"gauge": "Gauge",
"heatmap": "Heatmap",
"piechart": "Pie chart",
"stat": "Stat",
"table": "Table",
"timeseries": "Time series",
"barchart": "Bar chart",
"text": "Text",
"dashlist": "Dashboard list",
"logs": "Logs",
"nodeGraph": "Node Graph",
"histogram": "Histogram",
"candlestick": "Candlestick",
"state-timeline": "State timeline",
"status-history": "Status history",
"geomap": "Geomap",
"canvas": "Canvas",
"news": "News",
"xychart": "XY Chart",
"trend": "Trend",
"datagrid": "Datagrid",
"flamegraph": "Flame Graph",
"traces": "Traces",
}
def fetch_json(base_url: str, token: str, path: str) -> dict:
url = f"{base_url}{path}"
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
def fetch_dashboard(base_url: str, token: str, uid: str) -> dict:
return fetch_json(base_url, token, f"/api/dashboards/uid/{uid}")
def fetch_grafana_version(base_url: str) -> str:
req = urllib.request.Request(f"{base_url}/api/health")
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read())
# version string like "13.0.0-23940615780.patch2" -> take just the semver part
version = data.get("version", "")
# strip build metadata after the first hyphen if it looks like a pre-release
parts = version.split("-")
return parts[0] if parts else version
def collect_panel_types(panels: list) -> set[str]:
types = set()
for panel in panels:
ptype = panel.get("type", "")
if ptype and ptype != "row":
types.add(ptype)
# nested panels inside collapsed rows
for sub in panel.get("panels", []):
sub_type = sub.get("type", "")
if sub_type and sub_type != "row":
types.add(sub_type)
return types
def has_expression_datasource(dashboard: dict) -> bool:
return "__expr__" in json.dumps(dashboard)
def make_exportable(dashboard: dict, grafana_version: str = "") -> dict:
dash = json.loads(json.dumps(dashboard)) # deep copy
# --- Strip internal fields ---
dash.pop("id", None)
# --- Rewrite links: point to the public repo instead of internal ---
dash["links"] = [
{
"asDropdown": False,
"icon": "external link",
"includeVars": False,
"keepTime": False,
"tags": [],
"targetBlank": True,
"title": "Source (GitHub)",
"tooltip": "View source file in repository",
"type": "link",
"url": "https://github.com/paradigmxyz/reth/tree/main/etc/grafana/dashboards",
}
]
# --- Datasource: victoriametrics -> prometheus ---
dash_str = json.dumps(dash)
dash_str = dash_str.replace("victoriametrics-metrics-datasource", "prometheus")
dash = json.loads(dash_str)
# --- Templating: instance_label constant -> ${VAR_INSTANCE_LABEL} ---
# Also strip default-value fields the API returns that are not needed for import
STRIP_VAR_DEFAULTS = {"allowCustomValue", "regexApplyTo"}
for var in dash.get("templating", {}).get("list", []):
if var.get("name") == "instance_label" and var.get("type") == "constant":
var["query"] = "${VAR_INSTANCE_LABEL}"
var["current"] = {
"value": "${VAR_INSTANCE_LABEL}",
"text": "${VAR_INSTANCE_LABEL}",
"selected": False,
}
var["options"] = [
{
"value": "${VAR_INSTANCE_LABEL}",
"text": "${VAR_INSTANCE_LABEL}",
"selected": False,
}
]
# Clear current values for query/datasource vars (not meaningful for import)
elif var.get("type") in ("query", "datasource"):
var["current"] = {}
# Remove noisy default fields
for field in STRIP_VAR_DEFAULTS:
var.pop(field, None)
# Strip falsy defaults on query/datasource vars (API returns them, export omits them)
if var.get("type") in ("query", "datasource"):
for field in ("hide", "multi", "skipUrlSync"):
if not var.get(field):
var.pop(field, None)
# --- Build __inputs ---
inputs = [
{
"name": "DS_PROMETHEUS",
"label": "Prometheus",
"description": "",
"type": "datasource",
"pluginId": "prometheus",
"pluginName": "Prometheus",
},
]
if has_expression_datasource(dash):
inputs.append(
{
"name": "DS_EXPRESSION",
"label": "Expression",
"description": "",
"type": "datasource",
"pluginId": "__expr__",
}
)
inputs.append(
{
"name": "VAR_INSTANCE_LABEL",
"type": "constant",
"label": "Instance Label",
"value": "job",
"description": "",
}
)
# --- Build __requires ---
requires = []
if has_expression_datasource(dash):
requires.append({"type": "datasource", "id": "__expr__", "version": "1.0.0"})
panel_types = collect_panel_types(dash.get("panels", []))
for pt in sorted(panel_types):
requires.append(
{
"type": "panel",
"id": pt,
"name": PANEL_TYPE_NAMES.get(pt, pt),
"version": "",
}
)
requires.append(
{"type": "grafana", "id": "grafana", "name": "Grafana", "version": grafana_version}
)
requires.append(
{
"type": "datasource",
"id": "prometheus",
"name": "Prometheus",
"version": "1.0.0",
}
)
# --- Assemble output (with __inputs/__requires/__elements first) ---
output = {
"__inputs": inputs,
"__elements": {},
"__requires": requires,
}
output.update(dash)
return output
def main():
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <dashboard-uid>", file=sys.stderr)
sys.exit(1)
uid = sys.argv[1]
base_url = os.environ.get("FETCH_GRAFANA_DASHBOARD_URL", "").rstrip("/")
token = os.environ.get("FETCH_GRAFANA_DASHBOARD_TOKEN", "")
if not base_url or not token:
print(
"Error: FETCH_GRAFANA_DASHBOARD_URL and FETCH_GRAFANA_DASHBOARD_TOKEN env vars required",
file=sys.stderr,
)
sys.exit(1)
resp = fetch_dashboard(base_url, token, uid)
dashboard = resp["dashboard"]
grafana_version = fetch_grafana_version(base_url)
exported = make_exportable(dashboard, grafana_version)
print(json.dumps(exported, indent=2))
if __name__ == "__main__":
main()

View File

@@ -21,7 +21,6 @@ engine-cancun:
# Affects all clients, not just reth. Tracked: https://github.com/ethereum/hive/issues/1382
- Invalid Missing Ancestor Syncing ReOrg, Timestamp, EmptyTxs=False, CanonicalReOrg=False, Invalid P8 (Cancun) (reth)
- Invalid Missing Ancestor Syncing ReOrg, Timestamp, EmptyTxs=False, CanonicalReOrg=True, Invalid P8 (Cancun) (reth)
- Multiple New Payloads Extending Canonical Chain, Wait for Canonical Payload (Cancun) (reth)
engine-api:
- Transaction Re-Org, Re-Org Out (Paris) (reth)
- Transaction Re-Org, Re-Org to Different Block (Paris) (reth)
@@ -31,5 +30,3 @@ engine-api:
- Invalid Missing Ancestor Syncing ReOrg, Transaction Signature, EmptyTxs=False, CanonicalReOrg=True, Invalid P9 (Paris) (reth)
- Invalid Missing Ancestor Syncing ReOrg, Transaction Signature, EmptyTxs=False, CanonicalReOrg=False, Invalid P9 (Paris) (reth)
- Invalid Missing Ancestor ReOrg, StateRoot, EmptyTxs=True, Invalid P10 (Paris) (reth)
- Multiple New Payloads Extending Canonical Chain, Wait for Canonical Payload (Paris) (reth)
- Multiple New Payloads Extending Canonical Chain, Set Head to First Payload Received (Paris) (reth)

View File

@@ -1,11 +1,13 @@
# Scheduled regression benchmarks (nightly + hourly).
# Scheduled regression benchmarks (nightly + hourly + release).
#
# Two modes:
# Three modes:
# nightly — Compares the previous nightly Docker build against the current one.
# Runs daily after docker.yml produces a new nightly image.
# hourly — Compares main HEAD against the last benchmarked commit to catch
# regressions quickly. Falls back to HEAD~1 on first run.
# Skips if no new commits or if a previous run is still in progress.
# release — Compares the latest GitHub release tag against the current nightly
# Docker build. Runs daily to track nightly vs release performance.
#
# State is persisted between runs via the decofe/reth-bench-charts repo: each
# successful run saves the feature commit SHA so the next run knows what to
@@ -17,6 +19,8 @@ on:
- cron: "30 5 * * *"
# Hourly: compares main HEAD vs last benchmarked commit, skips if no new commits
- cron: "0 * * * *"
# Release: compares latest GitHub release tag vs current nightly Docker build
- cron: "0 9 * * *"
workflow_dispatch:
inputs:
force:
@@ -24,11 +28,16 @@ on:
required: false
default: false
type: boolean
no_slack:
description: "Suppress Slack notifications"
slack:
description: "Slack notification policy"
required: false
default: true
type: boolean
default: "never"
type: choice
options:
- always
- on-win
- on-error
- never
mode:
description: "Benchmark mode"
required: false
@@ -37,6 +46,7 @@ on:
options:
- nightly
- hourly
- release
env:
CARGO_TERM_COLOR: always
@@ -64,6 +74,7 @@ jobs:
stale-age-hours: ${{ steps.refs.outputs.stale-age-hours }}
nightly-created: ${{ steps.refs.outputs.nightly-created }}
long-running: ${{ steps.refs.outputs.long-running }}
release-tag: ${{ steps.refs.outputs.release-tag }}
steps:
- uses: actions/checkout@v6
with:
@@ -79,6 +90,8 @@ jobs:
MODE="${{ inputs.mode || 'nightly' }}"
elif [ "${{ github.event.schedule }}" = "30 5 * * *" ]; then
MODE="nightly"
elif [ "${{ github.event.schedule }}" = "0 9 * * *" ]; then
MODE="release"
else
MODE="hourly"
fi
@@ -98,8 +111,8 @@ jobs:
.github/scripts/bench-scheduled-refs.sh "$FORCE" "$MODE"
- name: Alert on long-running hourly
if: steps.mode.outputs.mode == 'hourly' && steps.refs.outputs.long-running == 'true'
uses: actions/github-script@v8
if: steps.mode.outputs.mode == 'hourly' && steps.refs.outputs.long-running == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.slack == 'never')
uses: actions/github-script@v9
env:
SLACK_BENCH_BOT_TOKEN: ${{ secrets.SLACK_BENCH_BOT_TOKEN }}
SLACK_BENCH_CHANNEL: ${{ secrets.SLACK_BENCH_CHANNEL }}
@@ -140,8 +153,8 @@ jobs:
});
- name: Alert on stale nightly
if: steps.mode.outputs.mode == 'nightly' && steps.refs.outputs.is-stale == 'true'
uses: actions/github-script@v8
if: steps.mode.outputs.mode == 'nightly' && steps.refs.outputs.is-stale == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.slack == 'never')
uses: actions/github-script@v9
env:
SLACK_BENCH_BOT_TOKEN: ${{ secrets.SLACK_BENCH_BOT_TOKEN }}
SLACK_BENCH_CHANNEL: ${{ secrets.SLACK_BENCH_CHANNEL }}
@@ -248,7 +261,7 @@ jobs:
BENCH_FEATURE_ARGS: ""
BENCH_ABBA: "true"
BENCH_COMMENT_ID: ""
BENCH_NO_SLACK: ${{ github.event_name == 'workflow_dispatch' && inputs.no_slack == true && 'true' || 'false' }}
BENCH_SLACK: ${{ github.event_name == 'workflow_dispatch' && inputs.slack || 'always' }}
BENCH_METRICS_ADDR: "127.0.0.1:9100"
BENCH_OTLP_DISABLED: "true"
BASELINE_REF: ${{ needs.resolve-refs.outputs.baseline-ref }}
@@ -265,7 +278,7 @@ jobs:
- name: Resolve job URL
id: job-url
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
@@ -294,12 +307,6 @@ jobs:
linux-tools-"$(uname -r)" || \
sudo apt-get install -y --no-install-recommends linux-tools-generic
# mc (MinIO client)
if ! command -v mc &>/dev/null; then
curl -sSfL https://dl.min.io/client/mc/release/linux-amd64/mc -o "$HOME/.local/bin/mc"
chmod +x "$HOME/.local/bin/mc"
fi
# uv (Python package manager)
if ! command -v uv &>/dev/null; then
curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR="$HOME/.local/bin" sh
@@ -327,7 +334,7 @@ jobs:
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
missing=()
for cmd in mc schelk cpupower taskset stdbuf python3 curl make uv pzstd jq; do
for cmd in schelk cpupower taskset stdbuf python3 curl make uv jq; do
command -v "$cmd" &>/dev/null || missing+=("$cmd")
done
if [ ${#missing[@]} -gt 0 ]; then
@@ -338,27 +345,24 @@ jobs:
- name: Resolve display names
id: refs
env:
RELEASE_TAG: ${{ needs.resolve-refs.outputs.release-tag }}
run: |
BASELINE_SHORT=$(echo "$BASELINE_REF" | cut -c1-8)
FEATURE_SHORT=$(echo "$FEATURE_REF" | cut -c1-8)
echo "baseline-name=${BENCH_MODE}-${BASELINE_SHORT}" >> "$GITHUB_OUTPUT"
if [ "$BENCH_MODE" = "release" ] && [ -n "$RELEASE_TAG" ]; then
echo "baseline-name=${RELEASE_TAG}" >> "$GITHUB_OUTPUT"
echo "baseline-ref=${RELEASE_TAG}" >> "$GITHUB_OUTPUT"
else
BASELINE_SHORT=$(echo "$BASELINE_REF" | cut -c1-8)
echo "baseline-name=${BENCH_MODE}-${BASELINE_SHORT}" >> "$GITHUB_OUTPUT"
echo "baseline-ref=$BASELINE_REF" >> "$GITHUB_OUTPUT"
fi
echo "feature-name=${BENCH_MODE}-${FEATURE_SHORT}" >> "$GITHUB_OUTPUT"
echo "baseline-ref=$BASELINE_REF" >> "$GITHUB_OUTPUT"
echo "feature-ref=$FEATURE_REF" >> "$GITHUB_OUTPUT"
- name: Check if snapshot needs update
- name: Validate local snapshot
id: snapshot-check
run: |
set +e
.github/scripts/bench-reth-snapshot.sh --check
rc=$?
set -e
case "$rc" in
0) echo "needed=false" >> "$GITHUB_OUTPUT" ;;
10) echo "needed=true" >> "$GITHUB_OUTPUT" ;;
*) echo "::error::Snapshot check failed (exit $rc)"
exit "$rc" ;;
esac
run: .github/scripts/bench-reth-snapshot.sh
- name: Prepare source dirs
run: |
@@ -398,15 +402,6 @@ jobs:
exit 1
fi
- name: Download snapshot
id: snapshot-download
if: steps.snapshot-check.outputs.needed == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BENCH_REPO: ${{ github.repository }}
BENCH_RETH_BINARY: ${{ github.workspace }}/../reth-feature/target/profiling/reth
run: .github/scripts/bench-reth-snapshot.sh
# System tuning for reproducible benchmarks
- name: System setup
run: |
@@ -559,11 +554,12 @@ jobs:
env:
BASELINE_NAME: ${{ steps.refs.outputs.baseline-name }}
FEATURE_NAME: ${{ steps.refs.outputs.feature-name }}
BASELINE_REF_DISPLAY: ${{ steps.refs.outputs.baseline-ref }}
run: |
SUMMARY_ARGS="--output-summary $BENCH_WORK_DIR/summary.json"
SUMMARY_ARGS="$SUMMARY_ARGS --output-markdown $BENCH_WORK_DIR/comment.md"
SUMMARY_ARGS="$SUMMARY_ARGS --repo ${{ github.repository }}"
SUMMARY_ARGS="$SUMMARY_ARGS --baseline-ref ${BASELINE_REF}"
SUMMARY_ARGS="$SUMMARY_ARGS --baseline-ref ${BASELINE_REF_DISPLAY}"
SUMMARY_ARGS="$SUMMARY_ARGS --baseline-name ${BASELINE_NAME}"
SUMMARY_ARGS="$SUMMARY_ARGS --feature-name ${FEATURE_NAME}"
SUMMARY_ARGS="$SUMMARY_ARGS --feature-ref ${FEATURE_REF}"
@@ -590,7 +586,11 @@ jobs:
CLICKHOUSE_USER: ${{ secrets.CLICKHOUSE_USER }}
CLICKHOUSE_PASSWORD: ${{ secrets.CLICKHOUSE_PASSWORD }}
run: |
WORKFLOW_NAME="workflows-nightly-regression-${{ github.run_id }}"
if [ "$BENCH_MODE" = "release" ]; then
WORKFLOW_NAME="workflows-release-regression-${{ github.run_id }}"
else
WORKFLOW_NAME="workflows-nightly-regression-${{ github.run_id }}"
fi
DIFF_URL="https://github.com/${{ github.repository }}/compare/${BASELINE_REF}...${FEATURE_REF}"
GRAFANA_URL='${{ steps.metrics.outputs.grafana-url }}'
JOB_URL="${BENCH_JOB_URL:-${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}}"
@@ -633,7 +633,7 @@ jobs:
if: success() && env.BENCH_MODE != 'hourly'
run: |
RUN_ID=${{ github.run_id }}
CHART_DIR="nightly/${RUN_ID}"
CHART_DIR="${BENCH_MODE}/${RUN_ID}"
CHARTS_REPO="https://x-access-token:${{ secrets.DEREK_TOKEN }}@github.com/decofe/reth-bench-charts.git"
TMP_DIR=$(mktemp -d)
@@ -655,7 +655,7 @@ jobs:
- name: Write job summary
if: success()
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
@@ -733,8 +733,8 @@ jobs:
await core.summary.addRaw(md).write();
- name: Send Slack notification (success)
if: success() && env.BENCH_NO_SLACK != 'true'
uses: actions/github-script@v8
if: success() && (env.BENCH_SLACK == 'always' || env.BENCH_SLACK == 'on-win')
uses: actions/github-script@v9
env:
SLACK_BENCH_BOT_TOKEN: ${{ secrets.SLACK_BENCH_BOT_TOKEN }}
SLACK_BENCH_CHANNEL: ${{ secrets.SLACK_BENCH_CHANNEL }}
@@ -761,7 +761,15 @@ jobs:
// Filter notifications based on mode
const changes = summary.changes || {};
const mode = process.env.BENCH_MODE || 'nightly';
const slackMode = process.env.BENCH_SLACK || 'always';
const hasRegression = Object.values(changes).some(c => c.sig === 'bad');
const hasImprovement = Object.values(changes).some(c => c.sig === 'good');
// on-win mode: only notify on improvements
if (slackMode === 'on-win' && !hasImprovement) {
core.info('on-win mode: no improvement detected, skipping Slack notification');
return;
}
// Hourly mode: only notify on regressions
if (mode === 'hourly' && !hasRegression) {
@@ -789,7 +797,7 @@ jobs:
function cell(text) { return { type: 'raw_text', text: String(text) || ' ' }; }
const modeLabel = mode === 'hourly' ? 'Hourly Regression' : 'Nightly Regression';
const modeLabel = mode === 'release' ? 'Release Regression' : mode === 'hourly' ? 'Hourly Regression' : 'Nightly Regression';
const sectionText = [
`*${modeLabel}*`,
'',
@@ -880,8 +888,8 @@ jobs:
}
- name: Send Slack notification (failure)
if: failure()
uses: actions/github-script@v8
if: failure() && env.BENCH_SLACK != 'never' && env.BENCH_SLACK != 'on-win'
uses: actions/github-script@v9
env:
SLACK_BENCH_BOT_TOKEN: ${{ secrets.SLACK_BENCH_BOT_TOKEN }}
SLACK_BENCH_CHANNEL: ${{ secrets.SLACK_BENCH_CHANNEL }}
@@ -892,8 +900,8 @@ jobs:
if (!token || !channel) return;
const steps_status = [
['validating local snapshot', '${{ steps.snapshot-check.outcome }}'],
['building binaries', '${{ steps.build.outcome }}'],
['downloading snapshot', '${{ steps.snapshot-download.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 }}'],
@@ -906,7 +914,7 @@ jobs:
const jobUrl = process.env.BENCH_JOB_URL || `${context.serverUrl}/${repo}/actions/runs/${context.runId}`;
const mode = process.env.BENCH_MODE || 'nightly';
const modeLabel = mode === 'hourly' ? 'Hourly' : 'Nightly';
const modeLabel = mode === 'release' ? 'Release' : mode === 'hourly' ? 'Hourly' : 'Nightly';
const blocks = [
{
@@ -915,7 +923,7 @@ jobs:
},
{
type: 'section',
text: { type: 'mrkdwn', text: `*${modeLabel} regression* failed while *${failedStep}*\ncc <@U09FARE0B9Q> <@U09FAL2UMLJ>` },
text: { type: 'mrkdwn', text: `*${modeLabel} regression* failed while *${failedStep}*\ncc <@U09FARE0B9Q> <@U09FAL2UMLJ>\n<@U0AAA8F0JEM> investigate this` },
},
{
type: 'actions',
@@ -942,6 +950,11 @@ jobs:
}),
});
- name: Clean build outputs
if: always()
run: |
sudo rm -rf ../reth-baseline/target ../reth-feature/target "$BENCH_WORK_DIR" 2>/dev/null || true
- name: Restore system settings
if: always()
run: |

View File

@@ -21,10 +21,20 @@ on:
required: false
default: "false"
type: boolean
bal:
description: "Replay block access lists during big-block benchmarks"
required: false
default: "false"
type: choice
options:
- "false"
- "true"
- "feature"
- "baseline"
warmup:
description: "Number of warmup blocks"
required: false
default: "100"
default: "200"
type: string
baseline:
description: "Baseline git ref (default: merge-base)"
@@ -61,11 +71,16 @@ on:
required: false
default: "0"
type: string
no_slack:
description: "Suppress Slack notifications for benchmark results"
slack:
description: "Slack notification policy"
required: false
default: "true"
type: boolean
default: "never"
type: choice
options:
- always
- on-win
- on-error
- never
abba:
description: "Run ABBA (BFFB) interleaved order; false = single AB pass"
required: false
@@ -105,9 +120,10 @@ 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 }}
slack: ${{ steps.args.outputs.slack }}
cores: ${{ steps.args.outputs.cores }}
big-blocks: ${{ steps.args.outputs.big-blocks }}
bal: ${{ steps.args.outputs.bal }}
wait-time: ${{ steps.args.outputs.wait-time }}
baseline-args: ${{ steps.args.outputs.baseline-args }}
feature-args: ${{ steps.args.outputs.feature-args }}
@@ -117,7 +133,7 @@ jobs:
steps:
- name: Check org membership
if: github.event_name == 'issue_comment'
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
@@ -136,22 +152,28 @@ jobs:
- name: Parse arguments
id: args
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{ secrets.DEREK_PAT }}
script: |
let pr, actor, blocks, warmup, baseline, feature, samply, cores, bigBlocks;
const validBalModes = new Set(['false', 'true', 'feature', 'baseline']);
const validSlackModes = new Set(['always', 'on-win', 'on-error', 'never']);
const usage = '`@decofe bench [blocks=N] [big-blocks[=true|false]] [bal=true|false|feature|baseline] [warmup=N] [baseline=REF] [feature=REF] [samply] [slack=always|on-win|on-error|never] [cores=N] [abba=true|false] [otlp=true|false] [wait-time=DURATION] [baseline-args="..."] [feature-args="..."]`';
let pr, actor, blocks, warmup, baseline, feature, samply, cores, bigBlocks, bal;
let explicitWarmup = false;
if (context.eventName === 'workflow_dispatch') {
actor = '${{ github.actor }}';
blocks = '${{ github.event.inputs.blocks }}' || '500';
warmup = '${{ github.event.inputs.warmup }}' || '100';
warmup = '${{ github.event.inputs.warmup }}' || '200';
if (warmup !== '200') explicitWarmup = true;
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';
var slack = '${{ github.event.inputs.slack }}' || 'never';
cores = '${{ github.event.inputs.cores }}' || '0';
bigBlocks = '${{ github.event.inputs.big_blocks }}' === 'true' ? 'true' : 'false';
bal = '${{ github.event.inputs.bal }}' || 'false';
var abba = '${{ github.event.inputs.abba }}' !== 'false' ? 'true' : 'false';
var otlp = '${{ github.event.inputs.otlp }}' !== 'false' ? 'true' : 'false';
var waitTime = '${{ github.event.inputs.wait_time }}' || '';
@@ -178,11 +200,12 @@ jobs:
const body = context.payload.comment.body.trim();
const intArgs = new Set(['warmup', 'cores', 'blocks']);
const refArgs = new Set(['baseline', 'feature']);
const boolArgs = new Set(['samply', 'no-slack', 'big-blocks']);
const boolArgs = new Set(['samply', 'big-blocks']);
const boolDefaultTrue = new Set(['abba', 'otlp']);
const enumArgs = new Map([['bal', validBalModes], ['slack', validSlackModes]]);
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', 'big-blocks': 'false', cores: '0', abba: 'true', otlp: 'true', 'wait-time': '', 'baseline-args': '', 'feature-args': '' };
const defaults = { blocks: '500', warmup: '200', baseline: '', feature: '', samply: 'false', slack: 'always', 'big-blocks': 'false', bal: 'false', cores: '0', abba: 'true', otlp: 'true', 'wait-time': '', 'baseline-args': '', 'feature-args': '' };
const unknown = [];
const invalid = [];
const args = body.replace(/^(?:@decofe|derek) bench\s*/, '');
@@ -209,7 +232,7 @@ jobs:
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (boolDefaultTrue.has(key)) {
if (boolArgs.has(key) || boolDefaultTrue.has(key)) {
if (value === 'true' || value === 'false') {
defaults[key] = value;
} else {
@@ -221,11 +244,18 @@ jobs:
} else {
invalid.push(`\`${key}=${value}\` (must be a duration like 500ms, 1s, 2m)`);
}
} else if (enumArgs.has(key)) {
if (enumArgs.get(key).has(value)) {
defaults[key] = value;
} else {
invalid.push(`\`${key}=${value}\` (must be true, false, feature, or baseline)`);
}
} else if (intArgs.has(key)) {
if (!/^\d+$/.test(value)) {
invalid.push(`\`${key}=${value}\` (must be a positive integer)`);
} else {
defaults[key] = value;
if (key === 'warmup') explicitWarmup = true;
}
} else if (refArgs.has(key)) {
if (!value) {
@@ -243,7 +273,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] [big-blocks] [warmup=N] [baseline=REF] [feature=REF] [samply] [no-slack] [cores=N] [abba=true|false] [otlp=true|false] [wait-time=DURATION] [baseline-args="..."] [feature-args="..."]\``;
const msg = `❌ **Invalid bench command**\n\n${errors.join('\n')}\n\n**Usage:** ${usage}`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
@@ -258,9 +288,10 @@ jobs:
baseline = defaults.baseline;
feature = defaults.feature;
samply = defaults.samply;
var noSlack = defaults['no-slack'];
var slack = defaults.slack;
cores = defaults.cores;
bigBlocks = defaults['big-blocks'];
bal = defaults.bal;
var abba = defaults.abba;
var otlp = defaults.otlp;
var waitTime = defaults['wait-time'];
@@ -268,6 +299,29 @@ jobs:
var featureNodeArgs = defaults['feature-args'];
}
// Default warmup to 20 for big-blocks mode unless explicitly set
if (bigBlocks === 'true' && !explicitWarmup) {
warmup = '20';
}
if (!validBalModes.has(bal)) {
core.setFailed(`Invalid bal mode: ${bal}`);
return;
}
if (bal !== 'false' && bigBlocks !== 'true') {
const msg = `❌ **Invalid bench command**\n\n\`bal\` requires \`big-blocks=true\`.\n\n**Usage:** ${usage}`;
if (context.eventName === 'issue_comment') {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: msg,
});
}
core.setFailed(msg);
return;
}
// Resolve display names for baseline/feature
let baselineName = baseline || 'main';
let featureName = feature;
@@ -293,9 +347,10 @@ jobs:
core.setOutput('baseline-name', baselineName);
core.setOutput('feature-name', featureName);
core.setOutput('samply', samply);
core.setOutput('no-slack', noSlack);
core.setOutput('slack', slack);
core.setOutput('cores', cores);
core.setOutput('big-blocks', bigBlocks);
core.setOutput('bal', bal);
core.setOutput('wait-time', waitTime);
core.setOutput('baseline-args', baselineNodeArgs);
core.setOutput('feature-args', featureNodeArgs);
@@ -304,7 +359,7 @@ jobs:
- name: Acknowledge request
id: ack
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{ secrets.DEREK_PAT }}
script: |
@@ -358,10 +413,12 @@ 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 slack = '${{ steps.args.outputs.slack }}' || 'always';
const bigBlocks = '${{ steps.args.outputs.big-blocks }}' === 'true';
const bal = '${{ steps.args.outputs.bal }}' || 'false';
const samplyNote = samply ? ', samply: `enabled`' : '';
const noSlackNote = noSlack ? ', no-slack' : '';
const slackNote = slack !== 'always' ? `, slack: \`${slack}\`` : '';
const balNote = bigBlocks && bal !== 'false' ? `, BAL: \`${bal}\`` : '';
const cores = '${{ steps.args.outputs.cores }}';
const coresNote = cores && cores !== '0' ? `, cores: \`${cores}\`` : '';
const abbaEnabled = '${{ steps.args.outputs.abba }}' !== 'false';
@@ -375,7 +432,7 @@ jobs:
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}${abbaNote}${otlpNote}${waitTimeNote}${baselineArgsNote}${featureArgsNote}`;
const config = `**Config:** ${blocksDesc}, baseline: \`${baseline}\`, feature: \`${feature}\`${samplyNote}${slackNote}${balNote}${coresNote}${abbaNote}${otlpNote}${waitTimeNote}${baselineArgsNote}${featureArgsNote}`;
const { data: comment } = await github.rest.issues.createComment({
owner: context.repo.owner,
@@ -388,7 +445,7 @@ jobs:
- name: Poll queue position
if: steps.ack.outputs.comment-id && steps.ack.outputs.queue-position != '0'
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{ secrets.DEREK_PAT }}
script: |
@@ -400,10 +457,12 @@ 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 slack = '${{ steps.args.outputs.slack }}' || 'always';
const bigBlocks = '${{ steps.args.outputs.big-blocks }}' === 'true';
const bal = '${{ steps.args.outputs.bal }}' || 'false';
const samplyNote = samply ? ', samply: `enabled`' : '';
const noSlackNote = noSlack ? ', no-slack' : '';
const slackNote = slack !== 'always' ? `, slack: \`${slack}\`` : '';
const balNote = bigBlocks && bal !== 'false' ? `, BAL: \`${bal}\`` : '';
const cores = '${{ steps.args.outputs.cores }}';
const coresNote = cores && cores !== '0' ? `, cores: \`${cores}\`` : '';
const abbaEnabled = '${{ steps.args.outputs.abba }}' !== 'false';
@@ -417,7 +476,7 @@ jobs:
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}${abbaNote}${otlpNote}${waitTimeNote}${baselineArgsNote}${featureArgsNote}`;
const config = `**Config:** ${blocksDesc}, baseline: \`${baseline}\`, feature: \`${feature}\`${samplyNote}${slackNote}${balNote}${coresNote}${abbaNote}${otlpNote}${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;
@@ -483,13 +542,14 @@ jobs:
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_BAL: ${{ needs.reth-bench-ack.outputs.bal }}
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_OTLP: ${{ needs.reth-bench-ack.outputs.otlp }}
BENCH_COMMENT_ID: ${{ needs.reth-bench-ack.outputs.comment-id }}
BENCH_NO_SLACK: ${{ needs.reth-bench-ack.outputs.no-slack }}
BENCH_SLACK: ${{ needs.reth-bench-ack.outputs.slack }}
BENCH_NODE_BIN: ${{ needs.reth-bench-ack.outputs.big-blocks == 'true' && 'reth-bb' || 'reth' }}
BENCH_METRICS_ADDR: "127.0.0.1:9100"
BENCH_OTLP_TRACES_ENDPOINT: ${{ needs.reth-bench-ack.outputs.otlp != 'false' && secrets.BENCH_OTLP_TRACES_ENDPOINT || '' }}
@@ -500,7 +560,7 @@ jobs:
- name: Resolve checkout ref
id: checkout-ref
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
if (!process.env.BENCH_PR) {
@@ -526,7 +586,7 @@ jobs:
- name: Resolve job URL and update status
if: env.BENCH_COMMENT_ID
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{ secrets.DEREK_PAT }}
script: |
@@ -544,10 +604,12 @@ 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 slack = process.env.BENCH_SLACK || 'always';
const bigBlocks = process.env.BENCH_BIG_BLOCKS === 'true';
const bal = process.env.BENCH_BAL || 'false';
const samplyNote = samply ? ', samply: `enabled`' : '';
const noSlackNote = noSlack ? ', no-slack' : '';
const slackNote = slack !== 'always' ? `, slack: \`${slack}\`` : '';
const balNote = bigBlocks && bal !== 'false' ? `, BAL: \`${bal}\`` : '';
const cores = process.env.BENCH_CORES || '0';
const coresNote = cores && cores !== '0' ? `, cores: \`${cores}\`` : '';
const abbaEnabled = (process.env.BENCH_ABBA || 'true') !== 'false';
@@ -561,7 +623,7 @@ jobs:
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}${abbaNote}${otlpNote}${waitTimeNote}${baselineArgsNote}${featureArgsNote}`);
core.exportVariable('BENCH_CONFIG', `**Config:** ${blocksDesc}, baseline: \`${baseline}\`, feature: \`${feature}\`${samplyNote}${slackNote}${balNote}${coresNote}${abbaNote}${otlpNote}${waitTimeNote}${baselineArgsNote}${featureArgsNote}`);
const { buildBody } = require('./.github/scripts/bench-update-status.js');
await github.rest.issues.updateComment({
@@ -588,12 +650,6 @@ jobs:
linux-tools-"$(uname -r)" || \
sudo apt-get install -y --no-install-recommends linux-tools-generic
# mc (MinIO client)
if ! command -v mc &>/dev/null; then
curl -sSfL https://dl.min.io/client/mc/release/linux-amd64/mc -o "$HOME/.local/bin/mc"
chmod +x "$HOME/.local/bin/mc"
fi
# uv (Python package manager)
if ! command -v uv &>/dev/null; then
curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR="$HOME/.local/bin" sh
@@ -628,7 +684,7 @@ jobs:
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
missing=()
for cmd in mc schelk cpupower taskset stdbuf python3 curl make uv pzstd jq; do
for cmd in schelk cpupower taskset stdbuf python3 curl make uv jq; do
command -v "$cmd" &>/dev/null || missing+=("$cmd")
done
if [ ${#missing[@]} -gt 0 ]; then
@@ -640,7 +696,7 @@ jobs:
# Build binaries
- name: Resolve PR head branch
id: pr-info
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
if (process.env.BENCH_PR) {
@@ -658,7 +714,7 @@ jobs:
- name: Resolve refs
id: refs
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const { execSync } = require('child_process');
@@ -704,61 +760,45 @@ jobs:
core.setOutput('feature-ref', featureRef);
core.setOutput('feature-name', featureName);
- name: Check big-blocks freshness
- name: Validate local big blocks
if: env.BENCH_BIG_BLOCKS == 'true'
id: big-blocks-check
run: |
set -euo pipefail
MC="mc --config-dir /home/ubuntu/.mc"
MANIFEST="minio/reth-snapshots/reth-1-minimal-stable-big-blocks.json"
HASH_FILE="$HOME/.reth-bench-big-blocks-hash"
echo "Fetching big-blocks manifest from $MANIFEST..."
BB_MANIFEST=$($MC cat "$MANIFEST" 2>/dev/null) || {
echo "::error::Failed to fetch big-blocks manifest from $MANIFEST"
BIG_BLOCKS_DIR="$HOME/.reth-bench-big-blocks"
PAYLOAD_DIR="$BIG_BLOCKS_DIR/payloads"
MANIFEST="$BIG_BLOCKS_DIR/manifest.json"
echo "BENCH_BIG_BLOCKS_DIR=${BIG_BLOCKS_DIR}" >> "$GITHUB_ENV"
if [ ! -f "$MANIFEST" ]; then
echo "::error::Missing local big-blocks manifest at $MANIFEST"
exit 1
}
BASE_SNAPSHOT=$(echo "$BB_MANIFEST" | jq -r '.base_snapshot // empty')
fi
BASE_SNAPSHOT=$(jq -r '.base_snapshot // empty' "$MANIFEST")
if [ -z "$BASE_SNAPSHOT" ]; then
echo "::error::Big-blocks manifest missing base_snapshot field"
exit 1
fi
echo "Big-blocks base snapshot: $BASE_SNAPSHOT"
echo "BENCH_SNAPSHOT_NAME=${BASE_SNAPSHOT}" >> "$GITHUB_ENV"
REMOTE_HASH=$(echo "$BB_MANIFEST" | sha256sum | awk '{print $1}')
LOCAL_HASH=""
[ -f "$HASH_FILE" ] && LOCAL_HASH=$(cat "$HASH_FILE")
if [ "$REMOTE_HASH" = "$LOCAL_HASH" ]; then
echo "Big blocks up-to-date (hash: ${REMOTE_HASH:0:16}…)"
echo "needed=false" >> "$GITHUB_OUTPUT"
else
echo "Big blocks need update (local: ${LOCAL_HASH:+${LOCAL_HASH:0:16}…}${LOCAL_HASH:-<none>}, remote: ${REMOTE_HASH:0:16}…)"
echo "needed=true" >> "$GITHUB_OUTPUT"
echo "remote-hash=${REMOTE_HASH}" >> "$GITHUB_OUTPUT"
if [ ! -d "$PAYLOAD_DIR" ]; then
echo "::error::Missing local big-block payload directory at $PAYLOAD_DIR"
exit 1
fi
- name: Check if snapshot needs update
id: snapshot-check
run: |
set +e
.github/scripts/bench-reth-snapshot.sh --check
rc=$?
set -e
case "$rc" in
0) echo "needed=false" >> "$GITHUB_OUTPUT" ;;
10) echo "needed=true" >> "$GITHUB_OUTPUT" ;;
*) echo "::error::Snapshot check failed (exit $rc)"
exit "$rc" ;;
esac
PAYLOAD_COUNT=$(find "$PAYLOAD_DIR" -name '*.json' | wc -l)
if [ "$PAYLOAD_COUNT" -eq 0 ]; then
echo "::error::No payload files found in $PAYLOAD_DIR"
exit 1
fi
- name: Update status (snapshot needed)
if: env.BENCH_COMMENT_ID && steps.snapshot-check.outputs.needed == 'true'
uses: actions/github-script@v8
with:
github-token: ${{ secrets.DEREK_PAT }}
script: |
const s = require('./.github/scripts/bench-update-status.js');
await s({github, context, status: 'Building binaries (snapshot update pending)...'});
echo "Big-blocks base snapshot: $BASE_SNAPSHOT"
echo "Payload files: $PAYLOAD_COUNT"
echo "BENCH_SNAPSHOT_NAME=${BASE_SNAPSHOT}" >> "$GITHUB_ENV"
- name: Validate local snapshot
id: snapshot-check
run: .github/scripts/bench-reth-snapshot.sh
- name: Prepare source dirs
run: |
@@ -800,15 +840,6 @@ jobs:
exit 1
fi
- name: Download snapshot
id: snapshot-download
if: steps.snapshot-check.outputs.needed == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BENCH_REPO: ${{ github.repository }}
BENCH_RETH_BINARY: ${{ github.workspace }}/../reth-feature/target/profiling/${{ needs.reth-bench-ack.outputs.big-blocks == 'true' && 'reth-bb' || 'reth' }}
run: .github/scripts/bench-reth-snapshot.sh
# System tuning for reproducible benchmarks
- name: System setup
run: |
@@ -848,8 +879,11 @@ jobs:
for p in /sys/kernel/mm/transparent_hugepage /sys/kernel/mm/transparent_hugepages; do
[ -d "$p" ] && echo never | sudo tee "$p/enabled" && echo never | sudo tee "$p/defrag" && break
done || true
# Replace any stale PM QoS holders left behind by earlier benchmark jobs.
sudo pkill -f '^bench-cpu-dma-latency' 2>/dev/null || true
# Prevent deep C-states (avoids wake-up latency jitter)
sudo sh -c 'exec 3<>/dev/cpu_dma_latency; echo -ne "\x00\x00\x00\x00" >&3; sleep infinity' &
sudo bash -c 'exec 3<>/dev/cpu_dma_latency; printf "\0\0\0\0" >&3; exec -a bench-cpu-dma-latency sleep infinity' &
echo "BENCH_CPU_DMA_LATENCY_PID=$!" >> "$GITHUB_ENV"
# Move all IRQs to core 0 (housekeeping core)
for irq in /proc/irq/*/smp_affinity_list; do
echo 0 | sudo tee "$irq" 2>/dev/null || true
@@ -876,45 +910,6 @@ 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
BIG_BLOCKS_DIR="$HOME/.reth-bench-big-blocks"
echo "BENCH_BIG_BLOCKS_DIR=${BIG_BLOCKS_DIR}" >> "$GITHUB_ENV"
if [ "${{ steps.big-blocks-check.outputs.needed }}" = "false" ]; then
echo "Big blocks cached at $BIG_BLOCKS_DIR, skipping download"
echo "Payload files: $(find "$BIG_BLOCKS_DIR/payloads" -name '*.json' | wc -l)"
exit 0
fi
MC="mc --config-dir /home/ubuntu/.mc"
MANIFEST="minio/reth-snapshots/reth-1-minimal-stable-big-blocks.json"
rm -rf "$BIG_BLOCKS_DIR"; mkdir -p "$BIG_BLOCKS_DIR"
# Download and parse manifest
echo "Downloading manifest from $MANIFEST..."
$MC cat "$MANIFEST" > "$BIG_BLOCKS_DIR/manifest.json"
UPLOAD_PATH=$(jq -r '.upload_path' "$BIG_BLOCKS_DIR/manifest.json")
COUNT=$(jq -r '.count' "$BIG_BLOCKS_DIR/manifest.json")
TARGET_GAS=$(jq -r '.target_gas' "$BIG_BLOCKS_DIR/manifest.json")
echo "Manifest: count=$COUNT, target_gas=$TARGET_GAS, archive=$UPLOAD_PATH"
# Download and extract archive
ARCHIVE="minio/$UPLOAD_PATH"
echo "Downloading big blocks from $ARCHIVE..."
$MC cat "$ARCHIVE" | 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/payloads" ]; then
echo "::error::Big blocks archive missing expected payloads/ directory"
ls -laR "$BIG_BLOCKS_DIR"
exit 1
fi
echo "Payload files: $(find "$BIG_BLOCKS_DIR/payloads" -name '*.json' | wc -l)"
# Save manifest hash for freshness check on next run
echo "${{ steps.big-blocks-check.outputs.remote-hash }}" > "$HOME/.reth-bench-big-blocks-hash"
- name: Start metrics proxy
run: |
BENCH_ID="ci-${{ github.run_id }}"
@@ -937,7 +932,7 @@ jobs:
- name: Update status (running benchmarks)
if: success() && env.BENCH_COMMENT_ID
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{ secrets.DEREK_PAT }}
script: |
@@ -1134,6 +1129,9 @@ jobs:
if [ -n "${BENCH_WAIT_TIME:-}" ]; then
SUMMARY_ARGS="$SUMMARY_ARGS --wait-time $BENCH_WAIT_TIME"
fi
if [ -n "${BENCH_BAL:-}" ] && [ "${BENCH_BAL}" != "false" ]; then
SUMMARY_ARGS="$SUMMARY_ARGS --bal-mode $BENCH_BAL"
fi
GRAFANA_URL='${{ steps.metrics.outputs.grafana-url }}'
if [ -n "$GRAFANA_URL" ]; then
SUMMARY_ARGS="$SUMMARY_ARGS --grafana-url $GRAFANA_URL"
@@ -1196,7 +1194,7 @@ jobs:
- name: Compare & comment
if: success() && env.BENCH_COMMENT_ID
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{ secrets.DEREK_PAT }}
script: |
@@ -1276,7 +1274,7 @@ jobs:
- name: Write job summary
if: success()
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const jobSummary = require('./.github/scripts/bench-job-summary.js');
@@ -1289,8 +1287,8 @@ jobs:
});
- name: Send Slack notification (success)
if: success() && env.BENCH_NO_SLACK != 'true'
uses: actions/github-script@v8
if: success() && (env.BENCH_SLACK == 'always' || env.BENCH_SLACK == 'on-win')
uses: actions/github-script@v9
env:
SLACK_BENCH_BOT_TOKEN: ${{ secrets.SLACK_BENCH_BOT_TOKEN }}
SLACK_BENCH_CHANNEL: ${{ secrets.SLACK_BENCH_CHANNEL }}
@@ -1301,14 +1299,16 @@ jobs:
- name: Update status (failed)
if: failure() && env.BENCH_COMMENT_ID
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{ secrets.DEREK_PAT }}
script: |
const abba = (process.env.BENCH_ABBA || 'true') !== 'false';
const bigBlocks = process.env.BENCH_BIG_BLOCKS === 'true';
const steps_status = [
...(bigBlocks ? [['validating local big-block data', '${{ steps.big-blocks-check.outcome }}']] : []),
['validating local snapshot', '${{ steps.snapshot-check.outcome }}'],
['building binaries', '${{ steps.build.outcome }}'],
['downloading snapshot', '${{ steps.snapshot-download.outcome }}'],
['running baseline benchmark (1/2)', '${{ steps.run-baseline-1.outcome }}'],
['running feature benchmark (1/2)', '${{ steps.run-feature-1.outcome }}'],
...(abba ? [['running feature benchmark (2/2)', '${{ steps.run-feature-2.outcome }}']] : []),
@@ -1334,17 +1334,19 @@ jobs:
});
- name: Send Slack notification (failure)
if: failure()
uses: actions/github-script@v8
if: failure() && env.BENCH_SLACK != 'never' && env.BENCH_SLACK != 'on-win'
uses: actions/github-script@v9
env:
SLACK_BENCH_BOT_TOKEN: ${{ secrets.SLACK_BENCH_BOT_TOKEN }}
SLACK_BENCH_CHANNEL: ${{ secrets.SLACK_BENCH_CHANNEL }}
with:
script: |
const abba = (process.env.BENCH_ABBA || 'true') !== 'false';
const bigBlocks = process.env.BENCH_BIG_BLOCKS === 'true';
const steps_status = [
...(bigBlocks ? [['validating local big-block data', '${{ steps.big-blocks-check.outcome }}']] : []),
['validating local snapshot', '${{ steps.snapshot-check.outcome }}'],
['building binaries', '${{ steps.build.outcome }}'],
['downloading snapshot', '${{ steps.snapshot-download.outcome }}'],
['running baseline benchmark (1/2)', '${{ steps.run-baseline-1.outcome }}'],
['running feature benchmark (1/2)', '${{ steps.run-feature-1.outcome }}'],
...(abba ? [['running feature benchmark (2/2)', '${{ steps.run-feature-2.outcome }}']] : []),
@@ -1357,7 +1359,7 @@ jobs:
- name: Update status (cancelled)
if: cancelled() && env.BENCH_COMMENT_ID
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{ secrets.DEREK_PAT }}
script: |
@@ -1368,6 +1370,11 @@ jobs:
body: `cc @${process.env.BENCH_ACTOR}\n\n⚠ Benchmark cancelled. [View logs](${jobUrl})`,
});
- name: Clean build outputs
if: always()
run: |
sudo rm -rf ../reth-baseline/target ../reth-feature/target "$BENCH_WORK_DIR" 2>/dev/null || true
- name: Restore system settings
if: always()
run: |
@@ -1380,5 +1387,9 @@ jobs:
done
# Restore amd-pstate to active (EPP) mode with powersave governor
echo active | sudo tee /sys/devices/system/cpu/amd_pstate/status 2>/dev/null || true
if [ -n "${BENCH_CPU_DMA_LATENCY_PID:-}" ]; then
sudo kill "$BENCH_CPU_DMA_LATENCY_PID" 2>/dev/null || true
fi
sudo pkill -f '^bench-cpu-dma-latency' 2>/dev/null || true
sudo cpupower frequency-set -g powersave 2>/dev/null || true
sudo systemctl start irqbalance cron atd 2>/dev/null || true

View File

@@ -50,7 +50,7 @@ jobs:
uses: actions/configure-pages@v6
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
uses: actions/upload-pages-artifact@v5
with:
path: "./docs/vocs/docs/dist"

View File

@@ -28,30 +28,12 @@ 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' && !failure() && !cancelled()
if: github.repository == 'paradigmxyz/reth'
name: Build Docker images
runs-on: ubuntu-24.04
needs: collect-pgo-profile
permissions:
packages: write
contents: read
@@ -76,30 +58,6 @@ 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: |
@@ -149,9 +107,6 @@ 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

@@ -0,0 +1,62 @@
name: Fetch Grafana Dashboard
on:
workflow_dispatch:
inputs:
dashboard_uid:
description: "Grafana dashboard UID to export"
required: true
default: "2k8BXz24x"
target_path:
description: "Target file path in the repo (e.g. etc/grafana/dashboards/overview.json)"
required: true
default: "etc/grafana/dashboards/overview.json"
jobs:
fetch:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Fetch dashboard from Grafana
env:
FETCH_GRAFANA_DASHBOARD_URL: ${{ secrets.FETCH_GRAFANA_DASHBOARD_URL }}
FETCH_GRAFANA_DASHBOARD_TOKEN: ${{ secrets.FETCH_GRAFANA_DASHBOARD_TOKEN }}
run: |
python3 .github/scripts/fetch-grafana-dashboard.py "${{ inputs.dashboard_uid }}" \
> "${{ inputs.target_path }}"
- name: Check for changes
id: diff
run: |
if git diff --quiet "${{ inputs.target_path }}"; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "No changes detected."
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Create pull request
if: steps.diff.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TARGET="${{ inputs.target_path }}"
FILENAME="$(basename "$TARGET")"
BRANCH="chore/sync-grafana-${FILENAME%.*}-$(date +%Y%m%d-%H%M%S)"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add "$TARGET"
git commit -m "chore: update Grafana dashboard ${FILENAME}"
git push origin "$BRANCH"
gh pr create \
--title "chore: update Grafana dashboard ${FILENAME}" \
--body "Automated export from Grafana (dashboard UID: \`${{ inputs.dashboard_uid }}\`, target: \`${TARGET}\`)."

View File

@@ -11,11 +11,22 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Check for ${DS_PROMETHEUS} in overview.json
- name: Validate dashboard format
run: |
if grep -Fn '${DS_PROMETHEUS}' etc/grafana/dashboards/overview.json; then
echo "Error: overview.json contains '\${DS_PROMETHEUS}' placeholder"
echo "Please replace it with '\${datasource}'"
exit 1
fi
echo "✓ overview.json does not contain '\${DS_PROMETHEUS}' placeholder"
python3 -c "
import json, sys
with open('etc/grafana/dashboards/overview.json') as f:
d = json.load(f)
errors = []
if '__inputs' not in d:
errors.append('missing __inputs')
if '__requires' not in d:
errors.append('missing __requires')
if d.get('id') is not None:
errors.append('contains internal id field — use export-dashboard.py')
if errors:
for e in errors:
print(f'Error: {e}', file=sys.stderr)
sys.exit(1)
print('✓ overview.json is a valid exported dashboard')
"

View File

@@ -16,7 +16,7 @@ jobs:
fetch-depth: 0
- name: Label PRs
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const label_pr = require('./.github/scripts/label_pr.js')

View File

@@ -117,7 +117,7 @@ jobs:
msrv:
name: MSRV
runs-on: ${{ github.repository == 'paradigmxyz/reth' && 'depot-ubuntu-latest' || 'ubuntu-latest' }}
runs-on: ${{ github.repository == 'paradigmxyz/reth' && 'depot-ubuntu-latest-8' || 'ubuntu-latest' }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v6

View File

@@ -1,99 +0,0 @@
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 schelk recover -y --kill || true
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: |
sudo schelk recover -y --kill || true

View File

@@ -13,14 +13,6 @@ 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
@@ -77,6 +69,12 @@ 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"
native: true
- target: aarch64-unknown-linux-gnu
os: ubuntu-24.04-arm
profile: maxperf
@@ -119,6 +117,8 @@ jobs:
echo "MACOSX_DEPLOYMENT_TARGET=$(xcrun -sdk macosx --show-sdk-platform-version)" >> $GITHUB_ENV
- name: Build Reth
env:
CC: clang
run: |
if [ "${{ matrix.configs.native }}" = "true" ]; then
make PROFILE=${{ matrix.configs.profile }} EXTRA_RUSTFLAGS="${{ matrix.configs.rustflags }}" ${{ matrix.build.command }}-native-${{ matrix.configs.target }}
@@ -157,93 +157,12 @@ jobs:
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@v7
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@v7
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, build-pgo, extract-version]
if: ${{ !failure() && !cancelled() && github.event.inputs.dry_run != 'true' }}
needs: [build, extract-version]
if: ${{ github.event.inputs.dry_run != 'true' }}
env:
VERSION: ${{ needs.extract-version.outputs.VERSION }}
permissions:
@@ -276,7 +195,7 @@ jobs:
fi
body=$(cat <<- "ENDBODY"
![image](https://raw.githubusercontent.com/paradigmxyz/reth/main/assets/reth-prod.png)
![image](https://raw.githubusercontent.com/paradigmxyz/reth/main/assets/reth-2.png)
## Testing Checklist (DELETE ME)

1527
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
[workspace.package]
version = "2.0.0"
version = "2.1.0"
edition = "2024"
rust-version = "1.93"
license = "MIT OR Apache-2.0"
@@ -129,6 +129,7 @@ members = [
"examples/custom-node-components/",
"examples/custom-payload-builder/",
"examples/custom-rlpx-subprotocol",
"examples/custom-auth-http-middleware",
"examples/custom-rpc-middleware",
"examples/db-access",
"examples/exex-subscription",
@@ -325,8 +326,8 @@ reth-cli = { path = "crates/cli/cli" }
reth-cli-commands = { path = "crates/cli/commands" }
reth-cli-runner = { path = "crates/cli/runner" }
reth-cli-util = { path = "crates/cli/util" }
reth-codecs = { version = "0.1.0", default-features = false }
reth-codecs-derive = "0.1.0"
reth-codecs = { version = "0.3.0", default-features = false }
reth-codecs-derive = "0.3.0"
reth-config = { path = "crates/config", default-features = false }
reth-consensus = { path = "crates/consensus/consensus", default-features = false }
reth-consensus-common = { path = "crates/consensus/common", default-features = false }
@@ -394,7 +395,7 @@ reth-payload-builder-primitives = { path = "crates/payload/builder-primitives" }
reth-payload-primitives = { path = "crates/payload/primitives" }
reth-payload-validator = { path = "crates/payload/validator" }
reth-payload-util = { path = "crates/payload/util" }
reth-primitives-traits = { version = "0.1.0", default-features = false }
reth-primitives-traits = { version = "0.3.0", default-features = false }
reth-provider = { path = "crates/storage/provider" }
reth-prune = { path = "crates/prune/prune" }
reth-prune-types = { path = "crates/prune/types", default-features = false }
@@ -410,7 +411,7 @@ reth-rpc-eth-types = { path = "crates/rpc/rpc-eth-types", default-features = fal
reth-rpc-layer = { path = "crates/rpc/rpc-layer" }
reth-rpc-server-types = { path = "crates/rpc/rpc-server-types" }
reth-rpc-convert = { path = "crates/rpc/rpc-convert" }
reth-rpc-traits = { version = "0.1.0", default-features = false }
reth-rpc-traits = { version = "0.3.0", default-features = false }
reth-stages = { path = "crates/stages/stages" }
reth-stages-api = { path = "crates/stages/api" }
reth-stages-types = { path = "crates/stages/types", default-features = false }
@@ -429,17 +430,17 @@ reth-trie-common = { path = "crates/trie/common", default-features = false }
reth-trie-db = { path = "crates/trie/db" }
reth-trie-parallel = { path = "crates/trie/parallel" }
reth-trie-sparse = { path = "crates/trie/sparse", default-features = false }
reth-zstd-compressors = { version = "0.1.0", default-features = false }
reth-zstd-compressors = { version = "0.3.0", default-features = false }
# revm
revm = { version = "36.0.0", default-features = false }
revm-bytecode = { version = "9.0.0", default-features = false }
revm-database = { version = "12.0.0", default-features = false }
revm-state = { version = "10.0.0", default-features = false }
revm-primitives = { version = "22.1.0", default-features = false }
revm-interpreter = { version = "34.0.0", default-features = false }
revm-database-interface = { version = "10.0.0", default-features = false }
revm-inspectors = "0.36.0"
revm = { version = "38.0.0", default-features = false }
revm-bytecode = { version = "10.0.0", default-features = false }
revm-database = { version = "13.0.0", default-features = false }
revm-state = { version = "11.0.0", default-features = false }
revm-primitives = { version = "23.0.0", default-features = false }
revm-interpreter = { version = "35.0.0", default-features = false }
revm-database-interface = { version = "11.0.0", default-features = false }
revm-inspectors = "0.39.0"
# eth
alloy-dyn-abi = "1.5.6"
@@ -448,40 +449,40 @@ alloy-sol-types = { version = "1.5.6", default-features = false }
alloy-chains = { version = "0.2.33", default-features = false }
alloy-eip2124 = { version = "0.2.0", default-features = false }
alloy-eip7928 = { version = "0.3.0", default-features = false }
alloy-evm = { version = "0.30.0", default-features = false }
alloy-eip7928 = { version = "0.3.4", default-features = false }
alloy-evm = { version = "0.33.0", default-features = false }
alloy-rlp = { version = "0.3.13", default-features = false, features = ["core-net"] }
alloy-trie = { version = "0.9.4", default-features = false }
alloy-hardforks = "0.4.5"
alloy-hardforks = "0.4.7"
alloy-consensus = { version = "1.8.2", default-features = false }
alloy-contract = { version = "1.8.2", default-features = false }
alloy-eips = { version = "1.8.2", default-features = false }
alloy-genesis = { version = "1.8.2", default-features = false }
alloy-json-rpc = { version = "1.8.2", default-features = false }
alloy-network = { version = "1.8.2", default-features = false }
alloy-network-primitives = { version = "1.8.2", default-features = false }
alloy-provider = { version = "1.8.2", features = ["reqwest", "debug-api"], default-features = false }
alloy-pubsub = { version = "1.8.2", default-features = false }
alloy-rpc-client = { version = "1.8.2", default-features = false }
alloy-rpc-types = { version = "1.8.2", features = ["eth"], default-features = false }
alloy-rpc-types-admin = { version = "1.8.2", default-features = false }
alloy-rpc-types-anvil = { version = "1.8.2", default-features = false }
alloy-rpc-types-beacon = { version = "1.8.2", default-features = false }
alloy-rpc-types-debug = { version = "1.8.2", default-features = false }
alloy-rpc-types-engine = { version = "1.8.2", default-features = false }
alloy-rpc-types-eth = { version = "1.8.2", default-features = false }
alloy-rpc-types-mev = { version = "1.8.2", default-features = false }
alloy-rpc-types-trace = { version = "1.8.2", default-features = false }
alloy-rpc-types-txpool = { version = "1.8.2", default-features = false }
alloy-serde = { version = "1.8.2", default-features = false }
alloy-signer = { version = "1.8.2", default-features = false }
alloy-signer-local = { version = "1.8.2", default-features = false }
alloy-transport = { version = "1.8.2" }
alloy-transport-http = { version = "1.8.2", features = ["reqwest-rustls-tls"], default-features = false }
alloy-transport-ipc = { version = "1.8.2", default-features = false }
alloy-transport-ws = { version = "1.8.2", default-features = false }
alloy-consensus = { version = "2.0.1", default-features = false }
alloy-contract = { version = "2.0.1", default-features = false }
alloy-eips = { version = "2.0.1", default-features = false }
alloy-genesis = { version = "2.0.1", default-features = false }
alloy-json-rpc = { version = "2.0.1", default-features = false }
alloy-network = { version = "2.0.1", default-features = false }
alloy-network-primitives = { version = "2.0.1", default-features = false }
alloy-provider = { version = "2.0.1", features = ["reqwest", "debug-api"], default-features = false }
alloy-pubsub = { version = "2.0.1", default-features = false }
alloy-rpc-client = { version = "2.0.1", default-features = false }
alloy-rpc-types = { version = "2.0.1", features = ["eth"], default-features = false }
alloy-rpc-types-admin = { version = "2.0.1", default-features = false }
alloy-rpc-types-anvil = { version = "2.0.1", default-features = false }
alloy-rpc-types-beacon = { version = "2.0.1", default-features = false }
alloy-rpc-types-debug = { version = "2.0.1", default-features = false }
alloy-rpc-types-engine = { version = "2.0.1", default-features = false }
alloy-rpc-types-eth = { version = "2.0.1", default-features = false }
alloy-rpc-types-mev = { version = "2.0.1", default-features = false }
alloy-rpc-types-trace = { version = "2.0.1", default-features = false }
alloy-rpc-types-txpool = { version = "2.0.1", default-features = false }
alloy-serde = { version = "2.0.1", default-features = false }
alloy-signer = { version = "2.0.1", default-features = false }
alloy-signer-local = { version = "2.0.1", default-features = false }
alloy-transport = { version = "2.0.1" }
alloy-transport-http = { version = "2.0.1", features = ["reqwest-rustls-tls"], default-features = false }
alloy-transport-ipc = { version = "2.0.1", default-features = false }
alloy-transport-ws = { version = "2.0.1", default-features = false }
# misc
either = { version = "1.15.0", default-features = false }
@@ -506,6 +507,7 @@ eyre = "0.6"
fdlimit = "0.3.0"
fixed-map = { version = "0.9", default-features = false }
humantime = "2.1"
imbl = "7"
humantime-serde = "1.1"
itertools = { version = "0.14", default-features = false }
linked_hash_set = "0.1"
@@ -558,7 +560,7 @@ proc-macro2 = "1.0"
quote = "1.0"
# tokio
tokio = { version = "1.44.2", default-features = false }
tokio = { version = "1.51.1", default-features = false }
tokio-stream = "0.1.11"
tokio-tungstenite = "0.28.0"
tokio-util = { version = "0.7.4", features = ["codec"] }
@@ -671,7 +673,6 @@ indexmap = "2"
interprocess = "2.2.0"
lz4_flex = { version = "0.12", default-features = false }
memmap2 = "0.9.4"
mev-share-sse = { version = "0.5.0", default-features = false }
num-traits = "0.2.15"
page_size = "0.6.0"
plain_hasher = "0.2"
@@ -699,44 +700,3 @@ vergen-git2 = "9.1.0"
# networking
ipnet = "2.11"
[patch.crates-io]
# alloy-consensus = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-contract = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-eips = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-genesis = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-json-rpc = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-network = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-network-primitives = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-provider = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-pubsub = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-rpc-client = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-rpc-types = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-rpc-types-admin = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-rpc-types-anvil = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-rpc-types-beacon = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-rpc-types-debug = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-rpc-types-engine = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-rpc-types-eth = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-rpc-types-mev = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-rpc-types-trace = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-rpc-types-txpool = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-serde = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-signer = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-signer-local = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-transport = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-transport-http = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-transport-ipc = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# alloy-transport-ws = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# revm-inspectors = { git = "https://github.com/paradigmxyz/revm-inspectors", rev = "1207e33" }
#
# jsonrpsee = { git = "https://github.com/paradigmxyz/jsonrpsee", branch = "matt/make-rpc-service-pub" }
# jsonrpsee-core = { git = "https://github.com/paradigmxyz/jsonrpsee", branch = "matt/make-rpc-service-pub" }
# jsonrpsee-server = { git = "https://github.com/paradigmxyz/jsonrpsee", branch = "matt/make-rpc-service-pub" }
# jsonrpsee-http-client = { git = "https://github.com/paradigmxyz/jsonrpsee", branch = "matt/make-rpc-service-pub" }
# jsonrpsee-types = { git = "https://github.com/paradigmxyz/jsonrpsee", branch = "matt/make-rpc-service-pub" }
# alloy-evm = { git = "https://github.com/alloy-rs/evm", rev = "9bc2dba" }
# revm-inspectors = { git = "https://github.com/paradigmxyz/revm-inspectors", rev = "3020ea8" }

View File

@@ -1,10 +1,8 @@
# 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
@@ -45,18 +43,6 @@ 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
@@ -67,21 +53,12 @@ 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 [ "$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; \
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 && \
sccache --show-stats
# Copy binary to a known location (ARG not resolved in COPY)

View File

@@ -9,7 +9,7 @@
![](./assets/reth-2.png)
**[Install](https://paradigmxyz.github.io/reth/installation/installation.html)**
**[Install](https://reth.rs/installation/installation)**
| [User Docs](https://reth.rs)
| [Developer Docs](./docs)
| [Crate Docs](https://reth.rs/docs)
@@ -18,51 +18,43 @@
[gh-lint]: https://github.com/paradigmxyz/reth/actions/workflows/lint.yml
[tg-badge]: https://img.shields.io/endpoint?color=neon&logo=telegram&label=chat&url=https%3A%2F%2Ftg.sumanjay.workers.dev%2Fparadigm%5Freth
> **Note: OP-Reth has moved**
>
> The Optimism (op-reth) crates have been moved to [ethereum-optimism/optimism](https://github.com/ethereum-optimism/optimism).
> Git contribution history has been preserved. If you are looking for op-reth, please see the new repository.
## What is Reth?
Reth (short for Rust Ethereum, [pronunciation](https://x.com/kelvinfichter/status/1597653609411268608)) is a new Ethereum full node implementation that is focused on being user-friendly, highly modular, as well as being fast and efficient. Reth is an Execution Layer (EL) and is compatible with all Ethereum Consensus Layer (CL) implementations that support the [Engine API](https://github.com/ethereum/execution-apis/tree/a0d03086564ab1838b462befbc083f873dcf0c0f/src/engine). It is originally built and driven forward by [Paradigm](https://paradigm.xyz/), and is licensed under the Apache and MIT licenses.
Reth (short for Rust Ethereum, [pronunciation](https://x.com/kelvinfichter/status/1597653609411268608)) is a production-ready Ethereum execution layer client focused on modularity, performance, and user-friendliness. Reth is compatible with all Ethereum Consensus Layer (CL) implementations that support the [Engine API](https://github.com/ethereum/execution-apis/tree/a0d03086564ab1838b462befbc083f873dcf0c0f/src/engine). It is built and driven forward by [Paradigm](https://paradigm.xyz/), and is licensed under the Apache and MIT licenses.
> **Note:** OP-Reth has moved to [ethereum-optimism/optimism](https://github.com/ethereum-optimism/optimism). Git history has been preserved.
## Goals
As a full Ethereum node, Reth allows users to connect to the Ethereum network and interact with the Ethereum blockchain. This includes sending and receiving transactions/logs/traces, as well as accessing and interacting with smart contracts. Building a successful Ethereum node requires creating a high-quality implementation that is both secure and efficient, as well as being easy to use on consumer hardware. It also requires building a strong community of contributors who can help support and improve the software.
1. **Modularity**: Every component is built to be used as a library: well-tested, documented and benchmarked. Import crates, mix and match, and innovate on top of them. Learn more about the project's components [here](./docs/repo/layout.md).
2. **Performance**: Built with Rust, [Alloy](https://github.com/alloy-rs/alloy/), [revm](https://github.com/bluealloy/revm/), and [Foundry](https://github.com/foundry-rs/foundry/) — battle-tested and optimized for speed. Check the [ethPandaOps Lab Dashboard](https://lab.ethpandaops.io/ethereum/execution/timings) for a third-party comparison against other Ethereum clients.
Here's what that looks like in practice, measured with [reth-bench](https://github.com/paradigmxyz/reth/tree/main/bin/reth-bench) on Ethereum Mainnet:
More concretely, our goals are:
![](./assets/reth-perf.png)
1. **Modularity**: Every component of Reth is built to be used as a library: well-tested, heavily documented and benchmarked. We envision that developers will import the node's crates, mix and match, and innovate on top of them. Examples of such usage include but are not limited to spinning up standalone P2P networks, talking directly to a node's database, or "unbundling" the node into the components you need. To achieve that, we are licensing Reth under the Apache/MIT permissive license. You can learn more about the project's components [here](./docs/repo/layout.md).
2. **Performance**: Reth aims to be fast, so we use Rust and the [Erigon staged-sync](https://erigon.substack.com/p/erigon-stage-sync-and-control-flows) node architecture. We also use our Ethereum libraries (including [Alloy](https://github.com/alloy-rs/alloy/) and [revm](https://github.com/bluealloy/revm/)) which we've battle-tested and optimized via [Foundry](https://github.com/foundry-rs/foundry/).
3. **Free for anyone to use any way they want**: Reth is free open source software, built for the community, by the community. By licensing the software under the Apache/MIT license, we want developers to use it without being bound by business licenses, or having to think about the implications of GPL-like licenses.
4. **Client Diversity**: The Ethereum protocol becomes more antifragile when no node implementation dominates. This ensures that if there's a software bug, the network does not finalize a bad block. By building a new client, we hope to contribute to Ethereum's antifragility.
5. **Support as many EVM chains as possible**: We aspire that Reth can full-sync not only Ethereum, but also other chains like Optimism, Polygon, BNB Smart Chain, and more. If you're working on any of these projects, please reach out. Note: OP-Reth has moved to [ethereum-optimism/optimism](https://github.com/ethereum-optimism/optimism).
6. **Configurability**: We want to solve for node operators that care about fast historical queries, but also for hobbyists who cannot operate on large hardware. We also want to support teams and individuals who want both sync from genesis and via "fast sync". We envision that Reth will be configurable enough and provide configurable "profiles" for the tradeoffs that each team faces.
3. **Free for anyone to use any way they want**: Apache/MIT licensed, no business license restrictions.
4. **Client Diversity**: More client implementations make Ethereum more antifragile.
5. **Support as many EVM chains as possible**: Reth can sync Ethereum and other EVM chains. If you're building one, reach out.
6. **Configurability**: Profiles for different use cases — from high-performance RPC operators to hobbyists on consumer hardware.
## Status
Reth is production ready, and suitable for usage in mission-critical environments such as staking or high-uptime services. We also actively recommend professional node operators to switch to Reth in production for performance and cost reasons in use cases where high performance with great margins is required such as RPC, MEV, Indexing, Simulations, and P2P activities.
More historical context below:
- We released **Reth 2.0** in April 2026. See the [release notes](https://github.com/paradigmxyz/reth/releases/tag/v2.0.0) and [blog post](https://www.paradigm.xyz/2026/04/releasing-reth-2-0).
- We released 1.0 "production-ready" stable Reth in June 2024.
- Reth completed an audit with [Sigma Prime](https://sigmaprime.io/), the developers of [Lighthouse](https://github.com/sigp/lighthouse), the Rust Consensus Layer implementation. Find it [here](./audit/sigma_prime_audit_v2.pdf).
- Revm (the EVM used in Reth) underwent an audit with [Guido Vranken](https://x.com/guidovranken) (#1 [Ethereum Bug Bounty](https://ethereum.org/en/bug-bounty)). We will publish the results soon.
- Revm (the EVM used in Reth) underwent an audit with [Guido Vranken](https://x.com/guidovranken) (#1 [Ethereum Bug Bounty](https://ethereum.org/en/bug-bounty)).
- We released multiple iterative beta versions, up to [beta.9](https://github.com/paradigmxyz/reth/releases/tag/v0.2.0-beta.9) on Monday June 3, 2024, the last beta release.
- We released [beta](https://github.com/paradigmxyz/reth/releases/tag/v0.2.0-beta.1) on Monday March 4, 2024, our first breaking change to the database model, providing faster query speed, smaller database footprint, and allowing "history" to be mounted on separate drives.
- We shipped iterative improvements until the last alpha release on February 28, 2024, [0.1.0-alpha.21](https://github.com/paradigmxyz/reth/releases/tag/v0.1.0-alpha.21).
- We [initially announced](https://www.paradigm.xyz/2023/06/reth-alpha) [0.1.0-alpha.1](https://github.com/paradigmxyz/reth/releases/tag/v0.1.0-alpha.1) on June 20, 2023.
### Database compatibility
### Storage compatibility
We do not have any breaking database changes since beta.1, and we do not plan any in the near future.
Storage V2 is the default for new nodes in Reth 2.0. Existing V1 nodes continue to work, but V1 support will be removed in a future release — all users are encouraged to migrate. V2 snapshots are available at [snapshots.reth.rs](https://snapshots.reth.rs/).
Reth [v0.2.0-beta.1](https://github.com/paradigmxyz/reth/releases/tag/v0.2.0-beta.1) includes
a [set of breaking database changes](https://github.com/paradigmxyz/reth/pull/5191) that makes it impossible to use database files produced by earlier versions.
If you had a database produced by alpha versions of Reth, you need to drop it with `reth db drop`
(using the same arguments such as `--config` or `--datadir` that you passed to `reth node`), and resync using the same `reth node` command you've used before.
![](./assets/reth-storage.png)
## For Users

BIN
assets/reth-perf.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
assets/reth-storage.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -11,7 +11,7 @@ use alloy_eips::eip7685::Requests;
use alloy_evm::{
block::{
BlockExecutionError, BlockExecutionResult, BlockExecutor, BlockExecutorFactory,
BlockExecutorFor, ExecutableTx, OnStateHook, StateChangeSource, StateDB,
BlockExecutorFor, ExecutableTx, GasOutput, OnStateHook, StateChangeSource, StateDB,
},
eth::{EthBlockExecutionCtx, EthBlockExecutor, EthEvmContext, EthTxResult},
precompiles::PrecompilesMap,
@@ -238,6 +238,7 @@ where
withdrawals: prev_segment.ctx.withdrawals.clone(),
extra_data: prev_segment.ctx.extra_data.clone(),
tx_count_hint: prev_segment.ctx.tx_count_hint,
slot_number: prev_segment.ctx.slot_number,
};
// Clone the next segment's data before we consume inner.
@@ -252,6 +253,7 @@ where
withdrawals: new_segment.ctx.withdrawals.clone(),
extra_data: new_segment.ctx.extra_data.clone(),
tx_count_hint: new_segment.ctx.tx_count_hint,
slot_number: new_segment.ctx.slot_number,
};
plan.next_segment += 1;
@@ -364,6 +366,7 @@ where
withdrawals: seg0.ctx.withdrawals.clone(),
extra_data: seg0.ctx.extra_data.clone(),
tx_count_hint: seg0.ctx.tx_count_hint,
slot_number: seg0.ctx.slot_number,
};
let inner = self.inner_mut();
@@ -386,7 +389,10 @@ where
self.inner_mut().execute_transaction_without_commit(tx)
}
fn commit_transaction(&mut self, output: Self::Result) -> Result<u64, BlockExecutionError> {
fn commit_transaction(
&mut self,
output: Self::Result,
) -> Result<GasOutput, BlockExecutionError> {
let gas_used = self.inner_mut().commit_transaction(output)?;
// Fix up cumulative_gas_used on the just-committed receipt so that
@@ -419,6 +425,7 @@ where
withdrawals: last_seg.ctx.withdrawals.clone(),
extra_data: last_seg.ctx.extra_data.clone(),
tx_count_hint: last_seg.ctx.tx_count_hint,
slot_number: last_seg.ctx.slot_number,
};
self.inner_mut().ctx = last_ctx;
}

View File

@@ -266,6 +266,7 @@ where
ommers: &[],
withdrawals: ctx.withdrawals.map(|w| std::borrow::Cow::Owned(w.into_owned())),
extra_data: ctx.extra_data,
slot_number: ctx.slot_number,
};
BigBlockSegment { start_tx, evm_env, ctx }
})

View File

@@ -176,6 +176,7 @@ impl BbAddOns {
BasicEngineApiBuilder::default(),
BasicEngineValidatorBuilder::default(),
Default::default(),
Default::default(),
)
}
}

View File

@@ -31,8 +31,10 @@ reth-tracing.workspace = true
# alloy
alloy-consensus.workspace = true
alloy-eip7928 = { workspace = true, features = ["rlp"] }
alloy-eips.workspace = true
alloy-json-rpc.workspace = true
alloy-rlp.workspace = true
alloy-primitives = { workspace = true, features = ["rand"] }
alloy-provider = { workspace = true, features = ["engine-api", "pubsub", "reqwest-rustls-tls"], default-features = false }

View File

@@ -54,13 +54,8 @@ impl BenchContext {
}
}
// 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)
});
// set up alloy client for blocks, retrying on any errors, whether HTTP or OS
let retry_policy = RateLimitRetryPolicy::default().or(|_| true);
let max_retries = bench_args.rpc_block_fetch_retries.as_max_retries();
let client = ClientBuilder::default()
.layer(RetryBackoffLayer::new_with_policy(max_retries, 800, u64::MAX, retry_policy))

View File

@@ -6,7 +6,12 @@
//! [`ExecutionData`] and environment switches at each block boundary.
use alloy_consensus::{TxEnvelope, TxReceipt};
use alloy_eips::{eip1559::BaseFeeParams, eip7840::BlobParams, Typed2718};
use alloy_eips::{
eip1559::BaseFeeParams,
eip7840::BlobParams,
eip7928::{AccountChanges, BlockAccessList, SlotChanges},
Typed2718,
};
use alloy_primitives::{Bloom, Bytes, B256};
use alloy_provider::{network::AnyNetwork, Provider, RootProvider};
use alloy_rpc_client::ClientBuilder;
@@ -24,9 +29,11 @@ use reth_ethereum_cli::chainspec::EthereumChainSpecParser;
use reth_ethereum_primitives::Receipt;
use reth_primitives_traits::proofs;
use serde::{Deserialize, Serialize};
use std::future::Future;
use std::{collections::HashMap, future::Future};
use tracing::{info, warn};
use crate::bench::helpers::fetch_block_access_list;
/// A single transaction with its gas used and raw encoded bytes.
#[derive(Debug, Clone)]
pub struct RawTransaction {
@@ -215,6 +222,9 @@ pub struct BigBlockPayload {
/// Big block data containing environment switches and prior block hashes.
#[serde(default)]
pub big_block_data: BigBlockData<ExecutionData>,
/// Flattened BAL across all constituent blocks, if requested during generation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub block_access_list: Option<BlockAccessList>,
}
/// `reth bench generate-big-block` command
@@ -252,6 +262,11 @@ pub struct Command {
/// Output directory for generated payloads.
#[arg(long, value_name = "OUTPUT_DIR")]
output_dir: std::path::PathBuf,
/// Query `eth_getBlockAccessListByBlockNumber` for each fetched block and persist
/// the flattened BAL on the stored payload.
#[arg(long, default_value_t = false)]
bal: bool,
}
impl Command {
@@ -273,6 +288,7 @@ impl Command {
from_block = self.from_block,
target_gas = self.target_gas,
num_big_blocks = self.num_big_blocks,
include_bal = self.bal,
chain = %chain_spec.chain(),
output_dir = %self.output_dir.display(),
"Generating big block payloads"
@@ -312,6 +328,7 @@ impl Command {
// Fetch consecutive blocks until the gas target is reached.
let mut blocks = Vec::new();
let mut block_receipts: Vec<Vec<Receipt>> = Vec::new();
let mut block_access_lists: Vec<Option<BlockAccessList>> = Vec::new();
let mut accumulated_block_gas: u64 = 0;
let mut reached_chain_tip = false;
@@ -338,6 +355,14 @@ impl Command {
Err(e) => return Err(e.into()),
};
let block_access_list = if self.bal {
Some(fetch_block_access_list(&provider, block_number).await.wrap_err_with(
|| format!("Failed to fetch BAL for block {block_number}"),
)?)
} else {
None
};
// Convert RPC receipts to consensus receipts
let consensus_receipts: Vec<Receipt> = receipts
.iter()
@@ -375,10 +400,14 @@ impl Command {
let execution_data = ExecutionData { payload, sidecar };
let block_gas = execution_data.payload.as_v1().gas_used;
let block_blob_gas =
execution_data.payload.as_v3().map(|v3| v3.blob_gas_used).unwrap_or(0);
info!(
target: "reth-bench",
block_number,
gas_used = block_gas,
blob_gas_used = block_blob_gas,
tx_count = execution_data.payload.transactions().len(),
receipts = consensus_receipts.len(),
"Fetched block"
@@ -387,6 +416,7 @@ impl Command {
accumulated_block_gas += block_gas;
blocks.push(execution_data);
block_receipts.push(consensus_receipts);
block_access_lists.push(block_access_list);
next_block += 1;
}
@@ -404,6 +434,7 @@ impl Command {
// Block 0 is the base
let mut base = blocks.remove(0);
let base_receipts = block_receipts.remove(0);
let mut merged_block_access_list = block_access_lists.remove(0);
let mut env_switches = Vec::new();
// Accumulate all receipts with corrected cumulative_gas_used.
@@ -439,12 +470,22 @@ impl Command {
let mut total_gas_limit = base.payload.as_v1().gas_limit;
// Concatenate transactions from subsequent blocks and build env_switches
for (block_data, receipts) in blocks.into_iter().zip(block_receipts) {
for ((block_data, receipts), block_access_list) in
blocks.into_iter().zip(block_receipts).zip(block_access_lists)
{
let block_v1 = block_data.payload.as_v1();
let block_gas = block_v1.gas_used;
total_gas_used += block_gas;
total_gas_limit += block_v1.gas_limit;
if let Some(block_access_list) = block_access_list {
merge_block_access_list(
merged_block_access_list.get_or_insert_with(Default::default),
block_access_list,
cumulative_tx_count as u64,
);
}
// Accumulate receipts with corrected cumulative_gas_used
all_receipts.extend(receipts.into_iter().map(|mut r| {
r.cumulative_gas_used += cumulative_gas_offset;
@@ -579,6 +620,7 @@ impl Command {
env_switches,
prior_block_hashes: accumulated_block_hashes.clone(),
},
block_access_list: merged_block_access_list,
};
// Accumulate real block hashes from this big block's env_switches for
@@ -610,6 +652,7 @@ impl Command {
total_gas_used = big_block.execution_data.payload.as_v1().gas_used,
env_switches = big_block.big_block_data.env_switches.len(),
prior_block_hashes = big_block.big_block_data.prior_block_hashes.len(),
bal_accounts = big_block.block_access_list.as_ref().map_or(0, Vec::len),
"Big block payload saved"
);
@@ -628,6 +671,71 @@ impl Command {
}
}
fn merge_block_access_list(
merged: &mut BlockAccessList,
incoming: BlockAccessList,
tx_index_offset: u64,
) {
let mut account_positions = merged
.iter()
.enumerate()
.map(|(idx, account)| (account.address, idx))
.collect::<HashMap<_, _>>();
for mut account_changes in incoming {
shift_account_changes(&mut account_changes, tx_index_offset);
if let Some(&idx) = account_positions.get(&account_changes.address) {
merge_account_changes(&mut merged[idx], account_changes);
} else {
account_positions.insert(account_changes.address, merged.len());
merged.push(account_changes);
}
}
}
fn shift_account_changes(account_changes: &mut AccountChanges, tx_index_offset: u64) {
for slot_changes in &mut account_changes.storage_changes {
for change in &mut slot_changes.changes {
change.block_access_index += tx_index_offset;
}
}
for change in &mut account_changes.balance_changes {
change.block_access_index += tx_index_offset;
}
for change in &mut account_changes.nonce_changes {
change.block_access_index += tx_index_offset;
}
for change in &mut account_changes.code_changes {
change.block_access_index += tx_index_offset;
}
}
fn merge_account_changes(existing: &mut AccountChanges, incoming: AccountChanges) {
merge_slot_changes(&mut existing.storage_changes, incoming.storage_changes);
existing.storage_reads.extend(incoming.storage_reads);
existing.balance_changes.extend(incoming.balance_changes);
existing.nonce_changes.extend(incoming.nonce_changes);
existing.code_changes.extend(incoming.code_changes);
}
fn merge_slot_changes(existing: &mut Vec<SlotChanges>, incoming: Vec<SlotChanges>) {
let mut slot_positions = existing
.iter()
.enumerate()
.map(|(idx, slot_changes)| (slot_changes.slot, idx))
.collect::<HashMap<_, _>>();
for slot_changes in incoming {
if let Some(&idx) = slot_positions.get(&slot_changes.slot) {
existing[idx].changes.extend(slot_changes.changes);
} else {
slot_positions.insert(slot_changes.slot, existing.len());
existing.push(slot_changes);
}
}
}
/// Computes the block hash for an [`ExecutionData`] by converting it to a raw block
/// and hashing the header.
pub fn compute_payload_block_hash(data: &ExecutionData) -> eyre::Result<B256> {
@@ -638,3 +746,94 @@ pub fn compute_payload_block_hash(data: &ExecutionData) -> eyre::Result<B256> {
.wrap_err("failed to convert payload to block for hash computation")?;
Ok(block.header.hash_slow())
}
#[cfg(test)]
mod tests {
use super::*;
use alloy_eips::eip7928::{BalanceChange, CodeChange, NonceChange, StorageChange};
use alloy_primitives::{Address, U256};
#[test]
fn merge_block_access_list_offsets_and_merges_accounts() {
let shared = Address::repeat_byte(0x11);
let other = Address::repeat_byte(0x22);
let mut merged = vec![AccountChanges {
address: shared,
storage_changes: vec![SlotChanges::new(
U256::from(1),
vec![StorageChange::new(0, U256::from(10))],
)],
storage_reads: vec![U256::from(3)],
balance_changes: vec![BalanceChange::new(1, U256::from(100))],
nonce_changes: vec![NonceChange::new(2, 7)],
code_changes: vec![],
}];
let incoming = vec![
AccountChanges {
address: shared,
storage_changes: vec![
SlotChanges::new(U256::from(1), vec![StorageChange::new(1, U256::from(20))]),
SlotChanges::new(U256::from(2), vec![StorageChange::new(2, U256::from(30))]),
],
storage_reads: vec![U256::from(4)],
balance_changes: vec![BalanceChange::new(0, U256::from(150))],
nonce_changes: vec![NonceChange::new(2, 8)],
code_changes: vec![CodeChange::new(1, Bytes::from_static(&[0xaa]))],
},
AccountChanges {
address: other,
storage_changes: vec![SlotChanges::new(
U256::from(9),
vec![StorageChange::new(0, U256::from(90))],
)],
storage_reads: vec![],
balance_changes: vec![],
nonce_changes: vec![],
code_changes: vec![],
},
];
merge_block_access_list(&mut merged, incoming, 3);
assert_eq!(merged.len(), 2);
let shared = &merged[0];
assert_eq!(shared.storage_reads, vec![U256::from(3), U256::from(4)]);
assert_eq!(
shared
.balance_changes
.iter()
.map(|change| change.block_access_index)
.collect::<Vec<_>>(),
vec![1, 3]
);
assert_eq!(
shared.nonce_changes.iter().map(|change| change.block_access_index).collect::<Vec<_>>(),
vec![2, 5]
);
assert_eq!(shared.code_changes[0].block_access_index, 4);
let slot_one = shared
.storage_changes
.iter()
.find(|slot_changes| slot_changes.slot == U256::from(1))
.unwrap();
assert_eq!(
slot_one.changes.iter().map(|change| change.block_access_index).collect::<Vec<_>>(),
vec![0, 4]
);
let slot_two = shared
.storage_changes
.iter()
.find(|slot_changes| slot_changes.slot == U256::from(2))
.unwrap();
assert_eq!(slot_two.changes[0].block_access_index, 5);
let other = &merged[1];
assert_eq!(other.address, Address::repeat_byte(0x22));
assert_eq!(other.storage_changes[0].changes[0].block_access_index, 3);
}
}

View File

@@ -1,5 +1,7 @@
//! Common helpers for reth-bench commands.
use alloy_eips::{eip7928::BlockAccessList, BlockNumberOrTag};
use alloy_provider::{network::AnyNetwork, Provider, RootProvider};
use eyre::Result;
use std::{
io::{BufReader, Read},
@@ -69,6 +71,21 @@ pub(crate) fn parse_duration(s: &str) -> eyre::Result<Duration> {
}
}
/// Fetches the block access list for a given block number using the provided provider.
pub(crate) async fn fetch_block_access_list(
provider: &RootProvider<AnyNetwork>,
block_number: u64,
) -> eyre::Result<BlockAccessList> {
provider
.client()
.request("eth_getBlockAccessListByBlockNumber", (BlockNumberOrTag::Number(block_number),))
.await
.map_err(Into::into)
.and_then(|block_access_list: Option<BlockAccessList>| {
block_access_list.ok_or_else(|| eyre::eyre!("BAL not found for block {block_number}"))
})
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -4,7 +4,7 @@
use crate::{
bench::{
context::BenchContext,
helpers::parse_duration,
helpers::{fetch_block_access_list, parse_duration},
metrics_scraper::MetricsScraper,
output::{
write_benchmark_results, CombinedResult, NewPayloadResult, TotalGasOutput, TotalGasRow,
@@ -75,6 +75,10 @@ pub struct Command {
)]
rpc_block_buffer_size: usize,
/// Weather to enable bal by default or not.
#[arg(long, default_value = "false", verbatim_doc_comment)]
enable_bal: bool,
#[command(flatten)]
benchmark: BenchmarkArgs,
}
@@ -198,12 +202,21 @@ impl Command {
finalized_block_hash: finalized,
};
let bal = if rlp.is_none() &&
(block.header.block_access_list_hash.is_some() || self.enable_bal)
{
Some(fetch_block_access_list(&block_provider, block.header.number).await?)
} else {
None
};
let (version, params) = block_to_new_payload(
block,
rlp,
use_reth_namespace,
wait_for_persistence,
no_wait_for_caches,
bal,
)?;
let start = Instant::now();
let server_timings =

View File

@@ -3,6 +3,7 @@
use crate::{
bench::{
context::BenchContext,
helpers::fetch_block_access_list,
metrics_scraper::MetricsScraper,
output::{
NewPayloadResult, TotalGasOutput, TotalGasRow, GAS_OUTPUT_SUFFIX,
@@ -69,7 +70,9 @@ impl Command {
let (error_sender, mut error_receiver) = tokio::sync::oneshot::channel();
let (sender, mut receiver) = tokio::sync::mpsc::channel(buffer_size);
let block_provider_clone = block_provider.clone();
tokio::task::spawn(async move {
let block_provider = block_provider_clone;
while benchmark_mode.contains(next_block) {
let block_res = block_provider
.get_block_by_number(next_block.into())
@@ -123,12 +126,19 @@ impl Command {
debug!(target: "reth-bench", number=?block.header.number, "Sending payload to engine");
let bal = if rlp.is_none() && block.header.block_access_list_hash.is_some() {
Some(fetch_block_access_list(&block_provider, block.header.number).await?)
} else {
None
};
let (version, params) = block_to_new_payload(
block,
rlp,
use_reth_namespace,
wait_for_persistence,
no_wait_for_caches,
bal,
)?;
let start = Instant::now();

View File

@@ -3,7 +3,7 @@
use crate::{
authenticated_transport::AuthenticatedTransportConnect,
bench::{
generate_big_block::BigBlockPayload,
generate_big_block::{compute_payload_block_hash, BigBlockPayload},
helpers::parse_duration,
metrics_scraper::MetricsScraper,
output::{
@@ -12,12 +12,14 @@ use crate::{
},
valid_payload::{call_forkchoice_updated_with_reth, call_new_payload_with_reth},
};
use alloy_eip7928::bal::Bal;
use alloy_eips::eip7928::BlockAccessList;
use alloy_primitives::B256;
use alloy_provider::{network::AnyNetwork, Provider, RootProvider};
use alloy_rpc_client::ClientBuilder;
use alloy_rpc_types_engine::{
CancunPayloadFields, ExecutionData, ExecutionPayloadEnvelopeV4, ExecutionPayloadSidecar,
ForkchoiceState, JwtSecret, PraguePayloadFields,
CancunPayloadFields, ExecutionData, ExecutionPayload, ExecutionPayloadEnvelopeV6,
ExecutionPayloadSidecar, ExecutionPayloadV4, ForkchoiceState, JwtSecret, PraguePayloadFields,
};
use clap::Parser;
use eyre::Context;
@@ -83,6 +85,14 @@ pub struct Command {
#[arg(long, default_value = "false", verbatim_doc_comment)]
reth_new_payload: bool,
/// Forward embedded block access lists to `reth_newPayload` when payload files contain them.
///
/// Disabled by default so the same payload set can be replayed with or without BALs.
///
/// Requires `--reth-new-payload`.
#[arg(long, default_value = "false", verbatim_doc_comment, requires = "reth_new_payload")]
bal: bool,
/// Control when `reth_newPayload` waits for in-flight persistence.
///
/// Accepts `always` (default — wait on every block), `never`, or a number N
@@ -126,6 +136,8 @@ struct LoadedPayload {
block_hash: B256,
/// Big block data containing environment switches and prior block hashes.
big_block_data: BigBlockData<ExecutionData>,
/// Optional BAL flattened into the payload file.
block_access_list: Option<BlockAccessList>,
}
impl Command {
@@ -139,6 +151,9 @@ impl Command {
}
if self.reth_new_payload {
info!("Using reth_newPayload and reth_forkchoiceUpdated endpoints");
if self.bal {
info!(target: "reth-bench", "Forwarding embedded block_access_list data");
}
}
let mut metrics_scraper = MetricsScraper::maybe_new(self.metrics_url.clone());
@@ -185,10 +200,13 @@ impl Command {
}
info!(target: "reth-bench", count = payloads.len(), "Loaded main payloads from disk");
let has_env_switches = payloads.iter().any(|p| !p.big_block_data.env_switches.is_empty());
let has_block_access_lists = payloads.iter().any(|p| {
p.block_access_list.as_ref().is_some_and(|bal: &BlockAccessList| !bal.is_empty())
});
// If any payload has env_switches but we're not using reth_newPayload, warn the user
if !self.reth_new_payload {
let has_env_switches =
payloads.iter().any(|p| !p.big_block_data.env_switches.is_empty());
if has_env_switches {
warn!(
target: "reth-bench",
@@ -196,6 +214,18 @@ impl Command {
env_switches are only supported with reth_newPayload and will be ignored."
);
}
if has_block_access_lists {
warn!(
target: "reth-bench",
"Payloads contain block_access_list data but --reth-new-payload is not set. \
BALs are only forwarded with reth_newPayload and will be ignored."
);
}
} else if has_block_access_lists && !self.bal {
info!(
target: "reth-bench",
"Payloads contain block_access_list data but --bal is not set. BALs will be ignored."
);
}
let mut parent_hash = initial_parent_hash;
@@ -205,7 +235,7 @@ impl Command {
for (i, payload) in payloads.iter().enumerate() {
let execution_data = &payload.execution_data;
let block_hash = payload.block_hash;
let mut block_hash = payload.block_hash;
let v1 = execution_data.payload.as_v1();
let gas_used = v1.gas_used;
@@ -243,10 +273,39 @@ impl Command {
.wait_for_persistence
.unwrap_or(WaitForPersistence::Never)
.rpc_value(block_number);
// Inject sidecar BAL into the inline V4 payload field when --bal is set.
// If the payload is not already V4 we upgrade it (V3→V4) so the BAL
// can be carried inline. This changes the block hash, so we recompute
// it and patch parent_hash to maintain the chain.
let mut execution_data = execution_data.clone();
if self.bal &&
let Some(bal) = &payload.block_access_list
{
let encoded_bal: alloy_primitives::Bytes =
alloy_rlp::encode(Bal::from(bal.clone())).into();
// Upgrade to V4 if necessary, then set the BAL field.
if execution_data.payload.as_v4().is_none() {
execution_data.payload = upgrade_to_v4(execution_data.payload, encoded_bal);
} else {
execution_data.payload.as_v4_mut().unwrap().block_access_list = encoded_bal;
}
// Patch parent_hash so this block chains off the (possibly
// rehashed) previous block.
execution_data.payload.as_v1_mut().parent_hash = parent_hash;
// Recompute block hash after payload modification and update
// the hash stored in the payload itself.
block_hash = compute_payload_block_hash(&execution_data)?;
execution_data.payload.as_v1_mut().block_hash = block_hash;
}
(
None,
serde_json::to_value((
RethNewPayloadInput::ExecutionData(execution_data.clone()),
RethNewPayloadInput::ExecutionData(execution_data),
wait_for_persistence,
self.no_wait_for_caches.then_some(false),
big_block_data_param,
@@ -256,7 +315,7 @@ impl Command {
let requests =
execution_data.sidecar.requests().cloned().unwrap_or_default().to_vec();
(
Some(EngineApiMessageVersion::V4),
Some(EngineApiMessageVersion::V6),
serde_json::to_value((
execution_data.payload.clone(),
Vec::<B256>::new(),
@@ -364,7 +423,7 @@ impl Command {
/// Load and parse all payload files from the directory.
///
/// Tries to load each file as a [`BigBlockPayload`] first (which includes `env_switches`),
/// falling back to [`ExecutionPayloadEnvelopeV4`] for backwards compatibility.
/// falling back to [`ExecutionPayloadEnvelopeV6`] for backwards compatibility.
fn load_payloads(&self) -> eyre::Result<Vec<LoadedPayload>> {
let mut payloads = Vec::new();
@@ -391,12 +450,11 @@ impl Command {
let name_str = name.to_string_lossy();
let index = if let Some(rest) = name_str.strip_prefix("payload_block_") {
rest.strip_suffix(".json")?.parse::<u64>().ok()?
} else if let Some(rest) = name_str.strip_prefix("big_block_") {
} else {
let rest = name_str.strip_prefix("big_block_")?;
// "big_block_FROM_to_TO.json" — use FROM as the index
let rest = rest.strip_suffix(".json")?;
rest.split("_to_").next()?.parse::<u64>().ok()?
} else {
return None;
};
Some((index, e.path()))
})
@@ -417,26 +475,27 @@ impl Command {
.wrap_err_with(|| format!("Failed to read {:?}", path))?;
// Try BigBlockPayload first, then fall back to legacy ExecutionPayloadEnvelopeV4
let (execution_data, big_block_data) =
if let Ok(big_block) = serde_json::from_str::<BigBlockPayload>(&content) {
(big_block.execution_data, big_block.big_block_data)
} else {
let envelope: ExecutionPayloadEnvelopeV4 = serde_json::from_str(&content)
.wrap_err_with(|| format!("Failed to parse {:?}", path))?;
let execution_data = ExecutionData {
payload: envelope.envelope_inner.execution_payload.clone().into(),
sidecar: ExecutionPayloadSidecar::v4(
CancunPayloadFields {
versioned_hashes: Vec::new(),
parent_beacon_block_root: B256::ZERO,
},
PraguePayloadFields {
requests: envelope.execution_requests.clone().into(),
},
),
};
(execution_data, BigBlockData::default())
let (execution_data, big_block_data, block_access_list) = if let Ok(big_block) =
serde_json::from_str::<BigBlockPayload>(&content)
{
(big_block.execution_data, big_block.big_block_data, big_block.block_access_list)
} else {
let envelope: ExecutionPayloadEnvelopeV6 = serde_json::from_str(&content)
.wrap_err_with(|| format!("Failed to parse {:?}", path))?;
let execution_data = ExecutionData {
payload: envelope.execution_payload.clone().into(),
sidecar: ExecutionPayloadSidecar::v4(
CancunPayloadFields {
versioned_hashes: Vec::new(),
parent_beacon_block_root: B256::ZERO,
},
PraguePayloadFields {
requests: envelope.execution_requests.clone().into(),
},
),
};
(execution_data, BigBlockData::default(), None)
};
let block_hash = execution_data.payload.as_v1().block_hash;
@@ -446,13 +505,48 @@ impl Command {
block_hash = %block_hash,
env_switches = big_block_data.env_switches.len(),
prior_block_hashes = big_block_data.prior_block_hashes.len(),
bal_accounts = block_access_list.as_ref().map_or(0, Vec::len),
path = %path.display(),
"Loaded payload"
);
payloads.push(LoadedPayload { index, execution_data, block_hash, big_block_data });
payloads.push(LoadedPayload {
index,
execution_data,
block_hash,
big_block_data,
block_access_list,
});
}
Ok(payloads)
}
}
/// Upgrades an [`ExecutionPayload`] to V4 by wrapping the inner V3 payload (constructing
/// default V2/V3 layers for V1 payloads if needed) and setting the provided BAL bytes.
fn upgrade_to_v4(
payload: ExecutionPayload,
block_access_list: alloy_primitives::Bytes,
) -> ExecutionPayload {
use alloy_rpc_types_engine::{ExecutionPayloadV2, ExecutionPayloadV3};
let v3 = match payload {
ExecutionPayload::V4(_) => unreachable!("caller checks as_v4().is_none()"),
ExecutionPayload::V3(v3) => v3,
ExecutionPayload::V2(v2) => {
ExecutionPayloadV3 { payload_inner: v2, blob_gas_used: 0, excess_blob_gas: 0 }
}
ExecutionPayload::V1(v1) => ExecutionPayloadV3 {
payload_inner: ExecutionPayloadV2 { payload_inner: v1, withdrawals: Vec::new() },
blob_gas_used: 0,
excess_blob_gas: 0,
},
};
ExecutionPayload::V4(ExecutionPayloadV4 {
payload_inner: v3,
block_access_list,
slot_number: 0,
})
}

View File

@@ -1,6 +1,8 @@
use alloy_eips::eip4895::Withdrawal;
use alloy_primitives::{Address, Bloom, Bytes, B256, U256};
use alloy_rpc_types_engine::{ExecutionPayloadV1, ExecutionPayloadV2, ExecutionPayloadV3};
use alloy_rpc_types_engine::{
ExecutionPayloadV1, ExecutionPayloadV2, ExecutionPayloadV3, ExecutionPayloadV4,
};
/// Configuration for invalidating payload fields
#[derive(Debug, Default)]
@@ -21,6 +23,7 @@ pub(super) struct InvalidationConfig {
pub(super) block_hash: Option<B256>,
pub(super) blob_gas_used: Option<u64>,
pub(super) excess_blob_gas: Option<u64>,
pub(super) slot_number: Option<u64>,
// Auto-invalidation flags
pub(super) invalidate_parent_hash: bool,
@@ -35,6 +38,8 @@ pub(super) struct InvalidationConfig {
pub(super) invalidate_withdrawals: bool,
pub(super) invalidate_blob_gas_used: bool,
pub(super) invalidate_excess_blob_gas: bool,
pub(super) invalidate_block_access_list: bool,
pub(super) invalidate_slot_number: bool,
}
impl InvalidationConfig {
@@ -216,4 +221,30 @@ impl InvalidationConfig {
changes
}
/// Applies invalidations to a V4 payload, returns list of what was changed.
pub(super) fn apply_to_payload_v4(&self, payload: &mut ExecutionPayloadV4) -> Vec<String> {
let mut changes = self.apply_to_payload_v3(&mut payload.payload_inner);
// Explicit override for slot_number
if let Some(slot_number) = self.slot_number {
payload.slot_number = slot_number;
changes.push(format!("slot_number = {slot_number}"));
}
// Handle block access list invalidation (V4+)
if self.invalidate_block_access_list {
let fake_bal = Bytes::from_static(&[0x01, 0x02, 0x03]);
payload.block_access_list = fake_bal.clone();
changes.push(format!("block_access_list = {fake_bal} (auto-invalidated)"));
}
// Handle slot number invalidation (V4+)
if self.invalidate_slot_number {
payload.slot_number = u64::MAX;
changes.push("slot_number = MAX (auto-invalidated)".to_string());
}
changes
}
}

View File

@@ -1,12 +1,18 @@
//! Command for sending invalid payloads to test Engine API rejection.
mod invalidation;
use alloy_rpc_client::ClientBuilder;
use invalidation::InvalidationConfig;
use crate::bench::helpers::fetch_block_access_list;
use super::helpers::{load_jwt_secret, read_input};
use alloy_consensus::TxEnvelope;
use alloy_primitives::{Address, B256};
use alloy_provider::network::AnyRpcBlock;
use alloy_primitives::{Address, Bytes, B256};
use alloy_provider::{
network::{AnyNetwork, AnyRpcBlock},
RootProvider,
};
use alloy_rpc_types_engine::ExecutionPayload;
use clap::Parser;
use eyre::{OptionExt, Result};
@@ -105,6 +111,9 @@ pub struct Command {
#[arg(long, value_name = "HASH", help_heading = "Explicit Value Overrides")]
requests_hash: Option<B256>,
/// Override the slot number with a specific value.
#[arg(long, value_name = "U64", help_heading = "Explicit Value Overrides")]
slot_number: Option<u64>,
// ==================== Auto-Invalidation Flags ====================
/// Invalidate the parent hash by setting it to a random value.
#[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")]
@@ -158,6 +167,14 @@ pub struct Command {
#[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")]
invalidate_requests_hash: bool,
/// Invalidate the block access list by setting it to a random value (EIP-7928).
#[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")]
invalidate_block_access_list: bool,
/// Invalidate the slot number by setting it to an random value.(EIP-7843).
#[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")]
invalidate_slot_number: bool,
// ==================== Meta Flags ====================
/// Skip block hash recalculation after modifications.
#[arg(long, default_value_t = false, help_heading = "Meta Flags")]
@@ -199,6 +216,7 @@ impl Command {
block_hash: self.block_hash,
blob_gas_used: self.blob_gas_used,
excess_blob_gas: self.excess_blob_gas,
slot_number: self.slot_number,
invalidate_parent_hash: self.invalidate_parent_hash,
invalidate_state_root: self.invalidate_state_root,
invalidate_receipts_root: self.invalidate_receipts_root,
@@ -211,6 +229,8 @@ impl Command {
invalidate_withdrawals: self.invalidate_withdrawals,
invalidate_blob_gas_used: self.invalidate_blob_gas_used,
invalidate_excess_blob_gas: self.invalidate_excess_blob_gas,
invalidate_block_access_list: self.invalidate_block_access_list,
invalidate_slot_number: self.invalidate_slot_number,
}
}
@@ -234,14 +254,21 @@ impl Command {
let blob_versioned_hashes =
block.body.blob_versioned_hashes_iter().copied().collect::<Vec<_>>();
let use_v4 = block.header.requests_hash.is_some();
let use_v5 = block.header.block_access_list_hash.is_some();
let requests_hash = self.requests_hash.or(block.header.requests_hash);
let mut execution_payload = ExecutionPayload::from_block_slow(&block).0;
let mut execution_payload = if use_v5 {
let encoded_bal = self.fetch_encoded_block_access_list(block.header.number).await?;
ExecutionPayload::from_block_slow_with_bal(&block, encoded_bal).0
} else {
ExecutionPayload::from_block_slow(&block).0
};
let changes = match &mut execution_payload {
ExecutionPayload::V1(p) => config.apply_to_payload_v1(p),
ExecutionPayload::V2(p) => config.apply_to_payload_v2(p),
ExecutionPayload::V3(p) => config.apply_to_payload_v3(p),
ExecutionPayload::V4(p) => config.apply_to_payload_v4(p),
};
let skip_recalc = self.skip_hash_recalc || config.should_skip_hash_recalc();
@@ -256,6 +283,9 @@ impl Command {
ExecutionPayload::V1(p) => p.block_hash,
ExecutionPayload::V2(p) => p.payload_inner.block_hash,
ExecutionPayload::V3(p) => p.payload_inner.payload_inner.block_hash,
ExecutionPayload::V4(p) => {
p.payload_inner.payload_inner.payload_inner.block_hash
}
}
}
};
@@ -264,6 +294,9 @@ impl Command {
ExecutionPayload::V1(p) => p.block_hash = new_hash,
ExecutionPayload::V2(p) => p.payload_inner.block_hash = new_hash,
ExecutionPayload::V3(p) => p.payload_inner.payload_inner.block_hash = new_hash,
ExecutionPayload::V4(p) => {
p.payload_inner.payload_inner.payload_inner.block_hash = new_hash
}
}
}
@@ -305,7 +338,13 @@ impl Command {
match self.mode {
Mode::Execute => {
let mut command = std::process::Command::new("cast");
let method = if use_v4 { "engine_newPayloadV4" } else { "engine_newPayloadV3" };
let method = if use_v5 {
"engine_newPayloadV5"
} else if use_v4 {
"engine_newPayloadV4"
} else {
"engine_newPayloadV3"
};
command.arg("rpc").arg(method).arg("--raw");
if let Some(rpc_url) = self.rpc_url {
command.arg("--rpc-url").arg(rpc_url);
@@ -346,4 +385,17 @@ impl Command {
Ok(())
}
async fn fetch_encoded_block_access_list(&self, block_number: u64) -> Result<Bytes> {
let rpc_url = self
.rpc_url
.as_deref()
.ok_or_eyre("--rpc-url is required to fetch the block access list for V5 payloads")?;
let client = ClientBuilder::default()
.layer(alloy_transport::layers::RetryBackoffLayer::new(10, 800, u64::MAX))
.http(rpc_url.parse()?);
let provider = RootProvider::<AnyNetwork>::new(client);
let bal = fetch_block_access_list(&provider, block_number).await?;
Ok(alloy_rlp::encode(bal).into())
}
}

View File

@@ -1,6 +1,11 @@
use super::helpers::{load_jwt_secret, read_input};
use super::helpers::{fetch_block_access_list, load_jwt_secret, read_input};
use alloy_consensus::TxEnvelope;
use alloy_provider::network::AnyRpcBlock;
use alloy_primitives::Bytes;
use alloy_provider::{
network::{AnyNetwork, AnyRpcBlock},
RootProvider,
};
use alloy_rpc_client::ClientBuilder;
use alloy_rpc_types_engine::ExecutionPayload;
use clap::Parser;
use eyre::{OptionExt, Result};
@@ -69,6 +74,9 @@ impl Command {
})?
.into_consensus();
let use_v4 = block.header.requests_hash.is_some();
let use_v5 = block.header.block_access_list_hash.is_some();
// Extract parent beacon block root
let parent_beacon_block_root = block.header.parent_beacon_block_root;
@@ -76,10 +84,14 @@ impl Command {
let blob_versioned_hashes =
block.body.blob_versioned_hashes_iter().copied().collect::<Vec<_>>();
// Convert to execution payload
let execution_payload = ExecutionPayload::from_block_slow(&block).0;
let use_v4 = block.header.requests_hash.is_some();
// V5 payloads must carry the full RLP-encoded block access list, not just the hash stored
// in the header.
let execution_payload = if use_v5 {
let encoded_bal = self.fetch_encoded_block_access_list(block.header.number).await?;
ExecutionPayload::from_block_slow_with_bal(&block, encoded_bal).0
} else {
ExecutionPayload::from_block_slow(&block).0
};
// Create JSON request data
let json_request = if use_v4 {
@@ -102,7 +114,13 @@ impl Command {
Mode::Execute => {
// Create cast command
let mut command = std::process::Command::new("cast");
let method = if use_v4 { "engine_newPayloadV4" } else { "engine_newPayloadV3" };
let method = if use_v5 {
"engine_newPayloadV5"
} else if use_v4 {
"engine_newPayloadV4"
} else {
"engine_newPayloadV3"
};
command.arg("rpc").arg(method).arg("--raw");
if let Some(rpc_url) = self.rpc_url {
command.arg("--rpc-url").arg(rpc_url);
@@ -146,4 +164,17 @@ impl Command {
Ok(())
}
async fn fetch_encoded_block_access_list(&self, block_number: u64) -> Result<Bytes> {
let rpc_url = self
.rpc_url
.as_deref()
.ok_or_eyre("--rpc-url is required to fetch the block access list for V5 payloads")?;
let client = ClientBuilder::default()
.layer(alloy_transport::layers::RetryBackoffLayer::new(10, 800, u64::MAX))
.http(rpc_url.parse()?);
let provider = RootProvider::<AnyNetwork>::new(client);
let bal = fetch_block_access_list(&provider, block_number).await?;
Ok(alloy_rlp::encode(bal).into())
}
}

View File

@@ -3,6 +3,7 @@
//! before sending additional calls.
use alloy_consensus::TxEnvelope;
use alloy_eips::eip7928::BlockAccessList;
use alloy_primitives::Bytes;
use alloy_provider::{ext::EngineApi, network::AnyRpcBlock, Network, Provider};
use alloy_rpc_types_engine::{
@@ -43,6 +44,14 @@ pub trait EngineApiValidWaitExt<N>: Send + Sync {
fork_choice_state: ForkchoiceState,
payload_attributes: Option<PayloadAttributes>,
) -> TransportResult<ForkchoiceUpdated>;
/// Calls `engine_forkChoiceUpdatedV4` with the given [`ForkchoiceState`] and optional
/// [`PayloadAttributes`], and waits until the response is VALID.
async fn fork_choice_updated_v4_wait(
&self,
fork_choice_state: ForkchoiceState,
payload_attributes: Option<PayloadAttributes>,
) -> TransportResult<ForkchoiceUpdated>;
}
#[async_trait::async_trait]
@@ -162,6 +171,40 @@ where
Ok(status)
}
async fn fork_choice_updated_v4_wait(
&self,
fork_choice_state: ForkchoiceState,
payload_attributes: Option<PayloadAttributes>,
) -> TransportResult<ForkchoiceUpdated> {
debug!(
target: "reth-bench",
method = "engine_forkchoiceUpdatedV3",
?fork_choice_state,
?payload_attributes,
"Sending forkchoiceUpdated"
);
let mut status =
self.fork_choice_updated_v4(fork_choice_state, payload_attributes.clone()).await?;
while !status.is_valid() {
if status.is_invalid() {
error!(
target: "reth-bench",
?status,
?fork_choice_state,
?payload_attributes,
"Invalid forkchoiceUpdatedV4 message",
);
panic!("Invalid forkchoiceUpdatedV4: {status:?}");
}
status =
self.fork_choice_updated_v4(fork_choice_state, payload_attributes.clone()).await?;
}
Ok(status)
}
}
/// Converts an RPC block into versioned engine API params and an [`ExecutionData`].
@@ -176,6 +219,7 @@ pub(crate) fn block_to_new_payload(
reth_new_payload: bool,
wait_for_persistence: WaitForPersistence,
no_wait_for_caches: bool,
bal: Option<BlockAccessList>,
) -> eyre::Result<(Option<EngineApiMessageVersion>, serde_json::Value)> {
let block_number = block.header.number;
let wait_for_persistence = wait_for_persistence.rpc_value(block_number);
@@ -198,7 +242,11 @@ pub(crate) fn block_to_new_payload(
tx.try_into().map_err(|_| eyre::eyre!("unsupported tx type"))
})?
.into_consensus();
let (payload, sidecar) = ExecutionPayload::from_block_slow(&block);
let block_access_list = alloy_rlp::encode(bal.unwrap_or_default());
let (payload, sidecar) =
ExecutionPayload::from_block_slow_with_bal(&block, block_access_list.into());
let (version, params, execution_data) = payload_to_new_payload(payload, sidecar, None)?;
if reth_new_payload {
@@ -227,6 +275,22 @@ pub(crate) fn payload_to_new_payload(
let execution_data = ExecutionData { payload: payload.clone(), sidecar: sidecar.clone() };
let (version, params) = match payload {
ExecutionPayload::V4(payload) => {
let cancun = sidecar
.cancun()
.ok_or_else(|| eyre::eyre!("missing cancun sidecar for V4 payload"))?;
let version = target_version.unwrap_or(EngineApiMessageVersion::V6);
let requests = sidecar.prague().map(|p| p.requests.clone()).unwrap_or_default();
(
version,
serde_json::to_value((
payload,
cancun.versioned_hashes.clone(),
cancun.parent_beacon_block_root,
requests,
))?,
)
}
ExecutionPayload::V3(payload) => {
let cancun = sidecar
.cancun()
@@ -370,6 +434,9 @@ pub(crate) async fn call_forkchoice_updated<N, P: EngineApiValidWaitExt<N>>(
) -> TransportResult<ForkchoiceUpdated> {
// FCU V3 is used for both Cancun and Prague (there is no FCU V4)
match message_version {
EngineApiMessageVersion::V6 => {
provider.fork_choice_updated_v4_wait(forkchoice_state, payload_attributes).await
}
EngineApiMessageVersion::V3 | EngineApiMessageVersion::V4 | EngineApiMessageVersion::V5 => {
provider.fork_choice_updated_v3_wait(forkchoice_state, payload_attributes).await
}

View File

@@ -70,7 +70,7 @@ aquamarine.workspace = true
clap = { workspace = true, features = ["derive", "env"] }
[dev-dependencies]
alloy-node-bindings = "1.6.3"
alloy-node-bindings = "2.0.0"
alloy-provider = { workspace = true, features = ["reqwest"] }
alloy-rpc-types-eth.workspace = true
backon.workspace = true

View File

@@ -274,8 +274,54 @@ impl DeferredTrieData {
/// In normal operation, the parent always has a cached overlay and this
/// function is never called.
///
/// Iterates ancestors oldest -> newest, then extends with current block's data,
/// so later state takes precedence.
/// When the `rayon` feature is enabled:
/// 1. Collects ancestor data (states and updates)
/// 2. Merges states and trie updates in parallel using k-way merge
#[cfg(feature = "rayon")]
fn merge_ancestors_into_overlay(
ancestors: &[Self],
sorted_hashed_state: &HashedPostStateSorted,
sorted_trie_updates: &TrieUpdatesSorted,
) -> TrieInputSorted {
// Early exit: no ancestors means just wrap current block's data
if ancestors.is_empty() {
return TrieInputSorted::new(
Arc::new(sorted_trie_updates.clone()),
Arc::new(sorted_hashed_state.clone()),
Default::default(),
);
}
// Collect ancestor data in reverse (newest to oldest) for merge_slice
let (states, updates): (Vec<_>, Vec<_>) = ancestors
.iter()
.rev()
.map(|a| {
// Note: we can assume that this trie data has already been computed
let data = a.wait_cloned();
(data.hashed_state, data.trie_updates)
})
.unzip();
// Merge state and nodes in parallel using k-way merge
let (state, nodes) = rayon::join(
|| {
let mut merged = HashedPostStateSorted::merge_slice(&states);
merged.extend_ref_and_sort(sorted_hashed_state);
merged
},
|| {
let mut merged = TrieUpdatesSorted::merge_slice(&updates);
merged.extend_ref_and_sort(sorted_trie_updates);
merged
},
);
TrieInputSorted::new(Arc::new(nodes), Arc::new(state), Default::default())
}
/// Sequential fallback when rayon is not available.
#[cfg(not(feature = "rayon"))]
fn merge_ancestors_into_overlay(
ancestors: &[Self],
sorted_hashed_state: &HashedPostStateSorted,
@@ -293,18 +339,8 @@ impl DeferredTrieData {
nodes_mut.extend_ref_and_sort(ancestor_data.trie_updates.as_ref());
}
// Extend with current block's sorted data last (takes precedence)
#[cfg(feature = "rayon")]
rayon::join(
|| state_mut.extend_ref_and_sort(sorted_hashed_state),
|| nodes_mut.extend_ref_and_sort(sorted_trie_updates),
);
#[cfg(not(feature = "rayon"))]
{
state_mut.extend_ref_and_sort(sorted_hashed_state);
nodes_mut.extend_ref_and_sort(sorted_trie_updates);
}
state_mut.extend_ref_and_sort(sorted_hashed_state);
nodes_mut.extend_ref_and_sort(sorted_trie_updates);
overlay
}

View File

@@ -1169,6 +1169,7 @@ mod tests {
&self,
_input: TrieInput,
_target: HashedPostState,
_mode: reth_trie::ExecutionWitnessMode,
) -> ProviderResult<Vec<Bytes>> {
Ok(Vec::default())
}

View File

@@ -197,9 +197,14 @@ impl<N: NodePrimitives> StateProofProvider for MemoryOverlayStateProviderRef<'_,
self.historical.multiproof(input, targets)
}
fn witness(&self, mut input: TrieInput, target: HashedPostState) -> ProviderResult<Vec<Bytes>> {
fn witness(
&self,
mut input: TrieInput,
target: HashedPostState,
mode: reth_trie::ExecutionWitnessMode,
) -> ProviderResult<Vec<Bytes>> {
input.prepend_self(self.trie_input().clone());
self.historical.witness(input, target)
self.historical.witness(input, target, mode)
}
}

View File

@@ -28,7 +28,7 @@ use alloy_consensus::{
};
use alloy_eips::{
eip1559::INITIAL_BASE_FEE, eip7685::EMPTY_REQUESTS_HASH, eip7840::BlobParams,
eip7892::BlobScheduleBlobParams,
eip7892::BlobScheduleBlobParams, eip7928::EMPTY_BLOCK_ACCESS_LIST_HASH,
};
use alloy_genesis::{ChainConfig, Genesis};
use alloy_primitives::{address, b256, Address, BlockNumber, B256, U256};
@@ -76,6 +76,18 @@ pub fn make_genesis_header(genesis: &Genesis, hardforks: &ChainHardforks) -> Hea
.active_at_timestamp(genesis.timestamp)
.then_some(EMPTY_REQUESTS_HASH);
// If Amsterdam is activated at genesis we set block access list hash to an empty bal hash
let block_access_list_hash = hardforks
.fork(EthereumHardfork::Amsterdam)
.active_at_timestamp(genesis.timestamp)
.then_some(EMPTY_BLOCK_ACCESS_LIST_HASH);
// If Amsterdam is activated at genesis we set slot number to 0
let slot_number = hardforks
.fork(EthereumHardfork::Amsterdam)
.active_at_timestamp(genesis.timestamp)
.then_some(0);
Header {
number: genesis.number.unwrap_or_default(),
parent_hash: genesis.parent_hash.unwrap_or_default(),
@@ -93,6 +105,8 @@ pub fn make_genesis_header(genesis: &Genesis, hardforks: &ChainHardforks) -> Hea
blob_gas_used,
excess_blob_gas,
requests_hash,
block_access_list_hash,
slot_number,
..Default::default()
}
}
@@ -275,7 +289,6 @@ pub fn create_chain_config(
// Check if DAO fork is supported (it has an activation block)
let dao_fork_support = hardforks.fork(EthereumHardfork::Dao) != ForkCondition::Never;
#[expect(clippy::needless_update)]
ChainConfig {
chain_id: chain.map(|c| c.id()).unwrap_or(0),
homestead_block: block_num(EthereumHardfork::Homestead),
@@ -298,6 +311,7 @@ pub fn create_chain_config(
cancun_time: timestamp(EthereumHardfork::Cancun),
prague_time: timestamp(EthereumHardfork::Prague),
osaka_time: timestamp(EthereumHardfork::Osaka),
amsterdam_time: timestamp(EthereumHardfork::Amsterdam),
bpo1_time: timestamp(EthereumHardfork::Bpo1),
bpo2_time: timestamp(EthereumHardfork::Bpo2),
bpo3_time: timestamp(EthereumHardfork::Bpo3),
@@ -305,10 +319,6 @@ pub fn create_chain_config(
bpo5_time: timestamp(EthereumHardfork::Bpo5),
terminal_total_difficulty,
terminal_total_difficulty_passed,
ethash: None,
clique: None,
parlia: None,
extra_fields: Default::default(),
deposit_contract_address,
blob_schedule,
..Default::default()
@@ -885,6 +895,7 @@ impl From<Genesis> for ChainSpec {
(EthereumHardfork::Bpo3.boxed(), genesis.config.bpo3_time),
(EthereumHardfork::Bpo4.boxed(), genesis.config.bpo4_time),
(EthereumHardfork::Bpo5.boxed(), genesis.config.bpo5_time),
(EthereumHardfork::Amsterdam.boxed(), genesis.config.amsterdam_time),
];
let mut time_hardforks = time_hardfork_opts
@@ -1191,6 +1202,19 @@ impl ChainSpecBuilder {
self
}
/// Enable Amsterdam at genesis.
pub fn amsterdam_activated(mut self) -> Self {
self = self.osaka_activated();
self.hardforks.insert(EthereumHardfork::Amsterdam, ForkCondition::Timestamp(0));
self
}
/// Enable Amsterdam at the given timestamp.
pub fn with_amsterdam_at(mut self, timestamp: u64) -> Self {
self.hardforks.insert(EthereumHardfork::Amsterdam, ForkCondition::Timestamp(timestamp));
self
}
/// Build the resulting [`ChainSpec`].
///
/// # Panics

View File

@@ -1,4 +1,4 @@
use alloy_primitives::{hex, BlockHash};
use alloy_primitives::{hex, Address, BlockHash, B256};
use clap::Parser;
use reth_db::{
static_file::{
@@ -10,16 +10,20 @@ use reth_db::{
use reth_db_api::{
cursor::{DbCursorRO, DbDupCursorRO},
database::Database,
models::{storage_sharded_key::StorageShardedKey, ShardedKey},
table::{Compress, Decompress, DupSort, Table},
tables,
transaction::DbTx,
RawKey, RawTable, Receipts, TableViewer, Transactions,
RawKey, RawTable, TableViewer,
};
use reth_db_common::DbTool;
use reth_node_api::{HeaderTy, ReceiptTy, TxTy};
use reth_node_builder::NodeTypesWithDB;
use reth_primitives_traits::ValueWithSubKey;
use reth_provider::{providers::ProviderNodeTypes, ChangeSetReader, StaticFileProviderFactory};
use reth_provider::{
providers::ProviderNodeTypes, ChangeSetReader, RocksDBProviderFactory,
StaticFileProviderFactory,
};
use reth_static_file_types::StaticFileSegment;
use reth_storage_api::StorageChangeSetReader;
use tracing::error;
@@ -73,6 +77,55 @@ enum Subcommand {
#[arg(long)]
raw: bool,
},
/// Gets the content of a RocksDB table for the given key
///
/// For history tables (accounts-history, storages-history), you can pass a plain address
/// instead of a full JSON ShardedKey. Use --block to query a specific block number
/// (seeks to the shard containing that block), or --all-shards to list all shards for
/// the address.
///
/// Examples:
/// reth db get rocksdb accounts-history 0xdBBE3D8c2d2b22A2611c5A94A9a12C2fCD49Eb29
/// reth db get rocksdb accounts-history 0xdBBE...Eb29 --block 1000000
/// reth db get rocksdb accounts-history 0xdBBE...Eb29 --all-shards
/// reth db get rocksdb storages-history 0xdBBE...Eb29 --storage-key 0x0000...0003
Rocksdb {
/// The RocksDB table
#[arg(value_enum)]
table: RocksDbTable,
/// The key to get content for. For history tables, this can be a plain address.
#[arg(value_parser = maybe_json_value_parser)]
key: String,
/// Target block number for history tables. Seeks to the shard containing this block.
/// Defaults to the latest shard if not specified.
#[arg(long)]
block: Option<u64>,
/// Storage key for storages-history table lookups.
#[arg(long)]
storage_key: Option<String>,
/// List all shards for the given key (history tables only).
#[arg(long)]
all_shards: bool,
/// Output bytes instead of human-readable decoded value
#[arg(long)]
raw: bool,
},
}
/// RocksDB tables that can be queried.
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum RocksDbTable {
/// Transaction hash to transaction number mapping
TransactionHashNumbers,
/// Account history indices
AccountsHistory,
/// Storage history indices
StoragesHistory,
}
impl Command {
@@ -82,6 +135,9 @@ impl Command {
Subcommand::Mdbx { table, key, subkey, end_key, end_subkey, raw } => {
table.view(&GetValueViewer { tool, key, subkey, end_key, end_subkey, raw })?
}
Subcommand::Rocksdb { table, key, block, storage_key, all_shards, raw } => {
get_rocksdb(tool, table, &key, block, storage_key.as_deref(), all_shards, raw)?;
}
Subcommand::StaticFile { segment, key, subkey, raw } => {
if let StaticFileSegment::StorageChangeSets = segment {
let storage_key =
@@ -208,15 +264,12 @@ impl Command {
);
}
StaticFileSegment::Transactions => {
let transaction = <<Transactions as Table>::Value>::decompress(
content[0].as_slice(),
)?;
let transaction = TxTy::<N>::decompress(content[0].as_slice())?;
println!("{}", serde_json::to_string_pretty(&transaction)?);
}
StaticFileSegment::Receipts => {
let receipt = <<Receipts as Table>::Value>::decompress(
content[0].as_slice(),
)?;
let receipt =
ReceiptTy::<N>::decompress(content[0].as_slice())?;
println!("{}", serde_json::to_string_pretty(&receipt)?);
}
StaticFileSegment::TransactionSenders => {
@@ -246,6 +299,208 @@ impl Command {
}
}
/// Gets a value from a RocksDB table by key.
fn get_rocksdb<N: ProviderNodeTypes>(
tool: &DbTool<N>,
table: RocksDbTable,
key: &str,
block: Option<u64>,
storage_key: Option<&str>,
all_shards: bool,
raw: bool,
) -> eyre::Result<()> {
let rocksdb = tool.provider_factory.rocksdb_provider();
match table {
RocksDbTable::TransactionHashNumbers => {
if block.is_some() || all_shards || storage_key.is_some() {
return Err(eyre::eyre!(
"--block, --all-shards, and --storage-key are only supported for history tables"
));
}
get_rocksdb_table::<tables::TransactionHashNumbers>(&rocksdb, key, raw)
}
RocksDbTable::AccountsHistory => {
if storage_key.is_some() {
return Err(eyre::eyre!("--storage-key is only supported for storages-history"));
}
get_rocksdb_account_history(&rocksdb, key, block, all_shards, raw)
}
RocksDbTable::StoragesHistory => {
get_rocksdb_storage_history(&rocksdb, key, storage_key, block, all_shards, raw)
}
}
}
/// Try to parse a key string as a plain address, falling back to JSON `ShardedKey` parsing.
fn parse_address(key: &str) -> eyre::Result<Address> {
// Strip surrounding quotes that `maybe_json_value_parser` may have added
let stripped = key.trim_matches('"');
stripped.parse::<Address>().map_err(|e| eyre::eyre!("failed to parse address: {e}"))
}
/// Gets account history from RocksDB with ergonomic key parsing.
///
/// Accepts a plain address and uses seek to find the relevant shard.
fn get_rocksdb_account_history(
rocksdb: &reth_provider::providers::RocksDBProvider,
key: &str,
block: Option<u64>,
all_shards: bool,
raw: bool,
) -> eyre::Result<()> {
// Try parsing as a plain address first, fall back to full JSON ShardedKey
match parse_address(key) {
Ok(address) => {
let block_number = block.unwrap_or(u64::MAX);
let seek_key = ShardedKey::new(address, block_number);
if all_shards {
// Iterate all shards: seek from (address, 0) until address changes
let start = ShardedKey::new(address, 0);
let iter = rocksdb.iter_from::<tables::AccountsHistory>(start)?;
for result in iter {
let (k, v) = result?;
if k.key != address {
break;
}
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"highest_block_number": k.highest_block_number,
"value": v,
}))?
);
}
} else {
// Seek to the first shard with highest_block_number >= target
let mut iter = rocksdb.iter_from::<tables::AccountsHistory>(seek_key)?;
match iter.next() {
Some(Ok((k, v))) if k.key == address => {
if raw {
let raw_val = rocksdb.get_raw::<tables::AccountsHistory>(k)?;
if let Some(bytes) = raw_val {
println!("{}", hex::encode_prefixed(&bytes));
}
} else {
println!("{}", serde_json::to_string_pretty(&v)?);
}
}
_ => {
error!(target: "reth::cli", "No content for the given table key.");
}
}
}
Ok(())
}
Err(_) => {
// Fall back to full JSON key parsing (e.g.
// `{"key":"0x...","highest_block_number":...}`)
if all_shards || block.is_some() {
return Err(eyre::eyre!(
"--block and --all-shards require a plain address, not a JSON key"
));
}
get_rocksdb_table::<tables::AccountsHistory>(rocksdb, key, raw)
}
}
}
/// Gets storage history from RocksDB with ergonomic key parsing.
///
/// Accepts a plain address + optional `--storage-key` and uses seek.
fn get_rocksdb_storage_history(
rocksdb: &reth_provider::providers::RocksDBProvider,
key: &str,
storage_key: Option<&str>,
block: Option<u64>,
all_shards: bool,
raw: bool,
) -> eyre::Result<()> {
match parse_address(key) {
Ok(address) => {
let storage_key = storage_key
.map(|s| s.trim_matches('"').parse::<B256>())
.transpose()
.map_err(|e| eyre::eyre!("failed to parse storage key: {e}"))?
.unwrap_or_default();
let block_number = block.unwrap_or(u64::MAX);
let seek_key = StorageShardedKey::new(address, storage_key, block_number);
if all_shards {
let start = StorageShardedKey::new(address, storage_key, 0);
let iter = rocksdb.iter_from::<tables::StoragesHistory>(start)?;
for result in iter {
let (k, v) = result?;
if k.address != address || k.sharded_key.key != storage_key {
break;
}
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"highest_block_number": k.sharded_key.highest_block_number,
"value": v,
}))?
);
}
} else {
let mut iter = rocksdb.iter_from::<tables::StoragesHistory>(seek_key)?;
match iter.next() {
Some(Ok((k, v)))
if k.address == address && k.sharded_key.key == storage_key =>
{
if raw {
let raw_val = rocksdb.get_raw::<tables::StoragesHistory>(k)?;
if let Some(bytes) = raw_val {
println!("{}", hex::encode_prefixed(&bytes));
}
} else {
println!("{}", serde_json::to_string_pretty(&v)?);
}
}
_ => {
error!(target: "reth::cli", "No content for the given table key.");
}
}
}
Ok(())
}
Err(_) => {
if all_shards || block.is_some() || storage_key.is_some() {
return Err(eyre::eyre!(
"--block, --all-shards, and --storage-key require a plain address, not a JSON key"
));
}
get_rocksdb_table::<tables::StoragesHistory>(rocksdb, key, raw)
}
}
}
/// Gets a value from a specific RocksDB table by exact key and prints it.
fn get_rocksdb_table<T: Table>(
rocksdb: &reth_provider::providers::RocksDBProvider,
key_str: &str,
raw: bool,
) -> eyre::Result<()> {
let key = table_key::<T>(key_str)?;
if raw {
let content = rocksdb.get_raw::<T>(key)?;
match content {
Some(bytes) => println!("{}", hex::encode_prefixed(&bytes)),
None => error!(target: "reth::cli", "No content for the given table key."),
}
} else {
let content = rocksdb.get::<T>(key)?;
match content {
Some(value) => println!("{}", serde_json::to_string_pretty(&value)?),
None => error!(target: "reth::cli", "No content for the given table key."),
}
}
Ok(())
}
/// Get an instance of key for given table
pub(crate) fn table_key<T: Table>(key: &str) -> Result<T::Key, eyre::Error> {
serde_json::from_str(key).map_err(|e| eyre::eyre!(e))

View File

@@ -0,0 +1,361 @@
//! `reth db migrate-v2` command for migrating v1 storage layout to v2.
//!
//! Migrates data that cannot be recomputed (changesets + receipts) from MDBX to
//! static files, clears recomputable tables (senders, indices, trie, plain
//! state), compacts MDBX, then runs the pipeline to rebuild them.
use crate::common::CliNodeTypes;
use clap::Parser;
use reth_db::{
mdbx::{self, ffi},
models::StorageBeforeTx,
DatabaseEnv,
};
use reth_db_api::{
cursor::DbCursorRO,
database::Database,
table::Table,
tables,
transaction::{DbTx, DbTxMut},
};
use reth_node_builder::NodeTypesWithDBAdapter;
use reth_provider::{
providers::ProviderNodeTypes, DBProvider, DatabaseProviderFactory, MetadataProvider,
MetadataWriter, ProviderFactory, PruneCheckpointReader, StageCheckpointWriter,
StaticFileProviderFactory, StaticFileWriter, StorageSettings,
};
use reth_prune_types::PruneSegment;
use reth_stages_types::{StageCheckpoint, StageId};
use reth_static_file_types::StaticFileSegment;
use reth_storage_api::StageCheckpointReader;
use tracing::info;
/// `reth db migrate-v2` command
#[derive(Debug, Parser)]
pub struct Command;
impl Command {
/// Execute the full v1 → v2 migration:
///
/// 1. Migrate changesets + receipts to static files
/// 2. Flip `StorageSettings` to v2
/// 3. Clear recomputable MDBX tables + reset stage checkpoints
/// 4. Compact MDBX
pub async fn execute<N: CliNodeTypes>(
self,
provider_factory: ProviderFactory<NodeTypesWithDBAdapter<N, DatabaseEnv>>,
) -> eyre::Result<()>
where
N::Primitives: reth_primitives_traits::NodePrimitives<
Receipt: reth_db_api::table::Value + reth_codecs::Compact,
>,
{
// === Phase 0: Preflight ===
info!(target: "reth::cli", "Starting v1 → v2 storage migration");
let provider = provider_factory.provider()?;
let current_settings = provider.storage_settings()?;
if current_settings.is_some_and(|s| s.is_v2()) {
info!(target: "reth::cli", "Storage is already v2, nothing to do");
return Ok(());
}
let tip =
provider.get_stage_checkpoint(StageId::Execution)?.map(|c| c.block_number).unwrap_or(0);
info!(target: "reth::cli", tip, "Chain tip block number");
let sf_provider = provider_factory.static_file_provider();
for segment in [StaticFileSegment::AccountChangeSets, StaticFileSegment::StorageChangeSets]
{
if sf_provider.get_highest_static_file_block(segment).is_some() {
eyre::bail!(
"Static file segment {segment:?} already contains data. \
Cannot migrate — target must be empty."
);
}
}
drop(provider);
// === Phase 1: Migrate changesets → static files ===
Self::migrate_account_changesets(&provider_factory, tip)?;
Self::migrate_storage_changesets(&provider_factory, tip)?;
// === Phase 2: Migrate receipts → static files ===
Self::migrate_receipts::<NodeTypesWithDBAdapter<N, DatabaseEnv>>(&provider_factory, tip)?;
// === Phase 3: Flip metadata to v2 ===
info!(target: "reth::cli", "Writing StorageSettings v2 metadata");
{
let provider_rw = provider_factory.database_provider_rw()?;
provider_rw.write_storage_settings(StorageSettings::v2())?;
provider_rw.commit()?;
}
info!(target: "reth::cli", "Storage settings updated to v2");
// === Phase 4: Clear recomputable tables ===
Self::clear_recomputable_tables(&provider_factory)?;
// === Phase 5: Compact MDBX (before pipeline, so it runs on a smaller DB) ===
let db_path = provider_factory.db_ref().path();
Self::compact_mdbx(provider_factory.db_ref())?;
// Drop to release DB handle for swap
drop(provider_factory);
let compact_path = db_path.with_file_name("db_compact");
Self::swap_compacted_db(&db_path, &compact_path)?;
// === Phase 6: Reopen DB and run pipeline ===
// The caller will reopen the environment and run the pipeline.
// We return here — the pipeline step is handled in mod.rs after
// reopening the database with the compacted copy.
info!(target: "reth::cli", "Migration complete. You should now restart the node and let it run the pipeline to rebuild the remaining data.");
Ok(())
}
fn migrate_account_changesets<N: ProviderNodeTypes>(
factory: &ProviderFactory<N>,
tip: u64,
) -> eyre::Result<()> {
info!(target: "reth::cli", "Migrating AccountChangeSets → static files");
let provider = factory.provider()?.disable_long_read_transaction_safety();
let sf_provider = factory.static_file_provider();
let mut cursor = provider.tx_ref().cursor_read::<tables::AccountChangeSets>()?;
let first_block = provider
.get_prune_checkpoint(PruneSegment::AccountHistory)?
.and_then(|cp| cp.block_number)
.map_or(0, |b| b + 1);
let mut writer =
sf_provider.get_writer(first_block, StaticFileSegment::AccountChangeSets)?;
let mut count = 0u64;
let mut walker = cursor.walk(Some(first_block))?.peekable();
for block in first_block..=tip {
let mut entries = Vec::new();
while let Some(Ok((block_number, _))) = walker.peek() {
if *block_number != block {
break;
}
let (_, entry) = walker.next().expect("peeked")?;
entries.push(entry);
}
count += entries.len() as u64;
writer.append_account_changeset(entries, block)?;
}
writer.commit()?;
info!(target: "reth::cli", count, "AccountChangeSets migrated");
Ok(())
}
fn migrate_storage_changesets<N: ProviderNodeTypes>(
factory: &ProviderFactory<N>,
tip: u64,
) -> eyre::Result<()> {
info!(target: "reth::cli", "Migrating StorageChangeSets → static files");
let provider = factory.provider()?.disable_long_read_transaction_safety();
let sf_provider = factory.static_file_provider();
let mut cursor = provider.tx_ref().cursor_read::<tables::StorageChangeSets>()?;
let first_block = provider
.get_prune_checkpoint(PruneSegment::StorageHistory)?
.and_then(|cp| cp.block_number)
.map_or(0, |b| b + 1);
let mut writer =
sf_provider.get_writer(first_block, StaticFileSegment::StorageChangeSets)?;
let mut count = 0u64;
let mut walker = cursor.walk(Some(Default::default()))?.peekable();
for block in first_block..=tip {
let mut entries = Vec::new();
while let Some(Ok((key, _))) = walker.peek() {
if key.block_number() != block {
break;
}
let (key, entry) = walker.next().expect("peeked")?;
entries.push(StorageBeforeTx {
address: key.address(),
key: entry.key,
value: entry.value,
});
}
count += entries.len() as u64;
writer.append_storage_changeset(entries, block)?;
}
writer.commit()?;
info!(target: "reth::cli", count, "StorageChangeSets migrated");
Ok(())
}
fn migrate_receipts<N: ProviderNodeTypes>(
factory: &ProviderFactory<N>,
tip: u64,
) -> eyre::Result<()>
where
N::Primitives: reth_primitives_traits::NodePrimitives<
Receipt: reth_db_api::table::Value + reth_codecs::Compact,
>,
{
let provider = factory.provider()?;
if !provider.prune_modes_ref().receipts_log_filter.is_empty() {
info!(target: "reth::cli", "Receipt log filter pruning is enabled, keeping receipts in MDBX");
return Ok(());
}
drop(provider);
let sf_provider = factory.static_file_provider();
let existing = sf_provider.get_highest_static_file_block(StaticFileSegment::Receipts);
if existing.is_some_and(|b| b >= tip) {
info!(target: "reth::cli", "Receipts already in static files, skipping");
return Ok(());
}
info!(target: "reth::cli", "Migrating Receipts → static files");
let provider = factory.provider()?.disable_long_read_transaction_safety();
let prune_start = provider
.get_prune_checkpoint(PruneSegment::Receipts)?
.and_then(|cp| cp.block_number)
.map_or(0, |b| b + 1);
let first_block = prune_start.max(existing.map_or(0, |b| b + 1));
let block_range = first_block..=tip;
let segment = reth_static_file::segments::Receipts;
reth_static_file::segments::Segment::copy_to_static_files(&segment, provider, block_range)?;
sf_provider.commit()?;
info!(target: "reth::cli", "Receipts migrated");
Ok(())
}
/// Clears tables that can be recomputed by the pipeline and resets their
/// stage checkpoints.
fn clear_recomputable_tables<N: ProviderNodeTypes>(
factory: &ProviderFactory<N>,
) -> eyre::Result<()> {
info!(target: "reth::cli", "Clearing recomputable MDBX tables");
let db = factory.db_ref();
macro_rules! clear_table {
($table:ty) => {{
let tx = db.tx_mut()?;
tx.clear::<$table>()?;
tx.commit()?;
info!(target: "reth::cli", table = <$table as Table>::NAME, "Cleared");
}};
}
// Migrated changeset tables (now in static files)
clear_table!(tables::AccountChangeSets);
clear_table!(tables::StorageChangeSets);
// Senders — rebuilt by SenderRecovery
clear_table!(tables::TransactionSenders);
// Indices — rebuilt by TransactionLookup / IndexAccountHistory / IndexStorageHistory
clear_table!(tables::TransactionHashNumbers);
clear_table!(tables::AccountsHistory);
clear_table!(tables::StoragesHistory);
// Plain state — superseded by hashed state in v2
clear_table!(tables::PlainAccountState);
clear_table!(tables::PlainStorageState);
// Trie — rebuilt by MerkleExecute
clear_table!(tables::AccountsTrie);
clear_table!(tables::StoragesTrie);
// Reset stage checkpoints so the pipeline rebuilds everything
info!(target: "reth::cli", "Resetting stage checkpoints");
let provider_rw = factory.database_provider_rw()?;
for stage in [
StageId::SenderRecovery,
StageId::TransactionLookup,
StageId::IndexAccountHistory,
StageId::IndexStorageHistory,
StageId::MerkleExecute,
StageId::MerkleUnwind,
] {
provider_rw.save_stage_checkpoint(stage, StageCheckpoint::new(0))?;
info!(target: "reth::cli", %stage, "Checkpoint reset to 0");
}
provider_rw.save_stage_checkpoint_progress(StageId::MerkleExecute, vec![])?;
provider_rw.commit()?;
info!(target: "reth::cli", "Recomputable tables cleared");
Ok(())
}
/// Creates a compacted copy of the MDBX database.
fn compact_mdbx(db: &mdbx::DatabaseEnv) -> eyre::Result<()> {
let db_path = db.path();
let compact_path = db_path.with_file_name("db_compact");
reth_fs_util::create_dir_all(&compact_path)?;
info!(target: "reth::cli", ?db_path, ?compact_path, "Compacting MDBX database");
let compact_dest = compact_path.join("mdbx.dat");
let dest_cstr = std::ffi::CString::new(
compact_dest.to_str().ok_or_else(|| eyre::eyre!("compact path must be valid UTF-8"))?,
)?;
let flags = ffi::MDBX_CP_COMPACT | ffi::MDBX_CP_FORCE_DYNAMIC_SIZE;
let rc = db.with_raw_env_ptr(|env_ptr| unsafe {
ffi::mdbx_env_copy(env_ptr, dest_cstr.as_ptr(), flags)
});
if rc != 0 {
eyre::bail!("mdbx_env_copy failed with error code {rc}: {}", unsafe {
std::ffi::CStr::from_ptr(ffi::mdbx_strerror(rc)).to_string_lossy()
});
}
info!(target: "reth::cli", "MDBX compaction complete");
Ok(())
}
/// Swaps the original MDBX database with a compacted copy.
fn swap_compacted_db(
db_path: &std::path::Path,
compact_path: &std::path::Path,
) -> eyre::Result<()> {
let backup_path = db_path.with_file_name("db_pre_compact");
info!(target: "reth::cli", ?db_path, ?compact_path, "Swapping compacted database");
std::fs::rename(db_path, &backup_path)?;
if let Err(e) = std::fs::rename(compact_path, db_path) {
let _ = std::fs::rename(&backup_path, db_path);
return Err(e.into());
}
std::fs::remove_dir_all(&backup_path)?;
info!(target: "reth::cli", "Database compaction swap complete");
Ok(())
}
}

View File

@@ -16,6 +16,7 @@ mod copy;
mod diff;
mod get;
mod list;
mod migrate_v2;
mod prune_checkpoints;
mod repair_trie;
mod settings;
@@ -77,6 +78,9 @@ pub enum Subcommands {
AccountStorage(account_storage::Command),
/// Gets account state and storage at a specific block
State(state::Command),
/// Migrate storage layout from v1 (MDBX-only) to v2 (static files + RocksDB)
#[command(name = "migrate-v2")]
MigrateV2(migrate_v2::Command),
}
impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> Command<C> {
@@ -231,6 +235,13 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> Command<C>
command.execute(&tool)?;
});
}
Subcommands::MigrateV2(command) => {
let Environment { provider_factory, .. } =
self.env.init::<N>(AccessRights::RW, ctx.task_executor.clone())?;
// Migrate changesets+receipts, clear tables, compact MDBX
command.execute::<N>(provider_factory).await?;
}
}
Ok(())

View File

@@ -0,0 +1,368 @@
use super::{
extract::{extract_archive_raw, streaming_download_and_extract, CompressionFormat},
fetch::ArchiveFetcher,
manifest::SnapshotArchive,
planning::{PlannedArchive, PlannedDownloads},
progress::{
spawn_progress_display, ArchiveDownloadProgress, ArchiveExtractionProgress,
ArchiveVerificationProgress, DownloadRequestLimiter, SharedProgress,
},
session::{ArchiveProcessContext, DownloadSession},
verify::OutputVerifier,
MAX_DOWNLOAD_RETRIES, RETRY_BACKOFF_SECS,
};
use eyre::Result;
use futures::stream::{self, StreamExt};
use reth_cli_util::cancellation::CancellationToken;
use reth_fs_util as fs;
use std::{
path::Path,
sync::{atomic::Ordering, Arc},
time::Duration,
};
use tokio::task;
use tracing::{debug, info, warn};
const DOWNLOAD_CACHE_DIR: &str = ".download-cache";
/// Runs all planned modular archive downloads for one command invocation.
pub(crate) async fn run_modular_downloads(
planned_downloads: PlannedDownloads,
target_dir: &Path,
download_concurrency: usize,
cancel_token: CancellationToken,
) -> Result<()> {
let download_cache_dir = target_dir.join(DOWNLOAD_CACHE_DIR);
fs::create_dir_all(&download_cache_dir)?;
let shared = SharedProgress::new(
planned_downloads.total_download_size,
planned_downloads.total_output_size,
planned_downloads.total_archives() as u64,
cancel_token.clone(),
);
let session = DownloadSession::new(
Some(Arc::clone(&shared)),
Some(DownloadRequestLimiter::new(download_concurrency)),
cancel_token,
);
let ctx =
ArchiveProcessContext::new(target_dir.to_path_buf(), Some(download_cache_dir), session);
ModularDownloadJob::new(ctx, download_concurrency).run(planned_downloads).await
}
/// Schedules modular archive work for one run of `reth download`.
struct ModularDownloadJob {
/// Shared paths and session state for each archive in this job.
ctx: ArchiveProcessContext,
/// Maximum number of archives processed at once.
archive_concurrency: usize,
}
impl ModularDownloadJob {
/// Creates the modular download job for one command run.
const fn new(ctx: ArchiveProcessContext, archive_concurrency: usize) -> Self {
Self { ctx, archive_concurrency }
}
/// Runs all planned archives and waits for the shared progress task to finish.
async fn run(self, planned_downloads: PlannedDownloads) -> Result<()> {
let shared = Arc::clone(
self.ctx.session().progress().expect("modular downloads always use shared progress"),
);
let progress_handle = spawn_progress_display(Arc::clone(&shared));
let ctx = self.ctx.clone();
let results: Vec<Result<()>> = stream::iter(planned_downloads.archives)
.map(move |archive| {
let ctx = ctx.clone();
async move { Self::process_archive(ctx, archive).await }
})
.buffer_unordered(self.archive_concurrency)
.collect()
.await;
shared.done.store(true, Ordering::Relaxed);
let _ = progress_handle.await;
for result in results {
result?;
}
Ok(())
}
/// Runs one archive on the blocking pool so fetch and extraction stay off the async executor.
async fn process_archive(ctx: ArchiveProcessContext, archive: PlannedArchive) -> Result<()> {
task::spawn_blocking(move || ArchiveProcessor::new(archive, ctx).run()).await??;
Ok(())
}
}
/// Explicit retry states for one modular archive.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ArchiveAttemptState {
/// Start or restart one full archive attempt.
RunAttempt,
/// Check whether the extracted outputs verify.
VerifyOutputs,
/// Wait and decide whether another full attempt should run.
RetryAttempt,
/// Finish successfully.
Complete,
/// Stop with an error after retries are exhausted.
Fail,
}
/// Processes one modular archive from reuse check through extraction and verification.
struct ArchiveProcessor {
/// The concrete archive and component being processed.
archive: PlannedArchive,
/// Shared paths and session state for this archive attempt.
ctx: ArchiveProcessContext,
}
impl ArchiveProcessor {
/// Creates a processor for one archive and the shared download context.
fn new(archive: PlannedArchive, ctx: ArchiveProcessContext) -> Self {
Self { archive, ctx }
}
/// Runs the archive retry state machine until outputs are verified or retries are exhausted.
fn run(self) -> Result<()> {
let archive = self.archive();
if self.try_reuse_outputs()? {
info!(target: "reth::cli", file = %archive.file_name, component = %self.archive.component, "Skipping already verified plain files");
return Ok(());
}
let mode = ArchiveMode::new(&self.ctx)?;
let format = CompressionFormat::from_url(&archive.file_name)?;
let mut attempt = 1;
let mut last_error: Option<eyre::Error> = None;
let mut state = ArchiveAttemptState::RunAttempt;
loop {
match state {
ArchiveAttemptState::RunAttempt => {
self.cleanup_outputs();
if attempt > 1 {
info!(target: "reth::cli",
file = %archive.file_name,
component = %self.archive.component,
attempt,
max = MAX_DOWNLOAD_RETRIES,
"Retrying archive from scratch"
);
}
match self.run_attempt(mode, format) {
Ok(()) => state = ArchiveAttemptState::VerifyOutputs,
Err(error) if mode.retries_fetch_errors() => {
warn!(target: "reth::cli",
file = %archive.file_name,
component = %self.archive.component,
attempt,
err = %format_args!("{error:#}"),
"Archive attempt failed, retrying from scratch"
);
last_error = Some(error);
state = ArchiveAttemptState::RetryAttempt;
}
Err(error) => return Err(error),
}
}
ArchiveAttemptState::VerifyOutputs => {
if self.verify_outputs_with_progress()? {
state = ArchiveAttemptState::Complete;
} else {
warn!(target: "reth::cli", file = %archive.file_name, component = %self.archive.component, attempt, "Archive extracted, but output verification failed, retrying");
state = ArchiveAttemptState::RetryAttempt;
}
}
ArchiveAttemptState::RetryAttempt => {
if attempt >= MAX_DOWNLOAD_RETRIES {
state = ArchiveAttemptState::Fail;
} else {
std::thread::sleep(Duration::from_secs(RETRY_BACKOFF_SECS));
attempt += 1;
state = ArchiveAttemptState::RunAttempt;
}
}
ArchiveAttemptState::Complete => return Ok(()),
ArchiveAttemptState::Fail => {
if let Some(error) = last_error {
return Err(error.wrap_err(format!(
"Failed after {} attempts for {}",
MAX_DOWNLOAD_RETRIES, archive.file_name
)));
}
eyre::bail!(
"Failed integrity validation after {} attempts for {}",
MAX_DOWNLOAD_RETRIES,
archive.file_name
)
}
}
}
}
/// Returns the concrete archive being fetched or verified.
fn archive(&self) -> &SnapshotArchive {
&self.archive.archive
}
/// Returns the verifier for this archive's output files.
fn output_verifier(&self) -> OutputVerifier<'_> {
OutputVerifier::new(self.ctx.target_dir())
}
/// Returns `true` if this archive can be reused from existing verified outputs.
/// Returns `false` if a fresh archive attempt is still needed.
fn try_reuse_outputs(&self) -> Result<bool> {
if self.verify_outputs()? {
self.mark_complete();
return Ok(true);
}
Ok(false)
}
/// Removes any partial outputs before a fresh archive attempt.
fn cleanup_outputs(&self) {
self.output_verifier().cleanup(&self.archive().output_files);
}
/// Returns `true` if all declared plain outputs verify.
/// Returns `false` if any output is missing or does not match.
fn verify_outputs(&self) -> Result<bool> {
self.output_verifier().verify(&self.archive().output_files)
}
/// Records archive completion in shared progress once outputs verify.
fn mark_complete(&self) {
self.ctx.session().record_reused_archive(self.archive().size, self.archive().output_size());
}
/// Executes one archive attempt according to the selected cache-vs-stream mode.
fn run_attempt(&self, mode: ArchiveMode, format: CompressionFormat) -> Result<()> {
mode.execute(self, format)
}
/// Downloads the archive into the cache, then extracts from the cached file.
fn run_cached_attempt(&self, format: CompressionFormat) -> Result<()> {
let cache_dir =
self.ctx.cache_dir().ok_or_else(|| eyre::eyre!("Missing download cache directory"))?;
let fetcher =
ArchiveFetcher::new(self.archive().url.clone(), cache_dir, self.ctx.session().clone());
if self.archive.ty == super::manifest::SnapshotComponentType::State {
debug!(target: "reth::cli", url = %self.archive().url, "Downloading state snapshot archive");
}
let download_result = {
let mut download_progress = ArchiveDownloadProgress::new(self.ctx.session().progress());
let result = fetcher.download(Some(&mut download_progress));
if let Ok(ref downloaded) = result &&
download_progress.has_tracked_bytes()
{
download_progress.complete(downloaded.size);
}
result
};
let downloaded = match download_result {
Ok(downloaded) => downloaded,
Err(error) => {
fetcher.cleanup_downloaded_files();
return Err(error);
}
};
info!(target: "reth::cli",
file = %self.archive().file_name,
component = %self.archive.component,
size = %super::progress::DownloadProgress::format_size(downloaded.size),
"Archive download complete"
);
let extract_result = self.extract_cached_archive(&downloaded.path, format);
fetcher.cleanup_downloaded_files();
extract_result
}
/// Streams the archive directly into extraction without keeping a cached copy.
fn run_streaming_attempt(&self, format: CompressionFormat) -> Result<()> {
let _download_progress = ArchiveDownloadProgress::new(self.ctx.session().progress());
streaming_download_and_extract(
&self.archive().url,
format,
self.ctx.target_dir(),
self.ctx.session(),
)
}
/// Extracts a cached archive file while updating shared extraction activity.
fn extract_cached_archive(&self, archive_path: &Path, format: CompressionFormat) -> Result<()> {
let mut extraction_progress = ArchiveExtractionProgress::new(self.ctx.session().progress());
let file = fs::open(archive_path)?;
let result = extract_archive_raw(
file,
format,
self.ctx.target_dir(),
Some(&mut extraction_progress),
);
extraction_progress.finish();
result
}
/// Returns `true` if all declared plain outputs verify while updating shared verification
/// progress.
fn verify_outputs_with_progress(&self) -> Result<bool> {
let mut verification_progress =
ArchiveVerificationProgress::new(self.ctx.session().progress());
let verified = self
.output_verifier()
.verify_with_progress(&self.archive().output_files, Some(&mut verification_progress))?;
if verified {
verification_progress.complete(self.archive().output_size());
}
Ok(verified)
}
}
/// Chooses whether an archive attempt uses the cache or streams directly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ArchiveMode {
/// Download the archive to the cache, then extract it.
Cached,
/// Stream the archive directly into extraction.
Streaming,
}
impl ArchiveMode {
/// Picks the archive mode from the process context.
fn new(ctx: &ArchiveProcessContext) -> Result<Self> {
if ctx.cache_dir().is_some() {
ctx.session().require_request_limiter()?;
return Ok(Self::Cached)
}
Ok(Self::Streaming)
}
/// Returns `true` when fetch failures should retry the whole archive attempt.
const fn retries_fetch_errors(&self) -> bool {
matches!(self, Self::Cached)
}
/// Runs the selected archive mode for a single attempt.
fn execute(&self, processor: &ArchiveProcessor, format: CompressionFormat) -> Result<()> {
match self {
Self::Cached => processor.run_cached_attempt(format),
Self::Streaming => processor.run_streaming_attempt(format),
}
}
}

View File

@@ -248,6 +248,7 @@ fn selection_to_prune_mode(
ComponentSelection::Distance(d) => {
Some(PruneMode::Distance(min_distance.map_or(d, |min| d.max(min))))
}
ComponentSelection::Since(block) => Some(PruneMode::Before(block)),
ComponentSelection::None => Some(min_distance.map_or(PruneMode::Full, PruneMode::Distance)),
}
}
@@ -269,6 +270,7 @@ pub(crate) fn describe_prune_config(config: &Config) -> Vec<String> {
.collect()
}
/// Formats one prune mode for the generated config summary.
fn format_mode(mode: &PruneMode) -> String {
match mode {
PruneMode::Full => "\"full\"".to_string(),
@@ -453,6 +455,36 @@ mod tests {
assert_eq!(config.prune.segments.storage_history, Some(PruneMode::Distance(10_064)));
}
#[test]
fn selections_since_maps_to_before_prune_mode() {
let mut selections = BTreeMap::new();
selections.insert(SnapshotComponentType::State, ComponentSelection::All);
selections.insert(SnapshotComponentType::Headers, ComponentSelection::All);
selections
.insert(SnapshotComponentType::Transactions, ComponentSelection::Since(15_537_394));
selections.insert(SnapshotComponentType::Receipts, ComponentSelection::Since(15_537_394));
selections.insert(
SnapshotComponentType::AccountChangesets,
ComponentSelection::Since(15_537_394),
);
selections.insert(
SnapshotComponentType::StorageChangesets,
ComponentSelection::Since(15_537_394),
);
let config = config_for_selections(
&selections,
&empty_manifest(),
None,
None::<&reth_chainspec::ChainSpec>,
);
assert_eq!(config.prune.segments.bodies_history, Some(PruneMode::Before(15_537_394)));
assert_eq!(config.prune.segments.receipts, Some(PruneMode::Before(15_537_394)));
assert_eq!(config.prune.segments.account_history, Some(PruneMode::Before(15_537_394)));
assert_eq!(config.prune.segments.storage_history, Some(PruneMode::Before(15_537_394)));
}
#[test]
fn full_preset_matches_default_full_prune_config() {
let mut selections = BTreeMap::new();

View File

@@ -0,0 +1,490 @@
use super::{
fetch::{ArchiveFetcher, DownloadedArchive},
progress::{
ArchiveExtractionProgress, ArchiveExtractionProgressHandle, DownloadProgress,
DownloadRequestLimiter, ProgressReader, SharedProgress, SharedProgressReader,
},
session::DownloadSession,
MAX_DOWNLOAD_RETRIES, RETRY_BACKOFF_SECS,
};
use eyre::{Result, WrapErr};
use lz4::Decoder;
use reqwest::blocking::Client as BlockingClient;
use reth_cli_util::cancellation::CancellationToken;
use reth_fs_util as fs;
use std::{
io::Read,
path::{Component, Path, PathBuf},
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
thread,
time::{Duration, Instant},
};
use tar::Archive;
use tokio::task;
use tracing::{info, warn};
use url::Url;
use zstd::stream::read::Decoder as ZstdDecoder;
const EXTENSION_TAR_LZ4: &str = ".tar.lz4";
const EXTENSION_TAR_ZSTD: &str = ".tar.zst";
const STREAMING_EXTRACTION_PROGRESS_MIN_FILE_SIZE: u64 = 64 * 1024 * 1024;
const EXTRACTION_PROGRESS_POLL_INTERVAL: Duration = Duration::from_millis(100);
/// Supported compression formats for snapshots
#[derive(Debug, Clone, Copy)]
pub(crate) enum CompressionFormat {
/// LZ4-compressed tar archive.
Lz4,
/// Zstandard-compressed tar archive.
Zstd,
}
impl CompressionFormat {
/// Detect compression format from file extension
pub(crate) fn from_url(url: &str) -> Result<Self> {
let path =
Url::parse(url).map(|u| u.path().to_string()).unwrap_or_else(|_| url.to_string());
if path.ends_with(EXTENSION_TAR_LZ4) {
Ok(Self::Lz4)
} else if path.ends_with(EXTENSION_TAR_ZSTD) {
Ok(Self::Zstd)
} else {
Err(eyre::eyre!(
"Unsupported file format. Expected .tar.lz4 or .tar.zst, got: {}",
path
))
}
}
}
/// Extracts a compressed tar archive to the target directory with progress tracking.
fn extract_archive<R: Read>(
reader: R,
total_size: u64,
format: CompressionFormat,
target_dir: &Path,
cancel_token: CancellationToken,
) -> Result<()> {
let progress_reader = ProgressReader::new(reader, total_size, cancel_token);
match format {
CompressionFormat::Lz4 => {
let decoder = Decoder::new(progress_reader)?;
Archive::new(decoder).unpack(target_dir)?;
}
CompressionFormat::Zstd => {
let decoder = ZstdDecoder::new(progress_reader)?;
Archive::new(decoder).unpack(target_dir)?;
}
}
println!();
Ok(())
}
/// Extracts a compressed tar archive without progress tracking.
pub(crate) fn extract_archive_raw<R: Read>(
reader: R,
format: CompressionFormat,
target_dir: &Path,
progress: Option<&mut ArchiveExtractionProgress>,
) -> Result<()> {
match format {
CompressionFormat::Lz4 => {
unpack_archive(Archive::new(Decoder::new(reader)?), target_dir, progress)?;
}
CompressionFormat::Zstd => {
unpack_archive(Archive::new(ZstdDecoder::new(reader)?), target_dir, progress)?;
}
}
Ok(())
}
fn unpack_archive<R: Read>(
mut archive: Archive<R>,
target_dir: &Path,
mut progress: Option<&mut ArchiveExtractionProgress>,
) -> Result<()> {
let entries = archive.entries().wrap_err_with(|| {
format!("failed to read archive entries for `{}`", target_dir.display())
})?;
for entry in entries {
let mut entry = entry.wrap_err_with(|| {
format!("failed to read archive entry for `{}`", target_dir.display())
})?;
extract_entry_with_progress(&mut entry, target_dir, progress.as_deref_mut())?;
}
Ok(())
}
fn extract_entry_with_progress<R: Read>(
entry: &mut tar::Entry<'_, R>,
target_dir: &Path,
progress: Option<&mut ArchiveExtractionProgress>,
) -> Result<()> {
let size = entry.header().entry_size().unwrap_or(0);
let entry_type = entry.header().entry_type();
if !entry_type.is_file() || size == 0 {
entry.unpack_in(target_dir).wrap_err_with(|| {
format!("failed to extract archive into `{}`", target_dir.display())
})?;
return Ok(())
}
if size < STREAMING_EXTRACTION_PROGRESS_MIN_FILE_SIZE {
entry.unpack_in(target_dir).wrap_err_with(|| {
format!("failed to extract archive into `{}`", target_dir.display())
})?;
if let Some(progress) = progress {
progress.record_extracted(size);
}
return Ok(())
}
let Some(progress_handle) = progress.as_ref().and_then(|progress| progress.handle()) else {
entry.unpack_in(target_dir).wrap_err_with(|| {
format!("failed to extract archive into `{}`", target_dir.display())
})?;
return Ok(())
};
let Some(entry_path) = entry_destination_path(entry, target_dir)? else {
entry.unpack_in(target_dir).wrap_err_with(|| {
format!("failed to extract archive into `{}`", target_dir.display())
})?;
return Ok(())
};
let stop = Arc::new(AtomicBool::new(false));
let monitor = spawn_extraction_progress_monitor(entry_path, progress_handle, Arc::clone(&stop));
let unpack_result = entry
.unpack_in(target_dir)
.wrap_err_with(|| format!("failed to extract archive into `{}`", target_dir.display()));
stop.store(true, Ordering::Relaxed);
let monitor_result = monitor.join();
unpack_result?;
monitor_result.map_err(|_| eyre::eyre!("extraction progress monitor panicked"))?;
Ok(())
}
fn entry_destination_path<R: Read>(
entry: &tar::Entry<'_, R>,
target_dir: &Path,
) -> Result<Option<PathBuf>> {
let mut file_dst = target_dir.to_path_buf();
let path = entry.path().wrap_err("invalid path in archive entry")?;
for part in path.components() {
match part {
Component::Prefix(..) | Component::RootDir | Component::CurDir => continue,
Component::ParentDir => return Ok(None),
Component::Normal(part) => file_dst.push(part),
}
}
if file_dst == target_dir {
return Ok(None)
}
Ok(Some(file_dst))
}
fn spawn_extraction_progress_monitor(
entry_path: PathBuf,
progress: ArchiveExtractionProgressHandle,
stop: Arc<AtomicBool>,
) -> thread::JoinHandle<()> {
thread::spawn(move || {
let mut extracted = 0_u64;
loop {
record_extracted_file_bytes(&entry_path, &progress, &mut extracted);
if stop.load(Ordering::Relaxed) {
break;
}
thread::sleep(EXTRACTION_PROGRESS_POLL_INTERVAL);
}
})
}
fn record_extracted_file_bytes(
entry_path: &Path,
progress: &ArchiveExtractionProgressHandle,
extracted: &mut u64,
) {
let Ok(meta) = fs::metadata(entry_path) else { return };
let len = meta.len();
if len > *extracted {
progress.record_extracted(len - *extracted);
*extracted = len;
}
}
/// Extracts a snapshot from a local file.
fn extract_from_file(path: &Path, format: CompressionFormat, target_dir: &Path) -> Result<()> {
let file = std::fs::File::open(path)?;
let total_size = file.metadata()?.len();
info!(target: "reth::cli",
file = %path.display(),
size = %DownloadProgress::format_size(total_size),
"Extracting local archive"
);
let start = Instant::now();
extract_archive(file, total_size, format, target_dir, CancellationToken::new())?;
info!(target: "reth::cli",
file = %path.display(),
elapsed = %DownloadProgress::format_duration(start.elapsed()),
"Local extraction complete"
);
Ok(())
}
/// Streams a remote archive directly into the extractor without writing to disk.
///
/// On failure, retries from scratch up to [`MAX_DOWNLOAD_RETRIES`] times.
pub(crate) fn streaming_download_and_extract(
url: &str,
format: CompressionFormat,
target_dir: &Path,
session: &DownloadSession,
) -> Result<()> {
let shared = session.progress();
let quiet = session.progress().is_some();
let mut last_error: Option<eyre::Error> = None;
for attempt in 1..=MAX_DOWNLOAD_RETRIES {
if attempt > 1 {
info!(target: "reth::cli",
url = %url,
attempt,
max = MAX_DOWNLOAD_RETRIES,
"Retrying streaming download from scratch"
);
}
let client = BlockingClient::builder().connect_timeout(Duration::from_secs(30)).build()?;
let _request_permit = session
.request_limiter()
.map(|limiter| limiter.acquire(session.progress(), session.cancel_token()))
.transpose()?;
let response = match client.get(url).send().and_then(|r| r.error_for_status()) {
Ok(r) => r,
Err(error) => {
let err = eyre::Error::from(error);
if attempt < MAX_DOWNLOAD_RETRIES {
warn!(target: "reth::cli",
url = %url,
attempt,
max = MAX_DOWNLOAD_RETRIES,
err = %err,
"Streaming request failed, retrying"
);
}
last_error = Some(err);
if attempt < MAX_DOWNLOAD_RETRIES {
std::thread::sleep(Duration::from_secs(RETRY_BACKOFF_SECS));
}
continue;
}
};
if !quiet && let Some(size) = response.content_length() {
info!(target: "reth::cli",
url = %url,
size = %DownloadProgress::format_size(size),
"Streaming archive"
);
}
let result = if let Some(progress) = shared {
let reader = SharedProgressReader { inner: response, progress: Arc::clone(progress) };
extract_archive_raw(reader, format, target_dir, None)
} else {
let total_size = response.content_length().unwrap_or(0);
extract_archive(
response,
total_size,
format,
target_dir,
session.cancel_token().clone(),
)
};
match result {
Ok(()) => return Ok(()),
Err(error) => {
if attempt < MAX_DOWNLOAD_RETRIES {
warn!(target: "reth::cli",
url = %url,
attempt,
max = MAX_DOWNLOAD_RETRIES,
err = %error,
"Streaming extraction failed, retrying"
);
}
last_error = Some(error);
if attempt < MAX_DOWNLOAD_RETRIES {
std::thread::sleep(Duration::from_secs(RETRY_BACKOFF_SECS));
}
}
}
}
Err(last_error.unwrap_or_else(|| {
eyre::eyre!("Streaming download failed after {MAX_DOWNLOAD_RETRIES} attempts")
}))
}
/// Fetches the snapshot from a remote URL with resume support, then extracts it.
fn download_and_extract(
url: &str,
format: CompressionFormat,
target_dir: &Path,
session: DownloadSession,
) -> Result<()> {
let quiet = session.progress().is_some();
let fetcher = ArchiveFetcher::new(url.to_string(), target_dir, session.clone());
let DownloadedArchive { path: downloaded_path, size: total_size } = fetcher.download(None)?;
let file_name =
downloaded_path.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
if !quiet {
info!(target: "reth::cli",
file = %file_name,
size = %DownloadProgress::format_size(total_size),
"Extracting archive"
);
}
let file = fs::open(&downloaded_path)?;
if quiet {
extract_archive_raw(file, format, target_dir, None)?;
} else {
extract_archive(file, total_size, format, target_dir, session.cancel_token().clone())?;
info!(target: "reth::cli",
file = %file_name,
"Extraction complete"
);
}
fetcher.cleanup_downloaded_files();
session.record_archive_output_complete(total_size);
Ok(())
}
/// Downloads and extracts a snapshot, blocking until finished.
///
/// Supports `file://` URLs for local files and HTTP(S) URLs for remote downloads.
/// When `resumable` is true, downloads to a `.part` file first with HTTP Range resume
/// support. Otherwise streams directly into the extractor.
fn blocking_download_and_extract(
url: &str,
target_dir: &Path,
shared: Option<Arc<SharedProgress>>,
resumable: bool,
request_limiter: Option<Arc<DownloadRequestLimiter>>,
cancel_token: CancellationToken,
) -> Result<()> {
let format = CompressionFormat::from_url(url)?;
if let Ok(parsed_url) = Url::parse(url) &&
parsed_url.scheme() == "file"
{
let session = DownloadSession::new(shared, request_limiter, cancel_token);
let file_path = parsed_url
.to_file_path()
.map_err(|_| eyre::eyre!("Invalid file:// URL path: {}", url))?;
let result = extract_from_file(&file_path, format, target_dir);
if result.is_ok() {
session.record_archive_output_complete(file_path.metadata()?.len());
}
result
} else if let Some(request_limiter) = request_limiter {
download_and_extract(
url,
format,
target_dir,
DownloadSession::new(shared, Some(request_limiter), cancel_token),
)
} else if resumable {
let session =
DownloadSession::new(shared, Some(DownloadRequestLimiter::new(1)), cancel_token);
download_and_extract(url, format, target_dir, session)
} else {
let session = DownloadSession::new(shared, None, cancel_token);
let result = streaming_download_and_extract(url, format, target_dir, &session);
if result.is_ok() {
session.record_archive_output_complete(0);
}
result
}
}
/// Downloads and extracts a snapshot archive asynchronously.
///
/// When `shared` is provided, download progress is reported to the shared
/// counter for aggregated display. Otherwise uses a local progress bar.
/// When `resumable` is true, uses two-phase download with `.part` files.
pub(crate) async fn stream_and_extract(
url: &str,
target_dir: &Path,
shared: Option<Arc<SharedProgress>>,
resumable: bool,
request_limiter: Option<Arc<DownloadRequestLimiter>>,
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,
request_limiter,
cancel_token,
)
})
.await??;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compression_format_detection() {
assert!(matches!(
CompressionFormat::from_url("https://example.com/snapshot.tar.lz4"),
Ok(CompressionFormat::Lz4)
));
assert!(matches!(
CompressionFormat::from_url("https://example.com/snapshot.tar.zst"),
Ok(CompressionFormat::Zstd)
));
assert!(matches!(
CompressionFormat::from_url("file:///path/to/snapshot.tar.lz4"),
Ok(CompressionFormat::Lz4)
));
assert!(matches!(
CompressionFormat::from_url("file:///path/to/snapshot.tar.zst"),
Ok(CompressionFormat::Zstd)
));
assert!(CompressionFormat::from_url("https://example.com/snapshot.tar.gz").is_err());
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,10 @@ use std::{
};
use tracing::info;
fn is_zero(value: &u64) -> bool {
*value == 0
}
/// A snapshot manifest describes available components for a snapshot at a given block height.
///
/// Each component is either a single archive (state) or a set of chunked archives (static file
@@ -62,6 +66,12 @@ pub struct SingleArchive {
pub file: String,
/// Compressed archive size in bytes.
pub size: u64,
/// Total extracted plain-output size in bytes.
///
/// Older manifests may omit this, in which case downloaders should derive it from
/// `output_files`.
#[serde(default, skip_serializing_if = "is_zero")]
pub decompressed_size: u64,
/// Optional BLAKE3 checksum of the compressed archive.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blake3: Option<String>,
@@ -83,6 +93,12 @@ pub struct ChunkedArchive {
/// Computed during manifest generation. Older manifests may omit this.
#[serde(default)]
pub chunk_sizes: Vec<u64>,
/// Extracted plain-output size of each chunk in bytes, ordered from first to last.
///
/// Older manifests may omit this, in which case downloaders should derive it from
/// `chunk_output_files`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub chunk_decompressed_sizes: Vec<u64>,
/// Expected extracted plain files per chunk, ordered from first to last.
///
/// This is the authoritative integrity source for the modular download path.
@@ -101,9 +117,9 @@ pub struct OutputFileChecksum {
pub blake3: String,
}
/// A single archive with concrete URL and optional integrity metadata.
/// A concrete snapshot archive with its download and verification metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchiveDescriptor {
pub struct SnapshotArchive {
pub url: String,
pub file_name: String,
pub size: u64,
@@ -111,6 +127,13 @@ pub struct ArchiveDescriptor {
pub output_files: Vec<OutputFileChecksum>,
}
impl SnapshotArchive {
/// Returns the total extracted plain-output size for this archive.
pub fn output_size(&self) -> u64 {
self.output_files.iter().map(|file| file.size).sum()
}
}
/// How much of a component to download.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ComponentSelection {
@@ -119,6 +142,9 @@ pub enum ComponentSelection {
/// Download only the most recent chunks covering at least `distance` blocks.
/// Maps to `PruneMode::Distance(distance)` in the generated config.
Distance(u64),
/// Download chunks starting at the specified block number.
/// Maps to `PruneMode::Before(block)` in the generated config.
Since(u64),
/// Don't download this component at all.
/// Maps to `PruneMode::Full` for tx-based segments, or a minimal distance for others.
None,
@@ -129,6 +155,7 @@ impl std::fmt::Display for ComponentSelection {
match self {
Self::All => write!(f, "All"),
Self::Distance(d) => write!(f, "Last {d} blocks"),
Self::Since(block) => write!(f, "Since block {block}"),
Self::None => write!(f, "None"),
}
}
@@ -311,19 +338,19 @@ impl SnapshotManifest {
}
}
/// Returns concrete archive descriptors for a component, optionally limited to distance.
pub fn archive_descriptors_for_distance(
/// Returns concrete snapshot archives for a component, optionally limited to distance.
pub fn snapshot_archives_for_distance(
&self,
ty: SnapshotComponentType,
distance: Option<u64>,
) -> Vec<ArchiveDescriptor> {
) -> Vec<SnapshotArchive> {
let Some(component) = self.component(ty) else {
return vec![];
};
match component {
ComponentManifest::Single(single) => {
vec![ArchiveDescriptor {
vec![SnapshotArchive {
url: format!("{}/{}", self.base_url_or_empty(), single.file),
file_name: single.file.clone(),
size: single.size,
@@ -353,7 +380,7 @@ impl SnapshotManifest {
let output_files =
chunked.chunk_output_files.get(i as usize).cloned().unwrap_or_default();
ArchiveDescriptor {
SnapshotArchive {
url: format!("{}/{}", self.base_url_or_empty(), file_name),
file_name,
size,
@@ -394,6 +421,36 @@ impl SnapshotManifest {
}
}
/// Returns the exact extracted plain-output size for a component given a distance selection.
pub fn output_size_for_distance(
&self,
ty: SnapshotComponentType,
distance: Option<u64>,
) -> u64 {
let Some(component) = self.component(ty) else {
return 0;
};
match component {
ComponentManifest::Single(single) => single.output_size(),
ComponentManifest::Chunked(chunked) => {
let num_chunks = chunked.num_chunks();
let start_chunk = match distance {
Some(dist) => {
let needed = dist.min(chunked.total_blocks);
let needed_chunks = needed.div_ceil(chunked.blocks_per_file);
num_chunks.saturating_sub(needed_chunks)
}
None => 0,
};
(start_chunk..num_chunks)
.map(|index| chunked.chunk_output_size(index as usize))
.sum()
}
}
}
/// Returns the number of chunks that would be downloaded for a given distance.
pub fn chunks_for_distance(&self, ty: SnapshotComponentType, distance: Option<u64>) -> u64 {
let Some(ComponentManifest::Chunked(chunked)) = self.component(ty) else {
@@ -417,6 +474,14 @@ impl ComponentManifest {
Self::Chunked(c) => c.chunk_sizes.iter().sum(),
}
}
/// Returns the total extracted plain-output size for this component.
pub fn total_output_size(&self) -> u64 {
match self {
Self::Single(single) => single.output_size(),
Self::Chunked(chunked) => chunked.total_output_size(),
}
}
}
impl ChunkedArchive {
@@ -424,6 +489,39 @@ impl ChunkedArchive {
pub fn num_chunks(&self) -> u64 {
self.total_blocks.div_ceil(self.blocks_per_file)
}
/// Returns the extracted plain-output size for one chunk.
pub fn chunk_output_size(&self, index: usize) -> u64 {
self.chunk_decompressed_sizes.get(index).copied().unwrap_or_else(|| {
self.chunk_output_files
.get(index)
.map(|files| files.iter().map(|file| file.size).sum())
.unwrap_or(0)
})
}
/// Returns the total extracted plain-output size across all chunks.
pub fn total_output_size(&self) -> u64 {
if !self.chunk_decompressed_sizes.is_empty() {
self.chunk_decompressed_sizes.iter().sum()
} else {
self.chunk_output_files
.iter()
.map(|files| files.iter().map(|file| file.size).sum::<u64>())
.sum()
}
}
}
impl SingleArchive {
/// Returns the total extracted plain-output size for this archive.
pub fn output_size(&self) -> u64 {
if self.decompressed_size != 0 {
self.decompressed_size
} else {
self.output_files.iter().map(|file| file.size).sum()
}
}
}
/// Fetch a snapshot manifest from a URL.
@@ -512,6 +610,10 @@ pub fn generate_manifest(
blocks_per_file,
total_blocks: block,
chunk_sizes,
chunk_decompressed_sizes: chunk_output_files
.iter()
.map(|files| files.iter().map(|file| file.size).sum())
.collect(),
chunk_output_files,
}),
);
@@ -528,6 +630,7 @@ pub fn generate_manifest(
ComponentManifest::Single(SingleArchive {
file: "state.tar.zst".to_string(),
size: state_size,
decompressed_size: state_output_files.iter().map(|file| file.size).sum(),
blake3: None,
output_files: state_output_files,
}),
@@ -542,6 +645,7 @@ pub fn generate_manifest(
ComponentManifest::Single(SingleArchive {
file: "rocksdb_indices.tar.zst".to_string(),
size: rocksdb_size,
decompressed_size: rocksdb_output_files.iter().map(|file| file.size).sum(),
blake3: None,
output_files: rocksdb_output_files,
}),
@@ -810,6 +914,7 @@ mod tests {
ComponentManifest::Single(SingleArchive {
file: "state.tar.zst".to_string(),
size: 100,
decompressed_size: 0,
blake3: None,
output_files: vec![],
}),
@@ -820,6 +925,7 @@ mod tests {
blocks_per_file: 500_000,
total_blocks: 1_500_000,
chunk_sizes: vec![80_000, 100_000, 120_000],
chunk_decompressed_sizes: vec![],
chunk_output_files: vec![vec![], vec![], vec![]],
}),
);
@@ -829,6 +935,7 @@ mod tests {
blocks_per_file: 500_000,
total_blocks: 1_500_000,
chunk_sizes: vec![40_000, 50_000, 60_000],
chunk_decompressed_sizes: vec![],
chunk_output_files: vec![vec![], vec![], vec![]],
}),
);
@@ -879,6 +986,7 @@ mod tests {
ComponentManifest::Single(SingleArchive {
file: "rocksdb_indices.tar.zst".to_string(),
size: 777,
decompressed_size: 0,
blake3: None,
output_files: vec![],
}),
@@ -936,6 +1044,7 @@ mod tests {
fn component_selection_display() {
assert_eq!(ComponentSelection::All.to_string(), "All");
assert_eq!(ComponentSelection::Distance(10_064).to_string(), "Last 10064 blocks");
assert_eq!(ComponentSelection::Since(15_537_394).to_string(), "Since block 15537394");
assert_eq!(ComponentSelection::None.to_string(), "None");
}
@@ -950,6 +1059,7 @@ mod tests {
blocks_per_file: 500_000,
total_blocks: 24_396_822,
chunk_sizes: vec![100; 49], // 49 chunks
chunk_decompressed_sizes: vec![],
chunk_output_files: vec![vec![]; 49],
}),
);
@@ -992,6 +1102,68 @@ mod tests {
assert_eq!(m.size_for_distance(SnapshotComponentType::Receipts, None), 0);
}
#[test]
fn output_size_for_distance_uses_manifest_or_output_files() {
let m = test_manifest();
assert_eq!(m.output_size_for_distance(SnapshotComponentType::Transactions, None), 0);
let mut components = BTreeMap::new();
components.insert(
"state".to_string(),
ComponentManifest::Single(SingleArchive {
file: "state.tar.zst".to_string(),
size: 100,
decompressed_size: 1_000,
blake3: None,
output_files: vec![OutputFileChecksum {
path: "db/mdbx.dat".to_string(),
size: 1_000,
blake3: "h0".to_string(),
}],
}),
);
components.insert(
"transactions".to_string(),
ComponentManifest::Chunked(ChunkedArchive {
blocks_per_file: 500_000,
total_blocks: 1_000_000,
chunk_sizes: vec![80_000, 120_000],
chunk_decompressed_sizes: vec![111, 222],
chunk_output_files: vec![
vec![OutputFileChecksum {
path: "static_files/static_file_transactions_0_499999.bin".to_string(),
size: 111,
blake3: "h0".to_string(),
}],
vec![OutputFileChecksum {
path: "static_files/static_file_transactions_500000_999999.bin".to_string(),
size: 222,
blake3: "h1".to_string(),
}],
],
}),
);
let manifest = SnapshotManifest {
block: 1_000_000,
chain_id: 1,
storage_version: 2,
timestamp: 0,
base_url: Some("https://example.com".to_string()),
reth_version: None,
components,
};
assert_eq!(manifest.output_size_for_distance(SnapshotComponentType::State, None), 1_000);
assert_eq!(
manifest.output_size_for_distance(SnapshotComponentType::Transactions, None),
333
);
assert_eq!(
manifest.output_size_for_distance(SnapshotComponentType::Transactions, Some(500_000)),
222
);
}
#[test]
fn archive_descriptors_include_checksum_metadata() {
let mut components = BTreeMap::new();
@@ -1000,6 +1172,7 @@ mod tests {
ComponentManifest::Single(SingleArchive {
file: "state.tar.zst".to_string(),
size: 100,
decompressed_size: 1_000,
blake3: Some("abc123".to_string()),
output_files: vec![OutputFileChecksum {
path: "db/mdbx.dat".to_string(),
@@ -1014,6 +1187,7 @@ mod tests {
blocks_per_file: 500_000,
total_blocks: 1_000_000,
chunk_sizes: vec![80_000, 120_000],
chunk_decompressed_sizes: vec![111, 222],
chunk_output_files: vec![
vec![OutputFileChecksum {
path: "static_files/static_file_transactions_0_499999.bin".to_string(),
@@ -1039,13 +1213,13 @@ mod tests {
components,
};
let state = m.archive_descriptors_for_distance(SnapshotComponentType::State, None);
let state = m.snapshot_archives_for_distance(SnapshotComponentType::State, None);
assert_eq!(state.len(), 1);
assert_eq!(state[0].file_name, "state.tar.zst");
assert_eq!(state[0].blake3.as_deref(), Some("abc123"));
assert_eq!(state[0].output_files.len(), 1);
let tx = m.archive_descriptors_for_distance(SnapshotComponentType::Transactions, None);
let tx = m.snapshot_archives_for_distance(SnapshotComponentType::Transactions, None);
assert_eq!(tx.len(), 2);
assert_eq!(tx[0].blake3, None);
assert_eq!(tx[1].blake3, None);
@@ -1068,6 +1242,7 @@ mod tests {
panic!("state should be a single archive")
};
assert_eq!(state.file, "state.tar.zst");
assert!(state.decompressed_size > 0);
assert!(!state.output_files.is_empty());
assert_eq!(state.output_files[0].path, "db/mdbx.dat");
assert!(output.path().join("state.tar.zst").exists());
@@ -1092,6 +1267,7 @@ mod tests {
panic!("rocksdb indices should be a single archive")
};
assert_eq!(rocksdb.file, "rocksdb_indices.tar.zst");
assert!(rocksdb.decompressed_size > 0);
assert!(!rocksdb.output_files.is_empty());
assert_eq!(rocksdb.output_files[0].path, "rocksdb/CURRENT");
assert!(output.path().join("rocksdb_indices.tar.zst").exists());

View File

@@ -45,6 +45,7 @@ pub struct SnapshotManifestCommand {
}
impl SnapshotManifestCommand {
/// Packages snapshot archives and writes the manifest file.
pub fn execute(self) -> Result<()> {
let block = match self.block {
Some(block) => block,
@@ -88,6 +89,7 @@ impl SnapshotManifestCommand {
}
}
/// Infers the snapshot block from the source datadir.
fn infer_snapshot_block(source_datadir: &std::path::Path) -> Result<u64> {
if let Ok(block) = infer_snapshot_block_from_db(source_datadir) {
return Ok(block);
@@ -102,6 +104,7 @@ fn infer_snapshot_block(source_datadir: &std::path::Path) -> Result<u64> {
Ok(block)
}
/// Reads the snapshot block from the source database Finish stage checkpoint.
fn infer_snapshot_block_from_db(source_datadir: &std::path::Path) -> Result<u64> {
let candidates = [source_datadir.join("db"), source_datadir.to_path_buf()];
@@ -126,6 +129,7 @@ fn infer_snapshot_block_from_db(source_datadir: &std::path::Path) -> Result<u64>
)
}
/// Infers the snapshot block from the highest header static-file range.
fn infer_snapshot_block_from_headers(source_datadir: &std::path::Path) -> Result<u64> {
let max_end = header_ranges(source_datadir)?
.into_iter()
@@ -135,6 +139,7 @@ fn infer_snapshot_block_from_headers(source_datadir: &std::path::Path) -> Result
Ok(max_end)
}
/// Infers the static-file block span from header file ranges.
fn infer_blocks_per_file(source_datadir: &std::path::Path) -> Result<u64> {
let mut inferred = None;
for (start, end) in header_ranges(source_datadir)? {
@@ -161,6 +166,7 @@ fn infer_blocks_per_file(source_datadir: &std::path::Path) -> Result<u64> {
})
}
/// Collects header static-file ranges from the source datadir.
fn header_ranges(source_datadir: &std::path::Path) -> Result<Vec<(u64, u64)>> {
let static_files_dir = source_datadir.join("static_files");
let static_files_dir =
@@ -183,6 +189,7 @@ fn header_ranges(source_datadir: &std::path::Path) -> Result<Vec<(u64, u64)>> {
Ok(ranges)
}
/// Parses the block range from a header static-file name.
fn parse_headers_range(file_name: &str) -> Option<(u64, u64)> {
let remainder = file_name.strip_prefix("static_file_headers_")?;
let (start, end_with_suffix) = remainder.split_once('_')?;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,322 @@
use super::{manifest::*, verify::OutputVerifier};
use eyre::Result;
use std::{collections::BTreeMap, path::Path};
use tracing::info;
/// One archive selected from the manifest, along with its component name.
#[derive(Debug, Clone)]
pub(crate) struct PlannedArchive {
/// Snapshot component type this archive belongs to.
pub(crate) ty: SnapshotComponentType,
/// User-facing component name used in logs.
pub(crate) component: String,
/// Concrete snapshot archive metadata resolved from the manifest.
pub(crate) archive: SnapshotArchive,
}
/// The archive list for a modular snapshot download.
#[derive(Debug)]
pub(crate) struct PlannedDownloads {
/// Concrete archives that still need reuse checks or processing.
pub(crate) archives: Vec<PlannedArchive>,
/// Total compressed download size of all planned archives.
pub(crate) total_download_size: u64,
/// Total extracted plain-output size of all planned archives.
pub(crate) total_output_size: u64,
}
impl PlannedDownloads {
/// Returns the number of concrete archives queued for this snapshot selection.
pub(crate) const fn total_archives(&self) -> usize {
self.archives.len()
}
}
/// Returns the sort priority used to schedule archives.
pub(crate) const fn archive_priority_rank(ty: SnapshotComponentType) -> u8 {
match ty {
SnapshotComponentType::State => 0,
SnapshotComponentType::RocksdbIndices => 1,
_ => 2,
}
}
/// Startup summary showing how much of the selected work can be reused.
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct DownloadStartupSummary {
/// Archives whose declared outputs already verify on disk.
pub(crate) reusable: usize,
/// Archives that still need to be downloaded or retried.
pub(crate) needs_download: usize,
}
/// Checks selected archives against existing output files before work begins.
pub(crate) fn summarize_download_startup(
all_downloads: &[PlannedArchive],
target_dir: &Path,
) -> Result<DownloadStartupSummary> {
let mut summary = DownloadStartupSummary::default();
let verifier = OutputVerifier::new(target_dir);
for planned in all_downloads {
if verifier.verify(&planned.archive.output_files)? {
summary.reusable += 1;
} else {
summary.needs_download += 1;
}
}
Ok(summary)
}
/// Converts a selection into the manifest distance form used for archive lookup.
fn selection_archive_distance(
selection: &ComponentSelection,
snapshot_block: u64,
) -> Option<Option<u64>> {
match selection {
ComponentSelection::All => Some(None),
ComponentSelection::Distance(distance) => Some(Some(*distance)),
ComponentSelection::Since(block) => Some(Some(snapshot_block.saturating_sub(*block) + 1)),
ComponentSelection::None => None,
}
}
/// Sorts planned archives into a stable processing order.
fn sort_planned_archives(all_downloads: &mut [PlannedArchive]) {
all_downloads.sort_by(|a, b| {
archive_priority_rank(a.ty)
.cmp(&archive_priority_rank(b.ty))
.then_with(|| a.component.cmp(&b.component))
.then_with(|| a.archive.file_name.cmp(&b.archive.file_name))
});
}
/// Expands component selections into the archives that need to be processed.
pub(crate) fn collect_planned_archives(
manifest: &SnapshotManifest,
selections: &BTreeMap<SnapshotComponentType, ComponentSelection>,
) -> Result<PlannedDownloads> {
let mut archives = Vec::new();
let mut total_download_size = 0;
let mut total_output_size = 0;
for (ty, selection) in selections {
let Some(distance) = selection_archive_distance(selection, manifest.block) else {
continue;
};
total_download_size += manifest.size_for_distance(*ty, distance);
total_output_size += manifest.output_size_for_distance(*ty, distance);
let snapshot_archives = manifest.snapshot_archives_for_distance(*ty, distance);
let component = ty.display_name().to_string();
if !snapshot_archives.is_empty() {
info!(target: "reth::cli",
component = %component,
archives = snapshot_archives.len(),
selection = %selection,
"Queued component for download"
);
}
for archive in snapshot_archives {
if archive.output_files.is_empty() {
eyre::bail!(
"Invalid modular manifest: {} is missing plain output checksum metadata",
archive.file_name
);
}
archives.push(PlannedArchive { ty: *ty, component: component.clone(), archive });
}
}
sort_planned_archives(&mut archives);
Ok(PlannedDownloads { archives, total_download_size, total_output_size })
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn summarize_download_startup_counts_reusable_and_needs_download() {
let dir = tempdir().unwrap();
let target_dir = dir.path();
let ok_file = target_dir.join("ok.bin");
std::fs::write(&ok_file, vec![1_u8; 4]).unwrap();
let ok_hash = blake3::hash(&[1_u8; 4]).to_hex().to_string();
let planned = vec![
PlannedArchive {
ty: SnapshotComponentType::State,
component: "State".to_string(),
archive: SnapshotArchive {
url: "https://example.com/ok.tar.zst".to_string(),
file_name: "ok.tar.zst".to_string(),
size: 10,
blake3: None,
output_files: vec![OutputFileChecksum {
path: "ok.bin".to_string(),
size: 4,
blake3: ok_hash,
}],
},
},
PlannedArchive {
ty: SnapshotComponentType::Headers,
component: "Headers".to_string(),
archive: SnapshotArchive {
url: "https://example.com/missing.tar.zst".to_string(),
file_name: "missing.tar.zst".to_string(),
size: 10,
blake3: None,
output_files: vec![OutputFileChecksum {
path: "missing.bin".to_string(),
size: 1,
blake3: "deadbeef".to_string(),
}],
},
},
PlannedArchive {
ty: SnapshotComponentType::Transactions,
component: "Transactions".to_string(),
archive: SnapshotArchive {
url: "https://example.com/bad-size.tar.zst".to_string(),
file_name: "bad-size.tar.zst".to_string(),
size: 10,
blake3: None,
output_files: vec![],
},
},
];
let summary = summarize_download_startup(&planned, target_dir).unwrap();
assert_eq!(summary.reusable, 1);
assert_eq!(summary.needs_download, 2);
}
#[test]
fn archive_priority_prefers_state_then_rocksdb() {
let mut planned = [
PlannedArchive {
ty: SnapshotComponentType::Transactions,
component: "Transactions".to_string(),
archive: SnapshotArchive {
url: "u3".to_string(),
file_name: "t.tar.zst".to_string(),
size: 1,
blake3: None,
output_files: vec![OutputFileChecksum {
path: "a".to_string(),
size: 1,
blake3: "x".to_string(),
}],
},
},
PlannedArchive {
ty: SnapshotComponentType::RocksdbIndices,
component: "RocksDB Indices".to_string(),
archive: SnapshotArchive {
url: "u2".to_string(),
file_name: "rocksdb_indices.tar.zst".to_string(),
size: 1,
blake3: None,
output_files: vec![OutputFileChecksum {
path: "b".to_string(),
size: 1,
blake3: "y".to_string(),
}],
},
},
PlannedArchive {
ty: SnapshotComponentType::State,
component: "State (mdbx)".to_string(),
archive: SnapshotArchive {
url: "u1".to_string(),
file_name: "state.tar.zst".to_string(),
size: 1,
blake3: None,
output_files: vec![OutputFileChecksum {
path: "c".to_string(),
size: 1,
blake3: "z".to_string(),
}],
},
},
];
planned.sort_by(|a, b| {
archive_priority_rank(a.ty)
.cmp(&archive_priority_rank(b.ty))
.then_with(|| a.component.cmp(&b.component))
.then_with(|| a.archive.file_name.cmp(&b.archive.file_name))
});
assert_eq!(planned[0].ty, SnapshotComponentType::State);
assert_eq!(planned[1].ty, SnapshotComponentType::RocksdbIndices);
assert_eq!(planned[2].ty, SnapshotComponentType::Transactions);
}
#[test]
fn collect_planned_archives_tracks_download_and_output_totals() {
let mut components = BTreeMap::new();
components.insert(
"state".to_string(),
ComponentManifest::Single(SingleArchive {
file: "state.tar.zst".to_string(),
size: 10,
decompressed_size: 100,
blake3: None,
output_files: vec![OutputFileChecksum {
path: "db/mdbx.dat".to_string(),
size: 100,
blake3: "h0".to_string(),
}],
}),
);
components.insert(
"transactions".to_string(),
ComponentManifest::Chunked(ChunkedArchive {
blocks_per_file: 500_000,
total_blocks: 1_000_000,
chunk_sizes: vec![20, 30],
chunk_decompressed_sizes: vec![200, 300],
chunk_output_files: vec![
vec![OutputFileChecksum {
path: "static_files/tx-0".to_string(),
size: 200,
blake3: "h1".to_string(),
}],
vec![OutputFileChecksum {
path: "static_files/tx-1".to_string(),
size: 300,
blake3: "h2".to_string(),
}],
],
}),
);
let manifest = SnapshotManifest {
block: 1_000_000,
chain_id: 1,
storage_version: 2,
timestamp: 0,
base_url: Some("https://example.com".to_string()),
reth_version: None,
components,
};
let selections = BTreeMap::from([
(SnapshotComponentType::State, ComponentSelection::All),
(SnapshotComponentType::Transactions, ComponentSelection::Distance(500_000)),
]);
let planned = collect_planned_archives(&manifest, &selections).unwrap();
assert_eq!(planned.total_download_size, 40);
assert_eq!(planned.total_output_size, 400);
assert_eq!(planned.archives.len(), 2);
}
}

View File

@@ -0,0 +1,844 @@
use eyre::Result;
use reth_cli_util::cancellation::CancellationToken;
use std::{
io::{self, Read, Write},
sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc, Condvar, Mutex,
},
time::{Duration, Instant},
};
use tracing::info;
const BYTE_UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
/// Tracks download progress and throttles display updates to every 100ms.
pub(crate) struct DownloadProgress {
/// Bytes copied so far for this single download.
pub(crate) downloaded: u64,
/// Total bytes expected for this single download.
total_size: u64,
/// Time when the progress line was last printed.
last_displayed: Instant,
/// Time when this progress tracker started.
started_at: Instant,
}
impl DownloadProgress {
/// Creates new progress tracker with given total size
pub(crate) fn new(total_size: u64) -> Self {
let now = Instant::now();
Self { downloaded: 0, total_size, last_displayed: now, started_at: now }
}
/// Converts bytes to human readable format (B, KB, MB, GB)
pub(crate) fn format_size(size: u64) -> String {
let mut size = size as f64;
let mut unit_index = 0;
while size >= 1024.0 && unit_index < BYTE_UNITS.len() - 1 {
size /= 1024.0;
unit_index += 1;
}
format!("{:.2} {}", size, BYTE_UNITS[unit_index])
}
/// Format duration as human readable string
pub(crate) fn format_duration(duration: Duration) -> String {
let secs = duration.as_secs();
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
}
}
/// Updates progress bar (for single-archive legacy downloads)
pub(crate) fn update(&mut self, chunk_size: u64) -> Result<()> {
self.downloaded += chunk_size;
if self.last_displayed.elapsed() >= Duration::from_millis(100) {
let formatted_downloaded = Self::format_size(self.downloaded);
let formatted_total = Self::format_size(self.total_size);
let progress = (self.downloaded as f64 / self.total_size as f64) * 100.0;
let elapsed = self.started_at.elapsed();
let eta = if self.downloaded > 0 {
let remaining = self.total_size.saturating_sub(self.downloaded);
let speed = self.downloaded as f64 / elapsed.as_secs_f64();
if speed > 0.0 {
Duration::from_secs_f64(remaining as f64 / speed)
} else {
Duration::ZERO
}
} else {
Duration::ZERO
};
let eta_str = Self::format_duration(eta);
print!(
"\rDownloading and extracting... {progress:.2}% ({formatted_downloaded} / {formatted_total}) ETA: {eta_str} ",
);
io::stdout().flush()?;
self.last_displayed = Instant::now();
}
Ok(())
}
}
#[derive(Debug, Clone, Copy)]
struct PhaseStart {
started_at: Instant,
baseline_bytes: u64,
}
/// Shared progress counters for parallel downloads.
pub(crate) struct SharedProgress {
/// Raw HTTP bytes fetched during this session, including retries.
pub(crate) session_fetched_bytes: AtomicU64,
/// Compressed bytes from archives that have fully downloaded.
pub(crate) completed_download_bytes: AtomicU64,
/// Compressed bytes written for currently active archive download attempts.
pub(crate) active_download_bytes: AtomicU64,
/// Total compressed bytes expected across all planned archives.
pub(crate) total_download_bytes: u64,
/// Plain-output bytes from archives that have fully verified.
pub(crate) completed_output_bytes: AtomicU64,
/// Plain-output bytes unpacked by currently active extractions.
pub(crate) active_extracted_output_bytes: AtomicU64,
/// Plain-output bytes hashed by currently active verifications.
pub(crate) active_verified_output_bytes: AtomicU64,
/// Total plain-output bytes expected across all planned archives.
pub(crate) total_output_bytes: u64,
/// Total number of planned archives.
pub(crate) total_archives: u64,
/// Time when the modular download job started.
pub(crate) started_at: Instant,
/// Time and baseline when the current extraction phase started.
extraction_phase: Mutex<Option<PhaseStart>>,
/// Time and baseline when the current verification phase started.
verification_phase: Mutex<Option<PhaseStart>>,
/// Number of archives that have fully finished.
pub(crate) archives_done: AtomicU64,
/// Number of archives currently in the fetch phase.
pub(crate) active_downloads: AtomicU64,
/// Number of in-flight HTTP requests.
pub(crate) active_download_requests: AtomicU64,
/// Number of archives currently extracting.
pub(crate) active_extractions: AtomicU64,
/// Number of archives currently verifying extracted outputs.
pub(crate) active_verifications: AtomicU64,
/// Signals the background progress task to exit.
pub(crate) done: AtomicBool,
/// Cancellation token shared by the whole command.
cancel_token: CancellationToken,
}
impl SharedProgress {
/// Creates the shared progress state for a modular download job.
pub(crate) fn new(
total_download_bytes: u64,
total_output_bytes: u64,
total_archives: u64,
cancel_token: CancellationToken,
) -> Arc<Self> {
Arc::new(Self {
session_fetched_bytes: AtomicU64::new(0),
completed_download_bytes: AtomicU64::new(0),
active_download_bytes: AtomicU64::new(0),
total_download_bytes,
completed_output_bytes: AtomicU64::new(0),
active_extracted_output_bytes: AtomicU64::new(0),
active_verified_output_bytes: AtomicU64::new(0),
total_output_bytes,
total_archives,
started_at: Instant::now(),
extraction_phase: Mutex::new(None),
verification_phase: Mutex::new(None),
archives_done: AtomicU64::new(0),
active_downloads: AtomicU64::new(0),
active_download_requests: AtomicU64::new(0),
active_extractions: AtomicU64::new(0),
active_verifications: AtomicU64::new(0),
done: AtomicBool::new(false),
cancel_token,
})
}
/// Returns whether the whole command has been cancelled.
pub(crate) fn is_cancelled(&self) -> bool {
self.cancel_token.is_cancelled()
}
/// Adds raw session traffic bytes without affecting logical progress.
pub(crate) fn record_session_fetched_bytes(&self, bytes: u64) {
self.session_fetched_bytes.fetch_add(bytes, Ordering::Relaxed);
}
pub(crate) fn add_active_download_bytes(&self, bytes: u64) {
self.active_download_bytes.fetch_add(bytes, Ordering::Relaxed);
}
pub(crate) fn sub_active_download_bytes(&self, bytes: u64) {
sub_bytes(&self.active_download_bytes, bytes);
}
fn add_active_extracted_output_bytes(&self, bytes: u64) {
self.active_extracted_output_bytes.fetch_add(bytes, Ordering::Relaxed);
}
fn sub_active_extracted_output_bytes(&self, bytes: u64) {
sub_bytes(&self.active_extracted_output_bytes, bytes);
}
fn add_active_verified_output_bytes(&self, bytes: u64) {
self.active_verified_output_bytes.fetch_add(bytes, Ordering::Relaxed);
}
fn sub_active_verified_output_bytes(&self, bytes: u64) {
sub_bytes(&self.active_verified_output_bytes, bytes);
}
/// Records an archive whose outputs were already present locally.
pub(crate) fn record_reused_archive(&self, download_bytes: u64, output_bytes: u64) {
self.completed_download_bytes.fetch_add(download_bytes, Ordering::Relaxed);
self.completed_output_bytes.fetch_add(output_bytes, Ordering::Relaxed);
self.archives_done.fetch_add(1, Ordering::Relaxed);
}
/// Records an archive whose compressed download completed successfully.
pub(crate) fn record_archive_download_complete(&self, bytes: u64) {
self.completed_download_bytes.fetch_add(bytes, Ordering::Relaxed);
}
/// Records an archive whose extracted outputs have fully verified.
pub(crate) fn record_archive_output_complete(&self, bytes: u64) {
self.completed_output_bytes.fetch_add(bytes, Ordering::Relaxed);
self.archives_done.fetch_add(1, Ordering::Relaxed);
}
/// Returns logical compressed download progress.
pub(crate) fn logical_downloaded_bytes(&self) -> u64 {
(self.completed_download_bytes.load(Ordering::Relaxed) +
self.active_download_bytes.load(Ordering::Relaxed))
.min(self.total_download_bytes)
}
/// Returns verified plain-output bytes.
pub(crate) fn verified_output_bytes(&self) -> u64 {
self.completed_output_bytes.load(Ordering::Relaxed).min(self.total_output_bytes)
}
/// Returns plain-output bytes currently represented by extraction progress.
pub(crate) fn extracting_output_bytes(&self) -> u64 {
(self.completed_output_bytes.load(Ordering::Relaxed) +
self.active_extracted_output_bytes.load(Ordering::Relaxed))
.min(self.total_output_bytes)
}
/// Returns plain-output bytes currently represented by verification progress.
pub(crate) fn verifying_output_bytes(&self) -> u64 {
(self.completed_output_bytes.load(Ordering::Relaxed) +
self.active_verified_output_bytes.load(Ordering::Relaxed))
.min(self.total_output_bytes)
}
fn restart_phase(slot: &Mutex<Option<PhaseStart>>, baseline_bytes: u64) {
*slot.lock().unwrap() = Some(PhaseStart { started_at: Instant::now(), baseline_bytes });
}
fn phase_eta(
slot: &Mutex<Option<PhaseStart>>,
current_bytes: u64,
total_bytes: u64,
) -> Option<Duration> {
let phase = *slot.lock().unwrap();
let phase = phase?;
let done = current_bytes.saturating_sub(phase.baseline_bytes);
let total = total_bytes.saturating_sub(phase.baseline_bytes);
eta_from_progress(phase.started_at.elapsed(), done, total)
}
fn extraction_eta(&self, current_bytes: u64) -> Option<Duration> {
Self::phase_eta(&self.extraction_phase, current_bytes, self.total_output_bytes)
}
fn verification_eta(&self, current_bytes: u64) -> Option<Duration> {
Self::phase_eta(&self.verification_phase, current_bytes, self.total_output_bytes)
}
/// Marks one archive as actively downloading.
pub(crate) fn download_started(&self) {
self.active_downloads.fetch_add(1, Ordering::Relaxed);
}
/// Marks one archive download as finished.
pub(crate) fn download_finished(&self) {
sub_bytes(&self.active_downloads, 1);
}
/// Marks one HTTP request as in flight.
pub(crate) fn request_started(&self) {
self.active_download_requests.fetch_add(1, Ordering::Relaxed);
}
/// Marks one HTTP request as finished.
pub(crate) fn request_finished(&self) {
sub_bytes(&self.active_download_requests, 1);
}
/// Marks one archive as actively extracting.
pub(crate) fn extraction_started(&self) {
if self.active_extractions.fetch_add(1, Ordering::Relaxed) == 0 {
Self::restart_phase(
&self.extraction_phase,
self.completed_output_bytes.load(Ordering::Relaxed),
);
}
}
/// Marks one archive extraction as finished.
pub(crate) fn extraction_finished(&self) {
sub_bytes(&self.active_extractions, 1);
}
/// Marks one archive as actively verifying outputs.
pub(crate) fn verification_started(&self) {
if self.active_verifications.fetch_add(1, Ordering::Relaxed) == 0 {
Self::restart_phase(
&self.verification_phase,
self.completed_output_bytes.load(Ordering::Relaxed),
);
}
}
/// Marks one archive verification as finished.
pub(crate) fn verification_finished(&self) {
sub_bytes(&self.active_verifications, 1);
}
}
fn sub_bytes(counter: &AtomicU64, bytes: u64) {
let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
Some(current.saturating_sub(bytes))
});
}
fn eta_from_progress(elapsed: Duration, done: u64, total: u64) -> Option<Duration> {
if done == 0 || done >= total {
return None;
}
let secs = elapsed.as_secs_f64();
if secs <= 0.0 {
return None;
}
let speed = done as f64 / secs;
if speed <= 0.0 {
return None;
}
Some(Duration::from_secs_f64((total - done) as f64 / speed))
}
fn format_percent(done: u64, total: u64) -> String {
if total == 0 {
return "100.0%".to_string();
}
format!("{:.1}%", (done as f64 / total as f64) * 100.0)
}
fn format_eta(eta: Option<Duration>) -> String {
eta.map(DownloadProgress::format_duration).unwrap_or_else(|| "unknown".to_string())
}
/// Global request limit for the blocking downloader.
///
/// This uses `Mutex + Condvar` because the segmented path runs blocking reqwest
/// clients on OS threads.
pub(crate) struct DownloadRequestLimiter {
/// Maximum number of in-flight HTTP requests.
limit: usize,
/// Current number of acquired request slots.
active: Mutex<usize>,
/// Wakes blocked threads when a slot is released.
notify: Condvar,
}
impl DownloadRequestLimiter {
/// Creates the shared request limiter.
pub(crate) fn new(limit: usize) -> Arc<Self> {
Arc::new(Self { limit: limit.max(1), active: Mutex::new(0), notify: Condvar::new() })
}
/// Returns the configured request limit.
pub(crate) fn max_concurrency(&self) -> usize {
self.limit
}
pub(crate) fn acquire<'a>(
&'a self,
progress: Option<&'a Arc<SharedProgress>>,
cancel_token: &CancellationToken,
) -> Result<DownloadRequestPermit<'a>> {
let mut active = self.active.lock().unwrap();
loop {
if cancel_token.is_cancelled() {
return Err(eyre::eyre!("Download cancelled"));
}
if *active < self.limit {
*active += 1;
if let Some(progress) = progress {
progress.request_started();
}
return Ok(DownloadRequestPermit { limiter: self, progress });
}
// Wake periodically so cancellation can interrupt waiters even if
// no request finishes.
let (next_active, _) =
self.notify.wait_timeout(active, Duration::from_millis(100)).unwrap();
active = next_active;
}
}
}
/// RAII permit for one in-flight HTTP request.
///
/// Dropping the permit releases a slot in the shared request limit and updates
/// the live progress counters.
pub(crate) struct DownloadRequestPermit<'a> {
/// Limiter that owns the request slot.
limiter: &'a DownloadRequestLimiter,
/// Shared progress counters updated when the permit drops.
progress: Option<&'a Arc<SharedProgress>>,
}
impl Drop for DownloadRequestPermit<'_> {
/// Releases the request slot and updates shared progress counters.
fn drop(&mut self) {
let mut active = self.limiter.active.lock().unwrap();
*active = active.saturating_sub(1);
drop(active);
self.limiter.notify.notify_one();
if let Some(progress) = self.progress {
progress.request_finished();
}
}
}
/// Tracks one active archive download attempt.
pub(crate) struct ArchiveDownloadProgress<'a> {
progress: Option<&'a Arc<SharedProgress>>,
downloaded: u64,
completed: bool,
}
impl<'a> ArchiveDownloadProgress<'a> {
/// Starts tracking one archive download attempt.
pub(crate) fn new(progress: Option<&'a Arc<SharedProgress>>) -> Self {
if let Some(progress) = progress {
progress.download_started();
}
Self { progress, downloaded: 0, completed: false }
}
/// Adds logical compressed bytes written by this attempt.
pub(crate) fn record_downloaded(&mut self, bytes: u64) {
self.downloaded += bytes;
if let Some(progress) = self.progress {
progress.add_active_download_bytes(bytes);
}
}
/// Returns whether this tracker has recorded any logical bytes itself.
pub(crate) fn has_tracked_bytes(&self) -> bool {
self.downloaded > 0
}
/// Moves this archive from active download bytes into completed download bytes.
pub(crate) fn complete(&mut self, total_bytes: u64) {
if self.completed {
return;
}
if let Some(progress) = self.progress {
progress.sub_active_download_bytes(self.downloaded);
progress.record_archive_download_complete(total_bytes);
}
self.downloaded = 0;
self.completed = true;
}
}
impl Drop for ArchiveDownloadProgress<'_> {
fn drop(&mut self) {
if let Some(progress) = self.progress {
progress.sub_active_download_bytes(self.downloaded);
progress.download_finished();
}
}
}
/// Tracks one active archive extraction attempt.
pub(crate) struct ArchiveExtractionProgress {
progress: Option<Arc<SharedProgress>>,
extracted: Arc<AtomicU64>,
finished: bool,
}
/// Cloneable handle for reporting extracted bytes from background monitoring.
#[derive(Clone)]
pub(crate) struct ArchiveExtractionProgressHandle {
progress: Arc<SharedProgress>,
extracted: Arc<AtomicU64>,
}
impl ArchiveExtractionProgress {
/// Starts tracking one archive extraction attempt.
pub(crate) fn new(progress: Option<&Arc<SharedProgress>>) -> Self {
if let Some(progress) = progress {
progress.extraction_started();
}
Self {
progress: progress.cloned(),
extracted: Arc::new(AtomicU64::new(0)),
finished: false,
}
}
/// Returns a cloneable handle that can report extraction progress from another thread.
pub(crate) fn handle(&self) -> Option<ArchiveExtractionProgressHandle> {
Some(ArchiveExtractionProgressHandle {
progress: Arc::clone(self.progress.as_ref()?),
extracted: Arc::clone(&self.extracted),
})
}
/// Adds plain-output bytes extracted by this attempt.
pub(crate) fn record_extracted(&mut self, bytes: u64) {
if let Some(handle) = self.handle() {
handle.record_extracted(bytes);
}
}
/// Ends extraction tracking before verification begins.
pub(crate) fn finish(&mut self) {
if self.finished {
return;
}
if let Some(progress) = &self.progress {
progress.sub_active_extracted_output_bytes(self.extracted.swap(0, Ordering::Relaxed));
}
self.finished = true;
}
}
impl Drop for ArchiveExtractionProgress {
fn drop(&mut self) {
if let Some(progress) = &self.progress {
progress.sub_active_extracted_output_bytes(self.extracted.swap(0, Ordering::Relaxed));
progress.extraction_finished();
}
}
}
impl ArchiveExtractionProgressHandle {
/// Adds plain-output bytes extracted by this attempt.
pub(crate) fn record_extracted(&self, bytes: u64) {
self.extracted.fetch_add(bytes, Ordering::Relaxed);
self.progress.add_active_extracted_output_bytes(bytes);
}
}
/// Tracks one active archive verification attempt.
pub(crate) struct ArchiveVerificationProgress<'a> {
progress: Option<&'a Arc<SharedProgress>>,
verified: u64,
completed: bool,
}
impl<'a> ArchiveVerificationProgress<'a> {
/// Starts tracking one archive verification attempt.
pub(crate) fn new(progress: Option<&'a Arc<SharedProgress>>) -> Self {
if let Some(progress) = progress {
progress.verification_started();
}
Self { progress, verified: 0, completed: false }
}
/// Adds plain-output bytes hashed by this verification attempt.
pub(crate) fn record_verified(&mut self, bytes: u64) {
self.verified += bytes;
if let Some(progress) = self.progress {
progress.add_active_verified_output_bytes(bytes);
}
}
/// Moves this archive from active verification bytes into completed output bytes.
pub(crate) fn complete(&mut self, total_bytes: u64) {
if self.completed {
return;
}
if let Some(progress) = self.progress {
progress.sub_active_verified_output_bytes(self.verified);
progress.record_archive_output_complete(total_bytes);
}
self.verified = 0;
self.completed = true;
}
}
impl Drop for ArchiveVerificationProgress<'_> {
fn drop(&mut self) {
if let Some(progress) = self.progress {
progress.sub_active_verified_output_bytes(self.verified);
progress.verification_finished();
}
}
}
/// Adapter to track progress while reading (used for extraction in legacy path)
pub(crate) struct ProgressReader<R> {
/// Wrapped reader that provides archive bytes.
reader: R,
/// Per-download progress tracker for legacy paths.
progress: DownloadProgress,
/// Cancellation token checked between reads.
cancel_token: CancellationToken,
}
impl<R: Read> ProgressReader<R> {
/// Wraps a reader with per-download progress tracking.
pub(crate) 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> {
/// Reads bytes, checks cancellation, and updates the local progress bar.
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(error) = self.progress.update(bytes as u64)
{
return Err(io::Error::other(error));
}
Ok(bytes)
}
}
/// Wrapper that bumps a shared atomic counter while writing data.
/// Used for parallel downloads where a single display task shows aggregated progress.
pub(crate) struct SharedProgressWriter<'a, W> {
/// Wrapped writer receiving downloaded bytes.
pub(crate) inner: W,
/// Shared counters updated as bytes are written.
pub(crate) progress: Arc<SharedProgress>,
/// Optional callback for logical bytes written by the current archive attempt.
pub(crate) on_written: Option<&'a mut dyn FnMut(u64)>,
}
impl<W: Write> Write for SharedProgressWriter<'_, W> {
/// Writes bytes and records them in shared progress.
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.record_session_fetched_bytes(n as u64);
if let Some(on_written) = self.on_written.as_deref_mut() {
on_written(n as u64);
}
Ok(n)
}
/// Flushes the wrapped writer.
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
/// Wrapper that bumps a shared atomic counter while reading data.
/// Used for streaming downloads where a single display task shows aggregated progress.
pub(crate) struct SharedProgressReader<R> {
/// Wrapped reader producing streamed bytes.
pub(crate) inner: R,
/// Shared counters updated as bytes are read.
pub(crate) progress: Arc<SharedProgress>,
}
impl<R: Read> Read for SharedProgressReader<R> {
/// Reads bytes and records them in shared progress.
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.record_session_fetched_bytes(n as u64);
Ok(n)
}
}
/// Spawns a background task that prints aggregated download progress.
/// Returns a handle; drop it (or call `.abort()`) to stop.
pub(crate) fn spawn_progress_display(progress: Arc<SharedProgress>) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(3));
interval.tick().await;
loop {
interval.tick().await;
if progress.done.load(Ordering::Relaxed) {
break;
}
let download_total = progress.total_download_bytes;
let output_total = progress.total_output_bytes;
if download_total == 0 && output_total == 0 {
continue;
}
let done = progress.archives_done.load(Ordering::Relaxed);
let all = progress.total_archives;
let active_downloads = progress.active_downloads.load(Ordering::Relaxed);
let active_requests = progress.active_download_requests.load(Ordering::Relaxed);
let active_extractions = progress.active_extractions.load(Ordering::Relaxed);
let active_verifications = progress.active_verifications.load(Ordering::Relaxed);
let downloaded = progress.logical_downloaded_bytes();
let extracted = progress.extracting_output_bytes();
let verified = progress.verifying_output_bytes();
let elapsed = DownloadProgress::format_duration(progress.started_at.elapsed());
let download_total_display = DownloadProgress::format_size(download_total);
let output_total_display = DownloadProgress::format_size(output_total);
let downloaded_display = DownloadProgress::format_size(downloaded);
let extracted_display = DownloadProgress::format_size(extracted);
let active_download_phase = active_downloads > 0 || active_requests > 0;
if active_download_phase {
info!(target: "reth::cli",
archives = format_args!("{done}/{all}"),
progress = %format_percent(downloaded, download_total),
elapsed = %elapsed,
eta = %format_eta(eta_from_progress(progress.started_at.elapsed(), downloaded, download_total)),
bytes = format_args!("{downloaded_display}/{download_total_display}"),
"Downloading snapshot archives"
);
} else if active_extractions > 0 {
info!(target: "reth::cli",
archives = format_args!("{done}/{all}"),
progress = %format_percent(extracted, output_total),
elapsed = %elapsed,
eta = %format_eta(progress.extraction_eta(extracted)),
bytes = format_args!("{extracted_display}/{output_total_display}"),
"Extracting snapshot archives"
);
} else if active_verifications > 0 {
info!(target: "reth::cli",
archives = format_args!("{done}/{all}"),
progress = %format_percent(verified, output_total),
elapsed = %elapsed,
eta = %format_eta(progress.verification_eta(verified)),
bytes = format_args!("{}/{output_total_display}", DownloadProgress::format_size(verified)),
"Verifying snapshot archives"
);
} else {
continue;
}
}
let completed = progress.verified_output_bytes();
let completed_display = DownloadProgress::format_size(completed);
let output_total = DownloadProgress::format_size(progress.total_output_bytes);
info!(target: "reth::cli",
archives = format_args!("{}/{}", progress.total_archives, progress.total_archives),
progress = "100.0%",
elapsed = %DownloadProgress::format_duration(progress.started_at.elapsed()),
eta = "0s",
bytes = format_args!("{completed_display}/{output_total}"),
"Snapshot archive processing complete"
);
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::Ordering;
#[test]
fn shared_progress_separates_session_fetch_from_logical_progress() {
let progress = SharedProgress::new(10, 20, 1, CancellationToken::new());
progress.record_session_fetched_bytes(10);
progress.record_session_fetched_bytes(10);
progress.record_archive_download_complete(10);
progress.record_archive_output_complete(20);
assert_eq!(progress.session_fetched_bytes.load(Ordering::Relaxed), 20);
assert_eq!(progress.logical_downloaded_bytes(), 10);
assert_eq!(progress.verified_output_bytes(), 20);
assert_eq!(progress.archives_done.load(Ordering::Relaxed), 1);
}
#[test]
fn archive_download_progress_rolls_back_unfinished_attempts() {
let progress = SharedProgress::new(10, 20, 1, CancellationToken::new());
{
let mut download = ArchiveDownloadProgress::new(Some(&progress));
download.record_downloaded(4);
assert_eq!(progress.logical_downloaded_bytes(), 4);
}
assert_eq!(progress.logical_downloaded_bytes(), 0);
assert_eq!(progress.active_downloads.load(Ordering::Relaxed), 0);
}
#[test]
fn extraction_phase_baseline_restarts_after_idle() {
let progress = SharedProgress::new(10, 100, 1, CancellationToken::new());
progress.extraction_started();
assert_eq!(progress.extraction_phase.lock().unwrap().as_ref().unwrap().baseline_bytes, 0);
progress.completed_output_bytes.store(25, Ordering::Relaxed);
progress.extraction_started();
assert_eq!(progress.extraction_phase.lock().unwrap().as_ref().unwrap().baseline_bytes, 0);
progress.extraction_finished();
progress.extraction_finished();
progress.extraction_started();
assert_eq!(progress.extraction_phase.lock().unwrap().as_ref().unwrap().baseline_bytes, 25);
}
#[test]
fn verification_phase_baseline_restarts_after_idle() {
let progress = SharedProgress::new(10, 100, 1, CancellationToken::new());
progress.verification_started();
assert_eq!(progress.verification_phase.lock().unwrap().as_ref().unwrap().baseline_bytes, 0);
progress.completed_output_bytes.store(40, Ordering::Relaxed);
progress.verification_started();
assert_eq!(progress.verification_phase.lock().unwrap().as_ref().unwrap().baseline_bytes, 0);
progress.verification_finished();
progress.verification_finished();
progress.verification_started();
assert_eq!(
progress.verification_phase.lock().unwrap().as_ref().unwrap().baseline_bytes,
40
);
}
}

View File

@@ -0,0 +1,100 @@
use super::progress::{DownloadRequestLimiter, SharedProgress};
use eyre::Result;
use reth_cli_util::cancellation::CancellationToken;
use std::{
path::{Path, PathBuf},
sync::Arc,
};
/// Shared state for one run of `reth download`.
#[derive(Clone)]
pub(crate) struct DownloadSession {
/// Shared progress counters for this command, when enabled.
progress: Option<Arc<SharedProgress>>,
/// Shared limit for concurrent HTTP requests, when enabled.
request_limiter: Option<Arc<DownloadRequestLimiter>>,
/// Cancellation token shared by the whole command.
cancel_token: CancellationToken,
}
impl DownloadSession {
/// Stores the shared progress, request limiter, and cancellation token.
pub(crate) fn new(
progress: Option<Arc<SharedProgress>>,
request_limiter: Option<Arc<DownloadRequestLimiter>>,
cancel_token: CancellationToken,
) -> Self {
Self { progress, request_limiter, cancel_token }
}
/// Returns the shared progress tracker, if this flow uses one.
pub(crate) fn progress(&self) -> Option<&Arc<SharedProgress>> {
self.progress.as_ref()
}
/// Returns the shared HTTP request limiter, if this flow uses one.
pub(crate) fn request_limiter(&self) -> Option<&Arc<DownloadRequestLimiter>> {
self.request_limiter.as_ref()
}
/// Returns the request limiter or errors if the caller needs one.
pub(crate) fn require_request_limiter(&self) -> Result<&Arc<DownloadRequestLimiter>> {
self.request_limiter().ok_or_else(|| eyre::eyre!("Missing download request limiter"))
}
/// Returns the cancellation token for this command.
pub(crate) fn cancel_token(&self) -> &CancellationToken {
&self.cancel_token
}
/// Records one archive whose outputs were already reusable on disk.
pub(crate) fn record_reused_archive(&self, download_bytes: u64, output_bytes: u64) {
if let Some(progress) = self.progress() {
progress.record_reused_archive(download_bytes, output_bytes);
}
}
/// Records one archive whose extracted outputs fully verified.
pub(crate) fn record_archive_output_complete(&self, bytes: u64) {
if let Some(progress) = self.progress() {
progress.record_archive_output_complete(bytes);
}
}
}
/// Paths used while processing one archive, plus the shared download session.
#[derive(Clone)]
pub(crate) struct ArchiveProcessContext {
/// Directory where extracted output files are written.
target_dir: PathBuf,
/// Directory used for cached archive downloads, when enabled.
cache_dir: Option<PathBuf>,
/// Shared command-scoped download state.
session: DownloadSession,
}
impl ArchiveProcessContext {
/// Creates the context used while processing modular archives.
pub(crate) fn new(
target_dir: PathBuf,
cache_dir: Option<PathBuf>,
session: DownloadSession,
) -> Self {
Self { target_dir, cache_dir, session }
}
/// Returns the directory where extracted outputs should be written.
pub(crate) fn target_dir(&self) -> &Path {
&self.target_dir
}
/// Returns the cache directory for two-phase downloads, if enabled.
pub(crate) fn cache_dir(&self) -> Option<&Path> {
self.cache_dir.as_deref()
}
/// Returns the shared download session.
pub(crate) fn session(&self) -> &DownloadSession {
&self.session
}
}

View File

@@ -0,0 +1,230 @@
use super::{manifest::SnapshotManifest, progress::DownloadProgress, DownloadDefaults};
use eyre::{Result, WrapErr};
use reqwest::Client;
use reth_fs_util as fs;
use std::path::{Path, PathBuf};
use tracing::info;
use url::Url;
/// An entry from the snapshot discovery API listing.
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) 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 {
/// Returns whether this discovery entry points to a modular manifest.
fn is_modular(&self) -> bool {
self.metadata_url.ends_with("manifest.json")
}
}
/// Discovers the latest snapshot manifest URL for the given chain from the snapshots API.
///
/// Queries the configured snapshot API and returns the manifest URL for the most
/// recent modular snapshot matching the requested chain.
pub(crate) async fn discover_manifest_url(chain_id: u64) -> Result<String> {
let defaults = DownloadDefaults::get_global();
let api_url = &*defaults.snapshot_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- {}\n\n\
Use --list to see all available snapshots.",
api_url.trim_end_matches("/api/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")),
}
}
/// Fetches the full snapshot listing from the snapshots API, filtered by chain ID.
pub(crate) async fn fetch_snapshot_api_entries(chain_id: u64) -> Result<Vec<SnapshotApiEntry>> {
let api_url = &*DownloadDefaults::get_global().snapshot_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(|entry| entry.chain_id == chain_id).collect())
}
/// Prints a formatted table of available modular snapshots.
pub(crate) fn print_snapshot_listing(entries: &[SnapshotApiEntry], chain_id: u64) {
let modular: Vec<_> = entries.iter().filter(|entry| entry.is_modular()).collect();
let api_url = &*DownloadDefaults::get_global().snapshot_api_url;
println!(
"Available snapshots for chain {chain_id} ({}):\n",
api_url.trim_end_matches("/api/snapshots"),
);
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>"
);
}
/// Loads a manifest from an HTTP(S) URL, `file://` URL, or local path.
pub(crate) async fn fetch_manifest_from_source(source: &str) -> Result<SnapshotManifest> {
if let Ok(parsed) = Url::parse(source) {
return match parsed.scheme() {
"http" | "https" => {
let response = Client::new()
.get(source)
.send()
.await
.and_then(|r| r.error_for_status())
.wrap_err_with(|| {
let sources = DownloadDefaults::get_global()
.available_snapshots
.iter()
.map(|snapshot| format!("\t- {snapshot}"))
.collect::<Vec<_>>()
.join("\n");
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{sources}"
)
})?;
Ok(response.json().await?)
}
"file" => {
let path = parsed
.to_file_path()
.map_err(|_| eyre::eyre!("Invalid file:// manifest path: {source}"))?;
let content = fs::read_to_string(path)?;
Ok(serde_json::from_str(&content)?)
}
_ => Err(eyre::eyre!("Unsupported manifest URL scheme: {}", parsed.scheme())),
};
}
let content = fs::read_to_string(source)?;
Ok(serde_json::from_str(&content)?)
}
/// Resolves the base URL used to join relative archive paths in a manifest.
pub(crate) fn resolve_manifest_base_url(
manifest: &SnapshotManifest,
source: &str,
) -> Result<String> {
if let Some(base_url) = manifest.base_url.as_deref() &&
!base_url.is_empty()
{
return Ok(base_url.trim_end_matches('/').to_string());
}
if let Ok(mut url) = Url::parse(source) {
if url.scheme() == "file" {
let mut path = url
.to_file_path()
.map_err(|_| eyre::eyre!("Invalid file:// manifest path: {source}"))?;
path.pop();
let mut base = Url::from_directory_path(path)
.map_err(|_| eyre::eyre!("Invalid manifest directory for source: {source}"))?
.to_string();
if base.ends_with('/') {
base.pop();
}
return Ok(base);
}
{
let mut segments = url
.path_segments_mut()
.map_err(|_| eyre::eyre!("manifest_url must have a hierarchical path"))?;
segments.pop_if_empty();
segments.pop();
}
return Ok(url.as_str().trim_end_matches('/').to_string());
}
let path = Path::new(source);
let manifest_dir = if path.is_absolute() {
path.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("."))
} else {
let joined = std::env::current_dir()?.join(path);
joined.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("."))
};
let mut base = Url::from_directory_path(&manifest_dir)
.map_err(|_| eyre::eyre!("Invalid manifest directory: {}", manifest_dir.display()))?
.to_string();
if base.ends_with('/') {
base.pop();
}
Ok(base)
}

View File

@@ -262,6 +262,7 @@ impl SelectorApp {
ComponentSelection::None => return 0,
ComponentSelection::All => None,
ComponentSelection::Distance(d) => Some(d),
ComponentSelection::Since(block) => Some(self.manifest.block - block + 1),
};
self.groups[group_idx]
.types
@@ -344,6 +345,7 @@ fn format_selection(sel: &ComponentSelection) -> String {
match sel {
ComponentSelection::All => "All".to_string(),
ComponentSelection::Distance(d) => format!("Last {d} blocks"),
ComponentSelection::Since(block) => format!("Since block {block}"),
ComponentSelection::None => "None".to_string(),
}
}

View File

@@ -0,0 +1,84 @@
use super::{manifest::OutputFileChecksum, progress::ArchiveVerificationProgress};
use blake3::Hasher;
use eyre::Result;
use reth_fs_util as fs;
use std::{io::Read, path::Path};
/// Verifies and cleans up extracted output files in one target directory.
pub(crate) struct OutputVerifier<'a> {
/// Directory containing the output files declared by the manifest.
target_dir: &'a Path,
}
impl<'a> OutputVerifier<'a> {
/// Creates a verifier for one extraction target directory.
pub(crate) const fn new(target_dir: &'a Path) -> Self {
Self { target_dir }
}
/// Returns `true` only when every declared output file exists and matches size and BLAKE3.
/// Returns `false` if any file is missing, mismatched, or no outputs were declared.
pub(crate) fn verify(&self, output_files: &[OutputFileChecksum]) -> Result<bool> {
self.verify_with_progress(output_files, None)
}
/// Returns `true` only when every declared output file exists and matches size and BLAKE3,
/// updating the optional verification progress as file bytes are hashed.
pub(crate) fn verify_with_progress(
&self,
output_files: &[OutputFileChecksum],
mut progress: Option<&mut ArchiveVerificationProgress<'_>>,
) -> Result<bool> {
if output_files.is_empty() {
return Ok(false);
}
for expected in output_files {
let output_path = self.target_dir.join(&expected.path);
let meta = match fs::metadata(&output_path) {
Ok(meta) => meta,
Err(_) => return Ok(false),
};
if meta.len() != expected.size {
return Ok(false);
}
let actual = Self::file_blake3_hex(&output_path, progress.as_deref_mut())?;
if !actual.eq_ignore_ascii_case(&expected.blake3) {
return Ok(false);
}
}
Ok(true)
}
/// Removes any declared output files so a fresh archive attempt can restart cleanly.
pub(crate) fn cleanup(&self, output_files: &[OutputFileChecksum]) {
for output in output_files {
let _ = fs::remove_file(self.target_dir.join(&output.path));
}
}
/// Computes the hex-encoded BLAKE3 checksum for one plain output file.
fn file_blake3_hex(
path: &Path,
mut progress: Option<&mut ArchiveVerificationProgress<'_>>,
) -> Result<String> {
let mut file = fs::open(path)?;
let mut hasher = Hasher::new();
let mut buf = [0_u8; 64 * 1024];
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
if let Some(progress) = progress.as_deref_mut() {
progress.record_verified(n as u64);
}
}
Ok(hasher.finalize().to_hex().to_string())
}
}

View File

@@ -78,9 +78,10 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> InitStateC
self.env.init::<N>(AccessRights::RW, runtime)?;
let static_file_provider = provider_factory.static_file_provider();
let provider_rw = provider_factory.database_provider_rw()?;
if self.without_evm {
let provider_rw = provider_factory.database_provider_rw()?;
// ensure header, total difficulty and header hash are provided
let header = self.header.ok_or_else(|| eyre::eyre!("Header file must be provided"))?;
let header = without_evm::read_header_from_file::<
@@ -106,23 +107,22 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> InitStateC
// SAFETY: it's safe to commit static files, since in the event of a crash, they
// will be unwound according to database checkpoints.
//
// Necessary to commit, so the header is accessible to provider_rw and
// init_state_dump
// Necessary to commit, so the header is accessible to init_from_state_dump
static_file_provider.commit()?;
} else if last_block_number > 0 && last_block_number < header.number() {
return Err(eyre::eyre!(
"Data directory should be empty when calling init-state with --without-evm."
));
}
provider_rw.commit()?;
}
info!(target: "reth::cli", "Initiating state dump");
let reader = BufReader::new(reth_fs_util::open(self.state)?);
let hash = init_from_state_dump(reader, &provider_rw, config.stages.etl)?;
provider_rw.commit()?;
let hash = init_from_state_dump(reader, &provider_factory, config.stages.etl)?;
info!(target: "reth::cli", hash = ?hash, "Genesis block written");
Ok(())

View File

@@ -19,6 +19,7 @@ use reth_provider::{
};
use reth_revm::database::StateProviderDatabase;
use reth_stages::stages::calculate_gas_used_from_headers;
use reth_storage_api::{DBProvider, TryIntoHistoricalStateProvider};
use std::{
sync::{
atomic::{AtomicU64, Ordering},
@@ -112,7 +113,12 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + Hardforks + EthereumHardforks>
let provider_factory = provider_factory.clone();
move |block_number: u64| {
StateProviderDatabase(
provider_factory.history_by_block_number(block_number).unwrap(),
provider_factory
.provider()
.unwrap()
.disable_long_read_transaction_safety()
.try_into_history_at_block(block_number)
.unwrap(),
)
}
};
@@ -138,7 +144,7 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + Hardforks + EthereumHardforks>
let cancellation = cancellation.clone();
let next_block = Arc::clone(&next_block);
tasks.spawn_blocking(move || {
let executor_lifetime = Duration::from_secs(120);
let executor_lifetime = Duration::from_secs(600);
loop {
if cancellation.is_cancelled() {
@@ -245,7 +251,7 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + Hardforks + EthereumHardforks>
let _ = stats_tx.send(block.gas_used());
// Reset DB once in a while to avoid OOM or read tx timeouts
if executor.size_hint() > 1_000_000 ||
if executor.size_hint() > 5_000_000 ||
executor_created.elapsed() > executor_lifetime
{
executor =

View File

@@ -29,7 +29,10 @@ use execution::dump_execution_stage;
mod merkle;
use merkle::dump_merkle_stage;
/// `reth dump-stage` command
/// `reth dump-stage` command.
///
/// Note: mutates the source datadir (unwinds hashing/merkle/execution before copying tables).
/// Stop the node and back up the datadir first.
#[derive(Debug, Parser)]
pub struct Command<C: ChainSpecParser> {
#[command(flatten)]
@@ -100,8 +103,9 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> Command<C>
Comp: CliNodeComponents<N>,
F: FnOnce(Arc<C::ChainSpec>) -> Comp,
{
// `unwind_and_copy` opens a RW provider on the source datadir, so open RW here.
let Environment { provider_factory, .. } =
self.env.init::<N>(AccessRights::RO, runtime.clone())?;
self.env.init::<N>(AccessRights::RW, runtime.clone())?;
let tool = DbTool::new(provider_factory)?;
let components = components(tool.chain());
let evm_config = components.evm_config().clone();

View File

@@ -403,7 +403,7 @@ pub fn validate_against_parent_gas_limit<
})
}
// Check if the self gas limit is below the minimum required limit.
else if header.gas_limit() < MINIMUM_GAS_LIMIT {
if header.gas_limit() < MINIMUM_GAS_LIMIT {
return Err(ConsensusError::GasLimitInvalidMinimum { child_gas_limit: header.gas_limit() })
}

View File

@@ -30,10 +30,16 @@
extern crate alloc;
use alloc::{boxed::Box, fmt::Debug, string::String, sync::Arc, vec::Vec};
use alloc::{
boxed::Box,
fmt::Debug,
string::{String, ToString},
sync::Arc,
vec::Vec,
};
use alloy_consensus::Header;
use alloy_primitives::{BlockHash, BlockNumber, Bloom, B256};
use core::error::Error;
use core::{error::Error, fmt::Display};
/// Pre-computed receipt root and logs bloom.
///
@@ -104,6 +110,18 @@ pub trait Consensus<B: Block>: HeaderValidator<B::Header> {
/// Note: validating blocks does not include other validations of the Consensus
fn validate_block_pre_execution(&self, block: &SealedBlock<B>) -> Result<(), ConsensusError>;
/// Returns `true` if the given consensus error is transient and may resolve on its own.
///
/// On fast chains, clock skew between nodes can cause a valid block's timestamp to
/// appear briefly in the future. Caching such blocks as permanently invalid would
/// prevent them from being re-validated once the local clock catches up.
///
/// Transient errors will not cause the block hash to be cached as permanently invalid,
/// allowing the block to be re-validated later.
fn is_transient_error(&self, _error: &ConsensusError) -> bool {
false
}
/// Validate a block disregarding world state using an optional pre-computed transaction root.
///
/// If `transaction_root` is provided, the implementation should use the pre-computed
@@ -456,19 +474,49 @@ pub enum ConsensusError {
/// EIP-7825: Transaction gas limit exceeds maximum allowed
#[error(transparent)]
TransactionGasLimitTooHigh(Box<TxGasLimitTooHighErr>),
/// Other, likely an injected L2 error.
#[error("{0}")]
Other(String),
/// Other unspecified error.
/// Any additional consensus error, for example L2-specific errors.
#[error(transparent)]
Custom(#[from] Arc<dyn Error + Send + Sync>),
Other(#[from] Arc<dyn Error + Send + Sync>),
}
impl ConsensusError {
/// Returns a new [`ConsensusError::Other`] instance with the given error.
pub fn other<E>(error: E) -> Self
where
E: Error + Send + Sync + 'static,
{
Self::Other(Arc::new(error))
}
/// Returns a new [`ConsensusError::Other`] instance with the given message.
pub fn msg(msg: impl Display) -> Self {
Self::other(MessageError(msg.to_string()))
}
/// Returns `true` if the error is a state root error.
pub const fn is_state_root_error(&self) -> bool {
matches!(self, Self::BodyStateRootDiff(_))
}
/// Returns the arbitrary error if it is [`ConsensusError::Other`].
pub fn as_other(&self) -> Option<&(dyn Error + Send + Sync + 'static)> {
match self {
Self::Other(err) => Some(err.as_ref()),
_ => None,
}
}
/// Returns a reference to the [`ConsensusError::Other`] value if it is of that type.
/// Returns `None` otherwise.
pub fn downcast_other_ref<T: Error + 'static>(&self) -> Option<&T> {
let other = self.as_other()?;
other.downcast_ref()
}
/// Returns `true` if this type is a [`ConsensusError::Other`] of that error type.
pub fn is_other<T: Error + 'static>(&self) -> bool {
self.as_other().map(|err| err.is::<T>()).unwrap_or(false)
}
}
impl From<InvalidTransactionError> for ConsensusError {
@@ -500,6 +548,10 @@ pub struct TxGasLimitTooHighErr {
pub max_allowed: u64,
}
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
struct MessageError(String);
#[cfg(test)]
mod tests {
use super::*;
@@ -509,24 +561,31 @@ mod tests {
struct CustomL2Error;
#[test]
fn test_custom_error_conversion() {
// Test conversion from custom error to ConsensusError
let custom_err = CustomL2Error;
let arc_err: Arc<dyn Error + Send + Sync> = Arc::new(custom_err);
let consensus_err: ConsensusError = arc_err.into();
// Verify it's the Custom variant
assert!(matches!(consensus_err, ConsensusError::Custom(_)));
fn test_other_error_conversion() {
let consensus_err = ConsensusError::other(CustomL2Error);
assert!(matches!(consensus_err, ConsensusError::Other(_)));
}
#[test]
fn test_custom_error_display() {
let custom_err = CustomL2Error;
let arc_err: Arc<dyn Error + Send + Sync> = Arc::new(custom_err);
let consensus_err: ConsensusError = arc_err.into();
// Verify the error message is preserved through transparent attribute
fn test_other_error_display() {
let consensus_err = ConsensusError::other(CustomL2Error);
let error_message = format!("{}", consensus_err);
assert_eq!(error_message, "Custom L2 consensus error");
}
#[test]
fn test_other_error_downcast() {
let consensus_err = ConsensusError::other(CustomL2Error);
assert!(consensus_err.is_other::<CustomL2Error>());
assert!(consensus_err.downcast_other_ref::<CustomL2Error>().is_some());
}
#[test]
fn test_other_msg() {
let consensus_err = ConsensusError::msg("consensus message");
assert_eq!(consensus_err.to_string(), "consensus message");
assert!(consensus_err.downcast_other_ref::<MessageError>().is_some());
}
}

View File

@@ -55,6 +55,8 @@ pub fn generate_test_blocks(chain_spec: &ChainSpec, count: u64) -> Vec<SealedBlo
excess_blob_gas: None,
parent_beacon_block_root: None,
requests_hash: None,
block_access_list_hash: None,
slot_number: None,
};
// Set required fields based on chain spec

View File

@@ -227,6 +227,7 @@ where
suggested_fee_recipient: alloy_primitives::Address::random(),
withdrawals: Some(vec![]),
parent_beacon_block_root: Some(B256::ZERO),
slot_number: None,
};
env.active_node_state_mut()?
@@ -299,6 +300,7 @@ where
suggested_fee_recipient: alloy_primitives::Address::random(),
withdrawals: Some(vec![]),
parent_beacon_block_root: Some(B256::ZERO),
slot_number: None,
};
let fresh_fcu_result = EngineApiClient::<Engine>::fork_choice_updated_v3(

View File

@@ -269,6 +269,7 @@ where
suggested_fee_recipient: alloy_primitives::Address::ZERO,
withdrawals: Some(vec![]),
parent_beacon_block_root: Some(B256::ZERO),
slot_number: None,
};
crate::setup_import::setup_engine_with_chain_import(
@@ -295,6 +296,7 @@ where
suggested_fee_recipient: alloy_primitives::Address::ZERO,
withdrawals: Some(vec![]),
parent_beacon_block_root: Some(B256::ZERO),
slot_number: None,
}
.into()
}

View File

@@ -3,7 +3,8 @@ use alloy_consensus::{
};
use alloy_eips::{eip7594::BlobTransactionSidecarVariant, eip7702::SignedAuthorization};
use alloy_network::{
eip2718::Encodable2718, Ethereum, EthereumWallet, TransactionBuilder, TransactionBuilder4844,
eip2718::Encodable2718, Ethereum, EthereumWallet, NetworkTransactionBuilder,
TransactionBuilder4844,
};
use alloy_primitives::{hex, Address, Bytes, TxKind, B256, U256};
use alloy_rpc_types_eth::{Authorization, TransactionInput, TransactionRequest};
@@ -117,7 +118,8 @@ impl TransactionTestContext {
let mut builder = SidecarBuilder::<SimpleCoder>::new();
builder.ingest(b"dummy blob");
tx.set_blob_sidecar(builder.build()?);
let sidecar: alloy_consensus::BlobTransactionSidecar = builder.build()?;
tx.set_blob_sidecar(alloy_eips::eip7594::BlobTransactionSidecarVariant::Eip4844(sidecar));
tx.set_max_fee_per_blob_gas(15e9 as u128);
let signed = Self::sign_tx(wallet, tx).await;
@@ -127,7 +129,9 @@ impl TransactionTestContext {
/// Signs an arbitrary [`TransactionRequest`] using the provided wallet
pub async fn sign_tx(wallet: PrivateKeySigner, tx: TransactionRequest) -> TxEnvelope {
let signer = EthereumWallet::from(wallet);
<TransactionRequest as TransactionBuilder<Ethereum>>::build(tx, &signer).await.unwrap()
<TransactionRequest as NetworkTransactionBuilder<Ethereum>>::build(tx, &signer)
.await
.unwrap()
}
/// Creates a tx with blob sidecar and sign it, returning bytes
@@ -151,7 +155,7 @@ impl TransactionTestContext {
));
let tx = tx(chain_id, 210000, Some(l1_block_info), None, nonce, Some(20e9 as u128));
let signer = EthereumWallet::from(wallet);
<TransactionRequest as TransactionBuilder<Ethereum>>::build(tx, &signer)
<TransactionRequest as NetworkTransactionBuilder<Ethereum>>::build(tx, &signer)
.await
.unwrap()
.encoded_2718()

View File

@@ -160,6 +160,7 @@ async fn test_testsuite_assert_mine_block() -> Result<()> {
suggested_fee_recipient: Address::random(),
withdrawals: None,
parent_beacon_block_root: None,
slot_number: None,
},
));

View File

@@ -90,6 +90,7 @@ const fn test_attributes_generator(timestamp: u64) -> PayloadAttributes {
suggested_fee_recipient: alloy_primitives::Address::ZERO,
withdrawals: Some(vec![]),
parent_beacon_block_root: Some(B256::ZERO),
slot_number: None,
}
}

View File

@@ -19,6 +19,7 @@ use reth_trie::{
MultiProofTargets, StorageMultiProof, StorageProof, TrieInput,
};
use std::{
fmt,
sync::{
atomic::{AtomicU64, AtomicUsize, Ordering},
Arc,
@@ -147,6 +148,29 @@ pub enum CachedStatus<T> {
Cached(T),
}
/// The source that is using the execution cache.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CachedStateMetricsSource {
/// Engine (validation).
Engine,
/// Payload builder.
Builder,
/// Tests.
#[cfg(any(test, feature = "test-utils"))]
Test,
}
impl fmt::Display for CachedStateMetricsSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Engine => f.write_str("engine"),
Self::Builder => f.write_str("builder"),
#[cfg(any(test, feature = "test-utils"))]
Self::Test => f.write_str("test"),
}
}
}
/// Metrics for the cached state provider, showing hits / misses for each cache
#[derive(Metrics, Clone)]
#[metrics(scope = "sync.caching")]
@@ -222,9 +246,10 @@ impl CachedStateMetrics {
self.account_cache_collisions.set(0);
}
/// Returns a new zeroed-out instance of [`CachedStateMetrics`].
pub fn zeroed() -> Self {
let zeroed = Self::default();
/// Returns a new zeroed-out instance of [`CachedStateMetrics`] with a `source` label
/// to distinguish between different callers (e.g., engine vs builder).
pub fn zeroed(source: CachedStateMetricsSource) -> Self {
let zeroed = Self::new_with_labels(&[("source", source.to_string())]);
zeroed.reset();
zeroed
}
@@ -566,8 +591,9 @@ impl<S: StateProofProvider, const PREWARM: bool> StateProofProvider
&self,
input: TrieInput,
target: HashedPostState,
mode: reth_trie::ExecutionWitnessMode,
) -> ProviderResult<Vec<alloy_primitives::Bytes>> {
self.state_provider.witness(input, target)
self.state_provider.witness(input, target, mode)
}
}
@@ -918,27 +944,15 @@ pub struct SavedCache {
/// The caches used for the provider.
caches: ExecutionCache,
/// Metrics for the cached state provider (includes size/capacity/collisions from fixed-cache)
metrics: CachedStateMetrics,
/// A guard to track in-flight usage of this cache.
/// The cache is considered available if the strong count is 1.
usage_guard: Arc<()>,
/// Whether to skip cache metrics recording (can be expensive with large cached state).
disable_cache_metrics: bool,
}
impl SavedCache {
/// Creates a new instance with the internals
pub fn new(hash: B256, caches: ExecutionCache, metrics: CachedStateMetrics) -> Self {
Self { hash, caches, metrics, usage_guard: Arc::new(()), disable_cache_metrics: false }
}
/// Sets whether to disable cache metrics recording.
pub const fn with_disable_cache_metrics(mut self, disable: bool) -> Self {
self.disable_cache_metrics = disable;
self
pub fn new(hash: B256, caches: ExecutionCache) -> Self {
Self { hash, caches, usage_guard: Arc::new(()) }
}
/// Returns the hash for this cache
@@ -946,11 +960,6 @@ impl SavedCache {
self.hash
}
/// Splits the cache into its caches, metrics, and `disable_cache_metrics` flag, consuming it.
pub fn split(self) -> (ExecutionCache, CachedStateMetrics, bool) {
(self.caches, self.metrics, self.disable_cache_metrics)
}
/// Returns true if the cache is available for use (no other tasks are currently using it).
pub fn is_available(&self) -> bool {
Arc::strong_count(&self.usage_guard) == 1
@@ -966,20 +975,11 @@ impl SavedCache {
&self.caches
}
/// Returns the metrics associated with this cache.
pub const fn metrics(&self) -> &CachedStateMetrics {
&self.metrics
}
/// Updates the cache metrics (size/capacity/collisions) from the stats handlers.
///
/// Note: This can be expensive with large cached state. Use
/// `with_disable_cache_metrics(true)` to skip.
pub fn update_metrics(&self) {
if self.disable_cache_metrics {
return
pub fn update_metrics(&self, metrics: Option<&CachedStateMetrics>) {
if let Some(metrics) = metrics {
self.caches.update_metrics(metrics);
}
self.caches.update_metrics(&self.metrics);
}
/// Clears all caches, resetting them to empty state,
@@ -1016,8 +1016,11 @@ mod tests {
provider.extend_accounts(vec![(address, account)]);
let caches = ExecutionCache::new(1000);
let state_provider =
CachedStateProvider::new(provider, caches, CachedStateMetrics::zeroed());
let state_provider = CachedStateProvider::new(
provider,
caches,
CachedStateMetrics::zeroed(CachedStateMetricsSource::Test),
);
let res = state_provider.storage(address, storage_key);
assert!(res.is_ok());
@@ -1036,8 +1039,11 @@ mod tests {
provider.extend_accounts(vec![(address, account)]);
let caches = ExecutionCache::new(1000);
let state_provider =
CachedStateProvider::new(provider, caches, CachedStateMetrics::zeroed());
let state_provider = CachedStateProvider::new(
provider,
caches,
CachedStateMetrics::zeroed(CachedStateMetricsSource::Test),
);
let res = state_provider.storage(address, storage_key);
assert!(res.is_ok());
@@ -1074,7 +1080,7 @@ mod tests {
#[test]
fn test_saved_cache_is_available() {
let execution_cache = ExecutionCache::new(1000);
let cache = SavedCache::new(B256::ZERO, execution_cache, CachedStateMetrics::zeroed());
let cache = SavedCache::new(B256::ZERO, execution_cache);
assert!(cache.is_available(), "Cache should be available initially");
@@ -1086,8 +1092,7 @@ mod tests {
#[test]
fn test_saved_cache_multiple_references() {
let execution_cache = ExecutionCache::new(1000);
let cache =
SavedCache::new(B256::from([2u8; 32]), execution_cache, CachedStateMetrics::zeroed());
let cache = SavedCache::new(B256::from([2u8; 32]), execution_cache);
let guard1 = cache.clone_guard_for_test();
let guard2 = cache.clone_guard_for_test();

View File

@@ -165,11 +165,7 @@ mod tests {
let hash = B256::from([1u8; 32]);
cache.update_with_guard(|slot| {
*slot = Some(SavedCache::new(
hash,
ExecutionCache::new(1_000),
CachedStateMetrics::zeroed(),
))
*slot = Some(SavedCache::new(hash, ExecutionCache::new(1_000)))
});
let first = cache.get_cache_for(hash);
@@ -185,11 +181,7 @@ mod tests {
let hash = B256::from([2u8; 32]);
cache.update_with_guard(|slot| {
*slot = Some(SavedCache::new(
hash,
ExecutionCache::new(1_000),
CachedStateMetrics::zeroed(),
))
*slot = Some(SavedCache::new(hash, ExecutionCache::new(1_000)))
});
let checked_out = cache.get_cache_for(hash);
@@ -207,11 +199,7 @@ mod tests {
let hash_b = B256::from([0xBB; 32]);
cache.update_with_guard(|slot| {
*slot = Some(SavedCache::new(
hash_a,
ExecutionCache::new(1_000),
CachedStateMetrics::zeroed(),
))
*slot = Some(SavedCache::new(hash_a, ExecutionCache::new(1_000)))
});
let checked_out = cache.get_cache_for(hash_b);

View File

@@ -160,7 +160,11 @@ fn generate(
hashed_state: reth_trie::HashedPostState,
state_provider: Box<dyn StateProvider>,
) -> eyre::Result<ExecutionWitness> {
let state = state_provider.witness(Default::default(), hashed_state)?;
let state = state_provider.witness(
Default::default(),
hashed_state,
reth_trie::ExecutionWitnessMode::Legacy,
)?;
Ok(ExecutionWitness {
state,
codes: codes.into_values().collect(),
@@ -239,6 +243,7 @@ where
DebugApiClient::<()>::debug_execution_witness(
healthy_node_client,
block_number.into(),
None,
)
.await
})?;

View File

@@ -141,6 +141,11 @@ pub struct LocalMiner<T: PayloadTypes, B, Pool: TransactionPool + Unpin> {
last_header: SealedHeaderFor<<T::BuiltPayload as BuiltPayload>::Primitives>,
/// Stores latest mined blocks.
last_block_hashes: VecDeque<B256>,
/// Optional sleep duration between initiating payload building and resolving.
///
/// When set, the miner sleeps after `fork_choice_updated` before calling
/// `resolve_kind`, giving the payload job time for multiple rebuild attempts.
payload_wait_time: Option<Duration>,
}
impl<T, B, Pool> LocalMiner<T, B, Pool>
@@ -170,9 +175,16 @@ where
payload_builder,
last_block_hashes: VecDeque::from([last_header.hash()]),
last_header,
payload_wait_time: None,
}
}
/// Sets the payload wait time, if any.
pub const fn with_payload_wait_time_opt(mut self, wait_time: Option<Duration>) -> Self {
self.payload_wait_time = wait_time;
self
}
/// Runs the [`LocalMiner`] in a loop, polling the miner and building payloads.
pub async fn run(mut self) {
let mut fcu_interval = tokio::time::interval(Duration::from_secs(1));
@@ -238,6 +250,10 @@ where
let payload_id = res.payload_id.ok_or_eyre("No payload id")?;
if let Some(wait_time) = self.payload_wait_time {
tokio::time::sleep(wait_time).await;
}
let Some(Ok(payload)) =
self.payload_builder.resolve_kind(payload_id, PayloadKind::WaitForPending).await
else {

View File

@@ -57,6 +57,7 @@ where
.chain_spec
.is_cancun_active_at_timestamp(timestamp)
.then(B256::random),
slot_number: self.chain_spec.is_amsterdam_active_at_timestamp(timestamp).then_some(0),
}
}
}

View File

@@ -15,6 +15,9 @@ pub const DEFAULT_MEMORY_BLOCK_BUFFER_TARGET: u64 = 0;
/// The size of proof targets chunk to spawn in one multiproof calculation.
pub const DEFAULT_MULTIPROOF_TASK_CHUNK_SIZE: usize = 5;
/// Default number of cache hits before an invalid header entry is evicted and reprocessed.
pub const DEFAULT_INVALID_HEADER_HIT_EVICTION_THRESHOLD: u8 = 128;
/// Gas threshold below which the small block chunk size is used.
pub const SMALL_BLOCK_GAS_THRESHOLD: u64 = 20_000_000;
@@ -102,6 +105,11 @@ pub struct TreeConfig {
block_buffer_limit: u32,
/// Number of invalid headers to keep in cache.
max_invalid_header_cache_length: u32,
/// Number of cache hits before an invalid header entry is evicted and reprocessed.
///
/// Setting this to `0` effectively disables the cache because entries are evicted on the
/// first lookup.
invalid_header_hit_eviction_threshold: u8,
/// Maximum number of blocks to execute sequentially in a batch.
///
/// This is used as a cutoff to prevent long-running sequential block execution when we receive
@@ -170,6 +178,23 @@ pub struct TreeConfig {
share_execution_cache_with_payload_builder: bool,
/// Whether to share sparse trie with the payload builder.
share_sparse_trie_with_payload_builder: bool,
/// Whether to suppress persistence cycles while building a payload.
///
/// When enabled, persistence is deferred from the moment an FCU with payload attributes
/// arrives until the next FCU without attributes. This avoids persistence I/O competing
/// with block building on latency-sensitive chains.
suppress_persistence_during_build: bool,
/// Whether to disable BAL (Block Access List, EIP-7928) based parallel execution.
/// When disabled, falls back to transaction-based prewarming even when a BAL is available.
disable_bal_parallel_execution: bool,
/// Whether to disable BAL-driven parallel state root computation.
/// When disabled, the BAL hashed post state is not sent to the multiproof task for
/// early parallel state root computation.
disable_bal_parallel_state_root: bool,
/// Whether to disable BAL (Block Access List) batched IO during prewarming.
/// When disabled, falls back to individual per-slot storage reads instead of
/// batched cursor reads via `storage_range`.
disable_bal_batch_io: bool,
/// 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.
@@ -189,6 +214,7 @@ impl Default for TreeConfig {
persistence_backpressure_threshold: DEFAULT_PERSISTENCE_BACKPRESSURE_THRESHOLD,
block_buffer_limit: DEFAULT_BLOCK_BUFFER_LIMIT,
max_invalid_header_cache_length: DEFAULT_MAX_INVALID_HEADER_CACHE_LENGTH,
invalid_header_hit_eviction_threshold: DEFAULT_INVALID_HEADER_HIT_EVICTION_THRESHOLD,
max_execute_block_batch_size: DEFAULT_MAX_EXECUTE_BLOCK_BATCH_SIZE,
legacy_state_root: false,
always_compare_trie_updates: false,
@@ -212,6 +238,10 @@ impl Default for TreeConfig {
state_root_task_timeout: Some(DEFAULT_STATE_ROOT_TASK_TIMEOUT),
share_execution_cache_with_payload_builder: false,
share_sparse_trie_with_payload_builder: false,
suppress_persistence_during_build: false,
disable_bal_parallel_execution: false,
disable_bal_parallel_state_root: false,
disable_bal_batch_io: false,
#[cfg(feature = "trie-debug")]
proof_jitter: None,
}
@@ -227,6 +257,7 @@ impl TreeConfig {
persistence_backpressure_threshold: u64,
block_buffer_limit: u32,
max_invalid_header_cache_length: u32,
invalid_header_hit_eviction_threshold: u8,
max_execute_block_batch_size: usize,
legacy_state_root: bool,
always_compare_trie_updates: bool,
@@ -260,6 +291,7 @@ impl TreeConfig {
persistence_backpressure_threshold,
block_buffer_limit,
max_invalid_header_cache_length,
invalid_header_hit_eviction_threshold,
max_execute_block_batch_size,
legacy_state_root,
always_compare_trie_updates,
@@ -283,6 +315,10 @@ impl TreeConfig {
state_root_task_timeout,
share_execution_cache_with_payload_builder,
share_sparse_trie_with_payload_builder,
suppress_persistence_during_build: false,
disable_bal_parallel_execution: false,
disable_bal_parallel_state_root: false,
disable_bal_batch_io: false,
#[cfg(feature = "trie-debug")]
proof_jitter: None,
}
@@ -313,6 +349,14 @@ impl TreeConfig {
self.max_invalid_header_cache_length
}
/// Return the invalid header cache hit eviction threshold.
///
/// Setting this to `0` effectively disables the cache because entries are evicted on the
/// first lookup.
pub const fn invalid_header_hit_eviction_threshold(&self) -> u8 {
self.invalid_header_hit_eviction_threshold
}
/// Return the maximum execute block batch size.
pub const fn max_execute_block_batch_size(&self) -> usize {
self.max_execute_block_batch_size
@@ -443,6 +487,15 @@ impl TreeConfig {
self
}
/// Setter for the invalid header cache hit eviction threshold.
pub const fn with_invalid_header_hit_eviction_threshold(
mut self,
invalid_header_hit_eviction_threshold: u8,
) -> Self {
self.invalid_header_hit_eviction_threshold = invalid_header_hit_eviction_threshold;
self
}
/// Setter for maximum execute block batch size.
pub const fn with_max_execute_block_batch_size(
mut self,
@@ -646,6 +699,56 @@ impl TreeConfig {
self
}
/// Returns whether persistence is suppressed during payload building.
pub const fn suppress_persistence_during_build(&self) -> bool {
self.suppress_persistence_during_build
}
/// Setter for whether to suppress persistence during payload building.
pub const fn with_suppress_persistence_during_build(mut self, value: bool) -> Self {
self.suppress_persistence_during_build = value;
self
}
/// Returns whether BAL-based parallel execution is disabled.
pub const fn disable_bal_parallel_execution(&self) -> bool {
self.disable_bal_parallel_execution
}
/// Setter for whether to disable BAL-based parallel execution.
pub const fn without_bal_parallel_execution(
mut self,
disable_bal_parallel_execution: bool,
) -> Self {
self.disable_bal_parallel_execution = disable_bal_parallel_execution;
self
}
/// Returns whether BAL-driven parallel state root computation is disabled.
pub const fn disable_bal_parallel_state_root(&self) -> bool {
self.disable_bal_parallel_state_root
}
/// Setter for whether to disable BAL-driven parallel state root computation.
pub const fn without_bal_parallel_state_root(
mut self,
disable_bal_parallel_state_root: bool,
) -> Self {
self.disable_bal_parallel_state_root = disable_bal_parallel_state_root;
self
}
/// Returns whether BAL batched IO is disabled.
pub const fn disable_bal_batch_io(&self) -> bool {
self.disable_bal_batch_io
}
/// Setter for whether to disable BAL batched IO.
pub const fn without_bal_batch_io(mut self, disable_bal_batch_io: bool) -> Self {
self.disable_bal_batch_io = disable_bal_batch_io;
self
}
/// Returns the proof jitter duration, if configured (trie-debug only).
#[cfg(feature = "trie-debug")]
pub const fn proof_jitter(&self) -> Option<Duration> {

View File

@@ -41,7 +41,7 @@ reth-trie-db.workspace = true
alloy-evm.workspace = true
alloy-consensus.workspace = true
alloy-eips.workspace = true
alloy-eip7928.workspace = true
alloy-eip7928 = { workspace = true, features = ["rlp"] }
alloy-primitives.workspace = true
alloy-rlp.workspace = true
alloy-rpc-types-engine.workspace = true
@@ -60,6 +60,7 @@ metrics.workspace = true
reth-metrics = { workspace = true, features = ["common"] }
# misc
indexmap.workspace = true
schnellru.workspace = true
rayon.workspace = true
tracing.workspace = true

View File

@@ -1,6 +1,7 @@
use crate::tree::metrics::BlockBufferMetrics;
use alloy_consensus::BlockHeader;
use alloy_primitives::{BlockHash, BlockNumber};
use indexmap::IndexSet;
use reth_primitives_traits::{Block, SealedBlock};
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
@@ -22,7 +23,7 @@ pub struct BlockBuffer<B: Block> {
/// Map of any parent block hash (even the ones not currently in the buffer)
/// to the buffered children.
/// Allows connecting buffered blocks by parent.
pub(crate) parent_to_child: HashMap<BlockHash, HashSet<BlockHash>>,
pub(crate) parent_to_child: HashMap<BlockHash, IndexSet<BlockHash>>,
/// `BTreeMap` tracking the earliest blocks by block number.
/// Used for removal of old blocks that precede finalization.
pub(crate) earliest_blocks: BTreeMap<BlockNumber, HashSet<BlockHash>>,
@@ -139,7 +140,7 @@ impl<B: Block> BlockBuffer<B> {
fn remove_from_parent(&mut self, parent_hash: BlockHash, hash: &BlockHash) {
// remove from parent to child connection, but only for this block parent.
if let Some(entry) = self.parent_to_child.get_mut(&parent_hash) {
entry.remove(hash);
entry.swap_remove(hash);
// if set is empty remove block entry.
if entry.is_empty() {
self.parent_to_child.remove(&parent_hash);

View File

@@ -237,8 +237,9 @@ impl<S: StateProofProvider> StateProofProvider for InstrumentedStateProvider<S>
&self,
input: TrieInput,
target: HashedPostState,
mode: reth_trie::ExecutionWitnessMode,
) -> ProviderResult<Vec<alloy_primitives::Bytes>> {
self.state_provider.witness(input, target)
self.state_provider.witness(input, target, mode)
}
}

View File

@@ -8,25 +8,28 @@ use schnellru::{ByLength, LruMap};
use std::fmt::Debug;
use tracing::warn;
/// The max hit counter for invalid headers in the cache before it is forcefully evicted.
///
/// In other words, if a header is referenced more than this number of times, it will be evicted to
/// allow for reprocessing.
const INVALID_HEADER_HIT_EVICTION_THRESHOLD: u8 = 128;
/// Keeps track of invalid headers.
#[derive(Debug)]
pub struct InvalidHeaderCache {
/// This maps a header hash to a reference to its invalid ancestor.
headers: LruMap<B256, HeaderEntry>,
/// Number of cache hits before an invalid header entry is evicted and reprocessed.
hit_eviction_threshold: u8,
/// Metrics for the cache.
metrics: InvalidHeaderCacheMetrics,
}
impl InvalidHeaderCache {
/// Invalid header cache constructor.
pub fn new(max_length: u32) -> Self {
Self { headers: LruMap::new(ByLength::new(max_length)), metrics: Default::default() }
///
/// Setting `hit_eviction_threshold` to `0` effectively disables the cache because entries are
/// evicted on the first lookup.
pub fn new(max_length: u32, hit_eviction_threshold: u8) -> Self {
Self {
headers: LruMap::new(ByLength::new(max_length)),
hit_eviction_threshold,
metrics: Default::default(),
}
}
fn insert_entry(&mut self, hash: B256, header: BlockWithParent) {
@@ -41,7 +44,7 @@ impl InvalidHeaderCache {
{
let entry = self.headers.get(hash)?;
entry.hit_count += 1;
if entry.hit_count < INVALID_HEADER_HIT_EVICTION_THRESHOLD {
if entry.hit_count < self.hit_eviction_threshold {
return Some(entry.header)
}
}
@@ -110,17 +113,28 @@ mod tests {
#[test]
fn test_hit_eviction() {
let mut cache = InvalidHeaderCache::new(10);
let hit_eviction_threshold = 3;
let mut cache = InvalidHeaderCache::new(10, hit_eviction_threshold);
let header = Header::default();
let header = SealedHeader::seal_slow(header);
cache.insert(header.block_with_parent());
assert_eq!(cache.headers.get(&header.hash()).unwrap().hit_count, 0);
for hit in 1..INVALID_HEADER_HIT_EVICTION_THRESHOLD {
for hit in 1..hit_eviction_threshold {
assert!(cache.get(&header.hash()).is_some());
assert_eq!(cache.headers.get(&header.hash()).unwrap().hit_count, hit);
}
assert!(cache.get(&header.hash()).is_none());
}
#[test]
fn test_zero_hit_eviction_threshold_effectively_disables_cache() {
let mut cache = InvalidHeaderCache::new(10, 0);
let header = SealedHeader::seal_slow(Header::default());
cache.insert(header.block_with_parent());
assert!(cache.get(&header.hash()).is_none());
assert_eq!(cache.headers.len(), 0);
}
}

View File

@@ -11,7 +11,7 @@ use alloy_primitives::B256;
use alloy_rpc_types_engine::{
ForkchoiceState, PayloadStatus, PayloadStatusEnum, PayloadValidationError,
};
use error::{InsertBlockError, InsertBlockFatalError};
use error::{InsertBlockError, InsertBlockFatalError, InsertBlockValidationError};
use reth_chain_state::{
CanonicalInMemoryState, ComputedTrieData, ExecutedBlock, ExecutionTimingStats,
MemoryOverlayStateProvider, NewCanonicalChain,
@@ -71,7 +71,8 @@ pub use payload_validator::{BasicEngineValidator, EngineValidator};
pub use persistence_state::PersistenceState;
pub use reth_engine_primitives::TreeConfig;
pub use reth_execution_cache::{
CachedStateMetrics, CachedStateProvider, ExecutionCache, PayloadExecutionCache, SavedCache,
CachedStateMetrics, CachedStateMetricsSource, CachedStateProvider, ExecutionCache,
PayloadExecutionCache, SavedCache,
};
pub mod state;
@@ -150,11 +151,15 @@ impl<N: NodePrimitives> EngineApiTreeState<N> {
fn new(
block_buffer_limit: u32,
max_invalid_header_cache_length: u32,
invalid_header_hit_eviction_threshold: u8,
canonical_block: BlockNumHash,
engine_kind: EngineApiKind,
) -> Self {
Self {
invalid_headers: InvalidHeaderCache::new(max_invalid_header_cache_length),
invalid_headers: InvalidHeaderCache::new(
max_invalid_header_cache_length,
invalid_header_hit_eviction_threshold,
),
buffer: BlockBuffer::new(block_buffer_limit),
tree_state: TreeState::new(canonical_block, engine_kind),
forkchoice_state_tracker: ForkchoiceStateTracker::default(),
@@ -304,6 +309,9 @@ where
/// 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>>,
/// Set when an FCU with payload attributes is received, cleared on the next FCU without.
/// Suppresses persistence cycles during payload building.
building_payload: bool,
/// Task runtime for spawning blocking work on named, reusable threads.
runtime: reth_tasks::Runtime,
}
@@ -395,6 +403,7 @@ where
evm_config,
changeset_cache,
execution_timing_stats: HashMap::new(),
building_payload: false,
runtime,
}
}
@@ -431,6 +440,7 @@ where
let state = EngineApiTreeState::new(
config.block_buffer_limit(),
config.max_invalid_header_cache_length(),
config.invalid_header_hit_eviction_threshold(),
header.num_hash(),
kind,
);
@@ -1111,6 +1121,8 @@ where
) -> ProviderResult<TreeOutcome<OnForkChoiceUpdated>> {
trace!(target: "engine::tree", ?attrs, "invoked forkchoice update");
self.building_payload = attrs.is_some() && self.config.suppress_persistence_during_build();
// Record metrics
self.record_forkchoice_metrics();
@@ -2004,9 +2016,13 @@ where
}
/// Returns true if the canonical chain length minus the last persisted
/// block is greater than or equal to the persistence threshold and
/// backfill is not running.
/// block is greater than or equal to the persistence threshold,
/// backfill is not running, and no payload is currently being built.
pub const fn should_persist(&self) -> bool {
if self.building_payload {
return false
}
if !self.backfill_sync_state.is_idle() {
// can't persist if backfill is running
return false
@@ -3008,8 +3024,14 @@ where
);
let latest_valid_hash = self.latest_valid_hash_for_invalid_payload(block.parent_hash())?;
// keep track of the invalid header
self.state.invalid_headers.insert(block.block_with_parent());
// keep track of the invalid header unless the consensus impl considers it transient
let is_transient = match &validation_err {
InsertBlockValidationError::Consensus(err) => self.consensus.is_transient_error(err),
_ => false,
};
if !is_transient {
self.state.invalid_headers.insert(block.block_with_parent());
}
self.emit_event(EngineApiEvent::BeaconConsensus(ConsensusEngineEvent::InvalidBlock(
Box::new(block),
)));

View File

@@ -1,539 +0,0 @@
//! BAL (Block Access List, EIP-7928) related functionality.
use alloy_consensus::constants::KECCAK_EMPTY;
use alloy_eip7928::BlockAccessList;
use alloy_primitives::{keccak256, Address, StorageKey, U256};
use reth_primitives_traits::Account;
use reth_provider::{AccountReader, ProviderError};
use reth_trie::{HashedPostState, HashedStorage};
use std::ops::Range;
/// Returns the total number of storage slots (both changed and read-only) across all accounts in
/// the BAL.
pub fn total_slots(bal: &BlockAccessList) -> usize {
bal.iter().map(|account| account.storage_changes.len() + account.storage_reads.len()).sum()
}
/// Iterator over storage slots in a [`BlockAccessList`], with range-based filtering.
///
/// Iterates over all `(Address, StorageKey)` pairs representing both changed and read-only
/// storage slots across all accounts in the BAL. For each account, changed slots are iterated
/// first, followed by read-only slots. The iterator intelligently skips accounts and slots
/// outside the specified range for efficient traversal.
#[derive(Debug)]
pub struct BALSlotIter<'a> {
bal: &'a BlockAccessList,
range: Range<usize>,
current_index: usize,
account_idx: usize,
/// Index within the current account's combined slots (changed + read-only).
/// If `slot_idx < storage_changes.len()`, we're in changed slots.
/// Otherwise, we're in read-only slots at index `slot_idx - storage_changes.len()`.
slot_idx: usize,
}
impl<'a> BALSlotIter<'a> {
/// Creates a new iterator over storage slots within the specified range.
pub fn new(bal: &'a BlockAccessList, range: Range<usize>) -> Self {
let mut iter = Self { bal, range, current_index: 0, account_idx: 0, slot_idx: 0 };
iter.skip_to_range_start();
iter
}
/// Skips to the first item within the range.
fn skip_to_range_start(&mut self) {
while self.account_idx < self.bal.len() {
let account = &self.bal[self.account_idx];
let slots_in_account = account.storage_changes.len() + account.storage_reads.len();
// Check if this account contains items in our range
let account_end = self.current_index + slots_in_account;
if account_end <= self.range.start {
// Entire account is before range, skip it
self.current_index = account_end;
self.account_idx += 1;
self.slot_idx = 0;
} else if self.current_index < self.range.start {
// Range starts somewhere in this account
let skip_slots = self.range.start - self.current_index;
self.slot_idx = skip_slots;
self.current_index = self.range.start;
break;
} else {
// We're at or past range start
break;
}
}
}
}
impl<'a> Iterator for BALSlotIter<'a> {
type Item = (Address, StorageKey);
fn next(&mut self) -> Option<Self::Item> {
// Check if we've exceeded the range
if self.current_index >= self.range.end {
return None;
}
// Find the next valid slot
while self.account_idx < self.bal.len() {
let account = &self.bal[self.account_idx];
let changed_len = account.storage_changes.len();
let total_len = changed_len + account.storage_reads.len();
if self.slot_idx < total_len {
let address = account.address;
let slot = if self.slot_idx < changed_len {
// We're in changed slots
account.storage_changes[self.slot_idx].slot
} else {
// We're in read-only slots
account.storage_reads[self.slot_idx - changed_len]
};
self.slot_idx += 1;
self.current_index += 1;
// Check if we've reached the end of range
if self.current_index > self.range.end {
return None;
}
return Some((address, StorageKey::from(slot)));
}
// Move to next account
self.account_idx += 1;
self.slot_idx = 0;
}
None
}
}
/// Converts a Block Access List into a [`HashedPostState`] by extracting the final state
/// of modified accounts and storage slots.
pub(crate) fn bal_to_hashed_post_state<P>(
bal: &BlockAccessList,
provider: P,
) -> Result<HashedPostState, ProviderError>
where
P: AccountReader,
{
let mut hashed_state = HashedPostState::with_capacity(bal.len());
for account_changes in bal {
let address = account_changes.address;
// Always fetch the account; even if we don't need the db account to construct the final
// `Account`, doing this fills the cache.
let existing_account = provider.basic_account(&address)?;
// Get the latest balance (last balance change if any)
let balance = account_changes.balance_changes.last().map(|change| change.post_balance);
// Get the latest nonce (last nonce change if any)
let nonce = account_changes.nonce_changes.last().map(|change| change.new_nonce);
// Get the latest code (last code change if any)
let code_hash = if let Some(code_change) = account_changes.code_changes.last() {
if code_change.new_code.is_empty() {
Some(Some(KECCAK_EMPTY))
} else {
Some(Some(keccak256(&code_change.new_code)))
}
} else {
None
};
// If the account was only read then don't add it to the HashedPostState
if balance.is_none() &&
nonce.is_none() &&
code_hash.is_none() &&
account_changes.storage_changes.is_empty()
{
continue
}
// Build the final account state
let account = Account {
balance: balance.unwrap_or_else(|| {
existing_account.as_ref().map(|acc| acc.balance).unwrap_or(U256::ZERO)
}),
nonce: nonce
.unwrap_or_else(|| existing_account.as_ref().map(|acc| acc.nonce).unwrap_or(0)),
bytecode_hash: code_hash.unwrap_or_else(|| {
existing_account.as_ref().and_then(|acc| acc.bytecode_hash).or(Some(KECCAK_EMPTY))
}),
};
let hashed_address = keccak256(address);
hashed_state.accounts.insert(hashed_address, Some(account));
// Process storage changes
if !account_changes.storage_changes.is_empty() {
let mut storage_map = HashedStorage::new(false);
for slot_changes in &account_changes.storage_changes {
let hashed_slot = keccak256(slot_changes.slot.to_be_bytes::<32>());
// Get the last change for this slot
if let Some(last_change) = slot_changes.changes.last() {
storage_map.storage.insert(hashed_slot, last_change.new_value);
}
}
hashed_state.storages.insert(hashed_address, storage_map);
}
}
Ok(hashed_state)
}
#[cfg(test)]
mod tests {
use super::*;
use alloy_eip7928::{
AccountChanges, BalanceChange, CodeChange, NonceChange, SlotChanges, StorageChange,
};
use alloy_primitives::{Address, Bytes, StorageKey, B256};
use reth_revm::test_utils::StateProviderTest;
#[test]
fn test_bal_to_hashed_post_state_basic() {
let provider = StateProviderTest::default();
let address = Address::random();
let account_changes = AccountChanges {
address,
storage_changes: vec![],
storage_reads: vec![],
balance_changes: vec![BalanceChange::new(0, U256::from(100))],
nonce_changes: vec![NonceChange::new(0, 1)],
code_changes: vec![],
};
let bal = vec![account_changes];
let result = bal_to_hashed_post_state(&bal, &provider).unwrap();
assert_eq!(result.accounts.len(), 1);
let hashed_address = keccak256(address);
let account_opt = result.accounts.get(&hashed_address).unwrap();
assert!(account_opt.is_some());
let account = account_opt.as_ref().unwrap();
assert_eq!(account.balance, U256::from(100));
assert_eq!(account.nonce, 1);
assert_eq!(account.bytecode_hash, Some(KECCAK_EMPTY));
}
#[test]
fn test_bal_with_storage_changes() {
let provider = StateProviderTest::default();
let address = Address::random();
let slot = U256::random();
let value = U256::random();
let slot_changes = SlotChanges { slot, changes: vec![StorageChange::new(0, value)] };
let account_changes = AccountChanges {
address,
storage_changes: vec![slot_changes],
storage_reads: vec![],
balance_changes: vec![BalanceChange::new(0, U256::from(500))],
nonce_changes: vec![NonceChange::new(0, 2)],
code_changes: vec![],
};
let bal = vec![account_changes];
let result = bal_to_hashed_post_state(&bal, &provider).unwrap();
let hashed_address = keccak256(address);
assert!(result.storages.contains_key(&hashed_address));
let storage = result.storages.get(&hashed_address).unwrap();
let hashed_slot = keccak256(slot.to_be_bytes::<32>());
let stored_value = storage.storage.get(&hashed_slot).unwrap();
assert_eq!(*stored_value, value);
}
#[test]
fn test_bal_with_code_change() {
let provider = StateProviderTest::default();
let address = Address::random();
let code = Bytes::from(vec![0x60, 0x80, 0x60, 0x40]); // Some bytecode
let account_changes = AccountChanges {
address,
storage_changes: vec![],
storage_reads: vec![],
balance_changes: vec![BalanceChange::new(0, U256::from(1000))],
nonce_changes: vec![NonceChange::new(0, 1)],
code_changes: vec![CodeChange::new(0, code.clone())],
};
let bal = vec![account_changes];
let result = bal_to_hashed_post_state(&bal, &provider).unwrap();
let hashed_address = keccak256(address);
let account_opt = result.accounts.get(&hashed_address).unwrap();
let account = account_opt.as_ref().unwrap();
let expected_code_hash = keccak256(&code);
assert_eq!(account.bytecode_hash, Some(expected_code_hash));
}
#[test]
fn test_bal_with_empty_code() {
let provider = StateProviderTest::default();
let address = Address::random();
let empty_code = Bytes::default();
let account_changes = AccountChanges {
address,
storage_changes: vec![],
storage_reads: vec![],
balance_changes: vec![BalanceChange::new(0, U256::from(1000))],
nonce_changes: vec![NonceChange::new(0, 1)],
code_changes: vec![CodeChange::new(0, empty_code)],
};
let bal = vec![account_changes];
let result = bal_to_hashed_post_state(&bal, &provider).unwrap();
let hashed_address = keccak256(address);
let account_opt = result.accounts.get(&hashed_address).unwrap();
let account = account_opt.as_ref().unwrap();
assert_eq!(account.bytecode_hash, Some(KECCAK_EMPTY));
}
#[test]
fn test_bal_multiple_changes_takes_last() {
let provider = StateProviderTest::default();
let address = Address::random();
// Multiple balance changes - should take the last one
let account_changes = AccountChanges {
address,
storage_changes: vec![],
storage_reads: vec![],
balance_changes: vec![
BalanceChange::new(0, U256::from(100)),
BalanceChange::new(1, U256::from(200)),
BalanceChange::new(2, U256::from(300)),
],
nonce_changes: vec![
NonceChange::new(0, 1),
NonceChange::new(1, 2),
NonceChange::new(2, 3),
],
code_changes: vec![],
};
let bal = vec![account_changes];
let result = bal_to_hashed_post_state(&bal, &provider).unwrap();
let hashed_address = keccak256(address);
let account_opt = result.accounts.get(&hashed_address).unwrap();
let account = account_opt.as_ref().unwrap();
// Should have the last values
assert_eq!(account.balance, U256::from(300));
assert_eq!(account.nonce, 3);
}
#[test]
fn test_bal_uses_provider_for_missing_fields() {
let mut provider = StateProviderTest::default();
let address = Address::random();
let code_hash = B256::random();
let existing_account =
Account { balance: U256::from(999), nonce: 42, bytecode_hash: Some(code_hash) };
provider.insert_account(address, existing_account, None, Default::default());
// Only change balance, nonce and code should come from provider
let account_changes = AccountChanges {
address,
storage_changes: vec![],
storage_reads: vec![],
balance_changes: vec![BalanceChange::new(0, U256::from(1500))],
nonce_changes: vec![],
code_changes: vec![],
};
let bal = vec![account_changes];
let result = bal_to_hashed_post_state(&bal, &provider).unwrap();
let hashed_address = keccak256(address);
let account_opt = result.accounts.get(&hashed_address).unwrap();
let account = account_opt.as_ref().unwrap();
// Balance should be updated
assert_eq!(account.balance, U256::from(1500));
// Nonce and bytecode_hash should come from provider
assert_eq!(account.nonce, 42);
assert_eq!(account.bytecode_hash, Some(code_hash));
}
#[test]
fn test_bal_multiple_storage_changes_per_slot() {
let provider = StateProviderTest::default();
let address = Address::random();
let slot = U256::random();
// Multiple changes to the same slot - should take the last one
let slot_changes = SlotChanges {
slot,
changes: vec![
StorageChange::new(0, U256::from(100)),
StorageChange::new(1, U256::from(200)),
StorageChange::new(2, U256::from(300)),
],
};
let account_changes = AccountChanges {
address,
storage_changes: vec![slot_changes],
storage_reads: vec![],
balance_changes: vec![BalanceChange::new(0, U256::from(100))],
nonce_changes: vec![NonceChange::new(0, 1)],
code_changes: vec![],
};
let bal = vec![account_changes];
let result = bal_to_hashed_post_state(&bal, &provider).unwrap();
let hashed_address = keccak256(address);
let storage = result.storages.get(&hashed_address).unwrap();
let hashed_slot = keccak256(slot.to_be_bytes::<32>());
let stored_value = storage.storage.get(&hashed_slot).unwrap();
// Should have the last value
assert_eq!(*stored_value, U256::from(300));
}
#[test]
fn test_bal_slot_iter() {
// Create test data with multiple accounts and slots (both changed and read-only)
let addr1 = Address::repeat_byte(0x01);
let addr2 = Address::repeat_byte(0x02);
let addr3 = Address::repeat_byte(0x03);
// Account 1: 2 changed slots + 1 read-only = 3 total slots (indices 0, 1, 2)
let account1 = AccountChanges {
address: addr1,
storage_changes: vec![
SlotChanges {
slot: U256::from(100),
changes: vec![StorageChange::new(0, U256::ZERO)],
},
SlotChanges {
slot: U256::from(101),
changes: vec![StorageChange::new(0, U256::ZERO)],
},
],
storage_reads: vec![U256::from(102)],
balance_changes: vec![],
nonce_changes: vec![],
code_changes: vec![],
};
// Account 2: 1 changed slot + 1 read-only = 2 total slots (indices 3, 4)
let account2 = AccountChanges {
address: addr2,
storage_changes: vec![SlotChanges {
slot: U256::from(200),
changes: vec![StorageChange::new(0, U256::ZERO)],
}],
storage_reads: vec![U256::from(201)],
balance_changes: vec![],
nonce_changes: vec![],
code_changes: vec![],
};
// Account 3: 2 changed slots + 1 read-only = 3 total slots (indices 5, 6, 7)
let account3 = AccountChanges {
address: addr3,
storage_changes: vec![
SlotChanges {
slot: U256::from(300),
changes: vec![StorageChange::new(0, U256::ZERO)],
},
SlotChanges {
slot: U256::from(301),
changes: vec![StorageChange::new(0, U256::ZERO)],
},
],
storage_reads: vec![U256::from(302)],
balance_changes: vec![],
nonce_changes: vec![],
code_changes: vec![],
};
let bal = vec![account1, account2, account3];
// Test 1: Iterate over all slots (range 0..8)
let items: Vec<_> = BALSlotIter::new(&bal, 0..8).collect();
assert_eq!(items.len(), 8);
// Account 1: changed slots first (100, 101), then read-only (102)
assert_eq!(items[0], (addr1, StorageKey::from(U256::from(100))));
assert_eq!(items[1], (addr1, StorageKey::from(U256::from(101))));
assert_eq!(items[2], (addr1, StorageKey::from(U256::from(102))));
// Account 2: changed slot (200), then read-only (201)
assert_eq!(items[3], (addr2, StorageKey::from(U256::from(200))));
assert_eq!(items[4], (addr2, StorageKey::from(U256::from(201))));
// Account 3: changed slots (300, 301), then read-only (302)
assert_eq!(items[5], (addr3, StorageKey::from(U256::from(300))));
assert_eq!(items[6], (addr3, StorageKey::from(U256::from(301))));
assert_eq!(items[7], (addr3, StorageKey::from(U256::from(302))));
// Test 2: Range that skips first account (range 3..6)
let items: Vec<_> = BALSlotIter::new(&bal, 3..6).collect();
assert_eq!(items.len(), 3);
assert_eq!(items[0], (addr2, StorageKey::from(U256::from(200))));
assert_eq!(items[1], (addr2, StorageKey::from(U256::from(201))));
assert_eq!(items[2], (addr3, StorageKey::from(U256::from(300))));
// Test 3: Range within first account (range 1..2)
let items: Vec<_> = BALSlotIter::new(&bal, 1..2).collect();
assert_eq!(items.len(), 1);
assert_eq!(items[0], (addr1, StorageKey::from(U256::from(101))));
// Test 4: Range spanning multiple accounts (range 2..5)
let items: Vec<_> = BALSlotIter::new(&bal, 2..5).collect();
assert_eq!(items.len(), 3);
// Last slot from account 1 (read-only)
assert_eq!(items[0], (addr1, StorageKey::from(U256::from(102))));
// Account 2 (changed + read-only)
assert_eq!(items[1], (addr2, StorageKey::from(U256::from(200))));
assert_eq!(items[2], (addr2, StorageKey::from(U256::from(201))));
// Test 5: Empty range
let items: Vec<_> = BALSlotIter::new(&bal, 5..5).collect();
assert_eq!(items.len(), 0);
// Test 6: Range beyond end (starts at index 6)
let items: Vec<_> = BALSlotIter::new(&bal, 6..100).collect();
assert_eq!(items.len(), 2);
assert_eq!(items[0], (addr3, StorageKey::from(U256::from(301))));
assert_eq!(items[1], (addr3, StorageKey::from(U256::from(302))));
// Test 7: Range that starts in read-only slots (index 2 is the read-only slot of account 1)
let items: Vec<_> = BALSlotIter::new(&bal, 2..4).collect();
assert_eq!(items.len(), 2);
assert_eq!(items[0], (addr1, StorageKey::from(U256::from(102))));
assert_eq!(items[1], (addr2, StorageKey::from(U256::from(200))));
}
}

View File

@@ -4,10 +4,10 @@ use super::precompile_cache::PrecompileCacheMap;
use crate::tree::{
payload_processor::prewarm::{PrewarmCacheTask, PrewarmContext, PrewarmMode, PrewarmTaskEvent},
sparse_trie::SparseTrieCacheTask,
CacheWaitDurations, CachedStateMetrics, ExecutionCache, PayloadExecutionCache, SavedCache,
StateProviderBuilder, TreeConfig, WaitForCaches,
CacheWaitDurations, CachedStateMetrics, CachedStateMetricsSource, ExecutionCache,
PayloadExecutionCache, SavedCache, StateProviderBuilder, TreeConfig, WaitForCaches,
};
use alloy_eip7928::BlockAccessList;
use alloy_eip7928::bal::DecodedBal;
use alloy_eips::{eip1898::BlockWithParent, eip4895::Withdrawal};
use alloy_primitives::B256;
use crossbeam_channel::{Receiver as CrossbeamReceiver, Sender as CrossbeamSender};
@@ -44,7 +44,6 @@ use std::{
};
use tracing::{debug, debug_span, instrument, trace, warn, Span};
pub mod bal;
pub mod multiproof;
mod preserved_sparse_trie;
pub mod prewarm;
@@ -97,6 +96,8 @@ where
executor: Runtime,
/// The most recent cache used for execution.
execution_cache: PayloadExecutionCache,
/// Metrics for the execution cache.
cache_metrics: Option<CachedStateMetrics>,
/// Metrics for trie operations
trie_metrics: MultiProofTaskMetrics,
/// Cross-block cache size in bytes.
@@ -121,8 +122,12 @@ where
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.
disable_cache_metrics: bool,
/// Whether to disable BAL-based parallel execution (falls back to tx-based prewarming).
disable_bal_parallel_execution: bool,
/// Whether to disable BAL-driven parallel state root computation.
disable_bal_parallel_state_root: bool,
/// Whether BAL batched IO is disabled.
disable_bal_batch_io: bool,
}
impl<N, Evm> PayloadProcessor<Evm>
@@ -156,7 +161,11 @@ where
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(),
cache_metrics: (!config.disable_cache_metrics())
.then(|| CachedStateMetrics::zeroed(CachedStateMetricsSource::Engine)),
disable_bal_parallel_execution: config.disable_bal_parallel_execution(),
disable_bal_parallel_state_root: config.disable_bal_parallel_state_root(),
disable_bal_batch_io: config.disable_bal_batch_io(),
}
}
}
@@ -241,7 +250,6 @@ where
provider_builder: StateProviderBuilder<N, P>,
multiproof_provider_factory: F,
config: &TreeConfig,
bal: Option<Arc<BlockAccessList>>,
) -> IteratorPayloadHandle<Evm, I, N>
where
P: BlockReader + StateProviderFactory + StateReader + Clone + 'static,
@@ -264,13 +272,12 @@ where
halve_workers,
config,
);
let install_state_hook = bal.is_none();
let install_state_hook = env.decoded_bal.is_none();
let prewarm_handle = self.spawn_caching_with(
env,
prewarm_rx,
provider_builder,
Some(state_root_handle.updates_tx().clone()),
bal,
);
PayloadHandle {
@@ -291,14 +298,13 @@ where
env: ExecutionEnv<Evm>,
transactions: I,
provider_builder: StateProviderBuilder<N, P>,
bal: Option<Arc<BlockAccessList>>,
) -> IteratorPayloadHandle<Evm, I, N>
where
P: BlockReader + StateProviderFactory + StateReader + Clone + 'static,
{
let (prewarm_rx, execution_rx) =
self.spawn_tx_iterator(transactions, env.transaction_count);
let prewarm_handle = self.spawn_caching_with(env, prewarm_rx, provider_builder, None, bal);
let prewarm_handle = self.spawn_caching_with(env, prewarm_rx, provider_builder, None);
PayloadHandle {
state_root_handle: None,
install_state_hook: false,
@@ -456,7 +462,7 @@ where
level = "debug",
target = "engine::tree::payload_processor",
skip_all,
fields(bal=%bal.is_some())
fields(bal=%env.decoded_bal.is_some())
)]
fn spawn_caching_with<P>(
&self,
@@ -464,7 +470,6 @@ where
transactions: mpsc::Receiver<(usize, impl ExecutableTxFor<Evm> + Clone + Send + 'static)>,
provider_builder: StateProviderBuilder<N, P>,
to_sparse_trie_task: Option<CrossbeamSender<StateRootMessage>>,
bal: Option<Arc<BlockAccessList>>,
) -> CacheTaskHandle<N::Receipt>
where
P: BlockReader + StateProviderFactory + StateReader + Clone + 'static,
@@ -475,7 +480,7 @@ where
let saved_cache = self.disable_state_cache.not().then(|| self.cache_for(env.parent_hash));
let executed_tx_index = Arc::new(AtomicUsize::new(0));
let maybe_decoded_bal = env.decoded_bal.clone();
// configure prewarming
let prewarm_ctx = PrewarmContext {
env,
@@ -483,10 +488,13 @@ where
saved_cache: saved_cache.clone(),
provider: provider_builder,
metrics: PrewarmMetrics::default(),
cache_metrics: self.cache_metrics.clone(),
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(),
disable_bal_parallel_state_root: self.disable_bal_parallel_state_root,
disable_bal_batch_io: self.disable_bal_batch_io,
};
let (prewarm_task, to_prewarm_task) = PrewarmCacheTask::new(
@@ -495,14 +503,16 @@ where
prewarm_ctx,
to_sparse_trie_task,
);
{
let to_prewarm_task = to_prewarm_task.clone();
let disable_bal_parallel_execution = self.disable_bal_parallel_execution;
self.executor.spawn_blocking_named("prewarm", move || {
let mode = if skip_prewarm {
PrewarmMode::Skipped
} else if let Some(bal) = bal {
PrewarmMode::BlockAccessList(bal)
} else if let Some(decoded_bal) =
maybe_decoded_bal.filter(|_| !disable_bal_parallel_execution)
{
PrewarmMode::BlockAccessList(decoded_bal)
} else {
PrewarmMode::Transactions(transactions)
};
@@ -510,7 +520,12 @@ where
});
}
CacheTaskHandle { saved_cache, to_prewarm_task: Some(to_prewarm_task), executed_tx_index }
CacheTaskHandle {
saved_cache,
to_prewarm_task: Some(to_prewarm_task),
executed_tx_index,
cache_metrics: self.cache_metrics.clone(),
}
}
/// Returns the cache for the given parent hash.
@@ -526,10 +541,10 @@ where
debug!("creating new execution cache on cache miss");
let start = Instant::now();
let cache = ExecutionCache::new(self.cross_block_cache_size);
let metrics = CachedStateMetrics::zeroed();
metrics.record_cache_creation(start.elapsed());
SavedCache::new(parent_hash, cache, metrics)
.with_disable_cache_metrics(self.disable_cache_metrics)
if let Some(metrics) = &self.cache_metrics {
metrics.record_cache_creation(start.elapsed());
}
SavedCache::new(parent_hash, cache)
}
}
@@ -591,6 +606,7 @@ where
proof_worker_handle,
trie_metrics.clone(),
sparse_state_trie,
parent_state_root,
chunk_size,
);
@@ -676,7 +692,7 @@ where
block_with_parent: BlockWithParent,
bundle_state: &BundleState,
) {
let disable_cache_metrics = self.disable_cache_metrics;
let cache_metrics = self.cache_metrics.clone();
self.execution_cache.update_with_guard(|cached| {
if cached.as_ref().is_some_and(|c| c.executed_block_hash() != block_with_parent.parent) {
debug!(
@@ -688,25 +704,19 @@ where
}
// Take existing cache (if any) or create fresh caches
let (caches, cache_metrics, _) = match cached.take() {
Some(existing) => existing.split(),
None => (
ExecutionCache::new(self.cross_block_cache_size),
CachedStateMetrics::zeroed(),
false,
),
let caches = match cached.take() {
Some(existing) => existing.cache().clone(),
None => ExecutionCache::new(self.cross_block_cache_size),
};
// Insert the block's bundle state into cache
let new_cache =
SavedCache::new(block_with_parent.block.hash, caches, cache_metrics)
.with_disable_cache_metrics(disable_cache_metrics);
let new_cache = SavedCache::new(block_with_parent.block.hash, caches);
if new_cache.cache().insert_state(bundle_state).is_err() {
*cached = None;
debug!(target: "engine::caching", "cleared execution cache on update error");
return
}
new_cache.update_metrics();
new_cache.update_metrics(cache_metrics.as_ref());
// Replace with the updated cache
*cached = Some(new_cache);
@@ -800,9 +810,9 @@ impl<Tx, Err, R: Send + Sync + 'static> PayloadHandle<Tx, Err, R> {
self.prewarm_handle.saved_cache.as_ref().map(|cache| cache.cache().clone())
}
/// Returns a clone of the cache metrics used by prewarming
/// Returns engine cache metrics if a cache exists for prewarming.
pub fn cache_metrics(&self) -> Option<CachedStateMetrics> {
self.prewarm_handle.saved_cache.as_ref().map(|cache| cache.metrics().clone())
self.prewarm_handle.cache_metrics.clone()
}
/// Returns a reference to the shared executed transaction index counter.
@@ -853,6 +863,8 @@ pub struct CacheTaskHandle<R> {
/// Shared counter tracking the next transaction index to be executed by the main execution
/// loop. Prewarm workers skip transactions below this index.
executed_tx_index: Arc<AtomicUsize>,
/// Metrics for the execution cache.
cache_metrics: Option<CachedStateMetrics>,
}
impl<R: Send + Sync + 'static> CacheTaskHandle<R> {
@@ -921,6 +933,9 @@ pub struct ExecutionEnv<Evm: ConfigureEvm> {
/// Withdrawals included in the block.
/// Used to generate prefetch targets for withdrawal addresses.
pub withdrawals: Option<Vec<Withdrawal>>,
/// Optional decoded BAL for the block.
/// Used to validate and optimize execution.
pub decoded_bal: Option<Arc<DecodedBal>>,
}
impl<Evm: ConfigureEvm> ExecutionEnv<Evm>
@@ -938,6 +953,7 @@ where
transaction_count: 0,
gas_used: 0,
withdrawals: None,
decoded_bal: None,
}
}
}
@@ -947,8 +963,7 @@ mod tests {
use crate::tree::{
payload_processor::{evm_state_to_hashed_post_state, ExecutionEnv, PayloadProcessor},
precompile_cache::PrecompileCacheMap,
CachedStateMetrics, ExecutionCache, PayloadExecutionCache, SavedCache,
StateProviderBuilder, TreeConfig,
ExecutionCache, PayloadExecutionCache, SavedCache, StateProviderBuilder, TreeConfig,
};
use alloy_eips::eip1898::{BlockNumHash, BlockWithParent};
use alloy_evm::block::StateChangeSource;
@@ -974,7 +989,7 @@ mod tests {
fn make_saved_cache(hash: B256) -> SavedCache {
let execution_cache = ExecutionCache::new(1_000);
SavedCache::new(hash, execution_cache, CachedStateMetrics::zeroed())
SavedCache::new(hash, execution_cache)
}
#[test]
@@ -1237,7 +1252,6 @@ mod tests {
StateProviderBuilder::new(provider_factory.clone(), genesis_hash, None),
OverlayStateProviderFactory::new(provider_factory, ChangesetCache::new()),
&TreeConfig::default(),
None, // No BAL for test
);
let mut state_hook = handle.state_hook().expect("state hook is None");

View File

@@ -12,12 +12,13 @@
//! 3. When actual block execution happens, it benefits from the warmed cache
use crate::tree::{
payload_processor::{bal, multiproof::StateRootMessage},
payload_processor::multiproof::StateRootMessage,
precompile_cache::{CachedPrecompile, PrecompileCacheMap},
CachedStateProvider, ExecutionEnv, PayloadExecutionCache, SavedCache, StateProviderBuilder,
CachedStateMetrics, CachedStateProvider, ExecutionEnv, PayloadExecutionCache, SavedCache,
StateProviderBuilder,
};
use alloy_consensus::transaction::TxHashRef;
use alloy_eip7928::BlockAccessList;
use alloy_eip7928::bal::DecodedBal;
use alloy_eips::eip4895::Withdrawal;
use alloy_primitives::{keccak256, StorageKey, B256};
use crossbeam_channel::Sender as CrossbeamSender;
@@ -38,6 +39,7 @@ use std::sync::{
mpsc::{self, channel, Receiver, Sender},
Arc,
};
use tokio::sync::oneshot;
use tracing::{debug, debug_span, instrument, trace, trace_span, warn, Span};
/// Determines the prewarming mode: transaction-based, BAL-based, or skipped.
@@ -46,7 +48,7 @@ pub enum PrewarmMode<Tx> {
/// Prewarm by executing transactions from a stream, each paired with its block index.
Transactions(Receiver<(usize, Tx)>),
/// Prewarm by prefetching slots from a Block Access List.
BlockAccessList(Arc<BlockAccessList>),
BlockAccessList(Arc<DecodedBal>),
/// Transaction prewarming is skipped (e.g. small blocks where the overhead exceeds the
/// benefit). No workers are spawned.
Skipped,
@@ -132,7 +134,7 @@ where
self.executor.spawn_blocking_named("prewarm-txs", move || {
let _enter = debug_span!(
target: "engine::tree::payload_processor::prewarm",
parent: span,
parent: &span,
"prewarm_txs"
)
.entered();
@@ -275,19 +277,20 @@ where
) {
let start = Instant::now();
let Self { execution_cache, ctx: PrewarmContext { env, metrics, saved_cache, .. }, .. } =
self;
let Self {
execution_cache,
ctx: PrewarmContext { env, metrics, cache_metrics, saved_cache, .. },
..
} = self;
let hash = env.hash;
if let Some(saved_cache) = saved_cache {
debug!(target: "engine::caching", parent_hash=?hash, "Updating execution cache");
// Perform all cache operations atomically under the lock
execution_cache.update_with_guard(|cached| {
// consumes the `SavedCache` held by the prewarming task, which releases its usage
// guard
let (caches, cache_metrics, disable_cache_metrics) = saved_cache.split();
let new_cache = SavedCache::new(hash, caches, cache_metrics)
.with_disable_cache_metrics(disable_cache_metrics);
let caches = saved_cache.cache().clone();
let new_cache = SavedCache::new(hash, caches);
// Insert state into cache while holding the lock
// Access the BundleState through the shared ExecutionOutcome
@@ -298,7 +301,7 @@ where
return;
}
new_cache.update_metrics();
new_cache.update_metrics(cache_metrics.as_ref());
if valid_block_rx.recv().is_ok() {
// Replace the shared cache with the new one; the previous cache (if any) is
@@ -319,28 +322,23 @@ where
}
}
/// Runs BAL-based prewarming by using the prewarming pool's parallel iterator to prefetch
/// accounts and storage slots.
/// Runs BAL-based prewarming and sparse-trie work inline.
///
/// Spawns two halves concurrently on separate pools, then waits for both to complete:
/// 1. Storage prefetch on the prewarming pool to populate the execution cache.
/// 2. Hashed state streaming on the BAL streaming pool so storage updates can reach the sparse
/// trie before account reads finish.
#[instrument(level = "debug", target = "engine::tree::payload_processor::prewarm", skip_all)]
fn run_bal_prewarm(
&self,
bal: Arc<BlockAccessList>,
decoded_bal: Arc<DecodedBal>,
actions_tx: Sender<PrewarmTaskEvent<N::Receipt>>,
) {
// Only prefetch if we have a cache to populate
if self.ctx.saved_cache.is_none() {
trace!(
target: "engine::tree::payload_processor::prewarm",
"Skipping BAL prewarm - no cache available"
);
self.send_bal_hashed_state(&bal);
let _ =
actions_tx.send(PrewarmTaskEvent::FinishedTxExecution { executed_transactions: 0 });
return;
}
let bal = decoded_bal.as_bal();
if bal.is_empty() {
self.send_bal_hashed_state(&bal);
if let Some(to_sparse_trie_task) = self.to_sparse_trie_task.as_ref() {
let _ = to_sparse_trie_task.send(StateRootMessage::FinishedStateUpdates);
}
let _ =
actions_tx.send(PrewarmTaskEvent::FinishedTxExecution { executed_transactions: 0 });
return;
@@ -353,66 +351,88 @@ where
);
let ctx = self.ctx.clone();
self.executor.prewarming_pool().install_fn(|| {
bal.par_iter().for_each_init(
|| (ctx.clone(), None::<CachedStateProvider<reth_provider::StateProviderBox>>),
|(ctx, provider), account| {
if ctx.should_stop() {
return;
}
ctx.prefetch_bal_account(provider, account);
},
);
});
let to_sparse_trie_task = self.to_sparse_trie_task.clone();
let executor = self.executor.clone();
let parent_span = Span::current();
let prefetch_parent_span = parent_span.clone();
let stream_parent_span = parent_span;
let prefetch_bal = Arc::clone(&decoded_bal);
let stream_bal = Arc::clone(&decoded_bal);
let (prefetch_tx, prefetch_rx) = oneshot::channel();
let (stream_tx, stream_rx) = oneshot::channel();
trace!(
target: "engine::tree::payload_processor::prewarm",
"All BAL prewarm accounts completed"
);
// Convert BAL to HashedPostState and send to sparse trie task
self.send_bal_hashed_state(&bal);
// Signal that execution has finished
let _ = actions_tx.send(PrewarmTaskEvent::FinishedTxExecution { executed_transactions: 0 });
}
/// Converts the BAL to [`HashedPostState`](reth_trie::HashedPostState) and sends it to the
/// sparse trie task.
fn send_bal_hashed_state(&self, bal: &BlockAccessList) {
let Some(to_sparse_trie_task) = &self.to_sparse_trie_task else { return };
let provider = match self.ctx.provider.build() {
Ok(provider) => provider,
Err(err) => {
warn!(
if ctx.saved_cache.is_some() {
let prefetch_ctx = ctx.clone();
executor.prewarming_pool().spawn(move || {
let branch_span = debug_span!(
target: "engine::tree::payload_processor::prewarm",
?err,
"Failed to build provider for BAL hashed state conversion"
parent: &prefetch_parent_span,
"bal_prefetch_storage",
bal_accounts = prefetch_bal.as_bal().len(),
);
return;
}
};
let provider_parent_span = branch_span.clone();
let _span = branch_span.entered();
match bal::bal_to_hashed_post_state(bal, &provider) {
Ok(hashed_state) => {
debug!(
target: "engine::tree::payload_processor::prewarm",
accounts = hashed_state.accounts.len(),
storages = hashed_state.storages.len(),
"Converted BAL to hashed post state"
prefetch_bal.as_bal().par_iter().for_each_init(
|| {
(
prefetch_ctx.clone(),
None::<CachedStateProvider<reth_provider::StateProviderBox, true>>,
provider_parent_span.clone(),
)
},
|(ctx, provider, parent_span), account| {
if ctx.should_stop() {
return;
}
ctx.prefetch_bal_storage(parent_span, provider, account);
},
);
let _ = to_sparse_trie_task.send(StateRootMessage::HashedStateUpdate(hashed_state));
let _ = to_sparse_trie_task.send(StateRootMessage::FinishedStateUpdates);
}
Err(err) => {
warn!(
target: "engine::tree::payload_processor::prewarm",
?err,
"Failed to convert BAL to hashed state"
);
}
let _ = prefetch_tx.send(());
});
} else {
let _ = prefetch_tx.send(());
}
if let Some(to_sparse_trie_task) = to_sparse_trie_task {
executor.bal_streaming_pool().spawn(move || {
let branch_span = debug_span!(
target: "engine::tree::payload_processor::prewarm",
parent: &stream_parent_span,
"bal_hashed_state_stream",
bal_accounts = stream_bal.as_bal().len(),
);
let provider_parent_span = branch_span.clone();
let _span = branch_span.entered();
stream_bal.as_bal().par_iter().for_each_init(
|| (ctx.clone(), None::<Box<dyn AccountReader>>, provider_parent_span.clone()),
|(ctx, provider, parent_span), account_changes| {
ctx.send_bal_hashed_state(
parent_span,
provider,
account_changes,
&to_sparse_trie_task,
);
},
);
let _ = to_sparse_trie_task.send(StateRootMessage::FinishedStateUpdates);
let _ = stream_tx.send(());
});
} else {
let _ = stream_tx.send(());
}
prefetch_rx
.blocking_recv()
.expect("BAL prefetch task dropped without signaling completion");
stream_rx
.blocking_recv()
.expect("BAL hashed-state streaming task dropped without signaling completion");
let _ = actions_tx.send(PrewarmTaskEvent::FinishedTxExecution { executed_transactions: 0 });
}
/// Executes the task.
@@ -504,6 +524,9 @@ where
pub provider: StateProviderBuilder<N, P>,
/// The metrics for the prewarm task.
pub metrics: PrewarmMetrics,
/// Metrics for the execution cache.
/// Metrics for the execution cache. `None` disables metrics recording.
pub cache_metrics: Option<CachedStateMetrics>,
/// An atomic bool that tells prewarm tasks to not start any more execution.
pub terminate_execution: Arc<AtomicBool>,
/// Shared counter tracking the next transaction index to be executed by the main execution
@@ -514,6 +537,10 @@ where
pub precompile_cache_disabled: bool,
/// The precompile cache map.
pub precompile_cache_map: PrecompileCacheMap<SpecFor<Evm>>,
/// Whether to disable BAL-driven parallel state root computation.
pub disable_bal_parallel_state_root: bool,
/// Whether BAL batched IO is disabled.
pub disable_bal_batch_io: bool,
}
/// Per-thread EVM state initialised by [`PrewarmContext::evm_for_ctx`] and stored in
@@ -545,9 +572,11 @@ where
// Use the caches to create a new provider with caching
if let Some(saved_cache) = &self.saved_cache {
let caches = saved_cache.cache().clone();
let cache_metrics = saved_cache.metrics().clone();
state_provider =
Box::new(CachedStateProvider::new_prewarm(state_provider, caches, cache_metrics));
state_provider = Box::new(CachedStateProvider::new_prewarm(
state_provider,
caches,
self.cache_metrics.clone().unwrap_or_default(),
));
}
let state_provider = StateProviderDatabase::new(state_provider);
@@ -593,18 +622,149 @@ where
self.terminate_execution.store(true, Ordering::Relaxed);
}
/// Prefetches a single account and all its storage slots from the BAL into the cache.
/// Hashes and streams a single BAL account's state to the sparse trie task.
///
/// For each account, storage slots are hashed and sent immediately, then the account is read
/// from the database and sent as a separate update.
///
/// The `provider` is lazily initialized on first call and reused across accounts on the same
/// thread.
fn prefetch_bal_account(
fn send_bal_hashed_state(
&self,
provider: &mut Option<CachedStateProvider<reth_provider::StateProviderBox>>,
parent_span: &Span,
provider: &mut Option<Box<dyn AccountReader>>,
account_changes: &alloy_eip7928::AccountChanges,
to_sparse_trie_task: &CrossbeamSender<StateRootMessage>,
) {
if self.disable_bal_parallel_state_root {
return;
}
let address = account_changes.address;
let mut hashed_address = None;
if !account_changes.storage_changes.is_empty() {
let hashed_address = *hashed_address.get_or_insert_with(|| keccak256(address));
let mut storage_map = reth_trie::HashedStorage::new(false);
for slot_changes in &account_changes.storage_changes {
let hashed_slot = keccak256(slot_changes.slot.to_be_bytes::<32>());
if let Some(last_change) = slot_changes.changes.last() {
storage_map.storage.insert(hashed_slot, last_change.new_value);
}
}
let mut hashed_state = reth_trie::HashedPostState::default();
hashed_state.storages.insert(hashed_address, storage_map);
let _ = to_sparse_trie_task.send(StateRootMessage::HashedStateUpdate(hashed_state));
}
if provider.is_none() {
let _span = debug_span!(
target: "engine::tree::payload_processor::prewarm",
parent: parent_span,
"bal_hashed_state_provider_init",
has_saved_cache = self.saved_cache.is_some(),
)
.entered();
let inner = match self.provider.build() {
Ok(p) => p,
Err(err) => {
warn!(
target: "engine::tree::payload_processor::prewarm",
?err,
"Failed to build provider for BAL account reads"
);
return;
}
};
let boxed: Box<dyn AccountReader> = if let Some(saved) = &self.saved_cache {
let caches = saved.cache().clone();
Box::new(CachedStateProvider::new_prewarm(
inner,
caches,
self.cache_metrics.clone().unwrap_or_default(),
))
} else {
Box::new(inner)
};
*provider = Some(boxed);
}
let account_reader = provider.as_ref().expect("provider just initialized");
let existing_account = account_reader.basic_account(&address).ok().flatten();
let balance = account_changes.balance_changes.last().map(|change| change.post_balance);
let nonce = account_changes.nonce_changes.last().map(|change| change.new_nonce);
let code_hash = account_changes.code_changes.last().map(|code_change| {
if code_change.new_code.is_empty() {
alloy_consensus::constants::KECCAK_EMPTY
} else {
keccak256(&code_change.new_code)
}
});
if balance.is_none() &&
nonce.is_none() &&
code_hash.is_none() &&
account_changes.storage_changes.is_empty()
{
return;
}
let account = reth_primitives_traits::Account {
balance: balance.unwrap_or_else(|| {
existing_account
.as_ref()
.map(|account| account.balance)
.unwrap_or(alloy_primitives::U256::ZERO)
}),
nonce: nonce.unwrap_or_else(|| {
existing_account.as_ref().map(|account| account.nonce).unwrap_or(0)
}),
bytecode_hash: code_hash.or_else(|| {
existing_account
.as_ref()
.and_then(|account| account.bytecode_hash)
.or(Some(alloy_consensus::constants::KECCAK_EMPTY))
}),
};
let hashed_address = hashed_address.unwrap_or_else(|| keccak256(address));
let mut hashed_state = reth_trie::HashedPostState::default();
hashed_state.accounts.insert(hashed_address, Some(account));
let _ = to_sparse_trie_task.send(StateRootMessage::HashedStateUpdate(hashed_state));
}
/// Prefetches storage slots for a single BAL account into the cache.
///
/// Account reads are handled separately by [`Self::send_bal_hashed_state`], so this method
/// only
/// warms storage.
///
/// The `provider` is lazily initialized on first call and reused across accounts on the same
/// thread.
fn prefetch_bal_storage(
&self,
parent_span: &Span,
provider: &mut Option<CachedStateProvider<reth_provider::StateProviderBox, true>>,
account: &alloy_eip7928::AccountChanges,
) {
if account.storage_changes.is_empty() && account.storage_reads.is_empty() {
return;
}
let state_provider = match provider {
Some(p) => p,
slot @ None => {
let _span = debug_span!(
target: "engine::tree::payload_processor::prewarm",
parent: parent_span,
"bal_prefetch_provider_init",
)
.entered();
let built = match self.provider.build() {
Ok(p) => p,
Err(err) => {
@@ -619,15 +779,16 @@ where
let saved_cache =
self.saved_cache.as_ref().expect("BAL prewarm should only run with cache");
let caches = saved_cache.cache().clone();
let cache_metrics = saved_cache.metrics().clone();
slot.insert(CachedStateProvider::new(built, caches, cache_metrics))
slot.insert(CachedStateProvider::new_prewarm(
built,
caches,
self.cache_metrics.clone().unwrap_or_default(),
))
}
};
let start = Instant::now();
let _ = state_provider.basic_account(&account.address);
for slot in &account.storage_changes {
let _ = state_provider.storage(account.address, StorageKey::from(slot.slot));
}

View File

@@ -27,8 +27,9 @@ use reth_trie_parallel::{
root::ParallelStateRootError,
};
use reth_trie_sparse::{
errors::SparseTrieResult, ConfigurableSparseTrie, DeferredDrops, LeafUpdate,
RevealableSparseTrie, SparseStateTrie, SparseTrie,
errors::{SparseStateTrieErrorKind, SparseTrieErrorKind, SparseTrieResult},
ConfigurableSparseTrie, DeferredDrops, LeafUpdate, RevealableSparseTrie, SparseStateTrie,
SparseTrie,
};
use revm_primitives::{hash_map::Entry, B256Map};
use tracing::{debug, debug_span, error, instrument, trace_span};
@@ -46,6 +47,8 @@ pub(super) struct SparseTrieCacheTask<A = ConfigurableSparseTrie, S = Configurab
updates: CrossbeamReceiver<SparseTrieTaskMessage>,
/// `SparseStateTrie` used for computing the state root.
trie: SparseStateTrie<A, S>,
/// The parent block's state root.
parent_state_root: B256,
/// Handle to the proof worker pools (storage and account).
proof_worker_handle: ProofWorkerHandle,
@@ -120,6 +123,7 @@ where
proof_worker_handle: ProofWorkerHandle,
metrics: MultiProofTaskMetrics,
trie: SparseStateTrie<A, S>,
parent_state_root: B256,
chunk_size: usize,
) -> Self {
let (proof_result_tx, proof_result_rx) = crossbeam_channel::unbounded();
@@ -128,7 +132,7 @@ where
let parent_span = tracing::Span::current();
let hashing_metrics = metrics.clone();
executor.spawn_blocking_named("trie-hashing", move || {
let _span = debug_span!(parent: parent_span, "run_hashing_task").entered();
let _span = trace_span!(parent: parent_span, "run_hashing_task").entered();
Self::run_hashing_task(updates, hashed_state_tx, hashing_metrics)
});
@@ -138,6 +142,7 @@ where
updates: hashed_state_rx,
proof_worker_handle,
trie,
parent_state_root,
chunk_size,
max_targets_for_chunking: DEFAULT_MAX_TARGETS_FOR_CHUNKING,
account_updates: Default::default(),
@@ -177,7 +182,7 @@ where
SparseTrieTaskMessage::PrefetchProofs(targets)
}
StateRootMessage::StateUpdate(_, state) => {
let _span = debug_span!(target: "engine::tree::payload_processor::sparse_trie", "hashing_state_update", n = state.len()).entered();
let _span = trace_span!(target: "engine::tree::payload_processor::sparse_trie", "hashing_state_update", n = state.len()).entered();
let hashed = evm_state_to_hashed_post_state(state);
SparseTrieTaskMessage::HashedState(hashed)
}
@@ -359,10 +364,25 @@ where
debug!(target: "engine::root", "All proofs processed, ending calculation");
let start = Instant::now();
let (state_root, trie_updates) =
self.trie.root_with_updates(&self.proof_worker_handle).map_err(|e| {
ParallelStateRootError::Other(format!("could not calculate state root: {e:?}"))
})?;
let (state_root, trie_updates) = match self.trie.root_with_updates() {
Ok(result) => result,
Err(err)
if matches!(
err.kind(),
SparseStateTrieErrorKind::Sparse(SparseTrieErrorKind::Blind)
) =>
{
// A still-blind account trie means this block never changed state, so preserve
// the cached parent root instead of fetching and revealing
// the unchanged root node.
(self.parent_state_root, TrieUpdates::default())
}
Err(err) => {
return Err(ParallelStateRootError::Other(format!(
"could not calculate state root: {err:?}"
)))
}
};
#[cfg(feature = "trie-debug")]
let debug_recorders = self.trie.take_debug_recorders();
@@ -542,7 +562,7 @@ where
/// Applies all account and storage leaf updates to corresponding tries and collects any new
/// multiproof targets.
#[instrument(
level = "debug",
level = "trace",
target = "engine::tree::payload_processor::sparse_trie",
skip_all
)]
@@ -551,7 +571,7 @@ where
if new { &mut self.new_storage_updates } else { &mut self.storage_updates };
// Process all storage updates, skipping tries with no pending updates.
let span = debug_span!("process_storage_leaf_updates").entered();
let span = trace_span!("process_storage_leaf_updates").entered();
for (address, updates) in storage_updates {
if updates.is_empty() {
continue;
@@ -596,7 +616,7 @@ where
///
/// Returns whether any updates were drained (applied to the trie).
#[instrument(
level = "debug",
level = "trace",
target = "engine::tree::payload_processor::sparse_trie",
skip_all
)]
@@ -638,11 +658,6 @@ where
/// 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
@@ -665,15 +680,28 @@ where
}
}
let parent_span = tracing::Span::current();
if tries_to_compute_roots.is_empty() {
return;
}
let parent_span =
debug_span!("compute_drained_storage_roots", n = tries_to_compute_roots.len());
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();
let span = if tracing::enabled!(tracing::Level::TRACE) {
debug_span!(
target: "engine::tree::payload_processor::sparse_trie",
parent: &parent_span,
"storage_root",
?address
)
} else {
debug_span!(
target: "engine::tree::payload_processor::sparse_trie",
parent: &parent_span,
"storage_root",
)
};
let _enter = span.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;
@@ -688,7 +716,7 @@ where
/// storage roots, and promotes corresponding pending account updates into proper leaf updates
/// for accounts trie.
#[instrument(
level = "debug",
level = "trace",
target = "engine::tree::payload_processor::sparse_trie",
skip_all
)]
@@ -702,7 +730,7 @@ where
self.compute_drained_storage_roots();
loop {
let span = debug_span!("promote_updates", promoted = tracing::field::Empty).entered();
let span = trace_span!("promote_updates", promoted = tracing::field::Empty).entered();
// Now handle pending account updates that can be upgraded to a proper update.
let account_rlp_buf = &mut self.account_rlp_buf;
let mut num_promoted = 0;
@@ -770,7 +798,7 @@ where
return;
}
let _span = debug_span!("dispatch_pending_targets").entered();
let _span = trace_span!("dispatch_pending_targets").entered();
let (targets, chunking_length) = self.pending_targets.take();
dispatch_with_chunking(
targets,
@@ -865,6 +893,11 @@ enum SparseTrieTaskMessage {
mod tests {
use super::*;
use alloy_primitives::{keccak256, Address, B256, U256};
use reth_provider::{
providers::OverlayStateProviderFactory, test_utils::create_test_provider_factory,
};
use reth_trie_db::ChangesetCache;
use reth_trie_parallel::proof_task::ProofTaskCtx;
use reth_trie_sparse::ArenaParallelSparseTrie;
#[test]
@@ -945,4 +978,43 @@ mod tests {
assert_eq!(decoded.storage_root, storage_root);
assert_eq!(account_rlp_buf, encoded);
}
#[test]
fn run_returns_parent_root_without_revealing_blind_trie_when_no_state_updates() {
let runtime = reth_tasks::Runtime::test();
let provider_factory = create_test_provider_factory();
let overlay_factory =
OverlayStateProviderFactory::new(provider_factory, ChangesetCache::new());
let proof_worker_handle =
ProofWorkerHandle::new(&runtime, ProofTaskCtx::new(overlay_factory), false);
let default_trie = RevealableSparseTrie::blind_from(ConfigurableSparseTrie::Arena(
ArenaParallelSparseTrie::default(),
));
let trie = SparseStateTrie::default()
.with_accounts_trie(default_trie.clone())
.with_default_storage_trie(default_trie)
.with_updates(true);
let parent_state_root = B256::from([0x55; 32]);
let (updates_tx, updates_rx) = crossbeam_channel::unbounded();
let mut task = SparseTrieCacheTask::new_with_trie(
&runtime,
updates_rx,
proof_worker_handle,
MultiProofTaskMetrics::default(),
trie,
parent_state_root,
1,
);
updates_tx.send(StateRootMessage::FinishedStateUpdates).unwrap();
drop(updates_tx);
let outcome = task.run().expect("state root computation should succeed");
assert_eq!(outcome.state_root, parent_state_root);
assert!(outcome.trie_updates.is_empty());
assert!(task.trie.state_trie_ref().is_none(), "blind trie should not be revealed");
}
}

View File

@@ -48,7 +48,10 @@ use crate::tree::{
PayloadHandle, StateProviderBuilder, StateProviderDatabase, TreeConfig, WaitForCaches,
};
use alloy_consensus::transaction::{Either, TxHashRef};
use alloy_eip7928::BlockAccessList;
use alloy_eip7928::{
bal::{Bal, DecodedBal},
BlockAccessList,
};
use alloy_eips::{eip1898::BlockWithParent, eip4895::Withdrawal, NumHash};
use alloy_evm::Evm;
use alloy_primitives::{map::B256Set, B256};
@@ -487,6 +490,12 @@ where
.in_scope(|| self.evm_env_for(&input))
.map_err(NewPayloadError::other)?;
// Extract the decoded BAL, if valid and available.
let decoded_bal = ensure_ok!(input
.try_decoded_access_list()
.map_err(|err| { Box::<dyn std::error::Error + Send + Sync>::from(err) }))
.map(Arc::new);
let env = ExecutionEnv {
evm_env,
hash: input.hash(),
@@ -495,6 +504,7 @@ where
transaction_count: input.transaction_count(),
gas_used: input.gas_used(),
withdrawals: input.withdrawals().map(|w| w.to_vec()),
decoded_bal,
};
// Plan the strategy used for state root computation.
@@ -509,14 +519,6 @@ where
// Get an iterator over the transactions in the payload
let txs = self.tx_iterator_for(&input)?;
// Extract the BAL, if valid and available
let block_access_list = ensure_ok!(input
.block_access_list()
.transpose()
// Eventually gets converted to a `InsertBlockErrorKind::Other`
.map_err(Box::<dyn std::error::Error + Send + Sync>::from))
.map(Arc::new);
// Create lazy overlay from ancestors - this doesn't block, allowing execution to start
// before the trie data is ready. The overlay will be computed on first access.
let (lazy_overlay, anchor_hash) = Self::get_parent_lazy_overlay(parent_hash, ctx.state());
@@ -532,10 +534,9 @@ where
let mut handle = ensure_ok!(self.spawn_payload_processor(
env.clone(),
txs,
provider_builder,
provider_builder.clone(),
overlay_factory.clone(),
strategy,
block_access_list,
));
// Create optional cache stats for detailed block logging
@@ -763,10 +764,19 @@ where
}
let (root, updates) = ensure_ok_post_block!(
Self::compute_state_root_serial(overlay_factory.clone(), &hashed_state),
Self::compute_state_root_serial_with_provider(
provider_builder.clone(),
&hashed_state
),
block
);
self.compare_trie_updates_with_serial(
overlay_factory.clone(),
&hashed_state,
updates.clone(),
);
if state_root_task_failed {
self.metrics.block_validation.state_root_task_fallback_success_total.increment(1);
}
@@ -1125,6 +1135,14 @@ where
.root_with_updates()?)
}
fn compute_state_root_serial_with_provider(
provider_builder: StateProviderBuilder<N, P>,
hashed_state: &LazyHashedPostState,
) -> ProviderResult<(B256, TrieUpdates)> {
let provider = provider_builder.build()?;
provider.state_root_with_updates(hashed_state.get().clone())
}
/// Awaits the state root from the background task, with an optional timeout fallback.
///
/// If a timeout is configured (`state_root_task_timeout`), this method first waits for the
@@ -1439,7 +1457,6 @@ where
provider_builder: StateProviderBuilder<N, P>,
overlay_factory: OverlayStateProviderFactory<P>,
strategy: StateRootStrategy,
block_access_list: Option<Arc<BlockAccessList>>,
) -> Result<
PayloadHandle<
impl ExecutableTxFor<Evm> + use<N, P, Evm, V, T>,
@@ -1459,7 +1476,6 @@ where
provider_builder,
overlay_factory,
&self.config,
block_access_list,
);
// record prewarming initialization duration
@@ -1472,12 +1488,8 @@ where
}
StateRootStrategy::Parallel | StateRootStrategy::Synchronous => {
let start = Instant::now();
let handle = self.payload_processor.spawn_cache_exclusive(
env,
txs,
provider_builder,
block_access_list,
);
let handle =
self.payload_processor.spawn_cache_exclusive(env, txs, provider_builder);
// Record prewarming initialization duration
self.metrics
@@ -2100,10 +2112,25 @@ impl<T: PayloadTypes> BlockOrPayload<T> {
}
}
/// Returns the block access list if available.
pub const fn block_access_list(&self) -> Option<Result<BlockAccessList, alloy_rlp::Error>> {
// TODO decode and return `BlockAccessList`
None
/// Returns the block access list embedded in a payload, if present.
pub fn block_access_list(&self) -> Option<Result<BlockAccessList, alloy_rlp::Error>> {
match self {
Self::Payload(payload) => payload.block_access_list().map(|block_access_list| {
alloy_rlp::decode_exact::<Bal>(block_access_list.as_ref()).map(Bal::into_inner)
}),
Self::Block(_) => None,
}
}
/// Returns the decoded block access list, if present and successfully decoded.
pub fn try_decoded_access_list(&self) -> Result<Option<DecodedBal>, alloy_rlp::Error> {
match self {
Self::Payload(payload) => payload
.block_access_list()
.map(|block_access_list| DecodedBal::from_rlp_bytes(block_access_list.clone()))
.transpose(),
Self::Block(_) => Ok(None),
}
}
/// Returns the number of transactions in the payload or block.
@@ -2138,4 +2165,15 @@ impl<T: PayloadTypes> BlockOrPayload<T> {
Self::Block(block) => block.gas_used(),
}
}
/// Returns the gas limit used by the block.
pub fn gas_limit(&self) -> u64
where
T::ExecutionData: ExecutionPayload,
{
match self {
Self::Payload(payload) => payload.gas_limit(),
Self::Block(block) => block.gas_limit(),
}
}
}

View File

@@ -10,6 +10,7 @@ use reth_primitives_traits::dashmap::DashMap;
use revm::precompile::{PrecompileId, PrecompileOutput, PrecompileResult};
use revm_primitives::Address;
use std::{hash::Hash, sync::Arc};
use tracing::error;
/// Default max cache size for [`PrecompileCache`]
const MAX_CACHE_SIZE: u32 = 10_000;
@@ -73,7 +74,9 @@ where
}
}
/// Cache entry, precompile successful output.
/// Cache entry for a successful precompile output.
///
/// We intentionally do not cache non-successful statuses or errors.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CacheEntry<S> {
output: PrecompileOutput,
@@ -85,8 +88,14 @@ impl<S> CacheEntry<S> {
self.output.gas_used
}
fn to_precompile_result(&self) -> PrecompileResult {
Ok(self.output.clone())
/// Converts the cache entry to a precompile result. Accepts state gas reservoir as input.
///
/// All cached precompiles are not expected to access/created state and thus reservoir is always
/// kept as is.
fn to_precompile_result(&self, reservoir: u64) -> PrecompileResult {
let mut output = self.output.clone();
output.reservoir = reservoir;
Ok(output)
}
}
@@ -173,20 +182,34 @@ where
input.gas >= entry.gas_used()
{
self.increment_by_one_precompile_cache_hits();
return entry.to_precompile_result()
return entry.to_precompile_result(input.reservoir);
}
let calldata = input.data;
let reservoir = input.reservoir;
let result = self.precompile.call(input);
match &result {
Ok(output) => {
let size = self.cache.insert(
Bytes::copy_from_slice(calldata),
CacheEntry { output: output.clone(), spec: self.spec_id.clone() },
);
self.set_precompile_cache_size_metric(size as f64);
self.increment_by_one_precompile_cache_misses();
// Only successful outputs are cacheable. Non-success statuses and errors must execute
// again instead of poisoning the cache for subsequent calls.
Ok(output) if output.is_success() => {
// Sanity-check precompile output to ensure that it does not affect state gas in any
// way.
//
// This does not fully protect us from caching stateful precompiles but might make
// it obvious when the node is misconfigured.
if output.reservoir != reservoir {
error!(target: "engine::tree", precompile_id = self.precompile.precompile_id().name(), "cacheable precompile decremented reservoir, skipping cache insertion");
} else if output.state_gas_used != 0 {
error!(target: "engine::tree", precompile_id = self.precompile.precompile_id().name(), "cacheable precompile used state gas, skipping cache insertion");
} else {
let size = self.cache.insert(
Bytes::copy_from_slice(calldata),
CacheEntry { output: output.clone(), spec: self.spec_id.clone() },
);
self.set_precompile_cache_size_metric(size as f64);
self.increment_by_one_precompile_cache_misses();
}
}
_ => {
self.increment_by_one_precompile_errors();
@@ -228,17 +251,22 @@ mod tests {
use super::*;
use reth_evm::{EthEvmFactory, Evm, EvmEnv, EvmFactory};
use reth_revm::db::EmptyDB;
use revm::{context::TxEnv, precompile::PrecompileOutput};
use revm::{
context::TxEnv,
precompile::{PrecompileOutput, PrecompileStatus},
};
use revm_primitives::hardfork::SpecId;
#[test]
fn test_precompile_cache_basic() {
let dyn_precompile: DynPrecompile = (|_input: PrecompileInput<'_>| -> PrecompileResult {
Ok(PrecompileOutput {
status: PrecompileStatus::Success,
gas_used: 0,
state_gas_used: 0,
reservoir: 0,
gas_refunded: 0,
bytes: Bytes::default(),
reverted: false,
})
})
.into();
@@ -247,10 +275,12 @@ mod tests {
CachedPrecompile::new(dyn_precompile, PrecompileCache::default(), SpecId::PRAGUE, None);
let output = PrecompileOutput {
status: PrecompileStatus::Success,
gas_used: 50,
state_gas_used: 0,
reservoir: 0,
gas_refunded: 0,
bytes: alloy_primitives::Bytes::copy_from_slice(b"cached_result"),
reverted: false,
};
let input = b"test_input";
@@ -279,10 +309,12 @@ mod tests {
assert_eq!(input.data, input_data);
Ok(PrecompileOutput {
status: PrecompileStatus::Success,
gas_used: 5000,
state_gas_used: 0,
reservoir: 0,
gas_refunded: 0,
bytes: alloy_primitives::Bytes::copy_from_slice(b"output_from_precompile_1"),
reverted: false,
})
}
})
@@ -294,10 +326,12 @@ mod tests {
assert_eq!(input.data, input_data);
Ok(PrecompileOutput {
status: PrecompileStatus::Success,
gas_used: 7000,
state_gas_used: 0,
reservoir: 0,
gas_refunded: 0,
bytes: alloy_primitives::Bytes::copy_from_slice(b"output_from_precompile_2"),
reverted: false,
})
}
})

View File

@@ -184,11 +184,18 @@ impl TestHarness {
let payload_validator = MockEngineValidator;
let (from_tree_tx, from_tree_rx) = unbounded_channel();
let tree_config =
TreeConfig::default().with_legacy_state_root(false).with_has_enough_parallelism(true);
let header = chain_spec.genesis_header().clone();
let header = SealedHeader::seal_slow(header);
let engine_api_tree_state =
EngineApiTreeState::new(10, 10, header.num_hash(), EngineApiKind::Ethereum);
let engine_api_tree_state = EngineApiTreeState::new(
10,
10,
tree_config.invalid_header_hit_eviction_threshold(),
header.num_hash(),
EngineApiKind::Ethereum,
);
let canonical_in_memory_state = CanonicalInMemoryState::with_head(header, None, None);
let (to_payload_service, _payload_command_rx) = unbounded_channel();
@@ -217,8 +224,7 @@ impl TestHarness {
persistence_handle,
PersistenceState { last_persisted_block: BlockNumHash::default(), rx: None },
payload_builder,
// always assume enough parallelism for tests
TreeConfig::default().with_legacy_state_root(false).with_has_enough_parallelism(true),
tree_config,
EngineApiKind::Ethereum,
evm_config,
changeset_cache,

View File

@@ -2,8 +2,8 @@
//! and injecting them into era1 files with `Era1Writer`.
use crate::calculate_td_by_number;
use alloy_consensus::BlockHeader;
use alloy_primitives::{BlockNumber, B256, U256};
use alloy_consensus::{BlockHeader, Sealable, TxReceipt};
use alloy_primitives::{BlockNumber, U256};
use eyre::{eyre, Result};
use reth_era::{
common::file_ops::{EraFileId, StreamWriter},
@@ -13,7 +13,7 @@ use reth_era::{
types::{
execution::{
Accumulator, BlockTuple, CompressedBody, CompressedHeader, CompressedReceipts,
TotalDifficulty, MAX_BLOCKS_PER_ERA1,
HeaderRecord, TotalDifficulty, MAX_BLOCKS_PER_ERA1,
},
group::{BlockIndex, Era1Id},
},
@@ -139,17 +139,21 @@ where
let headers = provider.headers_range(start_block..=end_block)?;
// Extract first 4 bytes of last block's state root as historical identifier
let historical_root = headers
.last()
.map(|header| {
let state_root = header.state_root();
[state_root[0], state_root[1], state_root[2], state_root[3]]
// Pre-compute accumulator from headers to determine filename
let mut precompute_td = total_difficulty;
let header_records: Vec<HeaderRecord> = headers
.iter()
.map(|h| {
precompute_td += h.difficulty();
HeaderRecord { block_hash: h.hash_slow(), total_difficulty: precompute_td }
})
.unwrap_or([0u8; 4]);
.collect();
let accumulator = Accumulator::from_header_records(&header_records)
.map_err(|e| eyre!("Failed to compute accumulator: {e}"))?;
let file_hash: [u8; 4] = accumulator.root[..4].try_into().unwrap();
let era1_id = Era1Id::new(&config.network, start_block, block_count as u32)
.with_hash(historical_root);
let era1_id =
Era1Id::new(&config.network, start_block, block_count as u32).with_hash(file_hash);
let era1_id = if config.max_blocks_per_file == MAX_BLOCKS_PER_ERA1 as u64 {
era1_id
@@ -166,7 +170,6 @@ where
let mut offsets = Vec::<i64>::with_capacity(block_count);
let mut position = VERSION_ENTRY_SIZE as i64;
let mut blocks_written = 0;
let mut final_header_data = Vec::new();
for (i, header) in headers.into_iter().enumerate() {
let expected_block_number = start_block + i as u64;
@@ -178,11 +181,6 @@ where
&mut total_difficulty,
)?;
// Save last block's header data for accumulator
if expected_block_number == end_block {
final_header_data = compressed_header.data.clone();
}
let difficulty = TotalDifficulty::new(total_difficulty);
let header_size = compressed_header.data.len() + ENTRY_HEADER_SIZE;
@@ -218,10 +216,12 @@ where
}
}
if blocks_written > 0 {
let accumulator_hash =
B256::from_slice(&final_header_data[0..32.min(final_header_data.len())]);
let accumulator = Accumulator::new(accumulator_hash);
let block_index = BlockIndex::new(start_block, offsets);
// Convert absolute offsets to relative (measured from block-index entry start)
let accumulator_entry_size = (ENTRY_HEADER_SIZE + 32) as i64;
let block_index_position = position + accumulator_entry_size;
let relative_offsets: Vec<i64> =
offsets.iter().map(|&abs| abs - block_index_position).collect();
let block_index = BlockIndex::new(start_block, relative_offsets);
writer.write_accumulator(&accumulator)?;
writer.write_block_index(&block_index)?;
@@ -310,7 +310,9 @@ where
let compressed_header = CompressedHeader::from_header(&header)?;
let compressed_body = CompressedBody::from_body(&body)?;
let compressed_receipts = CompressedReceipts::from_encodable_list(&receipts)
let receipts_with_bloom: Vec<_> =
receipts.iter().map(|r| TxReceipt::with_bloom_ref(r)).collect();
let compressed_receipts = CompressedReceipts::from_encodable_list(&receipts_with_bloom)
.map_err(|e| eyre!("Failed to compress receipts: {}", e))?;
Ok((compressed_header, compressed_body, compressed_receipts))

View File

@@ -24,6 +24,7 @@ snap.workspace = true
# ssz encoding and decoding
ethereum_ssz.workspace = true
ethereum_ssz_derive.workspace = true
sha2.workspace = true
[dev-dependencies]
eyre.workspace = true

View File

@@ -76,6 +76,7 @@ use crate::{
use alloy_consensus::{Block, BlockBody, Header};
use alloy_primitives::{B256, U256};
use alloy_rlp::{Decodable, Encodable};
use sha2::{Digest, Sha256};
use snap::{read::FrameDecoder, write::FrameEncoder};
use std::{
io::{Read, Write},
@@ -493,6 +494,73 @@ impl Accumulator {
Ok(Self { root: B256::from(root) })
}
/// Compute the accumulator from a list of header records.
///
/// Implements `hash_tree_root(List[HeaderRecord, 8192])` per the ERA1 spec:
/// - Each leaf is `sha256(block_hash || total_difficulty_le_bytes32)`
/// - Leaves are padded to `MAX_BLOCKS_PER_ERA1` (8192) with zero hashes
/// - Binary Merkle tree is computed bottom-up
/// - Final root is `sha256(merkle_root || le_bytes32(actual_count))`
///
/// Returns `Err` if `records` exceeds [`MAX_BLOCKS_PER_ERA1`].
pub fn from_header_records(records: &[HeaderRecord]) -> Result<Self, E2sError> {
let capacity = MAX_BLOCKS_PER_ERA1;
if records.len() > capacity {
return Err(E2sError::Ssz(format!(
"Too many header records: got {}, max {}",
records.len(),
capacity
)));
}
// Compute leaf hash for each header record
let mut leaves = Vec::with_capacity(capacity);
for record in records {
let mut data = [0u8; 64];
data[..32].copy_from_slice(record.block_hash.as_slice());
data[32..].copy_from_slice(&record.total_difficulty.to_le_bytes::<32>());
leaves.push(<[u8; 32]>::from(Sha256::digest(data)));
}
// Pad to capacity with zero hashes
leaves.resize(capacity, [0u8; 32]);
// Binary Merkle tree bottom-up (capacity is always a power of two)
while leaves.len() > 1 {
let mut next_level = Vec::with_capacity(leaves.len() / 2);
for pair in leaves.chunks_exact(2) {
let mut data = [0u8; 64];
data[..32].copy_from_slice(&pair[0]);
data[32..].copy_from_slice(&pair[1]);
next_level.push(<[u8; 32]>::from(Sha256::digest(data)));
}
leaves = next_level;
}
let merkle_root = leaves[0];
// mix_in_length: sha256(merkle_root || le_bytes32(actual_length))
let mut mix = [0u8; 64];
mix[..32].copy_from_slice(&merkle_root);
let length = records.len() as u64;
mix[32..40].copy_from_slice(&length.to_le_bytes());
// remaining bytes stay zero (uint256 LE padding)
Ok(Self { root: B256::from(<[u8; 32]>::from(Sha256::digest(mix))) })
}
}
/// A header record used to compute the ERA1 accumulator.
///
/// Per the ERA1 spec: `header-record := { block-hash: Bytes32, total-difficulty: Uint256 }`
#[derive(Debug, Clone)]
pub struct HeaderRecord {
/// The block hash (keccak256 of RLP-encoded header)
pub block_hash: B256,
/// The cumulative total difficulty at this block
pub total_difficulty: U256,
}
/// A block tuple in an Era1 file, containing all components for a single block
@@ -691,6 +759,44 @@ mod tests {
}
}
#[test]
fn test_accumulator_from_header_records_known_vectors() {
// Known-answer vectors computed from the SSZ spec:
// hash_tree_root(List[HeaderRecord, 8192])
let expected_empty: B256 =
"4a8c3a07c8d23adc5bac61157555c3c784d53d9bc110c1370809bd23cd93777d".parse().unwrap();
let expected_single_zero: B256 =
"81fd641249670887a731386e756a7a1538dc781b1b0bf016889045d350812817".parse().unwrap();
let expected_single_nonzero: B256 =
"ada35c48d81117f4fd588554cd4c4752356336e84cb41106dea1ceb4cfac8799".parse().unwrap();
// Empty list
let acc_empty = Accumulator::from_header_records(&[]).unwrap();
assert_eq!(acc_empty.root, expected_empty);
// Single record with zero values
let records = vec![HeaderRecord { block_hash: B256::ZERO, total_difficulty: U256::ZERO }];
let acc = Accumulator::from_header_records(&records).unwrap();
assert_eq!(acc.root, expected_single_zero);
// Single record with non-zero values
let records2 = vec![HeaderRecord {
block_hash: B256::from([1u8; 32]),
total_difficulty: U256::from(100u64),
}];
let acc2 = Accumulator::from_header_records(&records2).unwrap();
assert_eq!(acc2.root, expected_single_nonzero);
}
#[test]
fn test_accumulator_rejects_oversized_input() {
let records = vec![
HeaderRecord { block_hash: B256::ZERO, total_difficulty: U256::ZERO };
MAX_BLOCKS_PER_ERA1 + 1
];
assert!(Accumulator::from_header_records(&records).is_err());
}
#[test]
fn test_receipt_list_compression() {
let receipts = create_test_receipts();

View File

@@ -102,8 +102,8 @@ pub struct Era1Id {
/// Number of blocks in the file
pub block_count: u32,
/// Optional hash identifier for this file
/// First 4 bytes of the last historical root in the last state in the era file
/// Optional hash identifier for this file.
/// First 4 bytes of the accumulator root hash.
pub hash: Option<[u8; 4]>,
/// Whether to include era count in filename

View File

@@ -34,6 +34,8 @@ pub(crate) fn create_header() -> Header {
excess_blob_gas: None,
parent_beacon_block_root: None,
requests_hash: None,
block_access_list_hash: None,
slot_number: None,
}
}
@@ -138,6 +140,8 @@ pub(crate) fn create_test_block_with_compressed_data(number: BlockNumber) -> Blo
excess_blob_gas: None,
parent_beacon_block_root: None,
requests_hash: None,
block_access_list_hash: None,
slot_number: None,
};
// Create test body

View File

@@ -491,7 +491,6 @@ mod tests {
fn parse_env_filter_directives() {
let temp_dir = tempfile::tempdir().unwrap();
unsafe { std::env::set_var("RUST_LOG", "info,evm=debug") };
let reth = Cli::try_parse_args_from([
"reth",
"init",

View File

@@ -109,13 +109,8 @@ where
result: &BlockExecutionResult<N::Receipt>,
receipt_root_bloom: Option<ReceiptRootBloom>,
) -> Result<(), ConsensusError> {
let res = validate_block_post_execution(
block,
&self.chain_spec,
&result.receipts,
&result.requests,
receipt_root_bloom,
);
let res =
validate_block_post_execution(block, &self.chain_spec, result, receipt_root_bloom);
if self.skip_requests_hash_check &&
let Err(ConsensusError::BodyRequestsHashDiff(_)) = &res

View File

@@ -1,9 +1,10 @@
use alloc::vec::Vec;
use alloy_consensus::{proofs::calculate_receipt_root, BlockHeader, TxReceipt};
use alloy_eips::{eip7685::Requests, Encodable2718};
use alloy_eips::Encodable2718;
use alloy_primitives::{Bloom, Bytes, B256};
use reth_chainspec::EthereumHardforks;
use reth_consensus::ConsensusError;
use reth_execution_types::BlockExecutionResult;
use reth_primitives_traits::{
receipt::gas_spent_by_transactions, Block, GotExpected, Receipt, RecoveredBlock,
};
@@ -18,8 +19,7 @@ use reth_primitives_traits::{
pub fn validate_block_post_execution<B, R, ChainSpec>(
block: &RecoveredBlock<B>,
chain_spec: &ChainSpec,
receipts: &[R],
requests: &Requests,
result: &BlockExecutionResult<R>,
receipt_root_bloom: Option<(B256, Bloom)>,
) -> Result<(), ConsensusError>
where
@@ -28,12 +28,10 @@ where
ChainSpec: EthereumHardforks,
{
// Check if gas used matches the value set in header.
let cumulative_gas_used =
receipts.last().map(|receipt| receipt.cumulative_gas_used()).unwrap_or(0);
if block.header().gas_used() != cumulative_gas_used {
if block.header().gas_used() != result.gas_used {
return Err(ConsensusError::BlockGasUsed {
gas: GotExpected { got: cumulative_gas_used, expected: block.header().gas_used() },
gas_spent_by_tx: gas_spent_by_transactions(receipts),
gas: GotExpected { got: result.gas_used, expected: block.header().gas_used() },
gas_spent_by_tx: gas_spent_by_transactions(&result.receipts),
})
}
@@ -42,7 +40,7 @@ where
// transaction This was replaced with is_success flag.
// See more about EIP here: https://eips.ethereum.org/EIPS/eip-658
if chain_spec.is_byzantium_active_at_block(block.header().number()) {
let result = if let Some((receipts_root, logs_bloom)) = receipt_root_bloom {
let res = if let Some((receipts_root, logs_bloom)) = receipt_root_bloom {
compare_receipts_root_and_logs_bloom(
receipts_root,
logs_bloom,
@@ -50,11 +48,16 @@ where
block.header().logs_bloom(),
)
} else {
verify_receipts(block.header().receipts_root(), block.header().logs_bloom(), receipts)
verify_receipts(
block.header().receipts_root(),
block.header().logs_bloom(),
&result.receipts,
)
};
if let Err(error) = result {
let receipts = receipts
if let Err(error) = res {
let receipts = result
.receipts
.iter()
.map(|r| Bytes::from(r.with_bloom_ref().encoded_2718()))
.collect::<Vec<_>>();
@@ -68,7 +71,7 @@ where
let Some(header_requests_hash) = block.header().requests_hash() else {
return Err(ConsensusError::RequestsHashMissing)
};
let requests_hash = requests.requests_hash();
let requests_hash = result.requests.requests_hash();
if requests_hash != header_requests_hash {
return Err(ConsensusError::BodyRequestsHashDiff(
GotExpected::new(requests_hash, header_requests_hash).into(),

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