diff --git a/execution/stagedsync/exec3_parallel.go b/execution/stagedsync/exec3_parallel.go index c76f5fc1e34..3d965fe607a 100644 --- a/execution/stagedsync/exec3_parallel.go +++ b/execution/stagedsync/exec3_parallel.go @@ -595,9 +595,11 @@ func (pe *parallelExecutor) execImpl(ctx context.Context, execStage *StageState, } // Fallback for exit paths that publish no cause: a single-block // fork-validation batch exits via execLoopExitCheck (no cause), and - // real shutdown cancels with context.Canceled. A fully-applied - // requested range is a clean end; otherwise there is more work. - if lastBlockResult.BlockNum >= pe.maxBlockNum { + // real shutdown cancels with context.Canceled. A fully-applied range + // — or an empty loop that executed nothing because the range was + // already applied (async background commit advanced progress) — is a + // clean end; otherwise there is more work. + if applyLoopCloseIsClean(lastBlockResult.BlockNum, pe.maxBlockNum, len(txResultBlocks)) { return nil } return &ErrLoopExhausted{From: startBlockNum, To: lastBlockResult.BlockNum, Reason: "block batch is full"} @@ -1478,6 +1480,18 @@ func execLoopShouldExit(blockResult *blockResult, sizeEst, batchLimit, maxBlockN return execLoopContinue } +// applyLoopCloseIsClean reports whether an apply-loop close with no published +// stop cause is a clean end rather than a partial batch to resume. It is clean +// when the requested range was fully applied (lastBlockNum >= maxBlockNum) or +// when the loop executed nothing at all (no tx-results and no blockResult) — +// the range was already applied before this call, so there is no pending work. +func applyLoopCloseIsClean(lastBlockNum, maxBlockNum uint64, txResultCount int) bool { + if lastBlockNum >= maxBlockNum { + return true + } + return txResultCount == 0 && lastBlockNum == 0 +} + // closeApplyChannels closes the apply-loop-bound channels in the order // the calculator and apply loop require: commitResults FIRST so the // calculator drains and closes rootResults, then applyResults so the diff --git a/execution/stagedsync/exec3_parallel_robustness_test.go b/execution/stagedsync/exec3_parallel_robustness_test.go index b588942cbbc..4570f9a02e2 100644 --- a/execution/stagedsync/exec3_parallel_robustness_test.go +++ b/execution/stagedsync/exec3_parallel_robustness_test.go @@ -720,6 +720,38 @@ func TestExecLoopShouldExitPriority(t *testing.T) { } } +// TestApplyLoopCloseIsClean pins the no-stop-cause apply-loop close +// classification. The load-bearing case is the empty loop +// (txResultCount==0, lastBlockNum==0): under background commit the async +// commit can advance execution progress to the validation target before a +// single-block fork-validation step runs, so the exec loop executes nothing +// and produces no blockResult. Treating that as pending work returns a +// spurious ErrLoopExhausted, which the stage loop reports as +// "unexpected state step has more work". +func TestApplyLoopCloseIsClean(t *testing.T) { + cases := []struct { + name string + lastBlockNum uint64 + maxBlockNum uint64 + txResults int + want bool + }{ + {name: "fully applied", lastBlockNum: 5, maxBlockNum: 5, txResults: 3, want: true}, + {name: "past target", lastBlockNum: 6, maxBlockNum: 5, txResults: 3, want: true}, + {name: "partial batch is not clean", lastBlockNum: 3, maxBlockNum: 5, txResults: 2, want: false}, + {name: "empty loop, nothing executed", lastBlockNum: 0, maxBlockNum: 21, txResults: 0, want: true}, + {name: "tx-results without blockResult is not clean", lastBlockNum: 0, maxBlockNum: 21, txResults: 4, want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := applyLoopCloseIsClean(tc.lastBlockNum, tc.maxBlockNum, tc.txResults) + if got != tc.want { + t.Fatalf("applyLoopCloseIsClean(%d,%d,%d) = %v, want %v", tc.lastBlockNum, tc.maxBlockNum, tc.txResults, got, tc.want) + } + }) + } +} + // TestShouldMarkExhaustedAtBlock exercises the production // shouldMarkExhaustedAtBlock helper directly. The helper is the gate // that decides whether executeBlocks stamps a dispatched block with diff --git a/execution/stagedsync/exec3_serial.go b/execution/stagedsync/exec3_serial.go index 01da451dbd8..d7f08fa9397 100644 --- a/execution/stagedsync/exec3_serial.go +++ b/execution/stagedsync/exec3_serial.go @@ -195,7 +195,7 @@ func (se *serialExecutor) exec(ctx context.Context, execStage *StageState, u Unw return nil, rwTx, err } - if shouldGenerateChangesets { + if shouldGenerateChangesets && blockNum > 0 { se.doms.SavePastChangesetAccumulator(b.Hash(), blockNum, changeSet) } se.doms.SetChangesetAccumulator(nil) diff --git a/rpc/jsonrpc/eth_call.go b/rpc/jsonrpc/eth_call.go index 037f6faf3a2..8c8c6ad2a96 100644 --- a/rpc/jsonrpc/eth_call.go +++ b/rpc/jsonrpc/eth_call.go @@ -472,6 +472,9 @@ func (api *APIImpl) getProof(ctx context.Context, roTx kv.TemporalTx, address co if err != nil { return nil, err } + if header == nil { + return nil, fmt.Errorf("header not found for block %d", blockNrOrHash.BlockNumber.Uint64()) + } domains, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) if err != nil {