Skip to content

parallel commitment calculations implemented#20805

Merged
mh0lt merged 126 commits into
mainfrom
exec3/remove-rwtx-threading-merge-main
May 5, 2026
Merged

parallel commitment calculations implemented#20805
mh0lt merged 126 commits into
mainfrom
exec3/remove-rwtx-threading-merge-main

Conversation

@mh0lt

@mh0lt mh0lt commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Parallel commitment calculation now runs in its own goroutine, in parallel with execution and apply. The previous architecture serialized commitment behind ApplyStateWrites in 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 applyResult stream as the apply goroutine via fan-out:

Stage Goroutine Owns
execLoop / workers producer ApplyStateWrites via BlockStateCache, finalize via IBS, Flush before sending
Apply goroutine index-only consumer ApplyTxIndexes, accumulator, receipt + postValidator, ProcessBAL, changeset
Commitment calculator parallel consumer Commitment-domain writes in sd.mem via own roTx + asOfStateReader; publishes on rootResults

End-of-batch trigger: triggerBatchCommitment(ctx) fires from execLoop at three sites (sizeEst > batchLimit, blockNum >= maxBlockNum, StopAfterBlock); apply goroutine consumes rootResults for root check + lastCommittedBlockNum bump. BAL hash and state root are independent - ProcessBAL runs concurrent with commitment, not gated on it.

Shutdown: execLoop closes commitResults first, applyResults second; apply goroutine drains rootResults on !ok. Apply loop has no ctx.Done case by design - execLoop owns shutdown sequencing.

Changeset

Headline (commitment calculator)

  • 41c3b64 stagedsync: port commitment calculator architecture forward onto merged branch
    Restores pre-merge exec3_parallel.go + exec3.go (1803 line delta in exec3_parallel.go) - calculator goroutine, commitResults + rootResults channels, triggerBatchCommitment driver, fan-out via sendResult, four domain-flag toggles when calculator is authoritative (SetDisableInlineTouchKey, SetInMemHistoryReads, EnableTrieWarmup=false, SetSkipStepBoundaryCommitment), per-block BlockStateCache allocation, overlay-backed blockTx for executeBlocks, step-alignment check + lastFrozenTxNum gating. Adapters: BlockGasUsed -> BlockRegularGasUsed (main split for EIP-8037), re-added VersionMap.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=1 at commit time so freelist pages get reclaimed. These fixes close every observed concurrent-reader path:

  • 640bce8 execmodule/forkchoice: release bgRoTx before RW commit to drop openTxs 2->1
  • 63cb4e4 execmodule: two more openTxs=2 eliminations - ValidateChain + CommitCycle
  • bb7bd2f db/kv: GatedRoDB wrapper + BlockRetire commit-gate wiring
    Closes the residual ~5% openTxs=2 events that came from snapshot retirement reads. New kv.NewGatedRoDB(inner, gate) wraps an RoDB so each db.View() acquires gate.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)

  • 16ab034 execmodule, db/kv/mdbx: track lastFlushedCommitmentTxNum on FCU + gate per-commit log
    The 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 reading KeyCommitmentState from the just-committed RW tx and calling SetLastFlushedCommitmentTxNum after every FCU commit. Also gated the per-commit [mdbx] commit Info log behind MDBX_TRACE_TX (was producing 525+ log lines per 27h at tip).
  • 071e9f9 execmodule, db/state: kick CollateAndPruneIfNeeded on FCU + adaptive prune budget
    CollateAndPruneIfNeeded (which kicks BuildFilesInBackground) was only invoked from StageLoopIteration. 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 calling agg.CollateAndPruneIfNeeded(...) from runForkchoicePrune. Also adaptive prune budget: base = SecondsPerSlot/3 (= 4s on mainnet), +200ms per 100 prunable steps, capped at 2/3 of slot.

Diagnostics

  • 0683cde db/kv/mdbx: tx-lifecycle tracer (OPEN/COMMIT/ROLLBACK with traceID + stack)
  • 22f65cf db/kv/mdbx: env-gated tx-lifecycle tracer + concurrent-tx dump
    Permanent diagnostic gated behind MDBX_TRACE_TX=true env 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

  • b48c16e stagedsync, commitment, db/state, execution/protocol: remove leftover debug Printfs
    Removed 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.TraceXxx infrastructure.

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

  • 7h35m at chain tip, 37 calculator publish cycles, 146 commits
  • 95.2% openTxs=1, 4.8% openTxs=2 (residual snapshot-retirement, addressed by bb7bd2f8a3)
  • Zero panics / wrong trie root / FATAL throughout

