Skip to content

feat(l1): pruning of historical blocks/receipts/txs - #6673

Open
iovoid wants to merge 24 commits into
mainfrom
feat/history-pruning
Open

feat(l1): pruning of historical blocks/receipts/txs#6673
iovoid wants to merge 24 commits into
mainfrom
feat/history-pruning

Conversation

@iovoid

@iovoid iovoid commented May 18, 2026

Copy link
Copy Markdown
Contributor

Motivation

Block bodies, receipts and transaction locations grow without bound, and after Glamsterdam they grow faster. The consensus layer is only required to serve blocks over MIN_EPOCHS_FOR_BLOCK_REQUESTS — 33024 epochs, roughly 4.8 months — and that is the natural lower bound for execution history: the EL must be able to supply payloads at least as long as the CL has to serve them, and anything beyond that is optional. Following the ACDE discussion (ethereum/pm#2197), that window is now the default retention.

Description

Background pruning of block bodies, receipts and transaction locations below a retention barrier. Canonical headers are always kept. A new block-number → known-hashes-at-that-height table lets orphaned blocks be pruned too.

Flag Values Default
--history.retention cl-window | all | <N>epochs cl-window
--history.retention.below-cl-window bool false

Bare numbers and wall-clock durations are rejected. --history.chain already takes an absolute block number, so a neighbouring flag where a bare number meant a distance would read identically and mean something else.

The barrier is one subtraction on block numbers, not a timestamp search:

head - (epochs * SLOTS_PER_EPOCH + CL_WINDOW_SLACK_BLOCKS)

A block distance is a sound proxy for a slot distance because EIP-3675 permits at most one block per slot and a missed slot produces no block, so the first canonical block inside the window always sits above the barrier. The error is one-sided toward over-retention — missed slots make us keep more, never less. This removes two hazards the earlier timestamp-based cutoff had: it derived the cutoff from the host clock, so a machine skewed a year forward pruned a year of real history, and the bisection assumed every canonical height in range had a header, which is false on a database with a header hole. HistoryRetention::Blocks exposes the underlying unit, since an epoch is 32 blocks but the slack alone is 7200, so no epoch value can produce a barrier above zero on a test-sized chain.

A default that prunes must never delete history a datadir already holds. So: an explicit flag prunes, the default only prunes what it would have kept anyway. A node holding pre-window history keeps it and warns on every boot until the operator opts in. The staleness probe for the earliest-block pointer reads block 1, never 0 — genesis always has a body, so probing 0 always succeeds and would conclude a stale pointer was sound, grandfathering every node that snap-synced on a build predating #7024.

--history.chain disables pruning by default rather than fighting it: backfilled history would otherwise be deleted as fast as it arrives.

Read paths that could conflate "absent" with "empty" now report unavailable history with JSON-RPC code 4444 instead of returning a plausible-but-wrong value: eth_getLogs (both the blockHash and range branches, plus the per-transaction receipt fetch) and log_index_base, which previously summed a short receipt read and silently shifted every log index in a trace.

Two setters now exist where there was one. The pruner's floor only ever rises, so advance_earliest_block_number refuses to regress; backfill fills history in, so reconciliation moves the pointer down and needs set_earliest_block_number. A single monotone-up setter silently swallowed the second case.

Not in scope, deliberately

  • BALs are not pruned. A BAL miss sends engine_getPayloadBodiesBy*V2 through a full block re-execution before degrading, so pruning them would put repeated failed re-executions on the consensus-critical path. They grow below the barrier; that is a known, smaller cost than the alternative.
  • State is unaffected — only the latest state is kept regardless, so there is no "archive" mode to offer.
  • The trace_* namespace and eth_gasPrice's 20-block lookback are untouched; the latter is safe only because it sits under the pruner's 256-block near-head floor, which is a constant coincidence worth a follow-up.

Depends on

#7207 (eth_feeHistory range inversion — an unauthenticated remote abort that pruning makes far easier to hit), #7208 (debug_getRawReceipts answering empty for absent receipts) and #7210 (advertising the earliest block we can actually serve, which peers use to decide what to request). This branch's update.rs change overlaps #7210 and should keep this version — its error handling is stricter.

Tests

  • Barrier arithmetic: the default equals MIN_EPOCHS_FOR_BLOCK_REQUESTS; all never prunes; a chain shorter than the window keeps everything rather than underflowing; and the one-sided property that makes the block-distance proxy safe.
  • The pruner's five floors — finality, retention, near-head, persisted-state and the L2 committed-batch cap — each in isolation, plus multi-chunk passes, orphan deletion, genesis never being pruned, and restart resilience.
  • CLI parsing of every accepted and rejected spelling, including that an absent flag stays distinguishable from an explicit cl-window, since only an explicit one may delete existing history.
  • eth_getLogs returning 4444 rather than a wrong answer on pruned ranges.

Whole workspace is clean under cargo clippy --all-targets -- -D warnings; 127 storage, 121 rpc, 58 blockchain and 967 integration tests pass.

iovoid added 2 commits May 18, 2026 16:36
Adds opt-in rolling-window pruning of block bodies, receipts, and
transaction-location entries for canonical blocks below a configurable
wall-clock retention window. Canonical headers are preserved; orphan
block data at finalized heights is also deleted.

Architecture:
- New BLOCK_HASHES_BY_NUMBER CF (`BE block_number || block_hash` -> empty)
  indexes every known hash per height. Written on all header-insertion
  paths, consumed by the pruner for per-height enumeration.
- Schema v1 -> v2 migration: backfills the new CF from HEADERS via
  StorageReadView::full_scan (streaming, bounded heap, no Keccak
  recomputation). Also fixes EarliestBlockNumber on snap-synced v1 DBs
  by binary-searching for the lowest canonical block with a body.
- HistoryPruner background task: every 12s, computes
  `target = min(finalized, find_canonical_block_by_timestamp(now - retention))`
  and atomically prunes a contiguous range via Store::prune_block_heights.
  Each pass: rayon-parallel gather phase (one read txn per height) +
  single WriteBatch with DeleteRange on the per-height index and
  body-conditional bodies/receipts/tx_locations deletes (no pointless
  tombstones for snap-synced pre-pivot heights). Bounded by a 2s pass
  timeout and 4096-block batch. Feature-gated metrics
  (earliest/target/lag gauges, pass-duration/blocks histograms,
  per-category deletion counters).
- Snap-sync completion sets EarliestBlockNumber to the pivot from
  persisted chain metadata (before clearing snap state, so a crash in
  the window leaves the operation retryable).
- For chains without engine-API finality (or any node before its first
  FCU) the pruner falls back to `head - 256` as the prune floor so it
  still does useful work.
- RPC: eth_getBlockBy{Number,Hash} with include_txs=true returns the
  header with an empty transactions list for pruned blocks;
  eth_getLogs clamps fromBlock to EarliestBlockNumber while preserving
  the malformed-range error; tx-by-hash, receipt, and getBlockReceipts
  return null naturally.
- CLI: `--history.retention=<duration>` (e.g. 1d, 12h, 365d) enables
  the pruner; omitted by default.
# Conflicts:
#	cmd/ethrex/cli.rs
#	crates/storage/api/tables.rs
#	crates/storage/store.rs
@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown

Lines of code report

Total lines added: 2776
Total lines removed: 9
Total lines changed: 2785

Detailed view
+-------------------------------------------------+-------+------+
| File                                            | Lines | Diff |
+-------------------------------------------------+-------+------+
| ethrex/cmd/ethrex/cli.rs                        | 1504  | +75  |
+-------------------------------------------------+-------+------+
| ethrex/cmd/ethrex/ethrex.rs                     | 188   | +8   |
+-------------------------------------------------+-------+------+
| ethrex/cmd/ethrex/initializers.rs               | 1132  | +82  |
+-------------------------------------------------+-------+------+
| ethrex/cmd/ethrex/l2/initializers.rs            | 612   | +91  |
+-------------------------------------------------+-------+------+
| ethrex/crates/blockchain/metrics/mod.rs         | 62    | +2   |
+-------------------------------------------------+-------+------+
| ethrex/crates/blockchain/metrics/pruning.rs     | 92    | +92  |
+-------------------------------------------------+-------+------+
| ethrex/crates/blockchain/tracing.rs             | 300   | +7   |
+-------------------------------------------------+-------+------+
| ethrex/crates/l2/sequencer/state_updater.rs     | 282   | -9   |
+-------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/rlpx/eth/update.rs | 73    | +5   |
+-------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/sync/snap_sync.rs  | 1132  | +2   |
+-------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/eth/block.rs       | 510   | +113 |
+-------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/eth/fee_market.rs  | 275   | +40  |
+-------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/eth/logs.rs        | 768   | +164 |
+-------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/eth/transaction.rs | 772   | +87  |
+-------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/rpc.rs             | 1598  | +1   |
+-------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/utils.rs           | 357   | +7   |
+-------------------------------------------------+-------+------+
| ethrex/crates/storage/api/mod.rs                | 92    | +11  |
+-------------------------------------------------+-------+------+
| ethrex/crates/storage/api/tables.rs             | 38    | +12  |
+-------------------------------------------------+-------+------+
| ethrex/crates/storage/backend/in_memory.rs      | 199   | +13  |
+-------------------------------------------------+-------+------+
| ethrex/crates/storage/backend/rocksdb.rs        | 648   | +38  |
+-------------------------------------------------+-------+------+
| ethrex/crates/storage/lib.rs                    | 22    | +2   |
+-------------------------------------------------+-------+------+
| ethrex/crates/storage/migrations.rs             | 808   | +381 |
+-------------------------------------------------+-------+------+
| ethrex/crates/storage/pruner.rs                 | 802   | +802 |
+-------------------------------------------------+-------+------+
| ethrex/crates/storage/store.rs                  | 5803  | +730 |
+-------------------------------------------------+-------+------+
| ethrex/crates/storage/utils.rs                  | 28    | +11  |
+-------------------------------------------------+-------+------+

iovoid added 7 commits June 9, 2026 14:51
Resolved conflicts from main's TRANSACTION_LOCATIONS schema change and
overlapping schema-version bump:

- Schema bumped to v4. main's tx-locations rewrite stays v2->v3
  (migrate_2_to_3); this branch's BLOCK_HASHES_BY_NUMBER backfill becomes
  v3->v4 (migrate_3_to_4), so released-main nodes (already at v3) only run
  the new backfill.
- write_block_indices now uses the merge-based tx-location write
  (tx.merge / encode_tx_location_operand) instead of composite-key puts.
- prune_block_height reworked for the tx-hash-keyed Vec schema:
  read-modify-write per tx (trim pruned blocks' locations, delete key if
  empty), accumulated across the batch so a tx in multiple pruned blocks
  is handled once.
- Kept both api/backend trait methods (delete_range + merge) and both
  test modules (pruning_index_tests + merge_tests).
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

⚠️ Known Issues — intentionally skipped tests

Source: docs/known_issues.md

rpc-compat log-bearing cases excluded

Where: KNOWN_EXCLUDED_TESTS in .github/scripts/check-hive-results.sh counts out
eight hive rpc-compat cases — the four eth_getLogs cases, eth_getBlockReceipts/get-block-receipts-latest,
and three eth_getTransactionReceipt cases. They are exactly the cases whose recorded
response contains at least one log object; every case with an empty log array still runs.
Note this leaves eth_getLogs with no rpc-compat coverage at all, since all four of its
cases are in the set.

Why: ethrex populates blockTimestamp on log objects, as geth, besu, nethermind, reth
and erigon all do. hive's rpc-compat compares responses byte-exactly (jsondiff.FullMatch;
the lenient checkJSONStructure path applies only to cases upstream marks speconly), and
the corpus is pinned to execution-apis d08382ae (2025-02-10), whose recordings predate the
field — it entered the schema in execution-apis#639 and the fixtures in #846 (2026-07-22).
So the extra key cannot match, and this is a property of the pin rather than of the response.

The pin cannot move, and this is not temporary. The pin sits one commit before
execution-apis#627, which moved the test chain to a pre-merge genesis: the current corpus has
~36 proof-of-work blocks before its terminal total difficulty. ethrex does not support
pre-merge chains and will not, so importing that chain.rlp fails at block 1 —
validate_block_header has no pre-London base-fee path. Every revision carrying
blockTimestamp in its fixtures also carries that chain, so there is no revision that
satisfies both. Nor can the corpus be patched locally: rpc-compat's Dockerfile clones
ethereum/execution-apis by hard-coded URL, so the branch buildarg cannot point at a fork.

Coverage: the field itself is pinned by
block_timestamp_is_on_the_log_and_not_on_the_receipt in
crates/networking/rpc/types/receipt.rs, which asserts it is present on each log and absent
from the receipt level.

Removal: delete the entries if ethrex ever gains pre-merge chain import, or if upstream
marks these cases speconly so they are type-checked instead of compared byte-for-byte.


The stateless schema id does not identify the encoding

Where: STATELESS_INPUT_SCHEMA_ID in crates/common/types/stateless_ssz.rs.

Upstream keeps the stateless input schema id at 0x1501
(fork_index 0x15 << 8 | revision 0x01) across incompatible body changes. Three
encodings have now shipped under it: tests-zkevm@v0.6.2, then #3248 + #3278,
then #3356, which moved state, codes and public_keys from SszList to
ProgressiveList. ethrex speaks the last one.

The consequence is that the 2-byte prefix cannot be used to detect a stale or
mismatched bundle. A wrong-dialect input is accepted by the id check and then
fails later — in SSZ decode, or on a root that does not match — rather than being
rejected up front for what it is. only_amsterdam_schema_id_decodes therefore
proves less than its name suggests.

Worth raising upstream: a revision field that does not move across a body change
provides no version negotiation at all.


ZisK guest program hash changes with the unsync_cell gate

Where: crates/common/types/block.rs, transaction.rs.

The gate on the single-threaded unsync_cell::OnceCell moved from
all(feature = "eip-8025", target_arch = "riscv64") to
all(feature = "zisk", target_arch = "riscv64") when the eip-8025 feature was removed.

The guest ELFs were previously built --features "<zkvm>-build-elf,ci", which never enabled
eip-8025, so they compiled the atomic once_cell variant. bin/zisk/Cargo.toml does enable
ethrex-common/zisk, so the ZisK guest now compiles the unsafe impl Sync cell instead.
That changes the ELF bytes and therefore the program hash and verification key.

This is intended (the guest is single-threaded, so the unsync cell is sound and cheaper), but it
is a VK change rather than a no-op refactor, and the diffstat presents it as a file rename
(eip8025_cell.rsunsync_cell.rs). Anyone pinning a ZisK VK across this change must
re-register it. The stateless-validator crate now forwards ethrex-common/zisk from its own
zisk feature so the two ZisK guests do not disagree on the cell type.


Release signing key is an unprotected repository secret

Where: .github/workflows/tag_release.yaml.

MINISIGN_SECRET_KEY is a plain repository secret. There is no environment: on
finalize-release or dry-run-release-assets, and gh api repos/lambdaclass/ethrex/rulesets
shows only branch-targeted rulesets, so the github.ref_type == 'tag' condition is a workflow
check rather than an enforced boundary: anyone who can push a tag can reach the signing key.

This is a repository-settings change, not a code change, so it is recorded here rather than
fixed in the tree. Recommended:

  1. Move MINISIGN_SECRET_KEY / MINISIGN_PASSWORD into a GitHub Environment with required
    reviewers, and add environment: to the two jobs that sign.
  2. Add a ruleset targeting refs/tags/v* restricting who may create release tags.

Until then, the compromise of that key is silent and durable: signatures would still verify
against the committed .github/minisign.pub.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Benchmark Results Comparison

No significant difference was registered for any benchmark run.

Detailed Results

Benchmark Results: BubbleSort

Command Mean [s] Min [s] Max [s] Relative
main_revm_BubbleSort 3.059 ± 0.040 3.017 3.126 1.17 ± 0.02
main_levm_BubbleSort 2.633 ± 0.035 2.607 2.722 1.01 ± 0.02
pr_revm_BubbleSort 3.039 ± 0.020 3.022 3.086 1.16 ± 0.02
pr_levm_BubbleSort 2.617 ± 0.037 2.599 2.720 1.00

Benchmark Results: ERC20Approval

Command Mean [s] Min [s] Max [s] Relative
main_revm_ERC20Approval 1.003 ± 0.008 0.992 1.019 1.05 ± 0.01
main_levm_ERC20Approval 0.971 ± 0.010 0.958 0.985 1.01 ± 0.01
pr_revm_ERC20Approval 0.991 ± 0.009 0.979 1.003 1.03 ± 0.01
pr_levm_ERC20Approval 0.959 ± 0.007 0.947 0.971 1.00

Benchmark Results: ERC20Mint

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Mint 139.0 ± 2.7 136.6 145.5 1.01 ± 0.02
main_levm_ERC20Mint 150.3 ± 4.1 147.2 157.7 1.09 ± 0.03
pr_revm_ERC20Mint 137.3 ± 1.5 135.5 140.9 1.00
pr_levm_ERC20Mint 148.3 ± 1.2 146.5 150.2 1.08 ± 0.01

Benchmark Results: ERC20Transfer

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Transfer 239.5 ± 2.6 236.6 243.4 1.02 ± 0.02
main_levm_ERC20Transfer 244.3 ± 1.7 241.9 246.7 1.04 ± 0.01
pr_revm_ERC20Transfer 235.6 ± 2.3 232.5 239.0 1.00
pr_levm_ERC20Transfer 245.4 ± 5.4 240.8 260.0 1.04 ± 0.03

Benchmark Results: Factorial

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Factorial 226.5 ± 1.5 223.2 229.3 1.00
main_levm_Factorial 250.8 ± 0.8 249.3 251.7 1.11 ± 0.01
pr_revm_Factorial 227.0 ± 2.1 225.1 232.5 1.00 ± 0.01
pr_levm_Factorial 250.0 ± 1.0 248.5 251.9 1.10 ± 0.01

Benchmark Results: FactorialRecursive

Command Mean [s] Min [s] Max [s] Relative
main_revm_FactorialRecursive 1.638 ± 0.058 1.524 1.694 1.00
main_levm_FactorialRecursive 8.605 ± 0.026 8.555 8.648 5.25 ± 0.19
pr_revm_FactorialRecursive 1.657 ± 0.015 1.633 1.679 1.01 ± 0.04
pr_levm_FactorialRecursive 8.574 ± 0.030 8.529 8.621 5.23 ± 0.19

Benchmark Results: Fibonacci

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Fibonacci 209.9 ± 2.9 208.1 218.1 1.01 ± 0.01
main_levm_Fibonacci 235.6 ± 30.4 221.3 321.6 1.13 ± 0.15
pr_revm_Fibonacci 208.3 ± 0.9 205.8 208.9 1.00
pr_levm_Fibonacci 232.6 ± 15.7 220.7 274.9 1.12 ± 0.08

Benchmark Results: FibonacciRecursive

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_FibonacciRecursive 889.1 ± 10.3 874.5 906.4 1.26 ± 0.02
main_levm_FibonacciRecursive 715.1 ± 11.2 704.0 740.2 1.01 ± 0.02
pr_revm_FibonacciRecursive 890.9 ± 8.4 873.3 902.3 1.26 ± 0.01
pr_levm_FibonacciRecursive 707.9 ± 4.5 700.8 716.1 1.00

Benchmark Results: ManyHashes

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ManyHashes 8.3 ± 0.0 8.3 8.4 1.00
main_levm_ManyHashes 9.4 ± 0.1 9.2 9.6 1.13 ± 0.02
pr_revm_ManyHashes 8.4 ± 0.1 8.3 8.5 1.01 ± 0.01
pr_levm_ManyHashes 9.2 ± 0.0 9.2 9.3 1.11 ± 0.01

Benchmark Results: MstoreBench

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_MstoreBench 254.8 ± 3.4 252.4 263.9 1.33 ± 0.02
main_levm_MstoreBench 194.7 ± 8.9 189.7 219.8 1.01 ± 0.05
pr_revm_MstoreBench 258.1 ± 4.4 253.2 264.9 1.34 ± 0.03
pr_levm_MstoreBench 191.9 ± 1.6 189.5 193.8 1.00

Benchmark Results: Push

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Push 295.9 ± 0.7 294.0 296.7 1.22 ± 0.02
main_levm_Push 244.0 ± 3.5 240.9 250.4 1.01 ± 0.02
pr_revm_Push 296.0 ± 1.8 293.8 300.5 1.22 ± 0.02
pr_levm_Push 242.5 ± 3.8 240.4 253.0 1.00

Benchmark Results: SstoreBench_no_opt

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_SstoreBench_no_opt 176.6 ± 7.6 168.0 190.2 1.73 ± 0.08
main_levm_SstoreBench_no_opt 103.7 ± 1.5 101.6 105.1 1.02 ± 0.02
pr_revm_SstoreBench_no_opt 173.1 ± 5.4 168.1 188.0 1.70 ± 0.06
pr_levm_SstoreBench_no_opt 102.0 ± 1.0 101.3 104.5 1.00

@yorickdowne

Copy link
Copy Markdown

Wall clock is an interesting decision. Every other client does either blocks or epochs. Ofc slot time is deterministic and wall clock can be computed from epochs by the user

ilitteri added a commit that referenced this pull request Aug 24, 2026
get_receipts_for_block returns a bare Vec, so "this block's receipts are not
stored" and "this block had no transactions" are the same value. get_all_block_receipts
passed that straight through, so debug_getRawReceipts answered an empty list for a
block that has transactions — a wrong answer rather than a reported failure.

Check the receipt count against the block's own transaction count, and report a
block whose body is absent rather than validating against nothing. This mirrors the
mismatch check the by-index receipt path already performs. Genesis keeps its
existing short circuit, since it legitimately has none.

This matters ahead of history pruning (#6673): an absent receipt set stops being a
corruption signal and becomes a normal steady-state outcome, so every path that
conflates it with emptiness starts returning wrong answers on every node rather
than on a corrupted one.
Conflicts and API drift from 47 commits of main:

- chain_data_key: this branch hoists it into utils.rs as the single source of
  truth shared with migrations.rs, which its own code depends on at five call
  sites; main kept it in store.rs and added decode_block_number beside it. Kept
  the move, kept main's new decode_block_number, and dropped the From<u8> impl
  the branch re-added — main deleted it as dead in #7187 and it is unused here too.
- get_latest_block_number is sync on main now; dropped three stale .awaits.
- InMemoryPrefixIter was removed by #7186; full_scan returns the iterator
  directly, matching its sibling.
- update_earliest_block_number was renamed here to advance_earliest_block_number
  with monotone-up semantics, which main's descending writers cannot use. Added
  set_earliest_block_number for them: the pruner's floor only rises, but backfill
  fills history in and must be able to move the pointer down.
- BlockRangeUpdate::new awaited without being async, so the branch did not build.
Test setup sites establish a starting frontier rather than advancing a floor, so
the monotone-up guard is wrong for them.
The window a consensus client must serve blocks over is the natural lower bound
for execution history: the EL has to supply payloads at least as long as the CL
has to serve them. That window is MIN_EPOCHS_FOR_BLOCK_REQUESTS, 33024 epochs
(MIN_VALIDATOR_WITHDRAWABILITY_DELAY 256 + CHURN_LIMIT_QUOTIENT 65536 / 2), and it
is now the default.

The flag becomes `cl-window | all | <N>epochs` instead of a wall-clock duration.
Bare numbers are rejected: --history.chain already takes an absolute block number,
so a neighbouring flag where a bare number meant a distance would read identically
and mean something else.

The barrier is now one subtraction on block numbers rather than a timestamp search
from the host clock. A block distance is a sound proxy for a slot distance because
EIP-3675 permits at most one block per slot and a missed slot produces no block, so
the first canonical block inside the window is always above head - window - slack;
the error is one-sided toward keeping more. This removes two hazards: a host clock
skewed a year forward pruned a year of history, and the timestamp bisection assumed
every canonical height in range had a header, which is false on a database with a
header hole. HistoryRetention::Blocks carries the underlying unit, since an epoch is
32 blocks but the slack alone is 7200, so no epoch value can produce a barrier above
zero on a test-sized chain.

A default that prunes must not delete history an existing datadir already holds, so
the gate is: an explicit flag prunes, the default only prunes what it would have kept
anyway. A node holding pre-window history keeps it and warns every boot until the
operator opts in. The staleness probe reads block 1, never 0 — genesis always has a
body, so probing 0 always succeeds and would conclude a stale earliest-block pointer
was sound, grandfathering every node that snap-synced on an older build.

--history.chain disables pruning by default rather than fighting it: backfilled
history would otherwise be deleted as fast as it arrives.
Covers the default equalling MIN_EPOCHS_FOR_BLOCK_REQUESTS, All never pruning,
a chain shorter than the window keeping everything rather than underflowing, and
the one-sided property that makes a block distance a safe proxy for a slot
distance.
Two read paths treated an absent receipt set as something other than absent
history, which becomes the normal outcome below the prune barrier rather than a
corruption signal.

log_index_base summed whatever receipts came back with no count check, so a short
read silently under-counted and shifted every log index in the trace — a wrong
answer rather than a reported failure. It now checks the count against the block's
transaction count.

eth_getLogs reported a missing per-transaction receipt as an internal error. A
caller can act on 4444 by asking a node that still holds the range; it cannot act
on a 500.
@ilitteri
ilitteri marked this pull request as ready for review August 24, 2026 20:44
@ilitteri
ilitteri requested a review from a team as a code owner August 24, 2026 20:44
@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

This is a substantial and well-engineered PR introducing history pruning to ethrex. The implementation is conservative, safety-focused, and includes comprehensive tests. Below are specific findings and suggestions.

Critical Safety Checks (All Clear)

  1. Genesis Protection: LOWEST_PRUNABLE_BLOCK = 1 correctly preserves block 0 (crates/storage/pruner.rs:121).
  2. State Regeneration Safety: The pruner caps at get_latest_persisted_state_block using has_state_root_on_disk (not the in-memory cache), preventing deletion of bodies needed for restart recovery (crates/storage/store.rs:1802-1840).
  3. Atomicity: Bodies, receipts, and transaction locations are deleted in a single write batch; EarliestBlockNumber advances only on commit success (crates/storage/store.rs:1445-1593).
  4. No Regression: advance_earliest_block_number ignores lower values, preventing snap-sync ↔ pruner races (crates/storage/store.rs:1678-1691).

Issues Found

1. Documentation Typo (Minor)

File: docs/CLI.md:160
The environment variable for --history.retention.below-cl-window is incorrectly documented as ETHREX_HISTORY_RETENTION= (copy-paste error).
Fix: Change to ETHREX_HISTORY_RETENTION_BELOW_CL_WINDOW.

2. Redundant String Allocation (Minor)

File: cmd/ethrex/initializers.rs:1256
Using format! inside eyre::eyre! allocates twice.

// Current:
return Err(eyre::eyre!(format!(...)));
// Better:
return Err(eyre::eyre!(...)); // eyre! accepts format args directly

3. Transaction Location Race Documentation

File: crates/storage/store.rs:1536-1545
The acknowledged race between pruner read-modify-write and block import merge operations is acceptable for orphan pruning, but consider adding a metrics counter when remaining.is_empty() triggers a delete, allowing operators to detect if the race is occurring frequently in practice.

4. Migration Header Decoding

File: crates/storage/migrations.rs:75-76
The migration reads headers directly with BlockHeader::decode, assuming they were stored as raw RLP. Verify this matches the actual BlockHeaderRLP storage format (which appears to be the case based on store.rs usage, but warrants confirmation).

5. Test-Only Code Exposure

File: crates/storage/store.rs:1599-1609
prune_block_heights_for_test is gated by #[cfg(any(test, feature = "testing"))] but the testing feature enables this destructive primitive for any dependent crate. Ensure this is documented as "destructive test-only" in the crate-level documentation.

Performance Observations

1. Gather Phase Parallelism

The dedicated 4-thread pool for pruning gather operations (PRUNE_GATHER_THREADS) correctly avoids crowding the global rayon pool used for block execution (crates/storage/store.rs:1458-1468). Good.

2. Uncached Reads

Using multi_get_uncached for transaction locations prevents cold prune data from evicting the state trie working set (crates/storage/store.rs:1548). Correct for this workload.

3. Range Tombstones

The delete_range on BLOCK_HASHES_BY_NUMBER using BE-encoded keys is efficient (single tombstone vs. N point deletes). Ensure the CF uses the default comparator (lexicographic) and hasn't been configured with a custom prefix extractor that might interfere.

Architecture Suggestions

1. L2 Pruning Cap Fallback

File: cmd/ethrex/l2/initializers.rs:378-385
When l1_source is unavailable, the warning is emitted but pruning proceeds with the local fallback (which may be None for non-sequencers). Consider making this error-fatal if --history.retention was explicitly set, as silent no-op pruning may confuse operators.

2. Metrics Initialization

File: crates/storage/pruner.rs:234-236
Forcing metrics registration upfront is correct, but consider adding a gauge for "pruner enabled/disabled" so operators can confirm the feature is active even when no pruning has occurred yet.

3. Earliest Block Pointer Validation

File: cmd/ethrex/initializers.rs:891-904
The reconcile_frontier call on startup is good defensive practice. Consider adding a periodic (e.g., hourly) reconciliation in long-running processes to detect corruption early.

Testing Coverage

Excellent coverage including:

  • Multi-chunk pruning batches (tick_spans_multiple_chunks)
  • Persisted state floor isolation (tick_keeps_unpersisted_state_window)
  • L2 cap respect (tick_respects_max_prunable_cap)
  • RPC null responses for pruned data (pruning_rpc_tests)
  • Migration idempotency and edge cases (migrate_3_to_4_*)

Conclusion

The PR is ready for merge with minor nits addressed. The safety architecture (multiple floors, atomic commits, no pointer regression) is sound, and the test coverage is comprehensive. The separation of concerns between L1 (time/epoch based) and L2 (commitment-based) retention policies is well-designed.

Recommended fixes before merge:

  1. Fix CLI documentation env var typo.
  2. Remove redundant format! in eyre! call.
  3. Verify BlockHeaderRLPBlockHeader::decode compatibility in migration (add comment if confirmed).

Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Waiting for the review agents to complete.


Automated review by Claude (Anthropic) · sonnet · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

  1. cmd/ethrex/l2/initializers.rs:381 calls retention.as_secs(), but retention is now a HistoryRetention, not a Duration. That is a hard compile break for the L2 build path, so the new pruning code does not build as written.

  2. cmd/ethrex/l2/initializers.rs:362 only enables pruning when opts.node_opts.history_retention is explicitly set. That diverges from the advertised/default behavior in cmd/ethrex/cli.rs:535 and from the L1 path in cmd/ethrex/initializers.rs:1231, which resolves an omitted flag to HistoryRetention::CL_WINDOW. As a result, L2 nodes silently keep all history forever unless the operator passes --history.retention, which is a behavioral mismatch.

  3. cmd/ethrex/l2/initializers.rs:260 treats get_commit_tx_by_batch(batch).is_some() as enough to prune up to that batch. The same comment block at lines 228-233 already acknowledges this source can move in the unsafe direction on reverted commits or L1 reorgs. Since the pruner deletes canonical bodies/receipts, using a known-unsound fallback here can remove data the committer/prover still needs. I would withhold pruning entirely when the authoritative L1 source is unavailable rather than prune off this marker.

Aside from those points, the storage-side pruning work and the new coverage around pruned RPC behavior look careful.

I couldn’t verify with cargo check in this sandbox because rustup failed to create temp files on the read-only home path.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

Four failing checks, three root causes.

The L2 build could not compile: EthClient and StoreError were used without being
imported, and the L2 initializer has its own pruner wiring that still logged the
retention as seconds. Neither showed up under a default `cargo check` because the
L2 code sits behind a feature flag, so `--features l2` is the check that matters
here.

humantime became an unused dependency when the flag stopped taking a wall-clock
duration; removing it left three lockfiles stale, which failed `Check Cargo.lock`
and, through it, `Lint zisk backend` and the `Lint` aggregator that reports it.

docs/CLI.md is compared against `--help` output verbatim, so hand-editing the
flag text could not match. Regenerated it from the binary.
Regenerating docs/CLI.md from a local --help baked this machine's datadir default
into it. The committed doc carries the Linux form CI compares against, so the
machine-dependent line has to be substituted rather than copied.
Codex flagged the L2 path enabling pruning only on an explicit flag as a mismatch
with L1, where an omitted flag resolves to the CL block-retention window. The
divergence is deliberate: that window is the range a beacon chain must serve blocks
over, and no beacon chain serves an L2's blocks, so inheriting the default would
delete history for a reason that does not apply. It was undocumented, which is what
made it look like an oversight.

Also warn when cl-window is passed explicitly on an L2, since the operator has then
asked for a window defined by something that is not involved.
The wall-clock form was dropped along with the mechanism that made it dangerous,
but only the mechanism had to go. The hazard was never the unit: the old code read
SystemTime on every pruning pass and bisected the chain by timestamp, so a host
clock a year fast deleted a year of real history, and the bisection additionally
assumed every canonical height in range had a header.

Converting a duration to a block distance once, at startup, keeps all of that
fixed — no clock is read while running and the barrier is still one subtraction —
while giving back the spelling an operator budgeting disk actually thinks in.

`<N>d`, `<N>h` and `<N>m` are accepted and converted assuming
ASSUMED_SECONDS_PER_SLOT. That assumption is the cost, so the help text says
`<N>epochs` is exact and survives a slot-time change, and the constant is the one
place to edit if it ever does. Parsed by suffix rather than reintroducing humantime.
@ilitteri

Copy link
Copy Markdown
Collaborator

Wall clock is an interesting decision. Every other client does either blocks or epochs. Ofc slot time is deterministic and wall clock can be computed from epochs by the user

This has shifted now, as of what's been discussed in the ACDEs recently, but without removing the wall-clock variant. We'll now support CL block retention window as the default, and either none, <N> epochs, or wall-clock (<N>d / <N>h / <N>m), optionally.

The error still said only cl-window, all and <N>epochs were accepted, so an
operator who mistyped a duration was told durations are unsupported when they
are.
Pruning is irreversible without a resync, and tokio's interval fires its first
tick immediately, so a mis-gated pruner starts deleting at startup rather than
after the interval. That makes 'try it and watch the logs' an unsafe way to
validate the gate on a datadir you cannot replace.

Dry run resolves the policy, reports the barrier and the exact number of block
heights that would be deleted, and returns without spawning the pruner. It also
reports the grandfathered case, so an operator can see both what the default
decided and what an explicit flag would do.
NikhilSharmaWe pushed a commit to NikhilSharmaWe/ethrex that referenced this pull request Aug 25, 2026
…ass#7210)

**Motivation**

Three places hardcoded the advertised earliest block to `0`:

| Site | What it feeds |
|---|---|
| `crates/networking/p2p/rlpx/eth/status.rs:69` | shared eth/69+
handshake status (eth/69, eth/71) |
| `crates/networking/p2p/rlpx/eth/eth70/status.rs:108` | eth/70's own
status struct |
| `crates/networking/p2p/rlpx/eth/update.rs:33` | `BlockRangeUpdate` —
the ongoing half of the same advertisement |

So every eth/69+ peer was told we hold history from genesis. That is
already untrue on any snap-synced node, where the earliest retained
block is the pivot, and peers use the advertised range to decide what to
request from us — meaning we invite requests we then answer with empty
replies.

It gets materially worse with history pruning (lambdaclass#6673): the earliest
block moves continuously, so a hardcoded handshake value is wrong within
hours of starting, and `BlockRangeUpdate` exists precisely to keep peers
current as the range moves.

**Description**

Reads the value from the store in all three places.

`get_earliest_block_number` is async (`crates/storage/store.rs:1256`)
while these constructors were sync — there is no cached sync accessor
for it the way `get_latest_block_number` has one (`:1275`, backed by
`latest_block_header`). So the constructors and their four handshake
call sites in `rlpx/connection/server.rs` become async. The alternative,
caching earliest in memory alongside latest, is a larger change for no
benefit on a path that runs once per handshake.

**Tests**

Three tests in `crates/networking/p2p/rlpx/eth/status.rs`:
- the eth/69, eth/70 and eth/71 handshake statuses all advertise a
non-zero earliest block set on the store
- `BlockRangeUpdate` reads the field from the store rather than
hardcoding it, and the result still satisfies `validate`'s `earliest <=
latest` invariant
- a genesis-synced node still advertises `0`, so this is a no-op for
full-history nodes

Note the second test deliberately uses `earliest = 0` on a genesis-only
store: setting a non-zero earliest above the latest block manufactures a
range that `validate` correctly rejects, which is a state a real node
cannot reach.
NikhilSharmaWe pushed a commit to NikhilSharmaWe/ethrex that referenced this pull request Aug 25, 2026
…g empty (lambdaclass#7208)

**Motivation**

`get_receipts_for_block` returns a bare `Vec`
(`crates/storage/store.rs:1491-1497`), so "this block's receipts are not
stored" and "this block had no transactions" are the same value.
`get_all_block_receipts` passed that straight through with no length
check, so `debug_getRawReceipts` answered an **empty list** for a block
that has transactions — a wrong answer rather than a reported failure.

The sibling by-index path already guards against this
(`crates/networking/rpc/eth/block.rs:408-414`, "Return 500 on receipt
count mismatch"); the all-receipts path did not.

This matters ahead of history pruning (lambdaclass#6673). Today an absent receipt
set means corruption, which is rare. Once pruning lands it becomes a
normal steady-state outcome, so every path that conflates "absent" with
"empty" starts returning wrong answers on every node rather than on a
corrupted one. Making these paths honest is a prerequisite for pruning
being safe to enable, and it is worth doing on its own merits
regardless.

**Description**

`get_all_block_receipts` now checks the receipt count against the
block's own transaction count, and reports a block whose body is absent
rather than validating against nothing. Genesis keeps its existing short
circuit, since it legitimately has no receipts.

The error is `RpcErr::Internal` to match the sibling path. Once lambdaclass#7069
lands its `PrunedHistoryUnavailable` variant (JSON-RPC code 4444), both
sites should move to it together — an absent-because-pruned block
deserves a distinct code from a corrupt one, and splitting that out
keeps this change reviewable.

**Tests**

Two tests in `test/tests/rpc/raw_receipts_completeness_tests.rs`. The
harness stores blocks and their transactions but no receipts, which is
exactly the shape a pruned block presents — body present, receipts gone:
- a block with one transaction and no stored receipts must report a
failure rather than return an empty list. Verified to fail without the
change (`test result: FAILED`) and pass with it
- genesis must still answer with an empty list, so the completeness
check does not break the one block that legitimately has none
@yorickdowne

Copy link
Copy Markdown

history.retention.dry-run is great UX. Big kudos for this idea.

…ult in warnings

Both defects were found by running the gate tests against a mainnet node holding
history from the first post-merge block.

--history.chain with an explicit finite --history.retention was silently accepted
and pruning proceeded. Backfill fills history downward toward an absolute floor
while pruning deletes upward from a rolling barrier, so with the floor below the
barrier the node downloads blocks only to delete them, re-detects the gap, and
downloads again. On the node under test that is 9.2M blocks of churn against 84 GB
of free disk. It is now a startup error naming both flags.

The grandfathered-node warning said only that pruning was not enabled. It now
states how many block heights an explicit flag would delete, so an operator can
see the stakes without having to run anything.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants