Skip to content

fix(startup): resume from existing DB without a checkpoint URL - #554

Open
MegaRedHand wants to merge 4 commits into
mainfrom
fix/resume-db-without-checkpoint-url
Open

fix(startup): resume from existing DB without a checkpoint URL#554
MegaRedHand wants to merge 4 commits into
mainfrom
fix/resume-db-without-checkpoint-url

Conversation

@MegaRedHand

Copy link
Copy Markdown
Collaborator

🗒️ Description / Motivation

Restarting a node without --checkpoint-sync-url destroyed its chain. fetch_initial_state gated the on-disk state lookup on that flag:

if checkpoint_urls.is_empty() {
    info!("No checkpoint sync URL provided, initializing from genesis state");
    let genesis_state = State::from_genesis(genesis.genesis_time, validators);
    return Ok(Store::from_anchor_state(backend, genesis_state));
};
// ... only past this point was Store::from_db_state tried

So a redeploy against a populated RocksDB wrote a slot-0 genesis anchor over a perfectly current chain, and operators had to pass a checkpoint URL purely as a fallback trigger even when the DB was fresh and the URL was never fetched.

This makes on-disk state authoritative: --checkpoint-sync-url becomes a fallback for when there is nothing resumable, not a precondition for reading what is there.

What Changed

bin/ethlambda/src/main.rsfetch_initial_state tries Store::from_db_state before the empty-URL early return:

gap = current_slot − store.head_slot()
  gap ≤ MAX_RESUMABLE_DB_STATE_AGE  → resume from DB                    info!
  gap > MAX, no checkpoint URLs     → resume from DB                    warn!  (new)
  gap > MAX, checkpoint URLs set    → fall through to checkpoint sync
no resumable DB, checkpoint URLs    → checkpoint sync
no resumable DB, no URLs            → genesis

Also removes the info!(url_count, "Starting checkpoint sync") that was emitted before the DB was consulted. It fired on every successful resume, so grepping a boot log for "Starting checkpoint sync" false-positived on nodes that never synced. Each outcome now has exactly one unambiguous log line.

bin/ethlambda/src/cli.rs--checkpoint-sync-url help text no longer claims it "skips genesis initialization"; it is documented as a fallback.

docs/checkpoint_sync.md — new Restarts and Existing State section: precedence table, the resume window and why it is measured against the head rather than the finalized checkpoint, the P2P-catch-up caveat, and the boot-log table.

Correctness / Behavior Guarantees

DB state (matching GENESIS_TIME) --checkpoint-sync-url before after
absent omitted genesis genesis
absent set checkpoint sync checkpoint sync
fresh (head-lag ≤ 450) omitted genesis, resets to slot 0 resume
fresh set resume resume
stale (head-lag > 450) omitted genesis, resets to slot 0 resume + warning
stale set checkpoint sync checkpoint sync
  • MAX_RESUMABLE_DB_STATE_AGE keeps its value and its meaning in the URL-present case; it now only decides whether a checkpoint is preferable to what we already have, never whether the DB is readable.
  • Staleness is still measured against the head (current_slot - head_slot), so a node whose head is current resumes during a finality stall.
  • Stale DB + no URL resumes rather than refusing to boot. Resuming is non-destructive and P2P forward-sync can close the gap; a genesis re-init cannot. The bounded failure mode is called out in the warning: past peers' SIGNATURE_PRUNING_RANGE horizon (~1 day) BlocksByRange cannot serve the missing history, so a node that far behind needs a checkpoint URL. The alternative (refuse to start) would break unattended restarts after a routine 31-minute outage.
  • No new flag. Omitting --checkpoint-sync-url no longer means "start from genesis" when a DB exists; to deliberately start over, remove the data directory. That is already the documented idiom for a clean checkpoint sync, and an --ignore-existing-db flag would only reintroduce the write-genesis-over-live-data footgun behind a flag.
  • Unchanged / out of scope: a GENESIS_TIME mismatch still degrades silently (from_db_state logs "Persisted DB has a different genesis_time; treating as empty"), so with no URL the node writes genesis over a foreign-network DB. Pre-existing behavior, flagged for a separate change.

Tests Added / Run

Four unit tests in bin/ethlambda/src/main.rs driving fetch_initial_state against InMemoryBackend (no new dev-dependency; the resume and genesis paths need no network):