Run 13 - calculator + retirement gate + tracer enabled (uncovered the at-tip growth bug)

  • 27h at tip; demonstrated the file-count-stuck-at-7 / CommitmentVals=11.79 GB / live=21.84 GB issue
  • Diagnosed root cause: collation never invoked from FCU path

Run 14 - existing 26 GB chaindata + both at-tip-growth fixes

  • 11m: files 7 -> 10 per domain (collation kicked in immediately)
  • 33m: file count merged 10 -> 8, CommitmentVals 11.79 -> 9.38 GB
  • 95m: stable. Live data 21.84 -> 12.04 GB. Reclaimable 3.5 -> 14 GB.
  • Confirmed both fixes work against accumulated state

Run 15 - PRISTINE chaindata baseline (the verdict run)

  • +15m: DB on disk 5.52 GB. CommitmentVals 2.24 GB. Live 4.20 GB.
  • +77m: DB 6.59 GB (~1 GB/h growth in steady state). CommitmentVals 2.45 GB (plateaued).
  • New file v2.0-X.8880-8888.kv built across all 4 domains - file build at tip proven working.
  • File count merged 8 -> 6.
  • Within the 5-7 GB target band; no runaway accumulation.

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:

  1. Worker-side ApplyStateWrites (NOT in apply goroutine)
  2. Flush in execLoop before blockResult sent
  3. Apply goroutine: NO ctx.Done case
  4. Apply goroutine: NO inline ComputeCommitment
  5. Apply goroutine: NO blockApplied send
  6. Apply goroutine *blockResult handler: BAL + changeset + postValidator + RecentReceipts (concurrent with commitment)
  7. Apply goroutine rootResults handler: root check + lastCommittedBlockNum bump (ONLY place these happen)
  8. ExecLoop defer: close commitResults first, applyResults second
  9. Apply goroutine drain-rootResults pattern on !ok
  10. triggerBatchCommitment at three end-condition sites in execLoop, return nil after each
  11. BlockStateCache allocated per-block in executeBlocks, passed via TxTask
  12. Overlay-backed blockTx built once at executeBlocks entry
  13. Step alignment check + lastFrozenTxNum at executeBlocks entry

Test plan

  • Run 12: 7h35m tip-chase soak - clean
  • Run 13: 27h soak - exposed at-tip growth issue (root cause: FCU never kicked CollateAndPruneIfNeeded)
  • Run 14: existing chaindata + fixes - shrunk live data 21.84 -> 12.04 GB, file step advanced 8878 -> 8887
  • Run 15: pristine chaindata - DB stays at 5.5-6.6 GB at steady state, file build at tip proven (8888+ reached)
  • Bulk replay: re-execute 10k blocks from a known-good baseline (the only untested batch-size regime)
  • SIGINT shutdown test: clean exit at every batch boundary
  • Race detector on parallel path
  • exec3_finalize_test.go revival

Mark Holt and others added 30 commits March 18, 2026 19:37
…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>
Mark Holt and others added 2 commits May 3, 2026 19:34
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment on lines +18 to +33
// 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)

Comment thread execution/commitment/commitment.go Outdated
Comment on lines +1425 to +1429
// 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 yperbasis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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 (CollateAndPrune loop body):
    a.commitGate.Lock()
    err := db.UpdateTemporal(ctx, pruneFn)
    a.commitGate.Unlock()
  • db/state/aggregator.go:1683-1685 (CollateAndPruneIfNeeded early-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) success

Three 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 halfrunForkchoiceFlushCommit and ProcessFrozenBlocks both now log on GetLatest/BeginTemporalRo failure ✅ but both still open a fresh RO tx after rwTx.Commit() to read back what was just written. Functionally fine (tx is brief, openTxs=1-at-commit invariant holds), but the simpler rewrite — read from rwTx before commit — was acked then dropped. Worth a follow-up to halve the txns on the hot FCU path, not a blocker.
  • Copilot #16defer commitTx.Rollback() inside for more := true; more; { ... } at stageloop.go:238 accumulates 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 + changeset under Apply goroutine, but 06ed5c9d48 and 079b65d19e moved both to the exec loop. Update the table or strike the rows so future readers don't trust it as documentation.
  • Calculator roTx lifetime comment — the field comment at committer.go:122 says "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 in pe.exec() and its defer Stop() runs before the stageloop's rwTx.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 commitGate sites got defer treatment; 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 is defer-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:

  1. M1defer commitGate.Unlock() in CollateAndPrune + CollateAndPruneIfNeeded (the round-3 N2 follow-through that landed for the RLock site only)
  2. M2defer agg.UnlockCollation() in ProcessFrozenBlocks so the three flush-error paths don't leak the gate (Copilot #15)
  3. M3 — log error from cc.doms.ComputeCommitment in computeWithoutCheck; log + propagate (or fail) in calcState.ensureAccount / ensureStorage lazy-load paths
  4. M4 — rewrite the stale Updates.tree block comment at commitment.go:1425-1429; tighten the test comment at commitment_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
  • runForkchoiceFlushCommit rwTx-pre-commit read (follow-up perf cleanup)
  • Copilot #16 defer-in-loop rewrite
  • Calculator roTx lifetime 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 yperbasis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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-242collationLocked 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:

  1. Marks a valid block as bad and reports it to the PoS layer
  2. Triggers a binary-search unwind, throwing away potentially valid state
  3. Logs Wrong trie root of block %d: <empty hash> (since cr.rootHash is unset in lazy-load/compute-error paths) and does not include cr.err itself — 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 #9AlwaysGenerateChangesets guard removal at stage_execute.go:491 — never addressed. The change s.ForwardProgress > cfg.syncCfg.MaxReorgDepth && !cfg.syncCfg.AlwaysGenerateChangesetss.ForwardProgress > cfg.syncCfg.MaxReorgDepth is a behavioral regression for the integration tool default (syncCfg.AlwaysGenerateChangesets = !dbg.BatchCommitments) and the explicit --experimental.always-generate-changesets flag — changesets past MaxReorgDepth are 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 roTx lifetime safety across collate/prune cycles — comment at committer.go:125 says "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_ = a is dead (the line above already used a); 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 by func (cc *commitmentCalculator) computeWithoutCheck(...). The computeAndCheck doc-comment has migrated above the computeWithoutCheck definition. Move it to where it belongs (just before func (cc *commitmentCalculator) computeAndCheck at 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:

  1. N1 — gate handleIncorrectRootHashError behind errors.Is(cr.err, ErrWrongTrieRoot) and include cr.err in the log line. ~5 lines.

Acceptable as follow-ups (file or reuse #20961 / #20962):

  • Round-1 #9 — either revert the AlwaysGenerateChangesets guard removal at stage_execute.go:491 or call out the behavioral change in the PR description.
  • Round-3 N5 — one-line comment at committer.go:125 explaining roTx lifetime safety across retire/prune.
  • Cosmetic: _ = a removal, computeAndCheck doc-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.

Mark Holt and others added 2 commits May 4, 2026 16:38
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>
@mh0lt mh0lt added this pull request to the merge queue May 5, 2026
Merged via the queue into main with commit b72aa7b May 5, 2026
37 checks passed
@mh0lt mh0lt deleted the exec3/remove-rwtx-threading-merge-main branch May 5, 2026 08:59
Comment thread db/kv/mdbx/kv_mdbx.go
// 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

looks like duplication of leakDetector *dbg.LeakDetector AskAlexSharov field functionality

Comment thread db/state/aggregator.go
}

func (a *Aggregator) BuildFiles(toTxNum uint64) (err error) {
if cap := a.maxCollationTxNum.Load(); cap > 0 && toTxNum > cap {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

mh0lt added a commit that referenced this pull request May 16, 2026
…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.
mh0lt added a commit that referenced this pull request May 16, 2026
…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.
mh0lt added a commit that referenced this pull request May 17, 2026
…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.
mh0lt added a commit that referenced this pull request May 17, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants