Skip to content

feat: backfill sync v2 - #9238

Open
jeffoodchain wants to merge 12 commits into
ChainSafe:unstablefrom
jeffoodchain:feat/backfill-v2
Open

feat: backfill sync v2#9238
jeffoodchain wants to merge 12 commits into
ChainSafe:unstablefrom
jeffoodchain:feat/backfill-v2

Conversation

@jeffoodchain

@jeffoodchain jeffoodchain commented Apr 20, 2026

Copy link
Copy Markdown

Motivation

reconstructing backfill sync feature in lodestar.

Description

Block

DB schema related changes

  • a new BackfillStateRepository: per-epoch state with fields
    • hasBlock
    • hasBlob
    • columnIndices
  • turned BackfilledRanges from repository -> singleton since it's always a single value, never a keyed collection.

backfillV2.ts

  • Walks backward from the anchor block by parentRoot, one block per request. (This might seems not efficient but IMO backfill sync has the low priority so making the thread light is important.)
  • Buffers blocks per-epoch and flushes on epoch boundaries (handles skipped
    slots at boundaries correctly)
  • Stops when anchorRoot === ZERO_HASH (i.e. we've walked past the genesis
    block's parent) (TODO: changing to MIN_EPOCHS_FOR_BLOCK_REQUESTS as default for block, MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS for blobs)
  • Tracks peer failures locally; disconnects peers after repeated failures
  • Resumes from a previous session via db.backfilledRange

DB schema refactor:

  • New BackfillStateRepository (backfill_state bucket): per-epoch state
    (hasBlock, hasBlobs, columnIndices) so future work can fill blobs/
    columns independently of blocks.
  • backfilledRange moved from Repositorysingle/ (singleton pattern)
    since it's always a single value, never a keyed collection.
  • Added mock types in mockedBeaconDb for the new/changed entries.

Blobs

haven't done anything related to backfill blobs yet.

tests

Unit tests in test/unit/sync/backfill/backfillV2.test.ts cover:

  • Walking a mainnet fixture (slot 4 -> genesis; ) by root
  • Completing cleanly at genesis
  • Epoch-boundary flush ordering
  • Skipped-slot handling at epoch boundaries

AI Assistance Disclosure

This PR was written primarily by Claude Code. I consulted Claude Code to understand the codebase, but the solution was fully authored manually by myself.

TODOs

  • adding blobs syncing
  • adding flag for genesis block sync
  • making backfill sync an independent process (perhaps another PR)
  • having a e2e test

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new backfill synchronization implementation (BackfillSyncV2) and updates the database schema to track backfill progress using epochs. Key changes include the addition of BackfillStateRepository and the BackfilledRange singleton, while the legacy BackfillSync and BackfilledRanges are being deprecated or disabled. Feedback highlights a breaking database schema change due to bucket repurposing, the presence of non-functional legacy code, and performance concerns regarding block-by-block network fetching and sequential database lookups in the new implementation. Additionally, starting the sync process within the constructor is noted as a testing and control issue.

// index_lightClientInitProof = 36, // DEPRECATED on v0.32.0

backfilled_ranges = 42, // Backfilled From to To, inclusive of both From, To
backfill_state = 42, // Epoch -> EpochBackfillState

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Repurposing bucket ID 42 from backfilled_ranges (which stored Slot -> Slot) to backfill_state (which stores Epoch -> BackfillStateWrapper) is a breaking change for the database schema. Existing databases will contain data in the old format, which will cause deserialization errors or logic bugs when accessed by the new code. A database migration should be provided, or a new bucket ID should be allocated for the new backfill state.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — verified by diffing packages/beacon-node/src/db/buckets.ts against origin/unstable:

  • unstable@HEAD: backfilled_ranges = 42 // Backfilled From to To, inclusive of both From, To (Slot → Slot)
  • this PR: backfill_state = 42 // Epoch -> EpochBackfillState

Same ID, different schema. Any existing node DB has entries at bucket 42 in the old Slot → Slot shape; the new repository would try to deserialize those bytes as EpochBackfillState and either fail or load garbage. Two clean paths: allocate a fresh bucket ID for backfill_state and leave 42 as a deprecated reserved slot (lowest-risk), or add a one-shot migration that drops the old bucket 42 contents before the new repository is wired in. Reserving a new ID is the cheaper option.

Comment on lines +245 to +246
const backfillRangeWrittenSlot = null as number | null;
const previousBackfilledRanges = [] as {key: number; value: number}[];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The legacy BackfillSync implementation is being broken here by hardcoding backfillRangeWrittenSlot to null and previousBackfilledRanges to an empty array. If this class is intended to be deprecated and replaced by BackfillSyncV2, it should be formally deprecated or removed. Leaving it in a non-functional state with TODO comments can lead to confusion and bugs if it's still reachable in certain configurations.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — the legacy class is still wired into production via the barrel export export * from './backfill.js' and is what packages/beacon-node/src/node/nodejs.ts:292 instantiates via BackfillSync.init(...). With backfillRangeWrittenSlot = null and previousBackfilledRanges = [] hardcoded at lines 244-246 (plus the commented-out backfilledRanges.put(...) at line 394 that Codex flagged in discussion_r3393024831), the legacy path now silently loses its resume state — nodes with sync.backfillBatchSize > 0 will re-download from the anchor on every restart. Since the new v2 class isn't reachable from nodejs.ts either (see discussion_r3393024826), this PR effectively breaks backfill resume in production. Two scope options: keep legacy fully functional and add v2 alongside under a flag, OR replace legacy completely (wire v2 in nodejs.ts, delete backfill.ts).

Comment on lines +20 to +43
// try {
// const finalizedBlockFC = chain.forkChoice.getBlockHexDefaultStatus(finalized.rootHex);
// if (finalizedBlockFC && finalizedBlockFC.slot > chain.anchorStateLatestBlockSlot) {
// await db.backfilledRanges.put(finalizedBlockFC.slot, chain.anchorStateLatestBlockSlot);
//
// const filteredSeqs = await db.backfilledRanges.entries({
// gt: chain.anchorStateLatestBlockSlot,
// lt: finalizedBlockFC.slot,
// });
// logger.debug("updated backfilledRanges", {
// key: finalizedBlockFC.slot,
// value: chain.anchorStateLatestBlockSlot,
// });
// if (filteredSeqs.length > 0) {
// await db.backfilledRanges.batchDelete(filteredSeqs.map((entry) => entry.key));
// logger.debug(
// `Forward Sync - cleaned up backfilledRanges between ${finalizedBlockFC.slot},${chain.anchorStateLatestBlockSlot}`,
// {seqs: JSON.stringify(filteredSeqs)}
// );
// }
// }
// } catch (e) {
// logger.error("Error updating backfilledRanges on finalization", {epoch: finalized.epoch}, e as Error);
// }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for updating the backfill range during finalization has been commented out. This effectively disables progress tracking for backfill sync during normal node operation. If this logic is being replaced by the new backfillState and backfilledRange singleton, it should be updated to use them instead of being left as commented-out code.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — the entire updateBackfillRange body is now commented out (lines ~17-43 of packages/beacon-node/src/chain/archiveStore/utils/updateBackfillRange.ts). The function is still called from the finalization path but is a no-op. Same scope question as discussion_r3109761377 — either restore it pointing at the new singleton backfilledRange + per-epoch backfillState, or delete the call site and the helper entirely as part of the legacy-removal change.

Comment on lines +108 to +123
this.sync()
.then((oldestSlotSynced) => {
if (this.status !== BackfillSyncStatus.completed) {
throw new ErrorAborted(`Invalid BackfillSyncStatus at completion: status = ${this.status}`);
}
this.emit(BackfillSyncEvent.completed, oldestSlotSynced);
this.logger.info("BackfillSync completed", {oldestSlotSynced});
this.close();
})
.catch((e) => {
if (!(e instanceof ErrorAborted)) {
this.logger.error("BackfillSync processor error", e);
}
this.status = BackfillSyncStatus.aborted;
this.close();
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Starting a long-running asynchronous process like sync() directly in the constructor is generally considered a bad practice. It makes the object difficult to test, as the sync process starts immediately upon instantiation, and it doesn't allow the caller to control when the synchronization begins. Consider moving the call to this.sync() to an explicit start() method.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair pattern observation, though pragmatically it follows what the legacy BackfillSync.init does (backfill.ts also kicks off this.sync() after construction). For internal services owned by BeaconNode, fire-and-forget in the ctor is the existing convention in this codebase — moving to an explicit start() would be its own ergonomics PR across multiple services. Not a blocker for this PR's scope, but worth a brief code comment near line 174 documenting that the .catch chain is the lifecycle handle and that abort flows through the constructor-bound AbortSignal.

}
try {
// how could they fetch a block via root?
const [block] = await this.network.sendBeaconBlocksByRoot(peer, [this.anchorRoot]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Fetching blocks one by one using sendBeaconBlocksByRoot is highly inefficient due to network round-trip times (RTT). Even if backfill sync is considered low priority, fetching a single block per request will make the process extremely slow for a long chain. Consider fetching blocks in batches using sendBeaconBlocksByRange or requesting multiple roots at once if the protocol supports it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially — backfillV2.ts already has a batched path at line 377 (sendBeaconBlocksByRoot(peer, batch)). The one-at-a-time fetch is at line 572 inside the parent-walk loop, which is constrained by the dependency: each request's input root is the previous response's parentRoot, so you can't pipeline more than one until the response lands. The cheap improvement is switching from sendBeaconBlocksByRoot(peer, [root]) to sendBeaconBlocksByRange(peer, {startSlot, count}) once we know we want N contiguous slots ending at the anchor — same RTT for N blocks instead of N. Worth doing but agreed it's a perf-only follow-up, not a correctness blocker.

Comment on lines +330 to +333
while (epoch > 0) {
const state = await this.db.backfillState.get(epoch);
if (!state?.hasBlock) break;
epoch--;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This while loop performs sequential database lookups for every epoch. If a node has already backfilled a large number of epochs, this loop could perform thousands of asynchronous DB calls, potentially blocking the event loop and causing performance issues. It would be more efficient to use a reverse stream or iterator (e.g., this.db.backfillState.keysStream({lt: startEpoch, reverse: true})) to find the first gap in the backfilled epochs.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — skipFilledEpochs at lines 665-672 does sequential await this.db.backfillState.get(epoch) per epoch, walking down from startEpoch until it finds a gap. For a node that has already backfilled thousands of epochs this is thousands of awaits in a tight loop. The backfillState.keysStream({lt: startEpoch, reverse: true}) suggestion is the right shape — return the first epoch whose hasBlock is false, or the lowest key if none have a gap. Single-pass stream instead of point lookups.

@jeffoodchain jeffoodchain changed the title Feat/backfill v2 feat: backfill sync v2 Apr 20, 2026
- Introduced comprehensive tests for the backfillV2 synchronization process, covering scenarios such as chain walking, flushing blocks at epoch boundaries, and handling skipped slots.
- Added a check for the aborted signal in the BackfillSync class to ensure proper termination of the sync process when the signal is triggered.
@jeffoodchain jeffoodchain changed the title feat: backfill sync v2 (WIP) feat: backfill sync v2 Apr 20, 2026
I found the peer score should be not that harsh because some behaviors are legitimate according to the spec.
@jeffoodchain
jeffoodchain marked this pull request as ready for review June 11, 2026 03:08
@jeffoodchain
jeffoodchain requested a review from a team as a code owner June 11, 2026 03:08
@jeffoodchain

Copy link
Copy Markdown
Author

@lodekeeper could you review this PR?

@jeffoodchain jeffoodchain changed the title (WIP) feat: backfill sync v2 feat: backfill sync v2 Jun 11, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8a122a0622

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@@ -1 +1,2 @@
export * from "./backfill.js";
export {BackfillSync as BackfillSyncV2} from "./backfillV2.js";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wire v2 backfill into node startup

The barrel still exports the new implementation only as BackfillSyncV2, while packages/beacon-node/src/node/nodejs.ts imports BackfillSync from this barrel and calls BackfillSync.init(opts.sync, ...) whenever sync.backfillBatchSize > 0. In normal beacon-node startup the new v2 code and the new sync.backfillToGenesis default are therefore never used; nodes will continue running the legacy backfill path instead of stopping at the weak-subjectivity floor by default.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and this is the headline blocker. Verified end-to-end:

// packages/beacon-node/src/sync/backfill/index.ts
export * from "./backfill.js";
export {BackfillSync as BackfillSyncV2} from "./backfillV2.js";

// packages/beacon-node/src/node/nodejs.ts:21,292
import {BackfillSync} from "../sync/backfill/index.js";
...
opts.sync.backfillBatchSize > 0
  ? await BackfillSync.init(opts.sync, {...})

The BackfillSync identifier in nodejs.ts resolves to the legacy class via the export * re-export — the new v2 implementation is only reachable as BackfillSyncV2, which nothing imports. So nodejs.ts still constructs the legacy BackfillSync, and combined with the legacy-side changes in this PR (hardcoded null / [] per discussion_r3109761377, commented-out backfilledRanges.put per discussion_r3393024831), production gets a strictly worse legacy backfill and zero exposure to v2.

Need to either: (a) swap the export so the barrel's BackfillSync IS the v2 class and the legacy one is gone, or (b) update nodejs.ts to import BackfillSyncV2 and gate selection on a sync option. Either path also needs the bucket-42 reuse from discussion_r3109761372 resolved before merge.

Comment on lines +393 to +394
// TODO: migrate to backfillState
// await this.db.backfilledRanges.put(this.backfillStartFromSlot, this.backfillRangeWrittenSlot);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve legacy backfill resume progress

Because the node still instantiates the legacy BackfillSync path, commenting out this DB write means an interrupted backfill no longer persists its latest synced slot. On restart init now seeds backfillRangeWrittenSlot from null and previousBackfilledRanges from [], so any node with sync.backfillBatchSize > 0 redownloads from the checkpoint anchor instead of resuming the already verified range.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — follows directly from the v2-not-wired situation in discussion_r3393024826. Because nodejs.ts still instantiates the legacy BackfillSync, the commented-out await this.db.backfilledRanges.put(this.backfillStartFromSlot, this.backfillRangeWrittenSlot) at line 394 (plus the hardcoded previousBackfilledRanges = [] and backfillRangeWrittenSlot = null at init time per discussion_r3109761377) means nodes with sync.backfillBatchSize > 0 lose backfill resume on restart and re-download from the anchor every time.

Resolution is bundled with the wiring fix: either fully remove the legacy class once v2 is wired and the bucket-42 reuse from discussion_r3109761372 is sorted, or restore the legacy write path until v2 takes over.

@lodekeeper lodekeeper left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the contribution — the v2 design (per-epoch BackfillStateRepository, parent-walk with epoch-boundary buffering, singleton backfilledRange) is a reasonable direction conceptually. There are however two blocking issues that need to be sorted before this can merge, plus a handful of secondary items the bots flagged that I've confirmed.

Blockers

  1. The new v2 code isn't wired into production (discussion_r3393024826 / r3393041220). packages/beacon-node/src/sync/backfill/index.ts exports BackfillSync from the legacy backfill.js and re-exports the new class only as BackfillSyncV2. packages/beacon-node/src/node/nodejs.ts:292 imports BackfillSync from that barrel and instantiates it on sync.backfillBatchSize > 0, so the v2 class is dead code in production today. Either (a) swap the barrel so BackfillSync IS the v2 class and the legacy file is removed, or (b) gate on a sync option in nodejs.ts and import BackfillSyncV2 explicitly.

  2. Bucket ID 42 is reused for incompatible schemas (discussion_r3109761372 / r3393038740). Old: backfilled_ranges = 42 (Slot → Slot). New: backfill_state = 42 (Epoch → EpochBackfillState). Any node upgrading on an existing DB will hit deserialization errors or silently load garbage from bucket 42. Allocate a fresh bucket ID for backfill_state (cheapest path; mark 42 as deprecated reserved), or land a one-shot migration that clears bucket 42 contents before the new repository is wired in.

The two interact: while v2 isn't wired (#1), the changes to the legacy path in this PR — hardcoded backfillRangeWrittenSlot = null / previousBackfilledRanges = [] (discussion_r3109761377 / r3393039227) and commented-out backfilledRanges.put (discussion_r3393024831 / r3393041523) — actively break backfill resume for production nodes with sync.backfillBatchSize > 0. They'll re-download from the anchor on every restart. So this PR as-is is a regression for the legacy path AND ships the new path as dead code.

Secondary

  • updateBackfillRange is now a fully commented-out no-op (discussion_r3109761380 / r3393039455). Resolve as part of the legacy-removal direction.
  • skipFilledEpochs (backfillV2.ts:665-672) does sequential await db.backfillState.get(epoch) per epoch — for nodes that have already backfilled a long range this is thousands of point lookups. Switch to backfillState.keysStream({lt: startEpoch, reverse: true}) and find the first epoch missing hasBlock in one pass (discussion_r3109761400 / r3393040539).
  • sendBeaconBlocksByRoot(peer, [root]) in the parent-walk (line 572) is RTT-bound by design (each request's input is the previous response's parentRoot), so the existing batched path at line 377 is the right model — switching the parent-walk to sendBeaconBlocksByRange once N target slots are known would amortize the RTT (discussion_r3109761397 / r3393040366). Perf-only follow-up, not a correctness blocker.
  • sync() in the constructor (discussion_r3109761390 / r3393040025) matches the existing legacy convention; not a blocker, but a brief comment near line 174 about lifecycle ownership and the .catch chain would help readers.

Suggestion

Worth pinging @nflaig for direction on scope — the v2 wiring + bucket migration is a non-trivial design decision (specifically: do we drop legacy entirely now, or run them side-by-side under a flag while v2 stabilizes?). Once that's settled the rest is mechanical.

@lodekeeper

Copy link
Copy Markdown
Contributor

Reviewed — full breakdown in pullrequestreview-4473428866. Short version: the v2 design direction looks reasonable, but two blocking issues need to be sorted before this can merge:

  1. The new v2 code isn't actually reachable from nodejs.tsBackfillSync resolves to the legacy class via the barrel re-export, so the v2 implementation is dead code in production today.
  2. Bucket ID 42 is reused for an incompatible schema (Slot → Slot becomes Epoch → EpochBackfillState), which will break existing-DB upgrades. Allocate a fresh ID or land a migration.

Secondary perf/cleanup items in the inline replies. Worth pinging @nflaig for direction on whether legacy gets removed entirely or runs side-by-side under a flag while v2 stabilizes — once that's decided the rest is mechanical.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants