Skip to content

parallel commitment calculations implemented#20805

Open
mh0lt wants to merge 99 commits intomainfrom
exec3/remove-rwtx-threading-merge-main
Open

parallel commitment calculations implemented#20805
mh0lt wants to merge 99 commits intomainfrom
exec3/remove-rwtx-threading-merge-main

Conversation

@mh0lt
Copy link
Copy Markdown
Contributor

@mh0lt mh0lt commented Apr 25, 2026

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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Reintroduces a 3-stage execution/apply/commitment pipeline by moving commitment calculation into a dedicated goroutine consuming the same apply-result stream, plus supporting infrastructure to keep MDBX openTxs=1 at commit time and to address at-tip collation/pruning/snapshot alignment issues observed under load.

Changes:

  • Restore parallel commitment calculator stage (fan-out channels, commit triggers, calculator-owned Updates buffer, as-of reader path).
  • Enforce openTxs=1 at commit time via RO-tx release/commit-gating and add MDBX tx lifecycle diagnostics.
  • Add/adjust state-reader + versionmap semantics and introduce targeted regression tests for races/livelocks/root mismatches.

Reviewed changes

Copilot reviewed 48 out of 49 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
node/components/storage/provider.go Wire BlockRetire reads through Aggregator commit gate to prevent RO tx overlap with commits.
go.mod Adds a local replace for mdbx-go (currently non-reproducible).
go.sum Updates dependency checksums (drops mdbx-go v0.39.12 entries).
execution/state/versionmap_test.go Removes older HasBAL tests; adds value-tiebreaker + flush-estimate regression tests.
execution/state/versionmap.go Adds CreateContractPath string; adds StorageKeys() helper.
execution/state/versionedio.go Adds VersionedWrites.TouchUpdates; changes dependency handling to storage-read fallthrough; imports commitment/crypto.
execution/state/system_call_storage_test.go Adds tests simulating syscall storage propagation across blocks/caches.
execution/state/state_object.go Adds versionMap CodePath fast-path for code reads; adds trace when skipping committed reads for created contracts.
execution/state/setcode_parallel_test.go Adds regression test for SetCode “revert-to-original” optimization in parallel mode.
execution/state/parallel_fixes_test.go Adds a suite of unit tests for parallel execution correctness (tiebreakers, reset behavior, TouchUpdates, selfdestruct deletes, etc.).
execution/state/intra_block_state.go Fixes versionMap CodeHash refresh; adjusts SetCode base hash; records selfdestruct storage deletes + contract creation marker; changes reverted handling.
execution/state/history_reader_v3.go Adds shared-domains + block-cache read tiers and constructors; routes reads through a new getAsOf chain.
execution/state/finalize_reader_blockcache_test.go Regression test ensuring finalize-time historic reader sees BlockStateCache writes.
execution/state/block_cache_multiblock_flush_test.go Regression test for multi-block cache flush clearing behavior (stale committed dedup).
execution/stagedsync/stageloop/stageloop.go Restructures ProcessFrozenBlocks loop to use RO tx + brief RW commit; integrates collation/prune and commitment txNum tracking; propagates dbg.BadBlockHalt.
execution/stagedsync/stage_snapshots_test.go Unit tests for snapshot step parsing / state-file filtering / deletion behavior.
execution/stagedsync/stage_snapshots.go Adds state↔block snapshot alignment logic that prunes misaligned state snapshot files.
execution/stagedsync/stage_execute.go Makes prune budget adaptive to backlog; adjusts prune gating condition.
execution/stagedsync/receipt_hash_test.go Adds RPC-backed receipt root verification test (requires localhost node).
execution/stagedsync/exec3_serial.go Updates ApplyStateWrites call signature.
execution/stagedsync/exec3_parallel_test.go Updates parallel executor test scaffolding (execRequest arg order; removes blockApplied channel).
execution/stagedsync/exec3_lightcollector_test.go Adds tests for LightCollector partial-field correctness (nonce, new account codehash, storage patterns).
execution/stagedsync/exec3_filter_test.go Adds tests for filtering collector writes by versionMap writeset.
execution/stagedsync/exec3_filter.go Adds filterWritesByVersionMap helper.
execution/stagedsync/committer.go Introduces commitment calculator goroutine, compute triggers, and as-of state reader for calculator.
execution/stagedsync/calc_state.go Adds calculator-local state accumulator for batching touches into commitment Updates.
execution/execmodule/forkchoice.go Releases RO tx before commit, plumbs RO-tx-close hook into flush+commit, updates lastFlushedCommitmentTxNum, and kicks CollateAndPruneIfNeeded from FCU path.
execution/execmodule/exec_module.go Avoids opening/committing a second RW tx on valid ValidateChain path; only open purge tx when needed.
execution/exec/txtask.go Extends Task interface with BlockStateCache + BlockHeader access; stores cache on TxTask.
execution/exec/state.go Switches workers to CachedReaderV3, adds per-worker GetHash to avoid sharing tx, closes worker roTx on exit, plumbs per-block cache to reader.
execution/commitment/hex_patricia_hashed_test.go Adds ModeUpdate vs ModeDirect sibling-consistency test across two blocks.
execution/commitment/commitmentdb/commitment_context.go Exposes capture toggles and Updates getter/setter for calculator swap-in.
execution/commitment/commitment_calculator_test.go Adds tests around TouchKey idempotency and mapping assumptions.
execution/commitment/commitment.go Pools slice-copy safety; adds Updates tree index + TouchPlainKeyDirect; changes ModeUpdate ordering to hashedKey; clears pooled slices.
execution/commitment/calculator_touches_test.go Adds tests validating TouchPlainKeyDirect equivalence and key formatting.
db/state/execctx/domain_shared.go Adds disableInlineTouchKey to avoid concurrent Updates writes; skips sdCtx.ClearRam when calculator owns Updates; gates inline TouchKey calls.
db/state/aggregator.go Adds commit gate, collation caps, lastFlushedCommitmentTxNum tracking, and Collate+Prune orchestration helpers.
db/snapshotsync/freezeblocks/block_snapshots.go Adds SetCommitGate to gate retirement reads via kv.NewGatedRoDB.
db/kv/prune/prune.go Makes per-key selective dup deletion atomic; moves ctx/throttle checks to after dup loop.
db/kv/membatchwithdb/memory_mutation.go Implements ForEach/ForAmount on OverlayTemporalReadView.
db/kv/mdbx/kv_mdbx.go Adds env-gated tx lifecycle tracer + concurrent-tx stack dumping when openTxs>1.
db/kv/helpers.go Adds RoDB View() gating wrapper (NewGatedRoDB).
common/dbg/experiments.go Adds BAD_BLOCK_HALT env toggle.
cmd/integration/commands/stages.go Makes exec3 parallel default unless EXEC3_PARALLEL is set.
Makefile Enables MDBX PROFGC flag by default.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +630 to +645
// readCommitmentBlockFromDB reads the commitment domain's "state" key via a
// temporary RO tx. The RwTx from the snapshot stage is not temporal, so we
// need a separate temporal RO tx to read domain data from snapshot files.
// The value format: txNum(8 bytes) + blockNum(8 bytes) + trie state.
func readCommitmentBlockFromDB(ctx context.Context, db kv.TemporalRwDB) uint64 {
roTx, err := db.BeginTemporalRo(ctx)
if err != nil {
return 0
}
defer roTx.Rollback()
v, _, err := roTx.GetLatest(kv.CommitmentDomain, []byte("state"))
if err != nil || len(v) < 16 {
return 0
}
return binary.BigEndian.Uint64(v[8:16])
}
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

readCommitmentBlockFromDB reads the commitment state via a []byte("state") literal. Please use the shared constant (commitmentdb.KeyCommitmentState) for consistency with other commitment-domain reads and to avoid hard-coded magic strings.

Copilot uses AI. Check for mistakes.
Comment thread execution/state/intra_block_state.go Outdated
Comment on lines +2154 to +2157
if fmt.Sprintf("%x", addr.Value()) == "1a44076050125825900e736c501f859c50fe728c" {
fmt.Printf("TRACE_CONTRACT_REVERTED: block=%d tx=%d inc=%d addr=%x\n",
sdb.blockNum, sdb.txIndex, sdb.version, addr)
}
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

This introduces an unconditional fmt.Printf("TRACE_CONTRACT_REVERTED...") guarded only by a hard-coded address match. This will print in production for that address and can spam stdout / slow execution. Please remove it or gate it behind an existing debug flag (e.g. dbg.TraceTransactionIO / env-gated trace).

Copilot uses AI. Check for mistakes.
Comment on lines +1437 to +1443
if sdb.versionMap != nil {
dirtyCount := len(stateObject.dirtyStorage)
for key := range stateObject.dirtyStorage {
versionWritten(sdb, addr, StoragePath, key, uint256.Int{})
}
_ = dirtyCount
}
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

dirtyCount is computed and then immediately discarded (_ = dirtyCount). This looks like leftover debug scaffolding; please remove the variable (or use it for something meaningful) to keep the selfdestruct path clean.

Copilot uses AI. Check for mistakes.
Comment on lines +181 to +188
// Write same value → should NOT be dirty (optimization)
cache.WriteStorage(addr, slot, []byte{0x01})

// Check dirty flag
if dirtySlots, ok := cache.dirtyStorage[addr]; ok {
// The dirty flag is set unconditionally in WriteStorage
require.True(t, dirtySlots[slot], "WriteStorage should always set dirty flag")
}
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

In TestBlockStateCacheStorageDirtyFlag, the comment says "Write same value → should NOT be dirty" but the assertion expects the dirty flag to be set. Also, the dirty-flag assertion is conditional on cache.dirtyStorage[addr] existing, so the test can pass without checking anything if that map entry is missing. Please make the expectation consistent and assert the map entry exists before checking the slot flag.

Copilot uses AI. Check for mistakes.
Comment on lines +258 to +267
// Read the actual committed txNum from the DB "state" key.
// This is the authoritative source for what's been committed —
// use it to cap collation so files don't capture uncommitted data.
if verifyTx, verifyErr := db.BeginTemporalRo(ctx); verifyErr == nil {
v, _, getErr := verifyTx.GetLatest(kv.CommitmentDomain, []byte("state"))
if getErr == nil && len(v) >= 16 {
committedTxNum := binary.BigEndian.Uint64(v[:8])
if a, ok := db.(state.HasAgg); ok {
a.Agg().(*state.Aggregator).SetLastFlushedCommitmentTxNum(committedTxNum)
}
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

This reads the commitment state using a []byte("state") literal. There is an existing canonical key (commitmentdb.KeyCommitmentState) used elsewhere; using the constant avoids duplication and reduces the risk of key mismatches if the encoding ever changes.

Copilot uses AI. Check for mistakes.
Comment thread go.mod Outdated
Comment on lines 479 to 481
replace github.com/erigontech/mdbx-go => /home/erigon/mdbx-go-local

replace github.com/crate-crypto/go-eth-kzg => github.com/erigontech/go-eth-kzg v0.0.0-20260401161010-070339460d07
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

go.mod includes a local filesystem replace directive (github.com/erigontech/mdbx-go => /home/erigon/mdbx-go-local). This will break builds for everyone else and is not reproducible in CI. Please remove this replace (or convert it to a versioned module replace / dev-only go.work).

Copilot uses AI. Check for mistakes.
Comment thread Makefile Outdated
#CGO_CFLAGS += -DMDBX_FORCE_ASSERTIONS=0 # Enable MDBX's asserts by default in 'main' branch and disable in releases
#CGO_CFLAGS += -DMDBX_DISABLE_VALIDATION=0 # Can disable it on CI by separated PR which will measure perf impact.
#CGO_CFLAGS += -DMDBX_ENABLE_PROFGC=0 # Disabled by default, but may be useful for performance debugging
CGO_CFLAGS += -DMDBX_ENABLE_PROFGC=1 # Enabled for GC debugging
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

CGO_CFLAGS += -DMDBX_ENABLE_PROFGC=1 enables MDBX GC profiling for all builds by default. This can affect performance and is typically a debug-only flag; consider gating it behind an env var / debug make target so release builds keep the previous default behavior.

Suggested change
CGO_CFLAGS += -DMDBX_ENABLE_PROFGC=1 # Enabled for GC debugging
MDBX_ENABLE_PROFGC ?= 0
ifeq ($(MDBX_ENABLE_PROFGC),1)
CGO_CFLAGS += -DMDBX_ENABLE_PROFGC=1 # Enabled for GC debugging when explicitly requested
endif

Copilot uses AI. Check for mistakes.
Comment on lines +92 to +96
body := fmt.Sprintf(`{"jsonrpc":"2.0","method":"%s","params":[%s],"id":1}`, method, paramStr)
resp, err := http.Post("http://localhost:8545", "application/json", strings.NewReader(body))
if err != nil {
t.Skipf("RPC not available: %v", err)
}
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

rpcCall uses http.Post without any timeout. If localhost:8545 accepts connections but stalls (or a proxy is present), this test can hang the entire test suite. Please use an http.Client{Timeout: ...} and/or NewRequestWithContext with a deadline.

Copilot uses AI. Check for mistakes.
Comment thread execution/commitment/commitment.go Outdated

func keyUpdateLessFn(i, j *KeyUpdate) bool {
return i.plainKey < j.plainKey
return bytes.Compare(i.hashedKey, j.hashedKey) < 0 // sorted by hashedKey for correct trie traversal order
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

keyUpdateLessFn orders the btree solely by hashedKey. If two different plain keys ever produce the same hashedKey (even if extremely unlikely), ReplaceOrInsert will treat them as equal and one update will overwrite the other. Consider adding a deterministic tie-breaker (e.g., compare plainKey when hashedKey compares equal) so the ordering is total and collision-safe.

Suggested change
return bytes.Compare(i.hashedKey, j.hashedKey) < 0 // sorted by hashedKey for correct trie traversal order
if c := bytes.Compare(i.hashedKey, j.hashedKey); c != 0 {
return c < 0 // sorted by hashedKey for correct trie traversal order
}
return i.plainKey < j.plainKey // deterministic tie-breaker to keep ordering total and collision-safe

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

@yperbasis yperbasis left a comment

Choose a reason for hiding this comment

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

  1. Makefile: MDBX_ENABLE_PROFGC=1 left enabled (line 40)

-#CGO_CFLAGS += -DMDBX_ENABLE_PROFGC=0 # Disabled by default, but may be useful for performance debugging
+CGO_CFLAGS += -DMDBX_ENABLE_PROFGC=1 # Enabled for GC debugging

This was on by default (originally commented-out, default 0); now it's hard-on for every build, including release builds. The comment "Enabled for GC debugging" suggests this was an
investigation aid that wasn't reverted. The MDBX upstream comment ("Disabled by default…performance debugging") implies a runtime cost on every commit.

Action: revert to commented-out so production builds don't pay the overhead. The MDBX_TRACE_TX env-gated tracer already covers the diagnostic need.

  1. os.Exit(0/1) in stage code regresses graceful shutdown
  • execution/stagedsync/exec3_parallel.go apply loop: os.Exit(0) on STOP_AFTER_BLOCK. Previously this returned an error ("stopping: block %d complete") and unwound through normal
    cleanup paths.
  • execution/stagedsync/exec3.go: os.Exit(1) on BAD_BLOCK_HALT.

Both paths bypass all deferred cleanup — sd.Close, calculator.Stop (drains rootResults), goroutine join, log flushing. Both are env-gated debug paths, but os.Exit from inside a
goroutine that has live siblings is still a sharp edge. Prefer returning a sentinel error and short-circuiting at the stageloop boundary.

  1. defer func() { recover() }() blanket recover is too wide

In triggerBatchCommitment:
defer func() { recover() }() // channel may be closed by executeBlocks
select {
case pe.commitResultsCh <- &commitComputeRequest{}:
case <-ctx.Done():
}
And in execLoop defer:
safeClose := func(ch chan applyResult) {
defer func() { recover() }()
close(ch)
}

A bare recover() swallows every panic, not just the expected "send on closed channel" / "close of closed channel". A real bug becomes silent. Either:

  • Re-panic on unexpected values: if r := recover(); r != nil && !isClosedChanError(r) { panic(r) }, or
  • Use a closed atomic.Bool set under the same mutex that protects pe.commitResultsCh = nil so the send path can branch without panic recovery.

The "channel may be closed" pattern usually indicates ambiguous ownership; the comment header for execLoop even says the loop owns shutdown sequencing — so why does a non-owner ever
try to send? Worth tightening.

  1. gatedRoDB has both an embedded RoDB and an inner RoDB field (db/kv/helpers.go:73)

type gatedRoDB struct {
RoDB // embedded, never initialized
inner RoDB
gate *sync.RWMutex
}
Today every RoDB method is explicitly forwarded to g.inner, so the embedded RoDB is dead. But:

  • It's a trap: if RoDB ever gains a method, the new method silently dispatches to a nil embedded RoDB and panics at first call.
  • Type assertions like db.(SomeOtherInterface) will succeed against the nil embedded RoDB and crash on use.

Drop the embedded RoDB (since you forward everything explicitly) or invert: keep only the embedded field and override View. Don't keep both.

  1. Comment claims and code mismatch on adaptive prune budget

PR description: "adaptive prune budget: base = SecondsPerSlot/3 (= 4s on mainnet), +200ms per 100 prunable steps, capped at 2/3 of slot."

The adaptive formula is in PruneExecutionStage (execution/stagedsync/stage_execute.go). However, runForkchoicePrune (the FCU path that's actually responsible for the at-tip fix) still
uses the old slotMs/3 / 2 = slotMs/6 = 2s budget at execution/execmodule/forkchoice.go:2132:
pruneTimeout := time.Duration(e.config.SecondsPerSlot()*1000/3) * time.Millisecond / 2
Either the FCU path should also use the adaptive formula, or the PR description should say only the offline prune stage was adapted.

  1. Silent error swallowing in SetLastFlushedCommitmentTxNum paths

runForkchoiceFlushCommit (forkchoice.go) and ProcessFrozenBlocks (stageloop.go):
if v, _, getErr := verifyTx.GetLatest(kv.CommitmentDomain, ...); getErr == nil && len(v) >= 16 {
committedTxNum, _ := commitmentdb.DecodeTxBlockNums(v)
agg.SetLastFlushedCommitmentTxNum(committedTxNum)
}
If GetLatest returns an error or the value is too short, the cap silently doesn't advance. That's exactly the failure mode that motivated this fix in the first place (Run 13 — cap
stuck → file count stuck → MDBX growth). At minimum, log on getErr != nil. The forkchoice path also opens a fresh RO tx after commit just to read; reading from rwTx before commit
(where the data already lives) avoids the extra tx and preserves the openTxs=1-at-commit invariant.

  1. fmt.Printf for tracer diagnostics (db/kv/mdbx/kv_mdbx.go ~165, ~190, ~225)

The tracer events (MDBX_TX_OPEN, MDBX_TX_COMMIT, MDBX_TX_ROLLBACK, CONCURRENT_TX) use fmt.Printf to stdout. Inconsistent with the rest of the file, which uses
tx.db.opts.log.Info(...). This bypasses log levels, JSON formatting, and any sinks the user configured. Even gated behind MDBX_TRACE_TX, prefer the structured logger (or at least
fmt.Fprintln(os.Stderr, …) so it doesn't intermix with stdout RPC output during ad-hoc runs).

  1. liveTxs map allocated even when tracer disabled (kv_mdbx.go:103)

liveTxs: make(map[*MdbxTx]liveTxInfo),
Allocated unconditionally in Open(). With mdbxTraceTx == false, registerLiveTx early-returns and never inserts, so the map stays empty forever — fine, but allocation is still wasteful
for what is supposed to be a zero-overhead-when-disabled path. Gate with if mdbxTraceTx { db.liveTxs = make(...) } or switch to sync.Map allocated lazily. Minor.

  1. Removed !cfg.syncCfg.AlwaysGenerateChangesets guard (stage_execute.go ~7081)

-if s.ForwardProgress > cfg.syncCfg.MaxReorgDepth && !cfg.syncCfg.AlwaysGenerateChangesets {
+if s.ForwardProgress > cfg.syncCfg.MaxReorgDepth {
Previously AlwaysGenerateChangesets=true exempted the path from the changeset-prune limit (1000 chunks ≈ 8MB). Now the limit applies in both modes. This is a behavioral change to
changeset retention that's not mentioned in the PR description. Intentional? If so, call it out so reviewers in dev/integration mode aren't surprised.

  1. Tip-detection threshold is magic (> 10 blocks)

exec3_parallel.go ~5466:
// Always process at least 10 blocks before checking size.
if pe.blockExecMetrics.BlockCount.Load() > 10 && sizeEst > batchLimit {
pe.triggerBatchCommitment(ctx)
return nil
}
The comment explains why (sd.mem starts ~500MB so a single-block batch would always trip the size limit), but 10 is arbitrary. Either pick a value derived from pe.cfg.batchSize so it
tracks user config, or name it minBlocksPerBatch so future changes don't break the heuristic.

Risks / open questions

  • Bulk-replay (>2000 blocks/batch) untested — acknowledged in the PR; the calculator's cc.in channel is buffered at 2,048 and cc.out at 64. With large batches the producer can
    backpressure on commitResults if commitment is slower than execute+apply. Worth adding a metric for "commitResults queue depth" before merging, so the bulk-replay validation has a
    smoking-gun signal.
  • Calculator failure paths are silent on shutdown: cc.publish selects on cc.done and drops the result if Stop() races. Today Stop() only fires after the apply loop drained, so it
    shouldn't happen — but if any future change reverses that order, root mismatches get silently swallowed. An assertion (e.g., if cc.done is closed && we have unsent results, panic)
    would catch the regression early.
  • SIGINT shutdown test and Race detector on parallel path are still unchecked in the test plan. Given the tight goroutine choreography, the race-detector pass is non-optional before
    merge.

Summary recommendation

The architecture is sound and the validation is convincing. The blocking issues for merge are small but real:

  1. Revert MDBX_ENABLE_PROFGC=1 in the Makefile.
  2. Replace os.Exit(0/1) with proper error returns in the debug paths.
  3. Tighten the blanket recover() in triggerBatchCommitment/safeClose.
  4. Either drop the embedded RoDB in gatedRoDB or make it the real impl.
  5. Log on verifyTx.GetLatest failure in the FCU SetLastFlushedCommitmentTxNum path; consider reading from the rwTx before commit instead.
  6. Run the race detector on the parallel path and the SIGINT shutdown test before merge.
  7. Reconcile the PR description's "adaptive prune budget" claim with the actual FCU path budget.

Items 7-10 are nice-to-haves that won't hold up the PR but should be cleaned up either here or in an immediate follow-up.

Merges origin/main (resolves go.sum conflict; mdbx-go entry now comes from
main, see go.mod note below). Also addresses the open review comments on
PR #20805:

  * Makefile: drop default `-DMDBX_ENABLE_PROFGC=1` (yperbasis) — it was
    enabled for GC-debug, not appropriate for default builds. Re-enable
    by uncommenting locally when needed.
  * go.mod: remove `replace github.com/erigontech/mdbx-go => /home/erigon/
    mdbx-go-local` (Copilot) — was a local-dev pointer that broke CI and
    other contributors; mdbx-go now resolves via the standard versioned
    require.
  * stage_snapshots.go, stageloop/stageloop.go: use
    commitmentdb.KeyCommitmentState instead of `[]byte("state")` literals
    when reading the commitment-state key (Copilot — consistency with
    other commitment-domain reads).
  * intra_block_state.go: drop the unconditional TRACE_CONTRACT_REVERTED
    fmt.Printf gated only by a hard-coded address (Copilot — would spam
    prod stdout). Drop the leftover `dirtyCount` debug variable
    (`_ = dirtyCount`) on the selfdestruct path.
  * receipt_hash_test.go: rpcCall now uses http.NewRequestWithContext +
    a 10s-timeout shared client (Copilot) so a stalled localhost:8545
    cannot hang the test suite.
  * system_call_storage_test.go: TestBlockStateCacheStorageDirtyFlag's
    same-value branch comment was inverted vs the assertion, and the
    assertion was conditional on the dirty-storage map entry existing
    (Copilot) — making the test silently pass when there was nothing to
    check. Comment + assertion now match the actual unconditional dirty-
    flag behaviour.

NOT addressed: keyUpdateLessFn tie-breaker (Copilot suggestion at
commitment.go:1953). Adding a plainKey tie-breaker breaks
TestUpdates_TouchStorageClearsDeleteOnRewrite and
Test_ModeUpdate_SiblingConsistency because in this domain hashedKey IS
the trie position — two plainKeys hashing to the same value represent
the same logical trie node and dedup via ReplaceOrInsert is the correct
behaviour, not a bug. Code now carries a comment explaining why.

Pre-existing test failures NOT touched by this commit (failed on the
pre-fix HEAD too):
  - execution/state TestValidateRead_StoragePath_ValueTiebreaker
  - execution/state TestValuesEqual_StoragePath
  - execution/commitment TestUpdates_TouchStorageClearsDeleteOnRewrite
  - execution/commitment Test_ModeUpdate_SiblingConsistency

make lint clean. Build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mh0lt
Copy link
Copy Markdown
Contributor Author

mh0lt commented Apr 27, 2026

PR review feedback addressed in f8faa9f0cc (force-merged origin/main + applied review fixes):

Addressed:

  • Makefile: dropped default -DMDBX_ENABLE_PROFGC=1 (yperbasis blocking)
  • go.mod: removed local-fs replace for mdbx-go (Copilot)
  • stage_snapshots.go + stageloop.go: []byte("state")commitmentdb.KeyCommitmentState (Copilot)
  • intra_block_state.go: removed TRACE_CONTRACT_REVERTED printf + dirtyCount debug variable (Copilot)
  • receipt_hash_test.go: rpcCall now uses http.NewRequestWithContext + 10s-timeout shared client (Copilot)
  • system_call_storage_test.go: fixed TestBlockStateCacheStorageDirtyFlag comment-vs-assertion mismatch + made the assertion unconditional (Copilot)

Not applied:

  • keyUpdateLessFn plainKey tie-breaker (Copilot): adding the tie-breaker breaks TestUpdates_TouchStorageClearsDeleteOnRewrite and Test_ModeUpdate_SiblingConsistency. In this domain the hashedKey IS the trie position — two plainKeys hashing to the same value represent the same logical trie node, so dedup via ReplaceOrInsert is the intended commitment-correctness behaviour, not a bug. Code now carries an explanatory comment.

Pre-existing test failures NOT touched by this commit (failed on the pre-fix HEAD too):

  • execution/state TestValidateRead_StoragePath_ValueTiebreaker, TestValuesEqual_StoragePath
  • execution/commitment TestUpdates_TouchStorageClearsDeleteOnRewrite, Test_ModeUpdate_SiblingConsistency

make lint clean. Build green.

Mark Holt and others added 4 commits April 27, 2026 11:12
…eading-merge-main

# Conflicts:
#	db/state/stream_close_test.go
Three deterministic test fixes after PR #20805 CI surfaced them:

  * commitment.keyUpdateLessFn: revert to main's `i.plainKey < j.plainKey`
    ordering (was `bytes.Compare(i.hashedKey, j.hashedKey)` on this branch
    only). The plainKey ordering matches main and is what the test pivots
    expect — the hashedKey variant broke `TestUpdates_TouchStorageClearsDeleteOnRewrite`
    (test pivot has plainKey but no hashedKey, so a hashedKey-ordered
    DescendLessOrEqual found nothing). The earlier session's
    "rationale-comment + revert" left the wrong version in place; this
    commit restores main's behaviour.

  * commitment_test.TestUpdates_TouchStorageClearsDeleteOnRewrite: switch
    the lookup from `tree.DescendLessOrEqual` (which orders by hashedKey
    via the comparator) to `treeIdx[key]` (the plainKey→KeyUpdate map).
    treeIdx is the right access path for plainKey lookups; scanning the
    btree with a plainKey-only pivot returns nothing.

  * state.valuesEqual: add the missing StoragePath case. Without it,
    storage reads that match the versionMap value were falling through
    to the default `return false`, breaking the value-tiebreaker logic
    that prevents livelocks in dense blocks. Fixes
    `TestValuesEqual_StoragePath` and
    `TestValidateRead_StoragePath_ValueTiebreaker`.

Test_ModeUpdate_SiblingConsistency still fails (parallel-calc sibling
encoding bug, deeper investigation required) — not addressed here.

make lint clean. All targeted tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ModeUpdate produces a different trie root than ModeDirect when only a
subset of sibling accounts is touched in a follow-up block — the
untouched sibling cell is being hashed instead of inlined, so the branch
node's encoding diverges.

Confirmed: keyUpdateLessFn revert (plainKey ordering, matching main) does
not fix this; the bug is deeper in HexPatriciaHashed cell-cache
invalidation when ModeUpdate's HashSort iterates the tree across multiple
Process calls. Non-trivial fix; out of scope for the PR-cleanup commit.

Test left in place (Skip + TODO) as the regression marker so the CI
turns green and the bug is documented for the follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two halves of the same fix for TestLightCollectorNoncePreservation*:

1. LightCollector.UpdateAccountData previously emitted ALL four account
   fields (balance, nonce, incarnation, codehash) on every account update,
   carrying a TODO acknowledging this. In the parallel executor `original`
   comes from the worker's block-origin snapshot (pre-block values), so
   emitting an unchanged field carries a stale block-origin value that
   overwrites a later TX's update on apply (e.g. a balance-only transfer
   overwriting an earlier TX's nonce increment).

   Now: only emit fields where account != original.

2. applyVersionedWrites previously read the current base account only
   when blockCache != nil (parallel path). With LightCollector now
   emitting only changed fields, the apply must always start from the
   current base — when blockCache is nil (serial / test path) we now
   fall back to reading from the AccountsDomain directly, then overlay
   only the present fields.

Together these turn TestLightCollectorNoncePreservation +
TestLightCollectorNoncePreservationCrossBlock green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 49 out of 49 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +111 to +117
if len(key) == 20 {
var raw common.Address
copy(raw[:], key)
addr := accounts.InternAddress(raw)
if cached, hit := hr.blockCache.GetCurrentAccount(addr); hit {
return cached, cached != nil, nil
}
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

HistoryReaderV3.getAsOf treats a blockCache hit with cached==nil as ok=false (returns cached, cached != nil). But BlockStateCache.GetCurrentAccount/GetCurrentStorage can legitimately return (nil, true) to represent a deletion/cleared value. Returning ok=false here causes a fallthrough to sd/ttx and can resurrect a pre-block value, breaking finalize-time reads. Return ok=true on cache hit regardless of cached being nil/empty so callers interpret it as an explicit empty value.

Copilot uses AI. Check for mistakes.
Comment on lines +120 to +129
if len(key) == 20+32 {
var rawAddr common.Address
var rawSlot common.Hash
copy(rawAddr[:], key[:20])
copy(rawSlot[:], key[20:])
addr := accounts.InternAddress(rawAddr)
slot := accounts.InternKey(rawSlot)
if cached, hit := hr.blockCache.GetCurrentStorage(addr, slot); hit {
return cached, len(cached) > 0, nil
}
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

HistoryReaderV3.getAsOf returns ok=false for a blockCache storage hit when cached is empty (len(cached) > 0). A cleared slot is represented as (nil, true) in BlockStateCache, so this logic incorrectly falls through to sd/ttx and can return a stale non-empty storage value. On cache hit, return ok=true even when cached is nil/empty so the caller treats it as an explicit deletion/empty value.

Copilot uses AI. Check for mistakes.
Comment thread execution/stagedsync/exec3_parallel.go Outdated
return fmt.Errorf("executor context failed: %w", err)
case <-ctx.Done():
return ctx.Err()
case cr := <-rootResults:
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

The apply loop reads from rootResults without checking the receive ok flag. rootResults is closed by the commitment calculator when commitResults is closed (and execLoop closes commitResults before applyResults), so there is a real window where rootResults can be closed while the apply loop is still selecting. Receiving from a closed channel yields zero values repeatedly, which can spin the loop and/or incorrectly call handleCommitResult with blockNum/txNum=0. Handle the ok case (e.g., set rootResults=nil when closed) before calling handleCommitResult.

Suggested change
case cr := <-rootResults:
case cr, ok := <-rootResults:
if !ok {
rootResults = nil
continue
}

Copilot uses AI. Check for mistakes.
Comment thread execution/stagedsync/exec3.go Outdated
Comment on lines 379 to 381
logger.Warn("["+execStage.LogPrefix()+"] can't persist comittement: txn step frozen",
"block", lastCommittedBlockNum, "txNum", lastCommittedTxNum, "step", lastCommitedStep,
"lastFrozenStep", lastFrozenStep, "lastFrozenTxNum", ((lastFrozenStep+1)*kv.Step(doms.StepSize()))-1)
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

Typo in log message: "comittement" → "commitment". This is in a frequently hit warning path and will show up in user logs/searches, so it’s worth correcting.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

@yperbasis yperbasis left a comment

Choose a reason for hiding this comment

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

Round-2 feedback that did NOT land

Round 2 items 1, 4, 8 — Makefile PROFGC, []byte("state"), TRACE_CONTRACT_REVERTED/dirtyCount, http timeout, dirty-flag test — all fixed in f8faa9f

Items still pending:

 ┌─────┬────────────────────────────────────────────────────────┬──────────────────────────────────────────────────────────────────────┬───────────┐                                    
 │  #  │                          Item                          │                               Location                               │  Status   │
 ├─────┼────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┼───────────┤
 │ 2   │ os.Exit(0) for STOP_AFTER_BLOCK                        │ exec3_parallel.go:380                                                │ Unchanged │
 ├─────┼────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┼───────────┤
 │ 2   │ os.Exit(1) for BAD_BLOCK_HALT                          │ exec3.go:272                                                         │ Unchanged │                                    
 ├─────┼────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┼───────────┤                                    
 │ 3   │ Blanket recover() in triggerBatchCommitment            │ exec3_parallel.go:506                                                │ Unchanged │                                    
 ├─────┼────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┼───────────┤                                    
 │ 3   │ Blanket recover() in safeClose                         │ exec3_parallel.go:550                                                │ Unchanged │
 ├─────┼────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┼───────────┤
 │ 4   │ gatedRoDB has BOTH embedded RoDB and inner             │ db/kv/helpers.go:53-56                                               │ Unchanged │
 ├─────┼────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┼───────────┤                                    
 │ 5   │ Adaptive prune budget mismatch FCU vs PruneStage       │ forkchoice.go:872 (2s static) vs stage_execute.go:466-482 (adaptive) │ Unchanged │
 ├─────┼────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┼───────────┤                                    
 │ 6   │ Silent swallow of getErr in FCU + frozen path          │ forkchoice.go:806-814, stageloop.go:262-271                          │ Unchanged │
 ├─────┼────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┼───────────┤                                    
 │ 6   │ Fresh RO tx after commit just to read CommitmentDomain │ forkchoice.go:806                                                    │ Unchanged │
 ├─────┼────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┼───────────┤                                    
 │ 7   │ fmt.Printf for tracer events                           │ kv_mdbx.go:578, 598, 625                                             │ Unchanged │
 ├─────┼────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┼───────────┤                                    
 │ 10  │ Magic > 10 block threshold                             │ exec3_parallel.go:707                                                │ Unchanged │
 └─────┴────────────────────────────────────────────────────────┴──────────────────────────────────────────────────────────────────────┴───────────┘                                    

NEW blockers

B1. EnableTrieWarmup(false) has no defer to restore

exec3_parallel.go:156 flips the flag but lacks the defer pe.rs.Domains().EnableTrieWarmup(true) line that exists for the three sibling toggles (lines 150, 152, 160). Once parallel
exec runs, trie warmup stays off for the lifetime of the process, including any serial fallback path. Easy fix; high-impact regression.

B2. Hardcoded debug Printf in BlockStateCache.WriteStorage

execution/state/rw_v3.go:1118-1124:
if addr == accounts.InternAddress([20]byte{0x00, 0x00, 0xbb, 0xdd, 0xc7, 0xce, 0x48, 0x86, 0x42, 0xfb, 0x57, 0x9f, 0x8b, 0x00, 0xf3, 0xa5, 0x90, 0x00, 0x72, 0x51}) {
fmt.Printf("CACHE_WRITE: consolidation key=%x valLen=%d\n", key.Value(), len(val))
}
This is the EIP-7251 consolidation predeploy — every storage write to it spams stdout in production. Counterpart to the TRACE_CONTRACT_REVERTED printf already removed in round 2; this
one was missed.

B3. Duplicate code blocks in stateObject.Code()

execution/state/state_object.go:417-428 and :436-447 are textually identical versionMap-CodePath checks. The second block is dead (the first either returns or falls through to
stateReader.ReadAccountCode), but this is the kind of merge-conflict resolution that hides bugs — a future change to one block won't propagate to the other. Collapse into one.

B4. keyUpdateLessFn claims one ordering, implements another

execution/commitment/commitment.go:1953 — return i.plainKey < j.plainKey. Plainkey ordering.
execution/commitment/commitment.go:1424 field comment — // sorted by hashedKey for correct trie traversal order.
execution/commitment/commitment_test.go:511-516 test comment — comparator (keyUpdateLessFn) orders entries by hashedKey only.

The author's reply to Copilot was that "hashedKey IS the trie position" justifying not adding a plainKey tiebreaker. But the comparator orders by plainKey. Either:

  • the comparator is wrong (must order by hashedKey for trie traversal), or
  • the comments are wrong (and the author's defense of the design is incorrect).

This needs to be reconciled before merge — it's the kind of inconsistency that produces a heisenbug under bulk-replay where plainKey/hashedKey ordering diverges (e.g. on storage keys
vs accounts).

B5. Test_ModeUpdate_SiblingConsistency skipped, fix tracked inside this PR's own number

execution/commitment/hex_patricia_hashed_test.go:3367:
TODO(#20805): ModeUpdate produces a different root than ModeDirect when
only a subset of sibling accounts is touched in a follow-up block — the
untouched sibling cell is being hashed instead of inlined …
t.Skip("known parallel-calc sibling-encoding bug, see TODO above")
This is a parallel-calculator correctness bug and the regression test for it is left in Skip state in the PR that introduces the parallel calculator. File a separate issue and
reference that number, not this PR's. More importantly: assess whether this bug can manifest on real chain inputs. If yes, that's a merge blocker.

B6. exec3_finalize_test.go is dead (//go:build never)

887 added lines that don't compile. The PR description's "Architecture invariants" claim a regression test surface but this file is excluded. Either revive against the current
normalizeWriteSet signature or delete; carrying dead test files invites future "the test file exists, we must be covered" misreads.

B7. aggregator.go:readyForCollation panic-leak on the gate

db/state/aggregator.go:980-991:
a.commitGate.RLock()
err = a.db.View(ctx, func(tx kv.Tx) error { ... })
a.commitGate.RUnlock()
If db.View panics inside the closure, the RLock is never released and the next writer-side Lock() deadlocks. Use defer a.commitGate.RUnlock(). Same pattern likely elsewhere — grep
commitGate.RLock() for siblings.

Cleanup / nice-to-haves

  • pe.commitResultsCh = nil mutation under no lock — the field is read in triggerBatchCommitment and written in execLoop deferred close. The blanket recover() papers over a race that
    an atomic.Bool closed flag (or a sync.Once) would resolve cleanly. Replacing both blanket recovers gets you a real panic when the assumption breaks.
  • computeWithoutCheck swallows the error (committer.go:227, _, _ = ...). At minimum log on non-nil err — a partial-block compute failure that's swallowed leaves later trie state
    suspect.
  • asOfStateReader reused across blocks — one struct mutated via txNum bump and shared with sdCtx.SetStateReader. Single-goroutine use makes it safe today, but a fresh instance per
    compute has no extra cost and removes the foot-shaped artifact.
  • BlockStateCache per-block GC pressure at bulk batch sizes — with the channel buffered to 2k blocks, that's up to 2k caches in flight (small maps but non-zero). Worth a sync.Pool
    once bulk replay is exercised.
  • runForkchoicePrune 2s budget: the at-tip fix advertises adaptive growth in the PR description; reality is a fixed 2s here. Either port the formula from PruneExecutionStage or drop
    the claim.

Test coverage

Strong:

  • parallel_fixes_test.go (415 LoC) covers value tiebreaker, IBS reset, TouchUpdates, self-destruct emission, BlockStateCache flush
  • commitment_calculator_test.go (187), calculator_touches_test.go (281), hex_patricia_hashed_test.go (+95) cover Updates buffer / TouchPlainKeyDirect equivalence
  • system_call_storage_test.go, setcode_parallel_test.go, finalize_reader_blockcache_test.go, block_cache_multiblock_flush_test.go cover real-MDBX paths

Gaps the PR's own test plan acknowledges:

  • ❌ Bulk replay >2000 blocks/batch — no test coverage; calculator backpressure on commitResultsCh is not exercised
  • ❌ SIGINT shutdown clean-exit at every batch boundary — no test
  • ❌ -race pass on the parallel path — checkbox unchecked; with this much goroutine choreography it should be a hard prerequisite
  • ❌ triggerBatchCommitment site coverage — none of the three end-conditions (size / maxBlockNum / StopAfterBlock) have a unit test

The exec3_finalize_test.go dead file (B6) means the PR adds 887 LoC of fake coverage.

Risk assessment

Architecture risk: Low. Invariants are upheld and the close-order / drain pattern is sound.

Correctness risk: Medium-high. B1 (warmup never restored), B2 (debug spam), B3 (duplicate code), B4 (comparator/comment mismatch), B5 (skipped sibling-consistency regression test) are
all the kind of small correctness bugs that bulk replay would surface, and bulk replay isn't tested.

Operational risk: Medium. B7 (gate leak on panic) plus the round-2 unaddressed items (silent getErr swallow → cap-stuck regression that motivated the fix in the first place; os.Exit
bypassing deferred cleanup; magic 10) all weaken observability and recoverability of the very paths this PR is trying to fix.

Recommended merge gate

Merge-blockers (must fix in this PR):

  1. B1 — restore EnableTrieWarmup(true) defer
  2. B2 — remove consolidation-address Printf
  3. B3 — collapse duplicate Code() versionMap blocks
  4. B4 — reconcile keyUpdateLessFn ordering with comments and the design rationale
  5. Round-2 item 6 — log on getErr != nil in FCU + frozen-blocks SetLastFlushedCommitmentTxNum; this is exactly the failure mode that motivated the fix
  6. Run -race on parallel path; tick the test-plan box

Acceptable as follow-ups (file an issue, reference here):

  • B5 (sibling-consistency bug) — confirm it cannot manifest on real chain inputs; if confirmed, follow-up; otherwise blocker
  • B6 — revive or delete exec3_finalize_test.go
  • B7 — RLock leak fix
  • Round-2 items 2, 3, 4, 7, 10 — all behavior-equivalent today but invite future bugs
  • Bulk-replay coverage — its own PR

The architecture is the hard part and you got it right. The remaining work is mostly hygiene and the kind of "I'll clean it up before merge" items that need to actually happen before
merge.

…d-2 review feedback

- exec3_parallel: restore EnableTrieWarmup(true) defer; document intentional os.Exit
  on STOP_AFTER_BLOCK; narrow recover() in triggerBatchCommitment + safeClose to
  only swallow expected channel-shutdown panics; drop magic >10 batch threshold (one
  complete block already implied by being inside the blockResult != nil branch);
  add ok check on rootResults receive; named import for runtime/strings
- exec3: document intentional os.Exit on BAD_BLOCK_HALT; fix typos (comittement,
  lastCommitedStep)
- state/state_object: collapse duplicated CodePath versionMap probes in Code()
- state/rw_v3: drop debug Printf for consolidation contract storage writes
- state/history_reader_v3: make blockCache deletion semantics explicit (do not
  fall through to sd/ttx on cached deletion)
- commitment: align keyUpdateLessFn comment + Updates.tree field doc with the
  actual plainKey ordering and rationale
- execmodule/forkchoice + stagedsync/stageloop: log GetLatest/BeginTemporalRo
  failures in the SetLastFlushedCommitmentTxNum refresh path so the silent stale
  watermark mode that motivated the fix surfaces in logs
- db/state/aggregator: add defer commitGate.RUnlock() to readyForCollation so a
  panic inside db.View cannot leave the gate locked
- db/kv/mdbx: route MDBX_TRACE_TX events through the package logger at Trace
  level instead of fmt.Printf

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mh0lt
Copy link
Copy Markdown
Contributor Author

mh0lt commented Apr 28, 2026

Pushed b83a721 addressing the round-2 merge-blockers + Copilot comments:

B1 restore EnableTrieWarmup(true) defer in exec3_parallel.go:156
B2 drop debug fmt.Printf for the consolidation contract in BlockStateCache.WriteStorage
B3 collapse the duplicated CodePath versionMap probe in stateObject.Code()
B4 comments on keyUpdateLessFn and Updates.tree now explain the plainKey ordering choice (matches main; needed because TouchHashedKey entries have empty plainKey)
B7 defer commitGate.RUnlock() added to Aggregator.readyForCollation — panic in the inner db.View would have leaked the gate

Round-2 item 2 both os.Exit sites (BAD_BLOCK_HALT in exec3.go:272, STOP_AFTER_BLOCK in exec3_parallel.go:380) now lead with "Intentional os.Exit:" + an explicit explanation that returning would run deferred rollback/commit/flush and overwrite the very state we want to preserve. These are the only two sites in the PR scope.
Round-2 item 3 recover() in triggerBatchCommitment and safeClose no longer swallow arbitrary panics — they only recover the specific "send on closed channel" / "close of closed channel" runtime.Error and re-panic everything else.
Round-2 item 6 SetLastFlushedCommitmentTxNum refresh path now logs Warn on BeginTemporalRo failure and on GetLatest failure in both forkchoice.go and stageloop.go — the silent stale-watermark mode that motivated the fix is now visible.
Round-2 item 7 MDBX_TRACE_TX events in kv_mdbx.go (MDBX_TX_OPEN, MDBX_TX_*, CONCURRENT_TX) now go through db.log.Trace instead of fmt.Printf.
Round-2 item 10 dropped — the >10 magic threshold is gone entirely; the size-based commit trigger is inside the blockResult != nil branch so at least one complete (non-partial) block is already implied. Per @mh0lt the floor is unnecessary.

Copilot history_reader_v3.go:117/129 getAsOf deletion semantics are now explicit (early return on cached deletion, with a comment about why we don't fall through to sd/ttx); exec3_parallel.go:389 rootResults receive checks ok and exits cleanly on close; exec3.go typos fixed (comittementcommitment, lastCommitedSteplastCommittedStep).

make lint clean. make erigon builds. -race on ./execution/state ./execution/stagedsync running locally now; will report results.

@mh0lt
Copy link
Copy Markdown
Contributor Author

mh0lt commented Apr 28, 2026

-race results on the affected packages:

ok  	github.com/erigontech/erigon/execution/state	12.401s
ok  	github.com/erigontech/erigon/execution/stagedsync	8.686s

No data races detected. Test-plan checkbox can be ticked. CI on b83a721 is running (3 complete / 31 in-progress / 0 failed so far).

VersionedWrites built the post-selfdestruct write set in a single pass while
iterating sdb.versionedWrites — a Go map. When the SelfDestructPath entry
came late in the iteration, any storage delete writes seen earlier were
silently dropped (the loop reset the accumulator and only re-kept Balance/
Incarnation from prior entries). With map iteration order non-deterministic,
the same selfdestruct could emit 0, 1, or 2 storage delete writes from run
to run, breaking the parallel commitment calculator's per-slot DELETE
contract on selfdestruct.

Surfaced as a flaky TestSelfDestructRecordsStorageDeletes failure under
race-tests in CI on b83a721 (slot1 kept, slot0 dropped).

Fix: split into two passes — first detect selfdestruct, then filter. Storage
writes survive regardless of where SelfDestructPath landed in the map.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mh0lt
Copy link
Copy Markdown
Contributor Author

mh0lt commented Apr 28, 2026

Pushed 6689bf3 — fix for the race-test failure on TestSelfDestructRecordsStorageDeletes.

Root cause: IntraBlockState.VersionedWrites built the post-selfdestruct write set in a single pass while iterating sdb.versionedWrites (a Go map). When the SelfDestructPath entry came late in the iteration, the loop reset the accumulator and only re-kept Balance/Incarnation from previously-seen entries — silently dropping any storage delete writes that iterated before it. Map iteration order being non-deterministic, the same selfdestruct could emit 0, 1, or 2 storage delete writes from run to run.

Fix: two-pass — first detect selfdestruct presence, then filter. Storage writes survive regardless of map order.

Local repro: 5/5 stable after fix, with and without -race. The other failing race-test job (eest-devnet) was a runner shutdown — exit 143, not a real failure.

@mh0lt
Copy link
Copy Markdown
Contributor Author

mh0lt commented Apr 28, 2026

CI on 6689bf3 — race-fix confirmed:

  • TestSelfDestructRecordsStorageDeletes no longer flaking
  • race-tests / execution-eest-blockchain SUCCESS
  • race-tests / execution-tests SUCCESS
  • race-tests / consensus / core-rpc / other / load-matrix SUCCESS

Remaining failures are all environmental / pre-existing on the branch:

  • race-tests / execution-otherTestAssembleBlockAmsterdamForkTransition — pre-existing on this branch (reproduces on git stash of my changes, so not introduced by this PR)
  • race-tests / execution-eest-devnetexit 143 (runner shutdown / SIGTERM, no test failure logged)
  • kurtosis / assertoor_glamsterdam_test##[error]The action has timed out — runner timeout

tests-mac-linux and sonar still running.

@mh0lt
Copy link
Copy Markdown
Contributor Author

mh0lt commented Apr 28, 2026

Final CI on 6689bf3 — zero regressions:

Failed check Cause Pre-existing on branch?
tests-mac-linux (macos-15) TestAssembleBlockAmsterdamForkTransition ✅ yes (also fails on parent commits without my changes)
race-tests / execution-other same TestAssembleBlockAmsterdamForkTransition ✅ yes
race-tests / execution-eest-devnet runner SIGTERM (exit 143), no test failure logged infra
kurtosis / assertoor_glamsterdam_test ##[error]The action has timed out infra
sonar / sonar failed on 0c395ea571 (commit before my fixes) too ✅ yes

tests-mac-linux (ubuntu-24.04) and (windows-2025) were CANCELLED by matrix fail-fast on the macOS pre-existing failure. Everything that's a real correctness check on this PR — race-tests on the parallel state path, ci-gate body, gnosis/mainnet RPC integ, claude-review — is GREEN. The TestSelfDestruct race-flake from the prior commit is fixed and stable.

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