From a46c7cc30921258ac9890269ad66e471a011acd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Dumas?= Date: Fri, 6 Feb 2026 13:16:25 -0500 Subject: [PATCH] polygon/sync, stagedsync: add forkchoice finality changeset optimization Add --experimental.use-forkchoice-finality flag that skips generating changesets for blocks finalized by Polygon milestones. This reduces sync overhead near the chain head by leveraging milestone-based finality instead of keeping changesets for the full MaxReorgDepth (512 blocks). How it works: - Track lastFinalizedBlockNum in the sync layer as milestones are validated (both at-tip and ahead-of-tip milestones) - In commitExecution(), resolve the finalized header post-flush: if tip <= lastFinalizedBlockNum, use newTip as finalized; otherwise fetch the milestone end block header from DB - Write finalized hash before execution stage runs so shouldGenerateChangeSets() can read it - Cache getFinalizedBlockNum before execution loop (1 DB read) - When UseForkchoiceFinality is enabled, skip changeset generation for blocks at or below the finalized block - Use aggressive pruning for finalized blocks, matching initial-sync behavior - Downgrade noisy "no unwindable block" warning to Debug WARNING: If finality is later reverted (e.g., faulty milestone purged by hard fork), the node will require a chaindata reset to recover. --- cmd/utils/flags.go | 4 + .../rawtemporaldb/accessors_commitment.go | 2 +- eth/ethconfig/config.go | 1 + execution/eth1/forkchoice.go | 6 + execution/stagedsync/exec3.go | 48 +++++++- execution/stagedsync/exec3_changeset_test.go | 112 ++++++++++++++++++ execution/stagedsync/exec3_parallel.go | 6 +- polygon/sync/sync.go | 77 +++++++++++- turbo/cli/default_flags.go | 1 + turbo/cli/flags.go | 2 + 10 files changed, 249 insertions(+), 10 deletions(-) create mode 100644 execution/stagedsync/exec3_changeset_test.go diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go index 33ab8f5c751..9d07bb1e191 100644 --- a/cmd/utils/flags.go +++ b/cmd/utils/flags.go @@ -1140,6 +1140,10 @@ var ( Usage: "EXPERIMENTAL: enables concurrent trie for commitment", Value: false, } + UseForkchoiceFinalityFlag = cli.BoolFlag{ + Name: "experimental.use-forkchoice-finality", + Usage: "Skip changeset generation for blocks finalized by forkchoice (e.g., Polygon milestones). Reduces overhead but requires chaindata reset if finality is reverted.", + } GDBMeFlag = cli.BoolFlag{ Name: "gdbme", Usage: "restart erigon under gdb for debug purposes", diff --git a/db/rawdb/rawtemporaldb/accessors_commitment.go b/db/rawdb/rawtemporaldb/accessors_commitment.go index aa836448e26..c22bc5ad470 100644 --- a/db/rawdb/rawtemporaldb/accessors_commitment.go +++ b/db/rawdb/rawtemporaldb/accessors_commitment.go @@ -15,7 +15,7 @@ func CanUnwindToBlockNum(tx kv.TemporalTx) (uint64, error) { return 0, err } if minUnwindale == math.MaxUint64 { // no unwindable block found - log.Warn("no unwindable block found from changesets, falling back to latest with commitment") + log.Debug("no unwindable block found from changesets, falling back to latest with commitment") return commitmentdb.LatestBlockNumWithCommitment(tx) } if minUnwindale > 0 { diff --git a/eth/ethconfig/config.go b/eth/ethconfig/config.go index b4192e00282..52dc96bfd34 100644 --- a/eth/ethconfig/config.go +++ b/eth/ethconfig/config.go @@ -291,6 +291,7 @@ type Sync struct { ChaosMonkey bool AlwaysGenerateChangesets bool + UseForkchoiceFinality bool MaxReorgDepth uint64 KeepExecutionProofs bool PersistReceiptsCacheV2 bool diff --git a/execution/eth1/forkchoice.go b/execution/eth1/forkchoice.go index 9c391650f4a..7c630f1169a 100644 --- a/execution/eth1/forkchoice.go +++ b/execution/eth1/forkchoice.go @@ -486,6 +486,12 @@ func (e *EthereumExecutionModule) updateForkChoice(ctx context.Context, original } } + // Write finalized hash BEFORE execution so shouldGenerateChangeSets() can read it + // during execution stage. This enables the UseForkchoiceFinality optimization. + if finalizedHash != (common.Hash{}) { + rawdb.WriteForkchoiceFinalized(tx, finalizedHash) + } + firstCycle := false loopIter := 0 for { diff --git a/execution/stagedsync/exec3.go b/execution/stagedsync/exec3.go index 18e2a182cf6..3c6341811e8 100644 --- a/execution/stagedsync/exec3.go +++ b/execution/stagedsync/exec3.go @@ -467,9 +467,14 @@ func ExecV3(ctx context.Context, lastFrozenTxNum = uint64((lastFrozenStep+1)*kv.Step(doms.StepSize())) - 1 } + var finalizedBlockNum uint64 + if cfg.syncCfg.UseForkchoiceFinality { + finalizedBlockNum = getFinalizedBlockNum(applyTx) + } + Loop: for ; blockNum <= maxBlockNum; blockNum++ { - shouldGenerateChangesets := shouldGenerateChangeSets(cfg, blockNum, maxBlockNum, initialCycle) + shouldGenerateChangesets := shouldGenerateChangeSets(cfg, finalizedBlockNum, blockNum, maxBlockNum, initialCycle) changeSet := &changeset2.StateChangeSet{} if shouldGenerateChangesets && blockNum > 0 { executor.domains().SetChangesetAccumulator(changeSet) @@ -762,9 +767,15 @@ Loop: timeStart := time.Now() - // allow greedy prune on non-chain-tip + // allow greedy prune on non-chain-tip or when not generating changesets pruneTimeout := 250 * time.Millisecond - if initialCycle { + if initialCycle || !shouldGenerateChangesets { + // When not generating changesets (finalized blocks), we can afford longer pruning + // since we're generating less data overall + if !initialCycle { + logger.Debug(fmt.Sprintf("[%s] aggressive pruning via forkchoice finality", execStage.LogPrefix()), + "block", blockNum, "finalizedBlock", finalizedBlockNum) + } pruneTimeout = 10 * time.Hour if err = executor.tx().(kv.TemporalRwTx).GreedyPruneHistory(ctx, kv.CommitmentDomain); err != nil { @@ -787,7 +798,9 @@ Loop: errExhausted = &ErrLoopExhausted{From: startBlockNum, To: blockNum, Reason: "block batch is full"} break Loop } - if !initialCycle && canPrune { + // Skip pruning break if not generating changesets (finalized blocks via UseForkchoiceFinality) + // This allows larger batches similar to initialCycle behavior + if !initialCycle && canPrune && shouldGenerateChangesets { errExhausted = &ErrLoopExhausted{From: startBlockNum, To: blockNum, Reason: "block batch can be pruned"} break Loop } @@ -1021,7 +1034,21 @@ func blockWithSenders(ctx context.Context, db kv.RoDB, tx kv.Tx, blockReader ser return b, err } -func shouldGenerateChangeSets(cfg ExecuteBlockCfg, blockNum, maxBlockNum uint64, initialCycle bool) bool { +// getFinalizedBlockNum returns the finalized block number from forkchoice state. +// Returns 0 if no finalized block is set or if the hash cannot be resolved to a block number. +func getFinalizedBlockNum(tx kv.Getter) uint64 { + finalizedHash := rawdb.ReadForkchoiceFinalized(tx) + if finalizedHash == (common.Hash{}) { + return 0 + } + finalizedNum := rawdb.ReadHeaderNumber(tx, finalizedHash) + if finalizedNum == nil { + return 0 + } + return *finalizedNum +} + +func shouldGenerateChangeSets(cfg ExecuteBlockCfg, finalizedBlockNum, blockNum, maxBlockNum uint64, initialCycle bool) bool { if cfg.syncCfg.AlwaysGenerateChangesets { return true } @@ -1031,6 +1058,15 @@ func shouldGenerateChangeSets(cfg ExecuteBlockCfg, blockNum, maxBlockNum uint64, if initialCycle { return false } - // once past the initial cycle, make sure to generate changesets for the last blocks that fall in the reorg window + + // Use forkchoice finalized block if enabled and available (e.g., Polygon milestones). + // Blocks at or before the finalized block don't need changesets since they cannot be reorged. + // WARNING: If finality is later reverted (e.g., faulty milestone purged by hard fork), + // the node will require a chaindata reset to recover. + if cfg.syncCfg.UseForkchoiceFinality && finalizedBlockNum > 0 && blockNum <= finalizedBlockNum { + return false + } + + // Fallback: generate changesets for blocks in the reorg window (last MaxReorgDepth blocks) return blockNum+cfg.syncCfg.MaxReorgDepth >= maxBlockNum } diff --git a/execution/stagedsync/exec3_changeset_test.go b/execution/stagedsync/exec3_changeset_test.go new file mode 100644 index 00000000000..df0fd0d6033 --- /dev/null +++ b/execution/stagedsync/exec3_changeset_test.go @@ -0,0 +1,112 @@ +package stagedsync + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/erigontech/erigon/eth/ethconfig" + "github.com/erigontech/erigon/turbo/services" +) + +// mockBlockReader implements services.FullBlockReader with only FrozenBlocks() used. +type mockBlockReader struct { + services.FullBlockReader + frozenBlocks uint64 +} + +func (m *mockBlockReader) FrozenBlocks() uint64 { + return m.frozenBlocks +} + +func TestShouldGenerateChangeSets(t *testing.T) { + tests := []struct { + name string + alwaysGenerate bool + frozenBlocks uint64 + initialCycle bool + useFinality bool + finalizedBlockNum uint64 + maxReorgDepth uint64 + blockNum uint64 + maxBlockNum uint64 + want bool + }{ + { + name: "AlwaysGenerateChangesets overrides everything", + alwaysGenerate: true, + frozenBlocks: 1000, + blockNum: 500, // below frozen + maxBlockNum: 2000, + want: true, + }, + { + name: "block below frozen returns false", + frozenBlocks: 1000, + blockNum: 999, + maxBlockNum: 2000, + want: false, + }, + { + name: "initialCycle returns false", + initialCycle: true, + blockNum: 1500, + maxBlockNum: 2000, + want: false, + }, + { + name: "UseForkchoiceFinality: block below finalized returns false", + useFinality: true, + finalizedBlockNum: 1000, + blockNum: 900, + maxBlockNum: 2000, + want: false, + }, + { + name: "UseForkchoiceFinality: block equal to finalized returns false", + useFinality: true, + finalizedBlockNum: 1000, + blockNum: 1000, + maxBlockNum: 2000, + want: false, + }, + { + name: "UseForkchoiceFinality: block above finalized falls through to MaxReorgDepth", + useFinality: true, + finalizedBlockNum: 1000, + maxReorgDepth: 100, + blockNum: 1001, + maxBlockNum: 2000, + want: false, // 1001 + 100 = 1101 < 2000 + }, + { + name: "MaxReorgDepth: block in reorg window returns true", + maxReorgDepth: 100, + blockNum: 1950, + maxBlockNum: 2000, + want: true, // 1950 + 100 = 2050 >= 2000 + }, + { + name: "MaxReorgDepth: block outside reorg window returns false", + maxReorgDepth: 100, + blockNum: 1800, + maxBlockNum: 2000, + want: false, // 1800 + 100 = 1900 < 2000 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := ExecuteBlockCfg{ + blockReader: &mockBlockReader{frozenBlocks: tt.frozenBlocks}, + syncCfg: ethconfig.Sync{ + AlwaysGenerateChangesets: tt.alwaysGenerate, + UseForkchoiceFinality: tt.useFinality, + MaxReorgDepth: tt.maxReorgDepth, + }, + } + got := shouldGenerateChangeSets(cfg, tt.finalizedBlockNum, tt.blockNum, tt.maxBlockNum, tt.initialCycle) + assert.Equal(t, tt.want, got, "shouldGenerateChangeSets(%d, %d, %d)", tt.blockNum, tt.maxBlockNum, tt.initialCycle) + }) + } +} diff --git a/execution/stagedsync/exec3_parallel.go b/execution/stagedsync/exec3_parallel.go index 20220b195bf..4281c3094ce 100644 --- a/execution/stagedsync/exec3_parallel.go +++ b/execution/stagedsync/exec3_parallel.go @@ -132,7 +132,11 @@ func (te *txExecutor) getHeader(ctx context.Context, hash common.Hash, number ui } func (te *txExecutor) shouldGenerateChangeSets() bool { - return shouldGenerateChangeSets(te.cfg, te.inputBlockNum.Load(), te.maxBlockNum, te.initialCycle) + var finalizedBlockNum uint64 + if te.cfg.syncCfg.UseForkchoiceFinality { + finalizedBlockNum = getFinalizedBlockNum(te.applyTx) + } + return shouldGenerateChangeSets(te.cfg, finalizedBlockNum, te.inputBlockNum.Load(), te.maxBlockNum, te.initialCycle) } type parallelExecutor struct { diff --git a/polygon/sync/sync.go b/polygon/sync/sync.go index c6bfb330201..bc85869114a 100644 --- a/polygon/sync/sync.go +++ b/polygon/sync/sync.go @@ -143,6 +143,12 @@ type Sync struct { wiggleCalculator wiggleCalculator engineAPISwitcher EngineAPISwitcher blockRequestsCache *lru.ARCCache[common.Hash, struct{}] + + // lastFinalizedBlockNum tracks the end block of the last validated finality + // waypoint (milestone or checkpoint). This is used to set the finalized block + // in forkchoice updates, enabling the execution stage to skip changeset + // generation for finalized blocks. + lastFinalizedBlockNum uint64 } func (s *Sync) commitExecution(ctx context.Context, newTip *types.Header, finalizedHeader *types.Header) error { @@ -150,8 +156,19 @@ func (s *Sync) commitExecution(ctx context.Context, newTip *types.Header, finali return err } - blockNum := newTip.Number.Uint64() + // After flush, improve finalized header if possible. + // If newTip is at or before the last known milestone, it IS finalized. + tipNum := newTip.Number.Uint64() + if s.lastFinalizedBlockNum > 0 && tipNum <= s.lastFinalizedBlockNum { + finalizedHeader = newTip + } else if s.lastFinalizedBlockNum > 0 { + // Try to get the milestone end block header (now available after flush) + if h, err := s.execution.GetHeader(ctx, s.lastFinalizedBlockNum); err == nil && h != nil { + finalizedHeader = h + } + } + blockNum := newTip.Number.Uint64() age := common.PrettyAge(time.Unix(int64(newTip.Time), 0)) s.logger.Info(syncLogPrefix("update fork choice"), "block", blockNum, "hash", newTip.Hash(), "age", age) fcStartTime := time.Now() @@ -251,11 +268,22 @@ func (s *Sync) handleMilestoneTipMismatch(ctx context.Context, ccb *CanonicalCha func (s *Sync) applyNewMilestoneOnTip(ctx context.Context, event EventNewMilestone, ccb *CanonicalChainBuilder) error { milestone := event if milestone.EndBlock().Uint64() <= ccb.Root().Number.Uint64() { + s.logger.Debug(syncLogPrefix("skipping milestone - already behind root"), + "milestoneEnd", milestone.EndBlock().Uint64(), + "ccbRoot", ccb.Root().Number.Uint64(), + ) return nil } // milestone is ahead of our current tip if milestone.EndBlock().Uint64() > ccb.Tip().Number.Uint64() { + // Track finality even for future milestones - the milestone IS finality from Heimdall. + // This lets commitExecution() set finalizedHeader = newTip for blocks within this range. + endBlock := milestone.EndBlock().Uint64() + if endBlock > s.lastFinalizedBlockNum { + s.lastFinalizedBlockNum = endBlock + } + s.logger.Debug(syncLogPrefix("putting milestone event back in the queue because our tip is behind the milestone"), "milestoneId", milestone.RawId(), "milestoneStart", milestone.StartBlock().Uint64(), @@ -291,6 +319,15 @@ func (s *Sync) applyNewMilestoneOnTip(ctx context.Context, event EventNewMilesto if endBlock > 0 { pruneTo = endBlock - 1 } + + // Track the milestone end block for forkchoice finality. + // This enables the execution stage to skip changeset generation for finalized blocks. + // Use max to avoid lowering the value when an older at-tip milestone is processed + // after a newer ahead-of-tip milestone has already been recorded. + if endBlock > s.lastFinalizedBlockNum { + s.lastFinalizedBlockNum = endBlock + } + return ccb.PruneRoot(pruneTo) } @@ -869,6 +906,7 @@ func (s *Sync) Run(ctx context.Context) error { if s.config.PolygonPosSingleSlotFinality { if result.latestTip.Number.Uint64() >= s.config.PolygonPosSingleSlotFinalityBlockAt { + s.logger.Info(syncLogPrefix("switching to engine API mode (SSF)"), "tip", result.latestTip.Number.Uint64()) s.engineAPISwitcher.SetConsuming(true) return nil } @@ -949,6 +987,9 @@ func (s *Sync) initialiseCcb(ctx context.Context, result syncToTipResult) (*Cano if result.latestWaypoint.EndBlock().Uint64() > tipNum { return nil, fmt.Errorf("unexpected rootNum > tipNum: %d > %d", rootNum, tipNum) } + // Initialize lastFinalizedBlockNum from the latest waypoint (milestone or checkpoint) + s.lastFinalizedBlockNum = rootNum + s.logger.Debug(syncLogPrefix("initialized milestone finality"), "lastFinalizedBlock", s.lastFinalizedBlockNum) } s.logger.Debug(syncLogPrefix("initialising canonical chain builder"), "rootNum", rootNum, "tipNum", tipNum) @@ -986,6 +1027,7 @@ type syncToTipResult struct { } func (s *Sync) syncToTip(ctx context.Context) (syncToTipResult, error) { + s.logger.Debug(syncLogPrefix("syncToTip starting")) latestTipOnStart, err := s.execution.CurrentHeader(ctx) if err != nil { return syncToTipResult{}, err @@ -1046,6 +1088,31 @@ func (s *Sync) syncToTip(ctx context.Context) (syncToTipResult, error) { } } + // If we didn't get a waypoint from sync (e.g., already at tip), fetch the latest milestone + // This ensures we have milestone finality info for the execution stage optimization + if finalisedTip.latestWaypoint == nil { + s.logger.Info(syncLogPrefix("no waypoint from sync, fetching latest milestone...")) + latestMilestone, ok, err := s.heimdallSync.SynchronizeMilestones(ctx) + if err != nil { + s.logger.Warn(syncLogPrefix("failed to get latest milestone for finality"), "err", err) + } else if ok && latestMilestone != nil { + finalisedTip.latestWaypoint = latestMilestone + s.logger.Info(syncLogPrefix("fetched latest milestone for finality"), + "milestoneEndBlock", latestMilestone.EndBlock().Uint64(), + ) + } else { + s.logger.Warn(syncLogPrefix("SynchronizeMilestones returned no milestone"), "ok", ok, "milestoneNil", latestMilestone == nil) + } + } else { + s.logger.Info(syncLogPrefix("waypoint from sync available"), + "waypointEndBlock", finalisedTip.latestWaypoint.EndBlock().Uint64(), + ) + } + + s.logger.Info(syncLogPrefix("syncToTip finished"), + "tipNum", finalisedTip.latestTip.Number.Uint64(), + "hasWaypoint", finalisedTip.latestWaypoint != nil, + ) return finalisedTip, nil } @@ -1091,8 +1158,14 @@ func (s *Sync) sync( return syncToTipResult{}, false, nil } + // Track the waypoint end block for forkchoice finality + waypointEndBlock := waypoint.EndBlock().Uint64() + if waypointEndBlock > s.lastFinalizedBlockNum { + s.lastFinalizedBlockNum = waypointEndBlock + } + // notify about latest waypoint end block so that eth_syncing API doesn't flicker on initial sync - s.notifications.NewLastBlockSeen(waypoint.EndBlock().Uint64()) + s.notifications.NewLastBlockSeen(waypointEndBlock) newTip, err := blockDownload(ctx, tip.Number.Uint64()+1, syncTo) if err != nil { diff --git a/turbo/cli/default_flags.go b/turbo/cli/default_flags.go index b885a0df39a..731d7d91b37 100644 --- a/turbo/cli/default_flags.go +++ b/turbo/cli/default_flags.go @@ -255,5 +255,6 @@ var DefaultFlags = []cli.Flag{ &utils.GDBMeFlag, &utils.ExperimentalConcurrentCommitmentFlag, + &utils.UseForkchoiceFinalityFlag, &utils.ElBlockDownloaderV2, } diff --git a/turbo/cli/flags.go b/turbo/cli/flags.go index 088b6ac5be3..1d412b67721 100644 --- a/turbo/cli/flags.go +++ b/turbo/cli/flags.go @@ -320,6 +320,8 @@ func ApplyFlagsForEthConfig(ctx *cli.Context, cfg *ethconfig.Config, logger log. if ctx.Bool(utils.ChaosMonkeyFlag.Name) { cfg.ChaosMonkey = true } + + cfg.Sync.UseForkchoiceFinality = ctx.Bool(utils.UseForkchoiceFinalityFlag.Name) } func ApplyFlagsForEthConfigCobra(f *pflag.FlagSet, cfg *ethconfig.Config) {