Test Asserts
initializes_from_genesis_when_db_is_empty head slot 0
resumes_from_fresh_db_without_checkpoint_url head slot is the seeded slot, not 0
resumes_from_stale_db_without_checkpoint_url resumes despite gap > MAX_RESUMABLE_DB_STATE_AGE
initializes_from_genesis_when_db_genesis_time_differs head slot 0 (DB treated as empty)

The seeded anchor sits above slot 0 because a genesis re-init also yields head slot 0; that is what makes "resumed" distinguishable from "started over". Staleness is induced purely by choosing genesis_time (current_slot derives from the wall clock against it), so no clock injection. Both resume tests fail on main with left: 0, right: 12.

Commands run:

cargo test -p ethlambda --profile release-fast --bin ethlambda   # 31 passed
make fmt && make lint && make test                              # all clean

Local multi-client devnet verification is in progress; I'll post the boot logs showing a keep-DB restart with no --checkpoint-sync-url as a comment.

Related Issues / PRs

✅ Verification Checklist

  • Ran make fmt — clean
  • Ran make lint (clippy with -D warnings) — clean
  • Ran make test (cargo test --workspace --profile release-fast) — all passing

`fetch_initial_state` gated the on-disk state lookup on
`--checkpoint-sync-url`, so restarting a node without that flag wrote a
slot-0 genesis anchor over a populated RocksDB and discarded a current
chain. Operators had to pass a checkpoint URL purely as a fallback
trigger, even on a fresh DB where it was never used.

Try the persisted state first and treat the URL as a fallback for when
nothing on disk is resumable. When the DB is stale and no URL was given,
resume anyway instead of re-initializing: P2P forward-sync can close the
gap, a genesis re-init cannot. The staleness threshold now only decides
whether a checkpoint is preferable to what we already have.

Also drop the `Starting checkpoint sync` line emitted before the DB was
consulted; it fired on every successful resume, so grepping the boot log
for it falsely reported a resync.
@MegaRedHand
MegaRedHand marked this pull request as ready for review July 31, 2026 18:47
@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

Overall Assessment: This is a well-structured PR that correctly prioritizes resuming from local disk over checkpoint sync to prevent accidental chain re-initialization on restart. The logic is sound and the tests cover the critical edge cases. A few minor issues need attention.

Issues Found

1. Silent suppression of DB read errors

File: bin/ethlambda/src/main.rs, line ~677

The code silently ignores errors from Store::from_db_state, which could mask database corruption or I/O issues:

if let Ok(Some(store)) = Store::from_db_state(backend.clone(), genesis.genesis_time) {
    // ...
}
// Err(e)


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

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

No findings.

The changed control flow in bin/ethlambda/src/main.rs looks correct: it now preserves an existing same-network DB when --checkpoint-sync-url is omitted, while still preferring checkpoint sync for stale state when URLs are provided. That avoids the previous restart footgun of overwriting persisted chain state with a slot-0 genesis anchor. The added tests in bin/ethlambda/src/main.rs cover the important branches: empty DB, fresh DB resume, stale DB resume without URLs, and genesis-time mismatch.

Residual gap: I did not find a regression, but there is no direct test asserting that a stale DB still falls through to checkpoint sync when --checkpoint-sync-url is set. That is a coverage gap, not a correctness issue in the current diff.

Verification: I could not run cargo test in this environment because the installed toolchain is present but cargo/rustup need writable home directories for dependency resolution, and /home/runner/.cargo and /home/runner/.rustup are read-only here.


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

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Review: fix(startup): resume from existing DB without a checkpoint URL (PR 554)

Summary

The core fix is correct and well-targeted: fetch_initial_state now consults Store::from_db_state before ever considering checkpoint_urls.is_empty(), so a redeploy against a populated data directory no longer gets clobbered with a slot-0 genesis anchor just because --checkpoint-sync-url wasn't passed. The new control flow matches the documented precedence table, the four unit tests exercise all four resume/genesis/mismatch branches, and the docs/CLI help text updates are accurate and clear.

Findings

1. Checkpoint-sync failure discards a usable stale DB (design gap, not a regression)
In bin/ethlambda/src/main.rs:701-712, when the DB is stale and checkpoint_urls is non-empty, the code falls through to checkpoint sync via fetch_anchor_with_retry(...).await?. If every URL fails, the ? propagates the error and the process aborts — even though a resumable (just stale) store was already sitting right there and P2P could have closed the gap, per the PR's own rationale ("resuming is non-destructive... a genesis re-init cannot"). This isn't introduced by this PR (the abort-on-total-failure behavior pre-dates it), but the PR's narrative argues explicitly for preferring resume over refusing to boot, and this path still refuses to boot in a case the narrative covers. Worth a follow-up: on total checkpoint-sync failure, consider falling back to the stale store (with a warning) instead of erroring out, or note explicitly why that's out of scope.

2. if let Ok(Some(store)) = ... silently treats any future Err as "not resumable" (pre-existing, now higher-stakes)
Store::from_db_state (crates/storage/src/store.rs:611-648) currently never constructs an Err variant in practice (its internal fallible reads use .expect(...)), so this is inert today. But the reordering in this PR means the empty-URL path now depends on this call succeeding to avoid genesis re-init — previously it wasn't even invoked in that case. If from_db_state ever gains a real error path (e.g., a corrupted table read returning Err instead of panicking), this line would silently fall through to genesis and overwrite a live chain — precisely the bug this PR fixes, via a different trigger. Not a blocking issue given the current implementation, but worth a comment or a match that at least warn!s on Err rather than silently treating it the same as Ok(None).

3. CLAUDE.md diff is unrelated to this PR's purpose
The included CLAUDE.md hunk (RPC server port-merging behavior when --api-port == --metrics-port) has nothing to do with checkpoint sync / startup resume. Likely leftover from a rebase/merge-in from main. Harmless, but consider dropping it from this PR for a cleaner diff/history, since gh pr diff attributes it to this PR's commits.

Things done well

  • The precedence table in the docstring (main.rs:643-655) and docs/checkpoint_sync.md accurately reflects the implemented branches — I traced all four combinations (fresh/stale DB × present/absent URL × genesis mismatch) against the code and they match.
  • Test helper seed_db correctly relies on latest_block_header.state_root == H256::ZERO to pass init_store's consistency check while still landing at a distinguishable non-zero slot — a reasonable way to fake a "resumed" anchor without needing real state-root computation.
  • Staleness is correctly measured against head_slot, not latest_finalized, so a stalled-but-current node still resumes — matches the stated rationale about finality stalls.
  • Log line changes are unambiguous and non-overlapping (no more false-positive "Starting checkpoint sync" on a pure resume).
  • MAX_RESUMABLE_DB_STATE_AGE and SIGNATURE_PRUNING_RANGE values cited in the new docs match their actual definitions in crates/storage/src/store.rs.

No correctness issues in fork choice, attestation processing, or SSZ paths — this PR doesn't touch them.


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

@greptile-apps

greptile-apps Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR makes persisted database state the first startup choice and treats checkpoint URLs as a fallback.

  • Resumes fresh or stale matching state without requiring a checkpoint URL.
  • Retains checkpoint sync for stale state when URLs are configured.
  • Updates CLI help, startup logs, documentation, and startup-path tests.

Confidence Score: 4/5

The storage-error fallback must be fixed before merging because a restart can still replace an existing chain with genesis state.

fetch_initial_state ignores every error returned while loading persisted state and, without a checkpoint URL, immediately initializes the same backend from genesis.

Files Needing Attention: bin/ethlambda/src/main.rs

Important Files Changed

Filename Overview
bin/ethlambda/src/main.rs Reorders initial-state selection to prefer persisted state, but still conflates storage read failures with an empty database.
bin/ethlambda/src/cli.rs Updates checkpoint URL help text to describe its new fallback semantics.
docs/checkpoint_sync.md Documents restart precedence, stale-state behavior, and operational recovery guidance.
CLAUDE.md Updates unrelated RPC listener documentation without introducing a review finding.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  Start[Start node] --> ReadDB[Read persisted DB state]
  ReadDB -->|Matching state| Age{Within resume window?}
  Age -->|Yes| Resume[Resume from DB]
  Age -->|No, URLs absent| ResumeStale[Resume stale DB with warning]
  Age -->|No, URLs present| Sync[Checkpoint sync]
  ReadDB -->|No state| URLs{Checkpoint URLs configured?}
  ReadDB -->|Read error currently discarded| URLs
  URLs -->|Yes| Sync
  URLs -->|No| Genesis[Initialize from genesis]
Loading
Prompt To Fix All With AI
### Issue 1
bin/ethlambda/src/main.rs:684
**Storage errors trigger genesis fallback**

When a populated database returns an I/O, decoding, or consistency error from `Store::from_db_state` and no checkpoint URL is configured, `if let Ok(Some(store))` discards the error and proceeds to initialize the same backend from genesis, causing an unreadable existing chain to be overwritten instead of failing safely.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "docs: fix docs" | Re-trigger Greptile

Comment thread bin/ethlambda/src/main.rs
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.

1 participant