Skip to content

fix(l1): do not cap the interrupted-batch recovery at one execute batch - #7261

Merged
ilitteri merged 2 commits into
mainfrom
fix/uncap-interrupted-batch-recovery
Sep 8, 2026
Merged

fix(l1): do not cap the interrupted-batch recovery at one execute batch#7261
ilitteri merged 2 commits into
mainfrom
fix/uncap-interrupted-batch-recovery

Conversation

@ilitteri

@ilitteri ilitteri commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Motivation

Follow-up to #7260. That PR recovers a database whose canonical head lags the trie state committed by an interrupted full-sync batch, but it declines to recover when the journaled block is more than one execute batch (1024 blocks) above the head.

That bound is too tight for a real case. When the consensus client has already delivered every block of the gap, the full syncer executes the whole gap in a single add_blocks_in_batch call with no per-chunk forkchoice update (crates/networking/p2p/sync/full.rs, "Executing N pending blocks for full sync"). On a glamsterdam-devnet-8 node the gap was 1895 blocks: the node finished its fresh full sync, ran one such batch, was OOM-killed 1895 blocks in, and came back with the head marker 1895 blocks below the committed state. With the bound in place the recovery does nothing and the node loops on "Unknown state found in DB" until the database is wiped.

Description

  • Remove the one-execute-batch bound. The recovery is already gated by the checks that matter: the journaled block must chain down onto the current head and its state root must be present on disk. Neither depends on the size of the gap.
  • Keep sync-test (SYNC_BLOCK_NUM) working: that feature moves the head down on purpose right before regenerate_head_state runs, so the state above the head is not an interrupted batch and must not be re-adopted. The recovery is skipped when the variable is set under that feature.
  • Replace the "declines a gap wider than one batch" test with one that adopts a journaled block 1800 blocks above the head.
  • Document how the recovery relates to debug_setHead, the other path that moves the head down on purpose. That RPC only moves the head marker through apply_fork_choice: a rewind at or above the committed root leaves no journal entry above the head, so the recovery does not fire; a rewind below the committed root is re-adopted on restart, where the state walk previously failed with "Unknown state found in DB" (such a rewind never survived a restart).

How to Test

cargo test -p ethrex --lib interrupted_batch_recovery_tests
cargo clippy -p ethrex --bin ethrex --all-targets -- -D warnings
cargo clippy -p ethrex --bin ethrex --features sync-test --all-targets -- -D warnings

On a node bricked the same way (head marker below the last committed trie layer, gap > 1024), start the node with this build: it logs Recovered from an interrupted full-sync batch: canonical head moved from <head> to <committed> and resumes syncing instead of failing with "Unknown state found in DB".

Checklist

  • Updated STORE_SCHEMA_VERSION (crates/storage/lib.rs) if the PR includes breaking changes to the Store requiring a re-sync. (Not needed: no schema change.)

When the consensus client has already delivered every block of the gap, full
sync executes all of them in a single `add_blocks_in_batch` call (the
"Executing N pending blocks ... no peer download needed" path), with no
EXECUTE_BATCH_SIZE chunking and one `forkchoice_update` at the very end. Such a
batch can be thousands of blocks long: on Plataberget a node that had just
finished its initial sync was OOM-killed 1 895 blocks into one and came back
with the head 1 767 blocks below the state on disk. The one-batch bound added
at review would have declined exactly that recovery.

A head moved down on purpose is indistinguishable from an interrupted batch by
the data alone, so guard the one path that does it instead: skip the recovery
when the `sync-test` `SYNC_BLOCK_NUM` rewind is active. The bound test becomes a
test that a gap of 1 800 blocks is adopted.
@ilitteri
ilitteri requested a review from a team as a code owner September 8, 2026 13:37
@github-actions github-actions Bot added the L1 Ethereum client label Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 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 Sep 8, 2026

Copy link
Copy Markdown

🤖 Kimi Code Review

I'll review this PR which modifies interrupted batch recovery logic in ethrex's state sync mechanism.

Summary

This PR removes the MAX_INTERRUPTED_BATCH cap (previously EXECUTE_BATCH_SIZE_DEFAULT) from interrupted batch recovery, allowing recovery of arbitrarily large gaps. It adds a sync-test feature gate to prevent re-adoption of deliberately rewound heads.

Issues Found

1. Race condition / TOCTOU in environment variable check (cmd/ethrex/initializers.rs:1168-1172)

#[cfg(feature = "sync-test")]
let rewound_on_purpose = env::var("SYNC_BLOCK_NUM").is_ok();

The SYNC_BLOCK_NUM environment variable is checked at recovery time, but it's set by set_sync_block which runs before this code. However, if the node restarts without the sync-test feature but the env var is still present (e.g., from a previous run or inherited environment), the behavior differs from compilation to runtime. More critically:

  • Line 1168-1172: The env var check happens every recovery attempt, but the variable could be modified between when set_sync_block set it and when recovery runs. This is a time-of-check-time-of-use pattern.

  • Line 1169: is_ok() only checks existence, not value. A malformed or empty SYNC_BLOCK_NUM would still prevent recovery. Consider validating the value matches the expected block number.

Suggestion: Store the rewound state in the database itself rather than relying on env vars, or at minimum validate SYNC_BLOCK_NUM parses correctly and matches head_block_number:

let rewound_on_purpose = env::var("SYNC_BLOCK_NUM")
    .ok()
    .and_then(|s| s.parse::<u64>().ok())
    == Some(head_block_number);

2. Missing use std::env import (cmd/ethrex/initializers.rs)

The code uses env::var at line 1169, but I don't see use std::env; in the visible diff context. Verify this import exists; if not, this won't compile with sync-test feature enabled.

3. Comment inconsistency in adopt_committed_head_above (cmd/ethrex/initializers.rs:1257-1267)

The doc comment states:

"A head that was moved down on purpose is indistinguishable from an interrupted batch by the data alone; the one such path, set_sync_block (sync-test feature), is excluded by its caller."

This is accurate after the changes, but the function no longer has any knowledge of sync-test or the exclusion. The comment correctly notes this is the caller's responsibility, but this creates a fragile contract. If another caller is added in the future that doesn't perform this check, the function will incorrectly adopt deliberately rewound heads.

Suggestion: Consider adding a allow_rewound: bool parameter to make this contract explicit at the API level:

async fn adopt_committed_head_above(
    store: &Store, 
    head_number: BlockNumber,
    allow_rewound: bool,  // false when called from sync-test paths
) -> eyre::Result<bool>

4. Test determinism: H256::random() (cmd/ethrex/initializers.rs:1467-1470)

let root = if i == 1799 {
    genesis.state_root
} else {
    H256::random()
};

Line 1469: Using H256::random() in tests makes them non-deterministic. While unlikely, collisions could theoretically cause flaky tests. More importantly, it makes debugging harder if a test fails.

Suggestion: Use a deterministic pseudo-random or sequential pattern:

H256::from_low_u64_be(i + 1)

5. Potential state root collision in test (cmd/ethrex/initializers.rs:1467-1470)

The test sets root = genesis.state_root only at i == 1799 (the last block). This means block 1800 reuses the genesis state root, which is the condition that triggers journal-based recovery. However, if any intermediate H256::random() collides with genesis.state_root (astronomically unlikely but not impossible), the test behavior changes.

This is minor given 256-bit collision resistance, but deterministic roots would eliminate even this theoretical concern.

6. Removed constant still referenced elsewhere? (cmd/ethrex/initializers.rs)

The diff removes EXECUTE_BATCH_SIZE_DEFAULT imports from two locations. Verify no other code in this file or dependent modules still needs this constant. The removal appears correct for the visible diff.

7. Performance: 1800-iteration test with database operations (cmd/ethrex/initializers.rs:1459-1483)

The new test performs 1800 database insertions synchronously. While acceptable for a unit test, this may be slow in CI. Consider if a smaller number (e.g., 3-5 blocks) would adequately test the "unbounded gap" logic, since the cap removal makes the specific magnitude irrelevant.

Actually, re-reading: the original bug was specifically about large gaps from consensus-delivered batches, so testing a large gap has value. The test is fine but could be marked #[ignore] for slow tests if CI timing becomes an issue.

Positive Observations

  1. Correct bug fix: The original MAX_INTERRUPTED_BATCH cap was fundamentally incorrect for consensus-driven batch execution. Removing it fixes the reported issue where nodes fell behind and were killed mid-batch.

  2. Feature gating is appropriate: Using #[cfg(feature = "sync-test")] ensures the env::var dependency doesn't affect production builds.

  3. Test coverage: The new test directly validates the previously-failing scenario (large gap recovery).

  4. Comment quality: The updated comments clearly explain why the gap is unbounded and the sync-test exclusion rationale.

Minor Suggestions

  • Line 1163-1167: The comment about set_sync_block could reference the specific function name for greppability.

  • Line 1264: "the one such path" → "the only such path" for clarity.

Verdict

