Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/utils/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion db/rawdb/rawtemporaldb/accessors_commitment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions eth/ethconfig/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ type Sync struct {

ChaosMonkey bool
AlwaysGenerateChangesets bool
UseForkchoiceFinality bool
MaxReorgDepth uint64
KeepExecutionProofs bool
PersistReceiptsCacheV2 bool
Expand Down
6 changes: 6 additions & 0 deletions execution/eth1/forkchoice.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
48 changes: 42 additions & 6 deletions execution/stagedsync/exec3.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
112 changes: 112 additions & 0 deletions execution/stagedsync/exec3_changeset_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
6 changes: 5 additions & 1 deletion execution/stagedsync/exec3_parallel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions execution/stagedsync/stage_execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,12 @@ func PruneExecutionStage(s *PruneState, tx kv.RwTx, cfg ExecuteBlockCfg, ctx con
"externalTx", useExternalTx,
)
}
} else if cfg.syncCfg.UseForkchoiceFinality {
// Forkchoice finality keeps the node stable at tip, eliminating the natural
// syncToTip fallbacks that triggered initialCycle=true with aggressive pruning.
// Use aggressive timeout (>=1min triggers adaptive batch ramp-up in PruneSmallBatches)
// to drain accumulated commitment history at step boundaries.
pruneTimeout = 60 * time.Second
}

pruneSmallBatchesStartTime := time.Now()
Expand Down
Loading