Skip to content

exec module: decouple startup, execution, prune, retire, and publish flows #19873

Description

@mh0lt

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:

  1. ExecModule semaphore(1) — serializes Start/Validate/FCU; background prune holds it
  2. snBuildAllowed semaphore — serializes block retirement vs state aggregation
  3. 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:

  1. 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.

  2. 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

  • Snapshot download could run independently of execution (doesn't need the exec semaphore)
  • Block retirement could be event-driven (trigger when execution advances N blocks) rather than polled per-cycle
  • Domain pruning could have its own scheduling, decoupled from forkchoice timing pressure
  • The ProcessFrozenBlocks "catch up" path and the updateForkChoice "big jump" path are essentially the same work with different error handling — unify them
  • Move remaining rawdb RW writes out of execution into SD/memorydb to break the RwTx dependency
  • Distinguish "busy doing initial sync" from "busy processing a forkchoice" in semaphore status

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions