Skip to content
9 changes: 9 additions & 0 deletions execution/commitment/preload_parallel.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,11 @@ func (p *ContractTrunkPreloadParallel) Run(

// Cap the file fetch by what the budget can absorb after dbHits.
var fileMissDeferred []pathKey
noFileBudget := false
if fileBudget := stepCap - p.usedBytes - dbHitsBytes; fileBudget <= 0 {
fileMissDeferred = fileMiss
fileMiss = nil
noFileBudget = true
} else if maxFileFetch := fileBudget/minEntryBytes + 1; maxFileFetch < len(fileMiss) {
fileMissDeferred = fileMiss[maxFileFetch:]
fileMiss = fileMiss[:maxFileFetch]
Expand Down Expand Up @@ -261,6 +263,13 @@ func (p *ContractTrunkPreloadParallel) Run(
}
}

// Deferring the whole miss set is not progress: depth and frontier stay
// put, and pin() only trips budgetHit on a strict overflow, so a wave
// that fills usedBytes to exactly stepCap would re-enter here forever.
if noFileBudget && len(fileMissDeferred) > 0 {
budgetHit = true
}

if budgetHit {
// Preserve un-pinned items at current depth; pendingChildren stays
// at depth+1 for when this depth is drained on a future Run.
Expand Down
162 changes: 162 additions & 0 deletions execution/commitment/preload_parallel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"errors"
"slices"
"testing"
"time"

"github.com/erigontech/erigon/execution/commitment/nibbles"
)
Expand Down Expand Up @@ -785,3 +786,164 @@ func TestContractTrunkPreloadParallel_BadHashLengthError(t *testing.T) {
t.Fatal("expected error for 33-byte hash")
}
}

// TestContractTrunkPreloadParallel_ExactBudgetFillTerminates covers a wave whose
// pins land usedBytes exactly on stepCap, followed by a frontier that misses
// dbBranches entirely. That leaves no budget for a single file entry, so the
// whole miss set is deferred and nothing is pinned — depth, frontier and
// usedBytes all stay put and the wave must not be re-entered.
func TestContractTrunkPreloadParallel_ExactBudgetFillTerminates(t *testing.T) {
hash, tree, _ := buildSyntheticTree(t)
root := ""
for p := range tree {
if root == "" || len(p) < len(root) {
root = p
}
}
const valSz = 100
resolve := fakeResolver(tree, nil, valSz, "")

rootKey := bytes.Clone(nibbles.HexToCompact([]byte(root)))
rootVal := branchVal(tree[root], valSz)
// Shadow only the root, so every wave below depth 64 is a pure file miss.
dbBranches := map[string][]byte{string(rootKey): rootVal}

c := NewBranchCache(64)
p, err := NewContractTrunkPreloadParallel(hash)
if err != nil {
t.Fatal(err)
}

// Budget the root pin consumes exactly, landing usedBytes on stepCap.
stepBudget := estimatedEntryCost(rootKey, rootVal)

type runResult struct {
pinned int
queueEmpty bool
err error
}
res := make(chan runResult, 1)
go func() {
n, done, err := p.Run(stepBudget, dbBranches, resolve, c, nil)
res <- runResult{n, done, err}
}()

var got runResult
select {
case got = <-res:
case <-time.After(10 * time.Second):
t.Fatal("Run did not terminate: a wave with no file budget defers the whole frontier without pinning, so the loop re-enters on identical state")
}
Comment thread
lystopad marked this conversation as resolved.
if got.err != nil {
t.Fatal(got.err)
}
if got.queueEmpty {
t.Fatal("queue reported empty, but the root's children were never pinned")
}
if got.pinned != 1 {
t.Fatalf("pinned %d entries, want 1 (the root)", got.pinned)
}

// The deferred frontier must survive so a later, larger step finishes the tree.
if _, done, err := p.Run(1<<20, nil, resolve, c, nil); err != nil {
t.Fatal(err)
} else if !done {
t.Fatalf("expected done after a large budget; queue=%d", p.QueueRemaining())
}
if p.PinnedTotal() != len(tree) {
t.Fatalf("pinned %d entries, want the whole tree (%d)", p.PinnedTotal(), len(tree))
}
}

// TestContractTrunkPreloadParallel_StepBudgetSweepTerminates drives the wave-BFS
// to completion across step budgets straddling the exact cost of one entry, the
// values that leave a wave with zero file budget. No Run may spin, and any budget
// that can afford the root must finish the tree.
Comment thread
lystopad marked this conversation as resolved.
Outdated
func TestContractTrunkPreloadParallel_StepBudgetSweepTerminates(t *testing.T) {
hash, tree, _ := buildSyntheticTree(t)
root := ""
for p := range tree {
if root == "" || len(p) < len(root) {
root = p
}
}
const valSz = 100
resolve := fakeResolver(tree, nil, valSz, "")
rootKey := bytes.Clone(nibbles.HexToCompact([]byte(root)))
rootVal := branchVal(tree[root], valSz)
dbBranches := map[string][]byte{string(rootKey): rootVal}
exact := estimatedEntryCost(rootKey, rootVal)

// Entry cost grows with path depth, so only a budget comfortably above one
// entry is guaranteed to keep making progress; smaller ones must still return.
const maxSteps = 200
affordable := 4 * exact
budgets := []int{1, minEntryBytes, exact - 1, exact, exact + 1, affordable, 1 << 20}
Comment thread
lystopad marked this conversation as resolved.
Outdated

type sweepResult struct {
budget int
steps int
done bool
pinned int
err error
}
results := make(chan []sweepResult, 1)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

All 7 budgets in this sweep run inside one goroutine behind one generic panicOnStuck message. If a regression livelocks only one specific budget (e.g. exact+1), the panic text won't say which — unlike NoPinWaveEndsStep's t.Run(tc.name, ...) plus isolated timeout, which would name the failing case directly.

go func() {
out := make([]sweepResult, 0, len(budgets))
for _, budget := range budgets {
c := NewBranchCache(64)
p, err := NewContractTrunkPreloadParallel(hash)
if err != nil {
out = append(out, sweepResult{budget: budget, err: err})
continue
}
r := sweepResult{budget: budget}
for r.steps = 1; r.steps <= maxSteps; r.steps++ {
_, done, err := p.Run(budget, dbBranches, resolve, c, nil)
if err != nil {
r.err = err
break
}
if done {
r.done = true
break
}
}
r.pinned = p.PinnedTotal()
out = append(out, r)
}
results <- out
}()

var out []sweepResult
select {
case out = <-results:
case <-time.After(30 * time.Second):
t.Fatal("a Run did not terminate: a wave with no file budget must end the step, not re-enter on an unchanged frontier")
}

for _, r := range out {
if r.err != nil {
t.Errorf("budget %d: %v", r.budget, r.err)
continue
}
// A budget below one entry's cost can never pin the root; it must still
// return from every Run, but it cannot make progress.
if r.budget < exact {
if r.pinned != 0 {
t.Errorf("budget %d: pinned %d entries on a sub-entry budget", r.budget, r.pinned)
}
continue
}
if r.budget < affordable {
continue
}
if !r.done {
t.Errorf("budget %d: not complete after %d steps (pinned %d/%d)", r.budget, maxSteps, r.pinned, len(tree))
continue
}
if r.pinned != len(tree) {
t.Errorf("budget %d: pinned %d entries, want the whole tree (%d)", r.budget, r.pinned, len(tree))
}
}
}
Loading