The core logic change is correct and fixes a real consensus sync bug. Address the potential missing std::env import (Item 2) and consider the API contract strengthening (Item 3) before merge. The H256::random() (Item 4) is low priority.


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

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  1. cmd/ethrex/initializers.rs:1168-1174 and cmd/ethrex/initializers.rs:1264-1266 introduce a restart regression for deliberate head rewinds outside the sync-test path. After this change, any STATE_HISTORY entry above LatestBlockNumber is treated as recoverable interrupted-sync state unless SYNC_BLOCK_NUM is set. That is not true for at least debug_setHead, which is available in normal builds and explicitly rewinds head via apply_fork_choice without pruning journal entries above the new head (crates/networking/rpc/debug/set_head.rs:7-12,53-61; crates/storage/store.rs:1723-1763). On the next boot, adopt_committed_head_above will walk those old headers, see that they still extend the rewound head, and silently re-canonicalize them. The same pattern may also affect manual truncation flows like cmd/ethrex/l2/command.rs:730-735. This makes rewinds non-persistent across restart. I’d keep a durable “intentional rewind” marker or explicitly clear/prune conflicting STATE_HISTORY when lowering head, and add a restart regression test for that case.

No other correctness or Ethereum-rule issues stood out in this diff; the rest is documentation/test adjustment around the larger interrupted-batch case. I did not run tests.


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

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Lines of code report

Total lines added: 4
Total lines removed: 0
Total lines changed: 4

Detailed view
+-----------------------------------+-------+------+
| File                              | Lines | Diff |
+-----------------------------------+-------+------+
| ethrex/cmd/ethrex/initializers.rs | 1285  | +4   |
+-----------------------------------+-------+------+

@github-project-automation github-project-automation Bot moved this to In Review in ethrex_l1 Sep 8, 2026
…ad rewinds

The helper's doc claimed set_sync_block was the only path that moves the head
down on purpose. debug_setHead is another: it rewinds the head marker through
apply_fork_choice without touching the trie. A rewind at or above the committed
root leaves no journal entry above the head, so the recovery does not fire; a
rewind below the committed root is re-adopted on restart, where the state walk
previously failed with "Unknown state found in DB".
@ilitteri
ilitteri enabled auto-merge September 8, 2026 15:58
@ilitteri
ilitteri added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit 4857b8d Sep 8, 2026
76 of 80 checks passed
@ilitteri
ilitteri deleted the fix/uncap-interrupted-batch-recovery branch September 8, 2026 16:53
@github-project-automation github-project-automation Bot moved this from In Review to Done in ethrex_l1 Sep 8, 2026
ilitteri added a commit that referenced this pull request Sep 8, 2026
…ch (#7261)

**Motivation**

Follow-up to #7260. That PR recovers a database whose canonical head
lags the trie state committed by an interrupted full-sync batch, but it
declines to recover when the journaled block is more than one execute
batch (1024 blocks) above the head.

That bound is too tight for a real case. When the consensus client has
already delivered every block of the gap, the full syncer executes the
whole gap in a single `add_blocks_in_batch` call with no per-chunk
forkchoice update (`crates/networking/p2p/sync/full.rs`, "Executing N
pending blocks for full sync"). On a glamsterdam-devnet-8 node the gap
was 1895 blocks: the node finished its fresh full sync, ran one such
batch, was OOM-killed 1895 blocks in, and came back with the head marker
1895 blocks below the committed state. With the bound in place the
recovery does nothing and the node loops on "Unknown state found in DB"
until the database is wiped.

**Description**

- Remove the one-execute-batch bound. The recovery is already gated by
the checks that matter: the journaled block must chain down onto the
current head and its state root must be present on disk. Neither depends
on the size of the gap.
- Keep `sync-test` (`SYNC_BLOCK_NUM`) working: that feature moves the
head down on purpose right before `regenerate_head_state` runs, so the
state above the head is not an interrupted batch and must not be
re-adopted. The recovery is skipped when the variable is set under that
feature.
- Replace the "declines a gap wider than one batch" test with one that
adopts a journaled block 1800 blocks above the head.
- Document how the recovery relates to `debug_setHead`, the other path
that moves the head down on purpose. That RPC only moves the head marker
through `apply_fork_choice`: a rewind at or above the committed root
leaves no journal entry above the head, so the recovery does not fire; a
rewind below the committed root is re-adopted on restart, where the
state walk previously failed with "Unknown state found in DB" (such a
rewind never survived a restart).

**How to Test**

```sh
cargo test -p ethrex --lib interrupted_batch_recovery_tests
cargo clippy -p ethrex --bin ethrex --all-targets -- -D warnings
cargo clippy -p ethrex --bin ethrex --features sync-test --all-targets -- -D warnings
```

On a node bricked the same way (head marker below the last committed
trie layer, gap > 1024), start the node with this build: it logs
`Recovered from an interrupted full-sync batch: canonical head moved
from <head> to <committed>` and resumes syncing instead of failing with
"Unknown state found in DB".

**Checklist**

- [ ] Updated `STORE_SCHEMA_VERSION` (crates/storage/lib.rs) if the PR
includes breaking changes to the `Store` requiring a re-sync. (Not
needed: no schema change.)

(cherry picked from commit 4857b8d)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L1 Ethereum client

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants