Problem
The exec module's flows (ProcessFrozenBlocks, ValidateChain, UpdateForkChoice, prune, retire, publish) are heavily intertwined because they share a single RW transaction — an MDBX limitation where only one goroutine can hold an RwTx at a time.
This creates a serialization chain:
execute → flush → commit → prune → retire → publish
all serialized because they share one RwTx
Three resources compete:
- ExecModule semaphore(1) — serializes Start/Validate/FCU; background prune holds it
- snBuildAllowed semaphore — serializes block retirement vs state aggregation
- MDBX write lock — only one RwTx at a time
Current Flow Issues
Startup (ProcessFrozenBlocks)
ExecModule.Start() holds the semaphore for the entire duration of ProcessFrozenBlocks (potentially hours during initial sync)
- During this time,
Ready() returns false, ValidateChain()/UpdateForkChoice() return Busy
- Snapshot download runs inside
ProcessFrozenBlocks — downloading blocks the entire exec module
- No error recovery between startup and steady state: if
ProcessFrozenBlocks fails partway, the exec module releases the semaphore in an inconsistent state
Steady State (forkchoice/newpayload)
ValidateChain does NOT persist state — validates in a throwaway tx, stores in forkValidator.sharedDom
UpdateForkChoice merges extending fork state, then runs execution pipeline
- Two separate stage pipelines:
ProcessFrozenBlocks uses full pipeline, ValidateChain uses in-memory pipeline with different badBlockHalt settings
- No distinction between "catching up" (big jump) and "synced" — CL-driven catch-up takes a different code path than startup catch-up
Background Prune/Merge
- Triggered per-forkchoice via
runPostForkchoice()
- Semaphore is transferred to background goroutine — while pruning, all CL requests return
Busy
- Domain pruning bounded by slot time / 6
Block Retirement
- Triggered per-cycle in
SnapshotsPrune stage
- Non-blocking (background goroutine), but
snBuildAllowed semaphore serializes it with state aggregation
FrozenBlocks boundary advances when indices become visible (eventually consistent)
Snapshot Publishing
- Only happens after merge (not initial dump)
- Publishing via seeder → BitTorrent after merge step completes
Architectural Direction
The driver for the interconnection is the single-writer RwTx constraint. This is changing:
-
Execution no longer needs RwTx — it writes to memory (SharedDomains). Pending: a final refactor to move remaining rawdb RW interactions (WriteCanonicalHash, WriteHeader, SaveStageProgress, etc.) into SD writes + an additional memorydb overlay.
-
Async transactions allow cooperative sharing.
Once (1) is complete, flush/commit is the only point where execution needs a tx, and this can happen asynchronously:
BEFORE (current):
execute ──RwTx──► flush ──RwTx──► commit ──RwTx──► prune ──RwTx──► retire
[all serialized on one goroutine]
AFTER (target):
execute (memory only, no RwTx) ← can run continuously
│
▼ async handoff
flush/commit (brief RwTx window) ← only serialization point
│
▼ independent
prune (own RwTx, own schedule) ← no longer blocks execution
retire (own RwTx, own schedule) ← no longer blocks prune
publish (no RwTx needed) ← fully independent
Separation Opportunities
Related PRs
Entry Points Reference
| Chain type |
Code path |
File |
| PoS (mainnet) |
ExecModule.Start() → ProcessFrozenBlocks |
backend.go:1496 |
| Bor (polygon) |
ExecModule.Start() → ProcessFrozenBlocks |
backend.go:1502 |
| PoW (legacy) |
StageLoop() → ProcessFrozenBlocks + iteration loop |
backend.go:1523 |
Problem
The exec module's flows (ProcessFrozenBlocks, ValidateChain, UpdateForkChoice, prune, retire, publish) are heavily intertwined because they share a single RW transaction — an MDBX limitation where only one goroutine can hold an RwTx at a time.
This creates a serialization chain:
Three resources compete:
Current Flow Issues
Startup (ProcessFrozenBlocks)
ExecModule.Start()holds the semaphore for the entire duration ofProcessFrozenBlocks(potentially hours during initial sync)Ready()returns false,ValidateChain()/UpdateForkChoice()returnBusyProcessFrozenBlocks— downloading blocks the entire exec moduleProcessFrozenBlocksfails partway, the exec module releases the semaphore in an inconsistent stateSteady State (forkchoice/newpayload)
ValidateChaindoes NOT persist state — validates in a throwaway tx, stores inforkValidator.sharedDomUpdateForkChoicemerges extending fork state, then runs execution pipelineProcessFrozenBlocksuses full pipeline,ValidateChainuses in-memory pipeline with differentbadBlockHaltsettingsBackground Prune/Merge
runPostForkchoice()BusyBlock Retirement
SnapshotsPrunestagesnBuildAllowedsemaphore serializes it with state aggregationFrozenBlocksboundary advances when indices become visible (eventually consistent)Snapshot Publishing
Architectural Direction
The driver for the interconnection is the single-writer RwTx constraint. This is changing:
Execution no longer needs RwTx — it writes to memory (SharedDomains). Pending: a final refactor to move remaining
rawdbRW interactions (WriteCanonicalHash,WriteHeader,SaveStageProgress, etc.) into SD writes + an additional memorydb overlay.Async transactions allow cooperative sharing.
Once (1) is complete, flush/commit is the only point where execution needs a tx, and this can happen asynchronously:
Separation Opportunities
ProcessFrozenBlocks"catch up" path and theupdateForkChoice"big jump" path are essentially the same work with different error handling — unify themRelated PRs
Entry Points Reference
ExecModule.Start()→ProcessFrozenBlocksbackend.go:1496ExecModule.Start()→ProcessFrozenBlocksbackend.go:1502StageLoop()→ProcessFrozenBlocks+ iteration loopbackend.go:1523