cl/phase1, execution: give the execution module a typed busy signal and the caller's context - #23273
Conversation
There was a problem hiding this comment.
Pull request overview
This PR improves Caplin↔Execution-module interaction for local (in-process) execution by (1) introducing a typed “busy” signal for contention and (2) propagating the caller’s context.Context into AssembleBlock / GetAssembledBlock, so retries stop on real rejections and respect cancellation/deadlines.
Changes:
- Introduce
chainreader.ErrExecutionBusyand return it fromAssembleBlock/GetAssembledBlockwhen the exec module reportsBusy. - Replace the fixed sleep/retry loop with a context-aware
retryAssembleBlockhelper that retries only onErrExecutionBusy. - Add direct unit tests for the retry helper (success, rejection, exhaustion, cancellation, and zero-attempt guard).
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
execution/execmodule/exec_module_test.go |
Updates test call site to pass ctx into GetAssembledBlock. |
execution/execmodule/chainreader/chain_reader.go |
Adds ErrExecutionBusy; threads caller context into exec-module calls; returns typed busy error instead of an untyped “syncing” message. |
cl/phase1/execution_client/execution_client_engine.go |
Passes caller ctx through to the local chain reader on GetAssembledBlock. |
cl/phase1/execution_client/execution_client_direct.go |
Replaces retry loop with retryAssembleBlock that respects context and retries only on ErrExecutionBusy. |
cl/phase1/execution_client/execution_client_direct_test.go |
Adds unit tests covering the retry helper’s behavior (success, rejection, busy exhaustion, cancellation). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
daaea85 to
e46aa05
Compare
…nd the caller's context Split out of #23105 so it can be reviewed on its own. AssembleBlock and GetAssembledBlock reported contention as a bare error reading "execution data is still syncing", which is neither what happened nor distinguishable from a rejection. Both now return ErrExecutionBusy, and the assemble retry only waits on that: a rejection such as mismatched withdrawals used to be retried thirty times over six seconds before surfacing, which spends the slot instead of reporting it. The retry also ran with no regard for the caller, sleeping through a cancelled context for the full six seconds. It is now a helper that checks the context before every attempt and waits on it rather than on a bare sleep, and the chain reader takes the caller's context instead of substituting context.Background().
e46aa05 to
697974c
Compare
domiwei
left a comment
There was a problem hiding this comment.
The implementation looks correct. I found two non-blocking production-path coverage gaps that would make these lifecycle guarantees more regression-resistant.
| } | ||
| time.Sleep(200 * time.Millisecond) | ||
| } | ||
| id, err := retryAssembleBlock(ctx, 30, 200*time.Millisecond, func(ctx context.Context) (uint64, error) { |
There was a problem hiding this comment.
Could we add a public-path test that drives an ExecModule Busy result through ChainReaderWriterEth1 into ForkChoiceUpdate, then verifies Busy -> success retries and Busy -> permanent error stops? The current helper tests inject ErrExecutionBusy directly, so they would still pass if the Busy-to-sentinel mapping or this wiring regressed. Non-blocking, but this is the production sequence the change is protecting.
| func (cc *ExecutionClientEngine) GetAssembledBlock(ctx context.Context, id []byte, version clparams.StateVersion) (*cltypes.Eth1Block, *engine_types.BlobsBundle, *typesproto.RequestsBundle, *big.Int, error) { | ||
| if cc.isLocal() { | ||
| return cc.chainRW.GetAssembledBlock(binary.LittleEndian.Uint64(id)) | ||
| return cc.chainRW.GetAssembledBlock(ctx, binary.LittleEndian.Uint64(id)) |
There was a problem hiding this comment.
Could we cover this local-engine path, and the direct-client equivalent, with a canceled-context test that reaches the blocking builder fixture? The existing cancellation test stops at ExecModule.GetAssembledBlock, so replacing either forwarded context with context.Background() would not be caught. It would also be useful to assert that a healthy subsequent retrieval can still progress. Non-blocking coverage suggestion.
|
Reviewed — no blocking correctness bug in the new retry logic (attempt counting,
|
Follow-up to #23273, which made this reachable: BlockBuilder.Stop selected on the caller's context and on the finished payload at once, so when both were ready Go chose between them at random and about half the time returned a cancellation while holding a complete block. The caller had passed context.Background() before, so the race could not fire; now a validator client that times out can lose a proposal that was ready. The finished payload wins. A caller that gave up is also not a build failure, and was being reported as one. It now returns without an error-level record. The busy sentinel moves next to the Busy field it reports, so set_head.go's identically worded error shares its identity instead of only its wording. The hand-rolled cancellable sleep becomes common.Sleep, and the contention that caused a wait is kept in the error rather than replaced by the bare context error.
Follow-up to #23273, which made this reachable: BlockBuilder.Stop selected on the caller's context and on the finished payload at once, so when both were ready Go chose between them at random and about half the time returned a cancellation while holding a complete block. The caller had passed context.Background() before, so the race could not fire; now a validator client that times out can lose a proposal that was ready. The finished payload wins. A caller that gave up is also not a build failure, and was being reported as one. It now returns without an error-level record. The busy sentinel moves next to the Busy field it reports, so set_head.go's identically worded error shares its identity instead of only its wording. The hand-rolled cancellable sleep becomes common.Sleep, and the contention that caused a wait is kept in the error rather than replaced by the bare context error.
Split out of #23105, which grew too large to review in one piece. Independent of the other parts of that series.
Contention was indistinguishable from rejection
AssembleBlockandGetAssembledBlockreported a busy execution module aserrors.New("execution data is still syncing"). That message is inaccurate — it is weight-one semaphore contention with a forkchoice update or another payload request, not syncing — and being an untyped error, callers could not tell it apart from a real rejection.Both now return
chainreader.ErrExecutionBusy, and the assemble retry waits only on that. Previously a permanent rejection — mismatched withdrawals, for instance — was retried thirty times across six seconds before surfacing, which spends the proposal slot rather than reporting the problem.The retry ignored its caller
The loop was
for range 30 { ...; time.Sleep(200 * time.Millisecond) }, with no context check anywhere. A cancelled caller still waited the full six seconds. It is now an extracted helper that checks the context before each attempt and waits on it rather than on a bare sleep.While there,
ChainReaderWriterEth1.AssembleBlockandGetAssembledBlocktake the caller's context instead of substitutingcontext.Background(), so the deadline a caller sets actually reaches the execution module.Tests
The helper is covered directly: first success, stopping on a rejection, exhausting attempts on contention, cancellation mid-flight and before the first attempt, and the zero-attempts guard.
Part of a series splitting #23105.