Skip to content
65 changes: 55 additions & 10 deletions execution/builder/block_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,31 @@ import (
"github.com/erigontech/erigon/execution/types"
)

type BlockBuilderFunc func(param *Parameters, interrupt *atomic.Bool) (*types.BlockWithReceipts, error)
// buildStopGrace is how long a builder asked to stop is given to hand its block over. It is sized
// against how often the build loop looks at the interrupt flag, not against the slot: a build that
// is cooperating answers within one of those polls, and one that is not never will.
const buildStopGrace = 500 * time.Millisecond

// BlockBuilder wraps a goroutine that builds Proof-of-Stake payloads (PoS "mining")
// BlockBuilderFunc builds a payload. Its context ends when the payload is discarded, so anything
// that can block - opening a read view, waiting on a transaction provider - has to honour it.
type BlockBuilderFunc func(ctx context.Context, param *Parameters, interrupt *atomic.Bool) (*types.BlockWithReceipts, error)

// BlockBuilder wraps a goroutine that builds Proof-of-Stake payloads (PoS "mining").
//
// It answers to two different requests. Interrupting asks for the block it has so far, which is how
// a payload is collected. Discarding says the payload is not wanted at all, and cancels the work.
type BlockBuilder struct {
interrupt atomic.Bool
discard context.CancelFunc
mu sync.Mutex
done chan struct{}
result *types.BlockWithReceipts
err error
}

