parallel commitment calculations implemented#20805
Conversation
…ndaries LightCollector.UpdateAccountData unconditionally emitted all 4 account fields (balance, nonce, incarnation, codeHash) using values from MakeWriteSet(useBlockOrigin=true). When account A sends a TX (nonce 5→6) then receives transfers later in the same block, the transfer TXs carry the pre-block nonce=5 — overwriting the increment when ApplyStateWrites processes writes in txNum order. Fix: - LightCollector.UpdateAccountData: only emit nonce, incarnation, and codeHash when they differ from `original` (the block-origin value). Balance is always emitted since it changes on every touch. - applyVersionedWrites: read current account from the domain as the base, then overlay only present fields. This is required because LightCollector now emits partial writes. - Add snapshot step alignment check: fail early if state domain files (accounts, storage, code) are ahead of commitment domain files. The bug was pre-existing on main but masked by the gas mismatch error (which fires first at block 24,363,954). With DISCARD_COMMITMENT=true, main also crashes with nonce mismatches at the same block range. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
applyVersionedWrites reads the domain base before overlaying fields from LightCollector writes. For new accounts (no domain entry), the base must use accounts.NewAccount() (CodeHash=EmptyCodeHash), not a zero-value Account (CodeHash=0x000...). Otherwise SerialiseV3 encodes 32 zero bytes for CodeHash instead of omitting it — producing a different blob that causes downstream state divergence. Add TestLightCollectorNewAccountCodeHash to verify the round-trip serialization invariant for new accounts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The `original == value` skip is wrong for the revert case in the parallel executor: TX N writes slot S=X, then TX M reverts S to block-origin Y. The skip leaves the domain with X instead of Y. DomainPut's internal bytes.Equal skip handles the true no-change case. This fixes the 17,100 gas mismatch at block 24,363,954 caused by wrong SSTORE gas from stale storage values. There is a remaining receipt hash mismatch at block 24,363,971 where execution results (gas, logs, status) are identical to the serial path but the receipt trie hash differs — this is a receipt encoding issue, not a state/execution bug. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add TestLightCollectorStorageReentrancyGuard and TestLightCollectorStorageUnchangedSlot to exercise the storage skip removal pattern at the ApplyStateWrites level. Revert the validation finalize stateReader change (removing BufferedReader introduced a race with the apply loop's balanceIncreases processing). Remove storage skip from versionedWriteCollector.WriteAccountStorage (same rationale as LightCollector). The receipt hash mismatch at block 24,363,971 remains — it's a parallel-only execution divergence where workers produce different state reads than the serial path. The ApplyStateWrites tests pass, confirming the apply path is correct. The bug is in the execution/ versionMap interaction during parallel processing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ence Restoring the `original == value` skip in LightCollector and versionedWriteCollector WriteAccountStorage. Removing the skip was correct for the revert case but caused: - Receipt hash mismatch at block 24,363,971 (different log data) - 20 gas mismatch at block 24,363,974 (TX 26) Both are parallel-only — serial path with skip removed is correct. The root cause is that DomainPut.TouchKey is called before the skip check, creating spurious commitment updates for unchanged storage slots (reentrancy guards etc). This interacts with the parallel executor's versionMap/state read path to produce different execution results. Investigation narrowed the divergence to a specific DEX contract's storage but the exact versionMap interaction needs further debugging. The test TestLightCollectorStorageReentrancyGuard documents the known limitation: TX B's revert to block-origin is lost when the skip is active, causing the 17,100 gas mismatch at block 24,363,954. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…dWrites LightCollector.WriteAccountStorage no longer skips when original==value (blockOriginStorage is stale for the revert case). Instead, applyVersionedWrites pre-checks the domain value via GetLatest before calling DomainPut. If the domain already has the same value, the write is skipped entirely (no DomainPut, no TouchKey). This handles both: - Unchanged slots (reentrancy guards): domain value matches → skip - Revert case (TX N wrote X, TX M reverts to Y): domain has X ≠ Y → write This fixes the 17,100 gas mismatch at block 24,363,954 caused by the parallel executor's blockOriginStorage-based skip missing the revert. The test TestLightCollectorStorageReentrancyGuard now asserts correct behavior: TX B's revert to block-origin value is properly applied. There is a remaining 20 gas diff at block 24,363,974 and receipt hash mismatch at block 24,363,971 from a separate state divergence in the parallel executor. Serial path with the same code is correct. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…mitmentCalculator The prior commits introduced references to SwapUpdates, ProcessBAL, and commitmentCalculator that existed only in uncommitted RwTx decoupling changes. Revert to the main branch's commitment and BAL processing paths. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ect cache staleness 1. Move ApplyStateWrites from apply loop to execLoop (producer side of applyResults channel). The domain-base merge in applyVersionedWrites reads sd.mem which must be in the same goroutine as the versionMap to avoid cross-thread races. The apply loop now only does ApplyTxIndexes. 2. Refresh stateObject cache from versionMap on cache hit. The stateObject caches the full account (record-level) but the versionMap tracks individual fields (BalancePath, NoncePath). When a prior TX's re-execution updates field-level entries, the cached stateObject becomes stale. refreshVersionedAccount is now called on every cache hit when a versionMap is present. 3. Fold CommitStepBoundary into ApplyStateWrites. Both share the same arguments and the step-boundary commitment must follow state writes. Skip entirely when there are no writes. 4. Remove storage pre-check in applyVersionedWrites. Let DomainPut handle the skip via its internal GetLatest comparison. The pre-check was using domain state that could differ from the serial path's originStorage. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…+ remove BufferedReader Three changes to fix parallel executor state divergence: 1. BlockStateCache: per-block committed account cache on TxTask. Workers' CachedReaderV3 caches ReadAccountData on first access per block, providing a stable pre-block view for GetCommittedState. Prevents intra-block ApplyStateWrites from polluting committed-value reads used by SSTORE gas metering (EIP-2200). Fixes 11,482 gas diff at block 24,363,957. 2. LightCollector emits ALL account fields: balance, nonce, incarnation, codeHash — always. The `account` parameter from the IBS has correct values (read via versionMap). This eliminates the GetLatest domain-base merge in applyVersionedWrites, removing the cross-thread sd.mem race that caused WETH balance divergence. 3. Remove BufferedReader from worker setup: workers read directly from sd.mem via ReaderV3/CachedReaderV3. The old rs.accounts cache caused stale reads when parallel workers accessed accounts modified by prior TXs' ApplyStateWrites in the apply loop. Also folds CommitStepBoundary into ApplyStateWrites and removes the storage pre-check that was replaced by DomainPut's internal skip. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
GetCommittedState for storage slots reads from stateReader which falls through to sd.mem. Intra-block ApplyStateWrites can update sd.mem with values from prior TXs, making the "committed" view inconsistent. Cache ReadAccountStorage results in the per-block BlockStateCache alongside account data, so parallel workers always see the pre-block committed value for both accounts and storage. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Evolve BlockStateCache from a read-only cache to a block-level write buffer: - Per-TX ApplyStateWrites writes accounts/storage/code to the cache (with TouchKey for commitment) instead of DomainPut to sd.mem. - At block boundary, Flush writes dirty entries to SharedDomains, skipping storage slots whose value matches the pre-block committed value. - Block finalize runs AFTER flush, seeing all TX writes in sd.mem. This ensures sd.mem only changes at block boundaries, preventing intra-block partial state from leaking to concurrent workers or the block finalize IBS. Also: LightCollector emits all dirty storage (no skip) — the write buffer handles deduplication at flush time against committed values. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
All state mutations now happen in the execLoop goroutine: - Per-TX ApplyStateWrites writes to BlockStateCache + TouchKey - Block cache flush writes to sd.mem (in nextResult before blockResult) - Block finalize (engine.Finalize) writes directly to sd.mem - Apply loop only does ApplyTxIndexes and blockApplied signaling This cleanly separates state mutation (execLoop) from index writes (apply loop), matching the principle that the execLoop checks, orders, finalizes TXs and finalizes the block. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move ALL state mutation (per-TX writes, block finalize, step-boundary commitment) to the execLoop goroutine (producer side of the applyResults channel). The apply loop now only does ApplyTxIndexes and accumulator notifications. Key changes: 1. BlockStateCache write buffer: per-TX ApplyStateWrites writes to a per-block cache instead of sd.mem. The cache is flushed to sd.mem once at block end, ensuring workers always see consistent pre-block state. 2. Block finalize moved to execLoop: engine.Finalize + MakeWriteSet now runs in the execLoop before the blockResult crosses the channel. The finalize IBS uses CachedReaderV3 to read the accumulated per-TX state from the BlockStateCache (e.g., coinbase balance with all accumulated tips) instead of stale sd.mem values. 3. WriteAccount dirty tracking: only marks accounts dirty when the serialized blob differs from the committed (pre-block) value. Prevents the Flush from overwriting sd.mem with read-only values. 4. CommitStepBoundary folded into ApplyStateWrites. 5. ClearAccountsCache moved to execLoop (producer side). This eliminates the cross-thread sd.mem race that caused the 20 gas diff at block 24,363,974 and the nonce/balance divergences at earlier blocks. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…Result 1. Block finalize uses NewCurrentCachedReaderV3 to read accumulated per-TX state from the BlockStateCache write buffer (e.g., coinbase balance with all tips) instead of stale pre-block sd.mem values. 2. nextResult returns nil when block is incomplete instead of building a dummy blockResult. Simplifies processResults loop condition. 3. Remove redundant complete field check — blockResult is always complete when non-nil. 4. Remove per-TX Flush from partial return path — only flush at block end. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move ApplyStateWrites from the apply loop to the execLoop (producer side of the applyResults channel). The block finalize now runs in the execLoop via NewCurrentCachedReaderV3 which reads accumulated per-TX state from the BlockStateCache. Validation cross-check: AddressPath reads now also check BalancePath and NoncePath for newer versionMap entries. This catches stale record-level reads when field-level writes exist from prior TX finalizes (e.g. coinbase fee adjustments). Remove debug gas assertion and serial reference map. Update CollectorWrites with fee-adjusted coinbase balance from addWrites so the BlockStateCache sees correct accumulated fees. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The finalizeTx fee adjustment appends coinbase and burnt contract BalancePath writes to allWrites, but without setting the Version field. This caused them to be written at txIndex=0 in the versionMap instead of the current TX's index. Subsequent TXs reading the coinbase via vm.Read(coinbase, BalancePath, N) would find the worker's stale entry at txIndex=N instead of the finalize's correct entry at txIndex=0. Fix: set Version = task.Version() on the appended writes so they're written at the correct txIndex in the versionMap. This ensures subsequent TXs see the fee-adjusted coinbase balance. Also fix double-counting of FeeTipped when hasCoinbaseDelta=true: the delta from StripBalanceWrite already includes the tip (from state_transition.go AddBalance(coinbase, tip)), so adding FeeTipped again overcounts. Same fix for FeeBurnt on the burnt contract. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Gate FeeTipped/FeeBurnt addition on !hasCoinbaseDelta/!hasBurntDelta. When the worker interacted with the coinbase (hasCoinbaseDelta=true), the delta from StripBalanceWrite already includes the tip from state_transition.go:609 AddBalance(coinbase, tip). Adding FeeTipped again double-counts the tip. Remove debug FINALIZE_CHECK trace. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
These were not being cleared between TX executions on the same worker. While Prepare() creates a fresh access list before EVM execution, the stale data could leak between incarnations of the same TX. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a prior TX changes an account's code (e.g. EIP-7702 authorization setting/clearing a delegation prefix), the cached stateObject may have a stale CodeHash. Clear the cached code when the versionMap has a newer CodeHashPath entry so that subsequent Code() reads return fresh data. This prevents GetDelegatedDesignation from returning stale delegation results, which caused incorrect gas charges in the EIP-7702 CALL gas calculation (gasCallEIP7702). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a versionMap is present (parallel execution), check for CodePath entries from prior TXs before falling through to the domain stateReader. EIP-7702 authorizations write synthetic delegation code to the versionMap via versionWritten(CodePath), but ReadAccountCode reads from the domain (GetLatest(CodeDomain)) which doesn't have it. This caused GetDelegatedDesignation to return different results between incarnation 0 (speculative, reads old code from domain) and incarnation 1 (re-execution, should see prior TX's delegation code from versionMap). The missing delegation cost 2800 gas per CALL (3 CALLs with cold/warm delegation target resolution). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…-check Split fee handling: worker debits sender only (shouldDelayFeeCalc=true), finalizeTxSimple credits coinbase with FeeTipped and burnt with FeeBurnt. No StripBalanceWrite or delta computation needed for regular TXs. Restore validation cross-check for AddressPath vs BalancePath/NoncePath. With split fees, workers don't read coinbase for fee purposes, so all coinbase reads in the ReadSet are genuine EVM reads (BALANCE opcode) that must be validated against prior finalize writes. System TXs still use StripBalanceWrite since they don't go through the worker execution path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Read coinbase/burnt base from versionMap at txIndex+1 to see the current TX's flushed execution effects (ETH transfers to/from the coinbase during EVM execution). When CollectorWrites has a coinbase entry (worker modified coinbase), use that balance directly (it includes the correct base + execution delta from the validated worker execution). This eliminates the coinbase balance accumulation error that caused the 20 gas diff. 343 blocks pass gas checks with this fix. Also remove debug FTS trace from state_transition.go. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ComputeCommitment at step boundaries was failing with "step is frozen" when the step was already committed in snapshot files. Skip the commitment when the step number is below the last frozen step. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Skip ComputeCommitment when the step is already frozen (nothing to commit). Also change the post-execution check from <= to < to allow the boundary step itself (which is the starting step for new execution). Fixes 'step 2101 is frozen' error when execution starts at a step boundary. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… args Add 5 unit tests for the split fee logic in finalizeTxSimple: - BasicFeeCredit: coinbase gets correctBase + FeeTipped - VersionOnWrites: Version is from task.Version(), not zero - LondonBurntFees: burnt contract gets FeeBurnt - NoCoinbaseInVersionMap: falls through to stateReader - AccumulatedFees: 3 sequential TXs accumulate tips correctly Fix lightcollector tests for BlockStateCache parameter addition. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When readCurrent=true (block finalize IBS), ReadAccountStorage now checks GetCurrentStorage (write buffer) before GetCommittedStorage (pre-block values). This ensures system calls during engine.Finalize (Prague EIP-7685 deposit/withdrawal requests) see storage modifications from this block's regular TXs. Without this fix, the system call reads pre-block storage for the deposit/withdrawal request contracts, producing empty requests and an invalid requests root hash. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add 6 unit tests covering parallel executor fixes: - Reset clears accessList and transientStorage - stateObject.Code reads from versionMap CodePath (EIP-7702) - stateObject.Code falls back when no versionMap entry - getStateObject refreshes CodeHash from versionMap - Validation cross-check: AddressPath Done detects stale BalancePath - Validation cross-check: AddressPath None does NOT infinite loop Fix infinite incarnation loop: remove BalancePath/NoncePath cross-check from MVReadResultNone + AddressPath case. The VersionedStateReader's applyVersionedUpdates already incorporates field-level updates when constructing the account from StorageRead. Cross-checking this case causes infinite re-execution since the ReadSet always records StorageRead with UnknownVersion. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ock run Revert versionmap.go to the state from commit fd9e482 which passed 3049 blocks with zero errors. The incarnation-based cross-check skip was incorrect — need to investigate the too-many- incarnations error at block 24365006 properly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… run The frozen step fix accidentally removed the GetCurrentStorage check from CachedReaderV3.ReadAccountStorage. Restore it — needed for Prague system calls to see this block's storage modifications. Restore the None crosscheck for AddressPath vs BalancePath/NoncePath. It's needed for correctness (receipt hash mismatch without it). The too-many-incarnations error at block 24365006 is from worker ABORTS (ErrDependency), not validation loops. Needs separate investigation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The GetCurrentStorage check in CachedReaderV3.ReadAccountStorage was duplicated. Remove the duplicate. Add diagnostic info to deadlock dump. The infinite incarnation loop at block 24365006 is caused by the None crosscheck (AddressPath StorageRead vs BalancePath/NoncePath) creating cascading invalidations that lead to estimate entries in the versionMap. Workers abort on estimates, but the estimates are only resolved by validation which can't progress past the invalidated TX. This is a non-deterministic issue that depends on goroutine scheduling. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses PR #20805 review item B6. File was disabled by //go:build never with a "stale after calculator signature evolution" comment; the actual drift was a single dropped positional arg from normalizeWriteSet (12 call sites). All other call sites — finalizeTx (6 args), finalizeTxSimple (10 args), resolveStorageWrites (5 args) — already match current signatures. Result: 23 of 25 tests pass. The 2 failures are stale unit-level expectations after a behavior shift (coinbase floor-read at TxIndex=0 no longer sees a same-index prior write; coinbase-as-recipient does not get tip re-credited on top of TxOut transfer write). Both are skipped with TODO references to #20962. End-to-end coverage of the same paths exists in the eest_devnet sweep (13,696/13,696 PASS) and in StripBalanceWrite's dedicated unit tests in versionedio_test.go. Test surface now active: TestFinalizeTx_* 4 / 5 (1 subtest skipped) TestFinalizeTxSimple_* 4 / 5 (1 skipped) TestResolveStorageWrites_* 5 / 5 TestNormalizeWriteSet_* 12 / 12 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…design) Address yperbasis review item 2 on PR #20805. The intentional os.Exit behaviour is kept (see PR #19803 for the design rationale: bypass deferred commit/flush so the DB is left exactly as it was before the triggering block, and the next run reproduces the stop point), but the implementation is brought into alignment across both paths. - exec3.go: hoist BAD_BLOCK_HALT check out of the if-parallel branch and into the post-branch ErrInvalidBlock handler so the env var triggers os.Exit uniformly for serial AND parallel. Previously the serial path silently ignored BAD_BLOCK_HALT — execErr just propagated up and got retried. - exec3_serial.go: replace the residual panic("stopping: ...") for STOP_AFTER_BLOCK with the same os.Exit(0) pattern the parallel path already uses. panic() unwinds defers and fires the very commit/flush we're trying to avoid, defeating the harness. - comments on both halt sites: spell out *why* os.Exit is the right call (preserve pre-block DB state, no commit, debug-only) and reference PR #19803. Both halts respect the cfg.badBlockHalt && dbg.BadBlockHalt two-flag gate, so fork validation (NewInMemoryExecution sets cfg.badBlockHalt without the env var) still gets the safe error-return behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 51 out of 52 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // rpcHTTPClient has a bounded timeout so a stalled localhost:8545 cannot | ||
| // hang the test suite. The endpoint is best-effort (the test t.Skip()'s | ||
| // when RPC is unavailable), so a short timeout is appropriate. | ||
| var rpcHTTPClient = &http.Client{Timeout: 10 * time.Second} | ||
|
|
||
| // TestReceiptHashFromRPC fetches receipts from a running serial node | ||
| // and verifies that DeriveSha produces the correct receipt root. | ||
| // This test requires a running erigon node on localhost:8545. | ||
| func TestReceiptHashFromRPC(t *testing.T) { | ||
| if testing.Short() { | ||
| t.Skip("requires running erigon node") | ||
| } | ||
|
|
||
| blockNum := uint64(24363971) | ||
| blockHex := fmt.Sprintf("0x%x", blockNum) | ||
|
|
| // Sorted by plainKey (see keyUpdateLessFn). Trie traversal order is established | ||
| // later when entries are emitted into the etl collector keyed by hashedKey; | ||
| // this btree is only for in-memory dedup/lookup in ModeUpdate. Keeping plainKey | ||
| // ordering matches main and avoids regressing TouchPlainKey/TouchHashedKey | ||
| // callers that mix entries with and without a hashedKey. |
yperbasis
left a comment
There was a problem hiding this comment.
Round-4 review on top of 10b35f2d89 (123 commits, 70 files, +8.7k/-1.9k)
Recap: round-3 items that landed
| # | Item | Status | Where |
|---|---|---|---|
| N1 | sendResult blanket recover narrowed |
✅ | exec3_parallel.go:2147-2154 (only "send on closed channel" runtime.Error, re-panic everything else) |
| B5 | Sibling-consistency Skip TODO | ✅ | hex_patricia_hashed_test.go:3361-3366 now references #20961 (separate issue, body matches) |
| B6 | exec3_finalize_test.go revival |
✅ | 7feb4c67fc — //go:build never removed, 23/25 tests run, 2 stale subtests deferred to #20962 |
| Round-2 #4 | gatedRoDB dual-field trap |
✅ | helpers.go:53-56 — embedded RoDB dropped, only inner field remains |
| Round-2 #5 | FCU adaptive prune budget | ✅ | forkchoice.go:907-912 — formula now matches PruneExecutionStage (base/3 + 200ms/100steps, capped at 2/3 slot) |
| os.Exit rationale | os.Exit comments + alignment | ✅ | 10b35f2d89 — debug-halt paths now consistent across parallel + serial, BAD_BLOCK_HALT works for serial too, STOP_AFTER_BLOCK no longer uses panic() (which would unwind defers) |
| 5187cf1 | Apply-loop rootResults-close drain | ✅ | Exactly the Copilot-flagged race (no longer returns on !ok, sets channel=nil to avoid spin, rootResultsClosed flag prevents nil-range hang). Plus per-block completeness checks (appliedBlocks vs txResultBlocks) — a real instance of "silent invalid block canonical" was caught |
| 4c5f953 | BlockPostExecutionValidator per-block |
✅ | Eliminates the sticky-error + wg-leak class via per-block instance; mirrors the calculator's channel-based result delivery |
The post-round-3 commit stream also fixed several real correctness bugs that bulk-replay would have exposed: hashedKey ordering in ModeUpdate (5d48617e34), order-independent SelfDestruct strip (6689bf3092 + dfde9a0087), BAL fee_recipient double-count (a34cd02d77), genesis commitComputeRequest skip (e54f223a1f), wg.Add leak in the post-validator (47e8028e86). Race tests and the full eest_devnet sweep (13,696/13,696) are green on the latest HEAD. Good throughput on the residual list.
Merge-blockers still pending
M1. Writer-Lock leak in CollateAndPrune + CollateAndPruneIfNeeded — round-3 N2
Round-3 flagged three sibling sites that take commitGate without defer. de0c6be469 fixed the RLock site in buildFilesInBackground, but the two writer-Lock sites are still bare:
db/state/aggregator.go:1636-1638(CollateAndPruneloop body):a.commitGate.Lock() err := db.UpdateTemporal(ctx, pruneFn) a.commitGate.Unlock()
db/state/aggregator.go:1683-1685(CollateAndPruneIfNeededearly-out):a.commitGate.Lock() err = db.UpdateTemporal(ctx, pruneFn) a.commitGate.Unlock()
If anything inside pruneFn (i.e. RunPrune) panics — including a typed runtime.Error from a nested data-race or a future change — db.UpdateTemporal propagates it, the Unlock line is skipped, and commitGate is held in write mode forever. Every subsequent reader (gatedRoDB.View, readyForCollation, buildFilesInBackground) and writer deadlocks. Blast radius: the entire DB.
In production a panic would normally terminate the process, but: tests recover, -race runs catch panics, and any defer recover() higher in the call chain (e.g. the FCU loop) keeps the process alive with the lock held. Two defer commitGate.Unlock() lines fix it. This is the same shape as B7 — the round-3 wording asked specifically to grep commitGate.RLock() for siblings, but the writer-Lock siblings (which are the higher-blast-radius case) were missed.
M2. LockCollation leaks on flush errors — Copilot inline #15
execution/stagedsync/stageloop/stageloop.go:230-257 (the ProcessFrozenBlocks commit block):
if a, ok := db.(state.HasAgg); ok {
a.Agg().(*state.Aggregator).LockCollation()
}
commitTx, err := db.BeginTemporalRw(ctx) // (A) error here returns without UnlockCollation
if err != nil { return ... }
if err := overlay.Flush(ctx, commitTx); err != nil {
commitTx.Rollback() // (B) UnlockCollation never called
return ...
}
if err := doms.Flush(ctx, commitTx); err != nil {
commitTx.Rollback() // (C) UnlockCollation never called
return ...
}
doms.Close()
if err := commitTx.Commit(); err != nil { ... UnlockCollation() ... } // (D) handled
... UnlockCollation() ... // (E) successThree of the five exit paths leak the gate. Same fix shape as M1 — defer agg.UnlockCollation() immediately after the lock. (runForkchoiceFlushCommit already gets this right via the rwTx commit path, so the inconsistency between the two FCU-adjacent paths is the part that invites future copy-paste regressions.)
M3. Silent error swallows in the calculator's compute path — round-3 N3 / N4
Both still unaddressed in the latest committer.go / calc_state.go:
committer.go:298(computeWithoutCheck):_, _ = cc.doms.ComputeCommitment(ctx, cc.roTx, true, br.BlockNum, br.lastTxNum, cc.logPrefix, nil)
calc_state.go:100-105(ensureAccount):if dbAcc, err := cs.domainReader.ReadAccountData(addr); err == nil && dbAcc != nil { ... }
calc_state.go:122-127(ensureStorage):if v, found, err := cs.domainReader.ReadAccountStorage(addr, key); err == nil && found { ... }
These are the same anti-pattern that motivated round-2 #6 (the silent stale-watermark in SetLastFlushedCommitmentTxNum that broke at-tip growth for 27h before being noticed). The calculator's failure mode here is even harder to debug: a missed lazy-load returns "fresh state" (zero balance, empty code, no storage), the calculator builds Updates on top of a missing baseline, and the trie root mismatch surfaces downstream — masking the original I/O error that caused it.
At minimum: log on err != nil at all three sites. Better: in calc_state.go propagate the error upward and have the calculator publish a commitmentResult{err: ...} instead of silently producing wrong updates. The calculator already has the channel plumbing (computeAndCheck already publishes err on ComputeCommitment failure at line 320-328) — apply the same shape.
M4. Stale comment on Updates.tree after the hashedKey-ordering flip
5d48617e34 correctly flipped keyUpdateLessFn to sort by hashedKey first (with plainKey tiebreaker) and updated the function comment. But the matching block-comment on the Updates.tree field at execution/commitment/commitment.go:1425-1429 still reads:
// Sorted by plainKey (see keyUpdateLessFn). Trie traversal order is established
// later when entries are emitted into the etl collector keyed by hashedKey;
// this btree is only for in-memory dedup/lookup in ModeUpdate. Keeping plainKey
// ordering matches main and avoids regressing TouchPlainKey/TouchHashedKey
// callers that mix entries with and without a hashedKey.This is now actively wrong on the first sentence and misleading on the rationale. The comment is what caused the round-2 / B4 confusion that took two rounds to resolve — re-introducing it in the same area undoes that work. One-paragraph rewrite: lead with hashedKey ordering + plainKey tiebreaker + why (matches Process fold/unfold expectation, matches ModeDirect's etl ordering, divergence here was the root cause of eip1153 / eip7778 / eip7976 / eip7825 wrong-trie-root failures).
While there: the treeIdx map[string]*KeyUpdate comment a line below (plainKey → btree entry for O(1) lookup in ModeUpdate) is fine, but the test comment at commitment_test.go:511-514 still says the comparator orders by hashedKey only — it now orders by hashedKey then plainKey. Quick polish.
Cleanup / nice-to-haves
- Round-2 #6 second half —
runForkchoiceFlushCommitandProcessFrozenBlocksboth now log onGetLatest/BeginTemporalRofailure ✅ but both still open a fresh RO tx afterrwTx.Commit()to read back what was just written. Functionally fine (tx is brief, openTxs=1-at-commit invariant holds), but the simpler rewrite — read fromrwTxbefore commit — was acked then dropped. Worth a follow-up to halve the txns on the hot FCU path, not a blocker. - Copilot #16 —
defer commitTx.Rollback()insidefor more := true; more; { ... }atstageloop.go:238accumulates one defer per iteration that only runs on function return. Wrap the body in a closure or use explicit Rollback on error paths. - PR description architecture table is stale — the table at "Headline change" lists
ApplyTxIndexes+changesetunder Apply goroutine, but06ed5c9d48and079b65d19emoved both to the exec loop. Update the table or strike the rows so future readers don't trust it as documentation. - Calculator
roTxlifetime comment — the field comment atcommitter.go:122says "roTx lives for the calculator's lifetime — rolled back in Stop(), not deferred here" but doesn't explain why this is safe across collate/prune cycles. Analysis: the calculator is created inpe.exec()and itsdefer Stop()runs before the stageloop'srwTx.Commit(), and collate/prune is between batches via FCU. So the roTx never spans a prune. Worth one line documenting that for future maintainers — that's the round-3 N5 ask. - B7-style audit — only one of the four
commitGatesites gotdefertreatment; M1 + M2 add three more. Worth a single audit pass:git grep -nE "commitGate\.(R?Lock|Unlock|Lock())" db/ execution/and ensure every Lock/Unlock pair isdefer-protected or has a comment explaining why not.
Test plan status
| Item | Status | Notes |
|---|---|---|
| Race detector | ✅ | Green on execution/state + execution/stagedsync; TestSelfDestructRecordsStorageDeletes flake fixed in 6689bf3092 |
exec3_finalize_test.go revived |
✅ | 23/25 pass, 2 deferred to #20962 |
| eest_devnet sweep | ✅ | 13,696/13,696 PASS |
| All CI checks | ✅ | Lint, mainnet-rpc-integ, race-tests/* (consensus, core-rpc, execution-tests, execution-eest-blockchain, execution-eest-devnet, execution-other, load-matrix), tests-mac-linux (macos-15, ubuntu-24.04, windows-2025), repro, sonar — all green on 10b35f2d89 |
| Bulk replay (>2000 blocks/batch) | ❌ | Still no test coverage. Calculator backpressure on commitResultsCh (buffered 2048) untested |
| SIGINT shutdown clean-exit at every batch boundary | ❌ | No test |
Test_ModeUpdate_SiblingConsistency |
❌ Skip | Now references #20961; "can this trigger on real chain inputs?" — the issue body says eest_devnet didn't hit it but mainnet replay is still TBD. This is the only remaining "potentially-merge-blocking-if-it-can-occur-in-prod" item on the test side |
Risk assessment
Architecture: low. The shutdown sequencing, channel ownership, and apply-loop drain pattern are sound. The completeness-check scaffolding (appliedBlocks / txResultBlocks / applyLoopMissingBlocks) is exactly the right shape — it converts the silent-invalid-block class into loud ErrInvalidBlocks. 5187cf180c's detection of a real instance during dev validates the design.
Correctness: medium. M3 (calculator silent swallows) + M4 (comment-vs-code mismatch in the place B4 was supposed to settle) + #20961 (sibling-encoding bug, mainnet incidence unknown) + bulk-replay coverage gap are the residual correctness exposure. M3 in particular is the same shape that round-2 already paid for once.
Operational: medium. M1 + M2 are write-Lock-leak-on-panic deadlocks with DB-wide blast radius. Two-line defer fixes. They're the highest operational risk on the PR even if they don't fire in normal operation.
Recommended merge gate
Must fix in this PR:
- M1 —
defer commitGate.Unlock()inCollateAndPrune+CollateAndPruneIfNeeded(the round-3 N2 follow-through that landed for the RLock site only) - M2 —
defer agg.UnlockCollation()inProcessFrozenBlocksso the three flush-error paths don't leak the gate (Copilot #15) - M3 — log error from
cc.doms.ComputeCommitmentincomputeWithoutCheck; log + propagate (or fail) incalcState.ensureAccount/ensureStoragelazy-load paths - M4 — rewrite the stale
Updates.treeblock comment atcommitment.go:1425-1429; tighten the test comment atcommitment_test.go:511-514
Acceptable as follow-ups (file or reuse #20961 / #20962):
- B7 full audit pass (one combined commit)
- PR description architecture-table refresh
- Bulk-replay coverage — its own PR
runForkchoiceFlushCommitrwTx-pre-commit read (follow-up perf cleanup)- Copilot #16 defer-in-loop rewrite
- Calculator
roTxlifetime comment
The four merge-blockers above are all small, mechanical fixes that match the shape of work that's already landed in this PR. M1 and M2 are particularly worth getting in this PR because they're the same class as B7 — leaving two of three siblings unfixed is the kind of half-completed audit that future debugging will hate. M3 is the recurring "silent-swallow-of-the-signal-we'd-need-to-debug" pattern that round-2 #6 already paid for. M4 is comment hygiene in the exact area that took two review rounds to settle.
Once those land this is mergeable. The architecture is right, the correctness fixes since round-3 demonstrate de-risking is happening, and CI is fully green.
Four merge-blockers from yperbasis's round-4 review on commit 10b35f2: M1 (db/state/aggregator.go): defer commitGate.Unlock() in CollateAndPrune + CollateAndPruneIfNeeded. Bare Lock/Unlock pairs around db.UpdateTemporal(pruneFn) leaked the writer-Lock on any panic inside pruneFn — DB-wide deadlock. Same shape as the round-3 B7 fix (BuildFilesInBackground RLock); the writer-Lock siblings were missed. M2 (execution/stagedsync/stageloop/stageloop.go): defer agg.UnlockCollation() in ProcessFrozenBlocks. Three of five exit paths (overlay flush err, doms flush err, commit err) leaked the collation gate. Restructure with a collationLocked sentinel + deferred unlock; release explicitly on the success path so the post-commit RO read doesn't wait on the gate. M3 (calc_state.go + committer.go): propagate calculator lazy-load errors instead of silently producing wrong updates. ensureAccount / ensureStorage now record the first ReadAccountData / ReadAccountStorage error on a sticky calcState.lazyLoadErr field. All three compute paths (computeAndPublish, computeWithoutCheck, computeAndCheck) check LazyLoadErr() before flushing/computing and publish commitmentResult{err: ...} on failure. Same shape as the existing ComputeCommitment-error publication. Threads logger + logPrefix through newCommitmentCalculator + newCalcState so the underlying domain error is also Warn-logged at the failure site. M4 (execution/commitment/commitment.go + commitment_test.go): rewrite the Updates.tree block comment to match the post-5d48617e34 ordering (hashedKey first with plainKey tiebreaker). Old comment claimed plainKey ordering — which was the comparator bug rationale before the round-2/B4 flip. Tighten the matching test comment too. All targeted unit tests still green under -race; lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
yperbasis
left a comment
There was a problem hiding this comment.
Round-5 review on 0a9f01d0fd (round-4 follow-through)
Recap: round-4 merge-blockers
| # | Item | Status | Where |
|---|---|---|---|
| M1 | defer commitGate.Unlock() in CollateAndPrune + CollateAndPruneIfNeeded |
✅ | db/state/aggregator.go:1637-1638, 1685-1686 (writer-Lock sites) — completes the four-site audit (readyForCollation + BuildFilesInBackground already had defer, both writer-Lock siblings now do too) |
| M2 | defer agg.UnlockCollation() for ProcessFrozenBlocks flush-error paths |
✅ | stageloop.go:233-242 — collationLocked sentinel + deferred unlock; explicit release on success path before the post-commit RO read so it doesn't wait on the gate |
| M3 | Calculator error propagation (no silent swallows) | ✅ (with one follow-up, see N1 below) | calc_state.go:84-98, 121-135, 154-166 — sticky lazyLoadErr on ensureAccount/ensureStorage; committer.go:247-307, 332-339 — three compute paths check LazyLoadErr() and publish commitmentResult{err: ...}; logger+logPrefix threaded through the constructor |
| M4 | Updates.tree block comment matches post-5d48617e34 ordering |
✅ | commitment.go:1425-1431 rewritten (hashedKey first, plainKey tiebreaker, ties to the eip1153/eip7778/eip7976/eip7825 wrong-root failures); test comment at commitment_test.go:511-515 tightened to match |
All four are clean. Targeted unit tests + commitment package + state package all pass under -race.
NEW concern from round-5 read
N1. handleCommitResult conflates calculator error types — M3 added new sources without updating the consumer
exec3_parallel.go:284-296:
handleCommitResult := func(cr commitmentResult) error {
if cr.err != nil {
pe.logger.Error(fmt.Sprintf("[%s] Wrong trie root of block %d: %x",
pe.logPrefix, cr.blockNum, cr.rootHash))
if initialCycle {
return fmt.Errorf("%w, block=%d", ErrWrongTrieRoot, cr.blockNum)
}
return handleIncorrectRootHashError(cr.blockNum, lastBlockResult.BlockHash, lastBlockResult.ParentHash, rwTx, pe.cfg, execStage, pe.logger, u)
}
...
}Pre-M3 the only cr.err source was a real trie-root mismatch (both computeAndPublish:283 and computeAndCheck:378 wrap ErrWrongTrieRoot), so treating any non-nil cr.err as a wrong-root was OK. M3 added two new sources that do not wrap ErrWrongTrieRoot:
committer.go:247-253/300-306/332-338:fmt.Errorf("commitmentCalculator: lazy-load failed: %w", err)committer.go:265-272/355-362:fmt.Errorf("commitmentCalculator: %w", err)(ComputeCommitment failure)
In the non-initialCycle branch this routes through handleIncorrectRootHashError, which calls cfg.hd.ReportBadHeaderPoS and u.UnwindTo(allowedUnwindTo, BadBlock(blockHash, ErrInvalidStateRootHash)). So a transient I/O error during lazy-load now:
- Marks a valid block as bad and reports it to the PoS layer
- Triggers a binary-search unwind, throwing away potentially valid state
- Logs
Wrong trie root of block %d: <empty hash>(sincecr.rootHashis unset in lazy-load/compute-error paths) and does not includecr.erritself — so the original I/O error message disappears
This is the same shape as round-2 #6 / round-3 N3,N4 / round-4 M3 — silent loss of the signal we'd need to debug. M3 made the calculator scream the right errors; the consumer now mishandles them.
Two-line fix:
handleCommitResult := func(cr commitmentResult) error {
if cr.err != nil {
if !errors.Is(cr.err, ErrWrongTrieRoot) {
// lazy-load / ComputeCommitment failure — fail fast, don't unwind
return fmt.Errorf("commitment: %w", cr.err)
}
pe.logger.Error(fmt.Sprintf("[%s] Wrong trie root of block %d: %x (%v)",
pe.logPrefix, cr.blockNum, cr.rootHash, cr.err))
if initialCycle {
return fmt.Errorf("%w, block=%d", ErrWrongTrieRoot, cr.blockNum)
}
return handleIncorrectRootHashError(...)
}
...
}The pattern is already used at exec3.go:319,328. The (%v) add to the log line surfaces the actual error (also useful on the wrong-root path itself, where the existing line drops the "expected %x" detail wrapped into cr.err by committer.go:283).
Items still pending from prior rounds (status check)
-
Round-1 #9 —
AlwaysGenerateChangesetsguard removal atstage_execute.go:491— never addressed. The changes.ForwardProgress > cfg.syncCfg.MaxReorgDepth && !cfg.syncCfg.AlwaysGenerateChangesets→s.ForwardProgress > cfg.syncCfg.MaxReorgDepthis a behavioral regression for the integration tool default (syncCfg.AlwaysGenerateChangesets = !dbg.BatchCommitments) and the explicit--experimental.always-generate-changesetsflag — changesets pastMaxReorgDepthare now pruned instead of retained. Either revert or call out in PR description that the flag's semantics changed. -
#20961 (sibling-encoding bug) — issue body says mainnet-replay determination is still TBD. This remains the only "potentially-merge-blocking-if-it-can-occur-in-prod" item. Round-4 acceptable-as-followup; round-5 status unchanged.
-
Round-3 N5 — calculator
roTxlifetime safety across collate/prune cycles — comment atcommitter.go:125says "roTx lives for the calculator's lifetime — rolled back in Stop(), not deferred here" but still doesn't explain why this is safe across retire/prune cycles. Round-4 demoted this to follow-up; flagging again as still-open. -
Bulk replay (>2000 blocks/batch) — still no test coverage; calculator backpressure on
commitResultsCh(buffered 2048) untested. -
SIGINT shutdown clean-exit at every batch boundary — still no test.
Cosmetic
stageloop.go:267—_ = ais dead (the line above already useda); leftover from the M2 refactor.committer.go:291-296— the comment block// computeAndCheck computes per-block commitment and validates the root. ...is followed by// computeWithoutCheck computes commitment but doesn't verify the root. ...then byfunc (cc *commitmentCalculator) computeWithoutCheck(...). ThecomputeAndCheckdoc-comment has migrated above thecomputeWithoutCheckdefinition. Move it to where it belongs (just beforefunc (cc *commitmentCalculator) computeAndCheckat line 331).
Risk assessment
Architecture: low. Channel ownership / shutdown sequencing / defer-based gate hygiene are all clean now. M1+M2 close the panic-leak class; the four commitGate sites + the five LockCollation/UnlockCollation exit paths all use defer-protected unlock now.
Correctness: low-medium. N1 is the only new concern — narrow scope (transient I/O during lazy-load or ComputeCommitment), but the failure mode is destructive (incorrectly marks block as bad, triggers unwind). #20961 mainnet-applicability still pending. Bulk-replay coverage gap remains.
Operational: low. M1+M2 closed the deadlock-on-panic class. The remaining pending items are all coverage gaps, not active risks.
Recommended merge gate
Must fix in this PR:
- N1 — gate
handleIncorrectRootHashErrorbehinderrors.Is(cr.err, ErrWrongTrieRoot)and includecr.errin the log line. ~5 lines.
Acceptable as follow-ups (file or reuse #20961 / #20962):
- Round-1 #9 — either revert the
AlwaysGenerateChangesetsguard removal atstage_execute.go:491or call out the behavioral change in the PR description. - Round-3 N5 — one-line comment at
committer.go:125explainingroTxlifetime safety across retire/prune. - Cosmetic:
_ = aremoval,computeAndCheckdoc-comment relocation. - Bulk-replay coverage — own PR.
- #20961 mainnet-replay determination — answer the "can this trigger on real chain inputs" question; if yes, becomes a blocker.
The architecture is solid and the round-4 follow-through is exactly what was asked for. N1 is the same shape of "consumer-side mishandling of a recently-fixed producer-side signal" that we've been chasing across rounds — narrow scope, two-line fix, but worth getting in this PR rather than as a follow-up because it directly weakens the M3 fix that just landed.
N1 (exec3_parallel.go handleCommitResult): gate handleIncorrectRootHashError behind errors.Is(cr.err, ErrWrongTrieRoot). M3 added two new commitmentResult.err sources (lazy-load failure, ComputeCommitment failure) that don't wrap ErrWrongTrieRoot. The pre-M3 consumer treated any cr.err as a wrong-root, which would now incorrectly mark a valid block as bad (ReportBadHeaderPoS), trigger a binary-search unwind throwing away valid state, and log "Wrong trie root: <empty hash>" with the original error swallowed. Fail fast for non-wrong-root errors and include cr.err in the wrong-root log line. Cosmetic: - stageloop.go: drop dead `_ = a` left over from the M2 refactor. - committer.go: relocate the computeAndCheck doc-comment block — it had migrated above computeWithoutCheck during a prior edit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two PR #20805 round-5 follow-ups: 1. stage_execute.go: restore the `&& !cfg.syncCfg.AlwaysGenerateChangesets` guard on ChangeSets3 prune (round-1 #9). Commit 0212c6d dropped the guard to fix a 67GB DB-growth issue when BatchCommitments=false auto-flips AlwaysGenerateChangesets=true in the integration tool. But that broke the explicit user-facing contract of `--experimental.always-generate-changesets`: with the flag set, changesets must be retained beyond MaxReorgDepth so deeper unwinds (debug, integration tool) actually have history to unwind over. Without the guard the flag still controls *generation* but every generated changeset gets pruned 96 blocks later, defeating the point. The 67GB-growth case can be re-addressed separately by either removing the integration tool's auto-flip when BatchCommitments=false or by making it explicit in user-facing config. 2. committer.go: spell out why the calculator's persistent roTx is safe across collate/prune cycles (round-3 N5). Calculator is constructed in pe.exec() and its `defer Stop()` runs before the stageloop's rwTx.Commit(); CollateAndPruneIfNeeded only fires between batches via FCU. So the roTx never spans a prune — by the time prune holds commitGate.Lock(), Stop() has already rolled it back. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| // stacks of concurrent txs when a commit observes openTxs > 1 — answering | ||
| // "who held a tx alive while I was committing?" for diagnostic purposes. | ||
| // Keyed by the *MdbxTx pointer. Protected by txsCountMutex. | ||
| liveTxs map[*MdbxTx]liveTxInfo |
There was a problem hiding this comment.
looks like duplication of leakDetector *dbg.LeakDetector AskAlexSharov field functionality
| } | ||
|
|
||
| func (a *Aggregator) BuildFiles(toTxNum uint64) (err error) { | ||
| if cap := a.maxCollationTxNum.Load(); cap > 0 && toTxNum > cap { |
There was a problem hiding this comment.
It making Aggregator object state-full.
It took us year to remove txNum field from SharedDomains (and it's still there).
Also i see maxCollationTxNum field not set anywhere.
…rallel exec The defer at exec3_parallel.go:206 was introduced in b72aa7b (#20805 parallel commitment calculator) and hardcoded the post-exec value to false. That overwrote whatever the caller had set: - Engine API path: caller leaves the default (true, set in NewTemporalMemBatch). Defer-to-false flipped it. Subsequent forkchoice_updated -> GetAsOf failed with "GetAsOf called on TemporalMemBatch with inMemHistoryReads disabled", or post-batch trie-root computation read partial state -> wrong-trie-root. - cmd/integration stage_exec path: caller explicitly sets false (cmd/integration/commands/stages.go:752). Here the defer-to-false happened to match the caller's intent, so no breakage was visible. Fix: capture the caller's setting on entry, restore it on exit. This keeps the engine API path correct (defaults to true, stays true) AND preserves the integration tool's explicit-false setting. Adds an InMemHistoryReads() getter on TemporalMemBatch + SharedDomains + the kv.TemporalMemBatch interface to support the capture. Repro: EEST test_gas_limit_below_minimum[fork_Cancun-gas_limit_5000] in parallel mode. The block is valid (5000 == MinBlockGasLimit, not below) so exec succeeds; the next forkchoice_updated then errored out with the GetAsOf failure. Serial mode passes the same test — parallel-only divergence, fixed by this patch (verified locally via hive simulator). Likely also fixes the mainnet from-0 parallel wrong-trie-root at block 131578 (confirmed consistent across two CI runs of qa-stage-exec); verification dispatched.
…rallel exec The defer at exec3_parallel.go:206 was introduced in b72aa7b (#20805 parallel commitment calculator) and hardcoded the post-exec value to false. That overwrote whatever the caller had set: - Engine API path: caller leaves the default (true, set in NewTemporalMemBatch). Defer-to-false flipped it. Subsequent forkchoice_updated -> GetAsOf failed with "GetAsOf called on TemporalMemBatch with inMemHistoryReads disabled", or post-batch trie-root computation read partial state -> wrong-trie-root. - cmd/integration stage_exec path: caller explicitly sets false (cmd/integration/commands/stages.go:752). Here the defer-to-false happened to match the caller's intent, so no breakage was visible. Fix: capture the caller's setting on entry, restore it on exit. This keeps the engine API path correct (defaults to true, stays true) AND preserves the integration tool's explicit-false setting. Adds an InMemHistoryReads() getter on TemporalMemBatch + SharedDomains + the kv.TemporalMemBatch interface to support the capture. Repro: EEST test_gas_limit_below_minimum[fork_Cancun-gas_limit_5000] in parallel mode. The block is valid (5000 == MinBlockGasLimit, not below) so exec succeeds; the next forkchoice_updated then errored out with the GetAsOf failure. Serial mode passes the same test — parallel-only divergence, fixed by this patch (verified locally via hive simulator). Likely also fixes the mainnet from-0 parallel wrong-trie-root at block 131578 (confirmed consistent across two CI runs of qa-stage-exec); verification dispatched.
…rallel exec The defer at exec3_parallel.go:206 was introduced in b72aa7b (#20805 parallel commitment calculator) and hardcoded the post-exec value to false. That overwrote whatever the caller had set: - Engine API path: caller leaves the default (true, set in NewTemporalMemBatch). Defer-to-false flipped it. Subsequent forkchoice_updated -> GetAsOf failed with "GetAsOf called on TemporalMemBatch with inMemHistoryReads disabled", or post-batch trie-root computation read partial state -> wrong-trie-root. - cmd/integration stage_exec path: caller explicitly sets false (cmd/integration/commands/stages.go:752). Here the defer-to-false happened to match the caller's intent, so no breakage was visible. Fix: capture the caller's setting on entry, restore it on exit. This keeps the engine API path correct (defaults to true, stays true) AND preserves the integration tool's explicit-false setting. Adds an InMemHistoryReads() getter on TemporalMemBatch + SharedDomains + the kv.TemporalMemBatch interface to support the capture. Repro: EEST test_gas_limit_below_minimum[fork_Cancun-gas_limit_5000] in parallel mode. The block is valid (5000 == MinBlockGasLimit, not below) so exec succeeds; the next forkchoice_updated then errored out with the GetAsOf failure. Serial mode passes the same test — parallel-only divergence, fixed by this patch (verified locally via hive simulator). Likely also fixes the mainnet from-0 parallel wrong-trie-root at block 131578 (confirmed consistent across two CI runs of qa-stage-exec); verification dispatched.
…rallel exec The defer at exec3_parallel.go:206 was introduced in b72aa7b (#20805 parallel commitment calculator) and hardcoded the post-exec value to false. That overwrote whatever the caller had set: - Engine API path: caller leaves the default (true, set in NewTemporalMemBatch). Defer-to-false flipped it. Subsequent forkchoice_updated -> GetAsOf failed with "GetAsOf called on TemporalMemBatch with inMemHistoryReads disabled", or post-batch trie-root computation read partial state -> wrong-trie-root. - cmd/integration stage_exec path: caller explicitly sets false (cmd/integration/commands/stages.go:752). Here the defer-to-false happened to match the caller's intent, so no breakage was visible. Fix: capture the caller's setting on entry, restore it on exit. This keeps the engine API path correct (defaults to true, stays true) AND preserves the integration tool's explicit-false setting. Adds an InMemHistoryReads() getter on TemporalMemBatch + SharedDomains + the kv.TemporalMemBatch interface to support the capture. Repro: EEST test_gas_limit_below_minimum[fork_Cancun-gas_limit_5000] in parallel mode. The block is valid (5000 == MinBlockGasLimit, not below) so exec succeeds; the next forkchoice_updated then errored out with the GetAsOf failure. Serial mode passes the same test — parallel-only divergence, fixed by this patch (verified locally via hive simulator). Likely also fixes the mainnet from-0 parallel wrong-trie-root at block 131578 (confirmed consistent across two CI runs of qa-stage-exec); verification dispatched.
Summary
Parallel commitment calculation now runs in its own goroutine, in parallel with execution and apply. The previous architecture serialized commitment behind
ApplyStateWritesin the apply goroutine; this PR reinstates the three-stage pipeline.Everything else in the PR is supplementary infrastructure to make this work correctly under load.
Headline change: parallel commitment
The calculator is a third concurrent stage consuming the same
applyResultstream as the apply goroutine via fan-out:ApplyStateWritesviaBlockStateCache, finalize via IBS, Flush before sendingApplyTxIndexes, accumulator, receipt + postValidator,ProcessBAL, changesetsd.memvia own roTx +asOfStateReader; publishes onrootResultsEnd-of-batch trigger:
triggerBatchCommitment(ctx)fires from execLoop at three sites (sizeEst > batchLimit,blockNum >= maxBlockNum,StopAfterBlock); apply goroutine consumesrootResultsfor root check +lastCommittedBlockNumbump. BAL hash and state root are independent -ProcessBALruns concurrent with commitment, not gated on it.Shutdown: execLoop closes
commitResultsfirst,applyResultssecond; apply goroutine drainsrootResultson!ok. Apply loop has noctx.Donecase by design - execLoop owns shutdown sequencing.Changeset
Headline (commitment calculator)
stagedsync: port commitment calculator architecture forward onto merged branchRestores pre-merge
exec3_parallel.go+exec3.go(1803 line delta in exec3_parallel.go) - calculator goroutine,commitResults+rootResultschannels,triggerBatchCommitmentdriver, fan-out viasendResult, four domain-flag toggles when calculator is authoritative (SetDisableInlineTouchKey,SetInMemHistoryReads,EnableTrieWarmup=false,SetSkipStepBoundaryCommitment), per-blockBlockStateCacheallocation, overlay-backedblockTxfor executeBlocks, step-alignment check +lastFrozenTxNumgating. Adapters:BlockGasUsed->BlockRegularGasUsed(main split for EIP-8037), re-addedVersionMap.StorageKeys()for self-destruct delete emission.Supplementary (openTxs=1 invariant for clean MDBX GC)
For commitment to run cleanly in parallel with apply, MDBX needs
openTxs=1at commit time so freelist pages get reclaimed. These fixes close every observed concurrent-reader path:execmodule/forkchoice: release bgRoTx before RW commit to drop openTxs 2->1execmodule: two more openTxs=2 eliminations - ValidateChain + CommitCycledb/kv: GatedRoDB wrapper + BlockRetire commit-gate wiringCloses the residual ~5% openTxs=2 events that came from snapshot retirement reads. New
kv.NewGatedRoDB(inner, gate)wraps an RoDB so eachdb.View()acquiresgate.RLock().BlockRetire.SetCommitGate(gate)plumbs in the Aggregator's existing CommitGate, transparently gating all retirement reads (DumpBlocks -> DumpHeaders/Bodies/Txs -> BigChunks -> db.View).At-tip MDBX growth fixes (the bug that made this PR feel incomplete until validated)
execmodule, db/kv/mdbx: track lastFlushedCommitmentTxNum on FCU + gate per-commit logThe aggregator's collation safety cap was only being updated from
ProcessFrozenBlocks(initial-cycle path). During normal FCU at-tip operation the cap stayed at its initial value forever, contributing to the at-tip MDBX growth issue. Fixed by readingKeyCommitmentStatefrom the just-committed RW tx and callingSetLastFlushedCommitmentTxNumafter every FCU commit. Also gated the per-commit[mdbx] commitInfo log behindMDBX_TRACE_TX(was producing 525+ log lines per 27h at tip).execmodule, db/state: kick CollateAndPruneIfNeeded on FCU + adaptive prune budgetCollateAndPruneIfNeeded(which kicksBuildFilesInBackground) was only invoked fromStageLoopIteration. At chain tip, blocks flow through the FCU path so files were never built, prune had nothing to mark stale, and MDBX accumulated indefinitely. Run 13 demonstrated this: 27h at tip, file count stuck at 7 per domain, CommitmentVals 11.79 GB, total live data 21.84 GB on a 26 GB chaindata. Fixed by callingagg.CollateAndPruneIfNeeded(...)fromrunForkchoicePrune. Also adaptive prune budget: base = SecondsPerSlot/3 (= 4s on mainnet), +200ms per 100 prunable steps, capped at 2/3 of slot.Diagnostics
db/kv/mdbx: tx-lifecycle tracer (OPEN/COMMIT/ROLLBACK with traceID + stack)db/kv/mdbx: env-gated tx-lifecycle tracer + concurrent-tx dumpPermanent diagnostic gated behind
MDBX_TRACE_TX=trueenv var. Zero overhead when disabled. When openTxs>1 at commit, dumps the stacks of all other live txs so any new concurrent-reader callsite is identified immediately.Cleanup
stagedsync, commitment, db/state, execution/protocol: remove leftover debug PrintfsRemoved 67 unconditional debug Printfs from investigation work (FINALIZE_CHECK, REQUESTS dumps, FLUSH_CHECK, SEEK_CHECK, CALC_, FLOW, LIFECYCLE, COINBASE_, etc). Net -683 lines. Kept env-gated MDBX_TRACE_TX paths and conditional
if dbg.TraceXxxinfrastructure.Plus the prior branch history
~80 earlier commits on the architecture, BlockOverlay integration, channel ownership fixes, prune scheduling, etc. - supporting work that the commitment-calculator path depends on.
Validation
Run 12 - calculator architecture, no retirement gate, no FCU-collation fix
bb7bd2f8a3)Run 13 - calculator + retirement gate + tracer enabled (uncovered the at-tip growth bug)
Run 14 - existing 26 GB chaindata + both at-tip-growth fixes
Run 15 - PRISTINE chaindata baseline (the verdict run)
v2.0-X.8880-8888.kvbuilt across all 4 domains - file build at tip proven working.Scope and untested cases
Validated by runs 12 + 13 + 14 + 15: chain-tip operation (small per-batch sizes), moderate initial sync (~1300-block batches), at-tip steady-state DB-size behavior. Calculator publishes correct roots at every batch boundary observed.
Not yet exercised: bulk replay with batch sizes >2000 blocks (e.g. resync from far-behind). The "Bulk replay: re-execute 10k blocks" item in the test plan covers this. No reason to believe it's broken; just no live evidence yet.
Architecture invariants (preserve these)
For reviewers + future agents touching this code:
ApplyStateWrites(NOT in apply goroutine)ctx.DonecaseComputeCommitmentblockAppliedsend*blockResulthandler: BAL + changeset + postValidator + RecentReceipts (concurrent with commitment)rootResultshandler: root check +lastCommittedBlockNumbump (ONLY place these happen)commitResultsfirst,applyResultssecondrootResultspattern on!oktriggerBatchCommitmentat three end-condition sites in execLoop,return nilafter eachBlockStateCacheallocated per-block inexecuteBlocks, passed viaTxTaskblockTxbuilt once atexecuteBlocksentrylastFrozenTxNumatexecuteBlocksentryTest plan
exec3_finalize_test.gorevival