func NewBlockBuilder(build BlockBuilderFunc, param *Parameters, maxBuildTime time.Duration) *BlockBuilder {
builder := &BlockBuilder{done: make(chan struct{})}
func NewBlockBuilder(ctx context.Context, build BlockBuilderFunc, param *Parameters, maxBuildTime time.Duration) *BlockBuilder {
buildCtx, discard := context.WithCancel(ctx)
builder := &BlockBuilder{done: make(chan struct{}), discard: discard}

go func() {
var result *types.BlockWithReceipts
Expand All @@ -58,13 +70,18 @@ func NewBlockBuilder(build BlockBuilderFunc, param *Parameters, maxBuildTime tim
builder.err = err
builder.mu.Unlock()
close(builder.done)
discard()
}()

log.Info("Building block...")
t := time.Now()
result, err = build(param, &builder.interrupt)
result, err = build(buildCtx, param, &builder.interrupt)
if err != nil {
log.Warn("Failed to build a block", "err", err)
if buildCtx.Err() != nil {
log.Debug("Block builder discarded", "err", err)
} else {
log.Warn("Failed to build a block", "err", err)
}
} else {
block := result.Block
log.Info("Built block", "hash", block.Hash(), "height", block.NumberU64(), "txs", len(block.Transactions()), "executionRequests", len(result.Requests), "gasUsedPct", 100*float64(block.GasUsed())/float64(block.GasLimit()), "time", time.Since(t))
Expand All @@ -76,13 +93,19 @@ func NewBlockBuilder(build BlockBuilderFunc, param *Parameters, maxBuildTime tim
defer timer.Stop()
select {
case <-timer.C:
log.Warn("Stopping block builder due to max build time exceeded")
_, _ = builder.Stop(context.Background())
log.Debug("Stopped block builder due to max build time exceeded")
return
case <-builder.done:
return
}
// Ask for the block it has, which is what the budget was for, but do not wait on it
// indefinitely: a build parked somewhere that never reads the flag would hold its read view
// until the builder count forced it out, which on a quiet node is a very long time.
log.Warn("Stopping block builder due to max build time exceeded")
graceCtx, cancelGrace := context.WithTimeout(ctx, buildStopGrace)
defer cancelGrace()
if _, err := builder.Stop(graceCtx); err != nil {
builder.Discard()
}
log.Debug("Stopped block builder due to max build time exceeded")
}()

return builder
Expand All @@ -102,6 +125,28 @@ func (b *BlockBuilder) Stop(ctx context.Context) (*types.BlockWithReceipts, erro
return b.result, b.err
}

// Discard abandons the build and releases what it holds. A read view or a transaction provider
// blocked on the builder's context returns at once instead of waiting out its own deadline, which
// is the difference between an evicted builder freeing its resources now and freeing them a slot
// from now.
func (b *BlockBuilder) Discard() {
b.interrupt.Store(true)
b.discard()
}

// Failed reports whether the builder has finished and ended in an error, which a caller looking to
// reuse it has to read as absent because that error is latched.
func (b *BlockBuilder) Failed() bool {
select {
case <-b.done:
default:
return false
}
b.mu.Lock()
defer b.mu.Unlock()
return b.err != nil
}

func (b *BlockBuilder) Block() *types.Block {
b.mu.Lock()
defer b.mu.Unlock()
Expand Down
125 changes: 125 additions & 0 deletions execution/builder/block_builder_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Copyright 2026 The Erigon Authors
// This file is part of Erigon.
//
// Erigon is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Erigon is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with Erigon. If not, see <http://www.gnu.org/licenses/>.

package builder

import (
"context"
"errors"
"sync/atomic"
"testing"
"time"

"github.com/stretchr/testify/require"

"github.com/erigontech/erigon/execution/types"
)

func TestBlockBuilderRunningHasNotFailed(t *testing.T) {
t.Parallel()

release := make(chan struct{})
t.Cleanup(func() { close(release) })
b := NewBlockBuilder(t.Context(), func(_ context.Context, _ *Parameters, _ *atomic.Bool) (*types.BlockWithReceipts, error) {
<-release
return nil, errors.New("builder stopped")
}, &Parameters{}, time.Minute)

require.Never(t, b.Failed, 50*time.Millisecond, 5*time.Millisecond)
}

func TestBlockBuilderStoppedForItsPayloadHasNotFailed(t *testing.T) {
t.Parallel()

b := NewBlockBuilder(t.Context(), func(_ context.Context, _ *Parameters, interrupt *atomic.Bool) (*types.BlockWithReceipts, error) {
for !interrupt.Load() {
time.Sleep(time.Millisecond)
}
return &types.BlockWithReceipts{Block: types.NewBlock(&types.Header{}, nil, nil, nil, nil)}, nil
}, &Parameters{}, time.Minute)

_, err := b.Stop(t.Context())
require.NoError(t, err)

// Collecting the payload is what a proposal does. Reading that as failure would make a repeated
// request rebuild from scratch instead of being handed the block that was just built.
require.False(t, b.Failed())
}

func TestBlockBuilderHasFailedOnceItErrors(t *testing.T) {
t.Parallel()

b := NewBlockBuilder(t.Context(), func(_ context.Context, _ *Parameters, _ *atomic.Bool) (*types.BlockWithReceipts, error) {
return nil, errors.New("build failed")
}, &Parameters{}, time.Minute)

require.Eventually(t, b.Failed, time.Second, time.Millisecond)
}

func TestBlockBuilderStaysReusableOnceItFillsTheBlock(t *testing.T) {
t.Parallel()

built := make(chan struct{})
b := NewBlockBuilder(t.Context(), func(_ context.Context, _ *Parameters, _ *atomic.Bool) (*types.BlockWithReceipts, error) {
defer close(built)
return &types.BlockWithReceipts{Block: types.NewBlock(&types.Header{}, nil, nil, nil, nil)}, nil
}, &Parameters{}, time.Minute)

<-built
// A builder that ran out of room holds a complete payload, so its id is still worth reusing.
require.Never(t, b.Failed, 50*time.Millisecond, 5*time.Millisecond)
}

func TestBlockBuilderReleasesABuildThatIgnoresTheDeadline(t *testing.T) {
t.Parallel()

// A build parked in something that never reads the interrupt flag - a transaction provider
// waiting on a block, say - would hold its read view until the builder count forced it out.
released := make(chan error, 1)
b := NewBlockBuilder(t.Context(), func(ctx context.Context, _ *Parameters, _ *atomic.Bool) (*types.BlockWithReceipts, error) {
select {
case <-ctx.Done():
released <- ctx.Err()
case <-time.After(time.Minute):
released <- errors.New("build was never released")
}
return nil, errors.New("builder stopped")
}, &Parameters{}, time.Millisecond)

select {
case err := <-released:
require.ErrorIs(t, err, context.Canceled)
case <-time.After(10 * time.Second):
t.Fatal("build outlived its budget without being released")
}
require.Eventually(t, b.Failed, 5*time.Second, time.Millisecond)
}

func TestBlockBuilderStillHandsOverAPayloadWhenItsBudgetRunsOut(t *testing.T) {
t.Parallel()

// Reaching the budget asks for the block it has, which is what the budget is for. Only a build
// that will not answer is discarded.
b := NewBlockBuilder(t.Context(), func(_ context.Context, _ *Parameters, interrupt *atomic.Bool) (*types.BlockWithReceipts, error) {
for !interrupt.Load() {
time.Sleep(time.Millisecond)
}
return &types.BlockWithReceipts{Block: types.NewBlock(&types.Header{}, nil, nil, nil, nil)}, nil
}, &Parameters{}, time.Millisecond)

require.Eventually(t, func() bool { return b.Block() != nil }, 5*time.Second, time.Millisecond)
require.False(t, b.Failed())
}
16 changes: 8 additions & 8 deletions execution/builder/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ type SDProvider func() *execctx.SharedDomains
// without staged-sync machinery. Its Build method satisfies BlockBuilderFunc and can
// be passed directly to ExecModule.
type Builder struct {
ctx context.Context
db kv.TemporalRoDB
pendingBlockCh chan *types.Block
builderCfg *buildercfg.BuilderConfig
Expand All @@ -64,7 +63,6 @@ type Builder struct {
}

func NewBuilder(
ctx context.Context,
db kv.TemporalRoDB,
builderCfg *buildercfg.BuilderConfig,
chainConfig *chain.Config,
Expand All @@ -81,7 +79,6 @@ func NewBuilder(
logger log.Logger,
) *Builder {
return &Builder{
ctx: ctx,
db: db,
pendingBlockCh: make(chan *types.Block, 1),
builderCfg: builderCfg,
Expand All @@ -107,7 +104,10 @@ func (b *Builder) PendingBlockCh() chan *types.Block {
}

// Build satisfies BlockBuilderFunc. Pass b.Build directly to ExecModule.
func (b *Builder) Build(param *Parameters, interrupt *atomic.Bool) (result *types.BlockWithReceipts, err error) {
//
// Everything that can block runs under ctx, so discarding the payload releases the read view and
// unblocks the transaction provider instead of leaving them to finish on their own.
func (b *Builder) Build(ctx context.Context, param *Parameters, interrupt *atomic.Bool) (result *types.BlockWithReceipts, err error) {
defer func() {
if rec := recover(); rec != nil {
err = fmt.Errorf("%+v, trace: %s", rec, dbg.Stack())
Expand All @@ -124,7 +124,7 @@ func (b *Builder) Build(param *Parameters, interrupt *atomic.Bool) (result *type
BuiltBlock: &exec.AssembledBlock{},
}

tx, err := b.db.BeginTemporalRo(b.ctx)
tx, err := b.db.BeginTemporalRo(ctx)
if err != nil {
return nil, err
}
Expand All @@ -145,7 +145,7 @@ func (b *Builder) Build(param *Parameters, interrupt *atomic.Bool) (result *type
}
}

sd, err := execctx.NewSharedDomains(b.ctx, compositeTx, b.logger, execctx.WithoutDeferredBranchUpdates(), execctx.WithoutSharedBranchCache())
sd, err := execctx.NewSharedDomains(ctx, compositeTx, b.logger, execctx.WithoutDeferredBranchUpdates(), execctx.WithoutSharedBranchCache())
if err != nil {
return nil, err
}
Expand All @@ -172,10 +172,10 @@ func (b *Builder) Build(param *Parameters, interrupt *atomic.Bool) (result *type
execCfg := StageBuilderExecCfg(state, b.notifier, b.chainConfig, b.engine, b.vmConfig, b.tmpdir, interrupt, param.PayloadId, txnProvider, b.blockReader)
finishCfg := StageBuilderFinishCfg(b.chainConfig, b.engine, state, b.sealCancel, b.blockReader, b.latestBlockBuiltStore)

if err := createBlock(b.ctx, sd, compositeTx, executionAt, createCfg, b.logger); err != nil {
if err := createBlock(ctx, sd, compositeTx, executionAt, createCfg, b.logger); err != nil {
return nil, err
}
if err := execBlock(b.ctx, sd, compositeTx, executionAt, execCfg, b.executeBlockCfg, b.logger); err != nil {
if err := execBlock(ctx, sd, compositeTx, executionAt, execCfg, b.executeBlockCfg, b.logger); err != nil {
return nil, err
}
if err := finishBlock(compositeTx, finishCfg, b.logger); err != nil {
Expand Down
3 changes: 1 addition & 2 deletions execution/builder/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,13 @@ func TestBuilder_Build_DBError(t *testing.T) {

want := errors.New("db open failed")
b := &Builder{
ctx: context.Background(),
db: &errDB{err: want},
builderCfg: &buildercfg.BuilderConfig{},
pendingBlockCh: make(chan *types.Block, 1),
logger: log.New(),
}

_, err := b.Build(&Parameters{}, &atomic.Bool{})
_, err := b.Build(t.Context(), &Parameters{}, &atomic.Bool{})
require.ErrorIs(t, err, want)
}

Expand Down
35 changes: 35 additions & 0 deletions execution/builder/parameters.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
package builder

import (
"bytes"

"github.com/erigontech/erigon/common"
"github.com/erigontech/erigon/execution/types"
"github.com/erigontech/erigon/txnprovider"
Expand All @@ -40,3 +42,36 @@ type Parameters struct {
// ExtraData overrides the builder's configured extra data when non-nil.
ExtraData []byte
}

// Copy returns parameters that no longer share anything mutable with the receiver, so a caller
// cannot change what a builder was asked for after the fact. Reference-typed fields added to
// Parameters have to be handled here; TestParametersCopyCoversEveryField fails if one is not.
func (p *Parameters) Copy() *Parameters {
if p == nil {
return nil
}
copied := *p
copied.ExtraData = bytes.Clone(p.ExtraData)
if p.Withdrawals != nil {
copied.Withdrawals = make([]*types.Withdrawal, len(p.Withdrawals))
for i, withdrawal := range p.Withdrawals {
if withdrawal != nil {
w := *withdrawal
copied.Withdrawals[i] = &w
}
}
}
if p.ParentBeaconBlockRoot != nil {
root := *p.ParentBeaconBlockRoot
copied.ParentBeaconBlockRoot = &root
}
if p.SlotNumber != nil {
slot := *p.SlotNumber
copied.SlotNumber = &slot
}
if p.TargetGasLimit != nil {
limit := *p.TargetGasLimit
copied.TargetGasLimit = &limit
}
return &copied
}
Loading
Loading