Skip to content

execution/commitment: fix wave-BFS livelock when a step budget fills exactly - #23066

Merged
lystopad merged 7 commits into
mainfrom
feature/lystopad/preload-parallel-livelock
Aug 10, 2026
Merged

execution/commitment: fix wave-BFS livelock when a step budget fills exactly#23066
lystopad merged 7 commits into
mainfrom
feature/lystopad/preload-parallel-livelock

Conversation

@lystopad

@lystopad lystopad commented Aug 6, 2026

Copy link
Copy Markdown
Member

Problem

ContractTrunkPreloadParallel.Run can spin forever in its wave loop, wedging the node.

The loop only breaks on budgetHit, which pin() raises on a strict overflow. A wave that pins nothing never raises it, and the frontier is reassigned at the same depth — identical state on re-entry. Two ways a wave pins nothing:

  • Whole miss set deferred. fileBudget on re-entry is stepCap - (usedBytes + dbHitsBytes), bit-identical to the value that caused the deferral, because the db-hits consume exactly the bytes the deferral already accounted for. Infinite.
  • Capped fetch resolves to nothing. Every fetched key is absent from the file layer — the normal shape at the BFS fringe, where a set afterMap bit names a leaf with no branch record. Re-enters once per chunk: bounded, but quadratic.

Impact

Hit in production on a Gnosis validator (release/3.6, 4402a1d):

sortAndPartitionFrontier  preload_parallel.go:121
Run                       preload_parallel.go:216
runExtensionLocked        adaptive_pin.go:354
OnBlockComplete           adaptive_pin.go:206
SharedDomains.Commit      domain_shared.go:1030
updateForkChoice          forkchoice.go:742

The stuck goroutine holds the execution semaphore inside an FCU, so NewPayload blocks while holding the fork-choice write mutex. The CL slot ticker stops and ~420 getAttesterDuties handlers pile up on the read lock. The node stopped attesting and producing for 17h while burning a core; eth_syncing still reported false.

Two dumps minutes apart showed the same goroutine id, receiver and dbBranches pointers — livelock, not a slow walk. 68,732 extension runs over 5.9d uptime before it hit.

Fix

End the step when a wave defers part of its miss set and pinned nothing. Each iteration now either breaks, strictly shrinks the frontier, or increments nextDepth. Deferred work resumes on the next Run, so there is no throughput cost.

Also:

  • Floor the file fetch at minEntryBytes — below one entry's cost the batch is pure waste.
  • Add the missing cache == nil check to PreloadContractTrunkParallel: Run returns the error, then the wrapper's logger block dereferences cache.PinnedCount(), so callers got a panic instead.
  • Fold two disagreeing queueEmpty expressions into one method.

Testing

Differential run of the fixed Run against the pre-guard one over 40k configs / 2.3M steps: identical pins, queueEmpty and usedBytes at every step. The pre-guard code livelocked in 18,306 of those configs. Thanks @awskii.

  • ExactBudgetFillTerminates — the production state; hangs before the fix.
  • NoPinWaveEndsStep — both no-pin paths as two budgets one byte apart, asserting the exact resolver call count. Each case fails against exactly one of the two production changes reverted, so neither is left unpinned by the other.
  • DeferWithDbHitsInSameWave — deferral boundary with db-hits pinned in the same wave.
  • StepBudgetSweepTerminates — budgets straddling one entry's cost.

Hang guards abort the binary rather than t.Fatal, since Run has no cancellation and a spinning goroutine would outlive the failure. execution/commitment green under -race -count=2; make lint clean.

Scope

preload_parallel.go is the only site with this shape: the serial ContractTrunkPreload.Run pops its queue head unconditionally, OnBlockComplete ranges a bounded set, and execStatusList.drainDeferred already has a progress net. release/3.6 needs a backport; release/3.5 does not have the feature.

Notes (not fixed here)

  • adaptive_pin.go: c.misses entries are never removed, so one sync.Map entry accumulates per contract hash ever touched and OnBlockComplete ranges the whole set every block. Found by @awskii.
  • commitment_trunk_preload_{bytes,duration_seconds}_total are declared but never written — no metric signal for preload work while this was wedged. Fixed in execution/commitment: record the trunk-preload duration and bytes counters #23067.
  • When PerContractMaxBudgetBytes - usedBytes drops below one entry's cost, runExtensionLocked runs every block and pins nothing. Bounded per-block work, not a hang.

…exactly

ContractTrunkPreloadParallel.Run could spin forever inside its wave loop. Once
fileBudget (stepCap - usedBytes - dbHitsBytes) reached zero, the whole file-miss
set was deferred and fileMiss set to nil, so no pin ran for that wave. budgetHit
is only raised from inside pin() on a strict overflow, so it stayed false, the
loop did not break, and p.frontier = slices.Clone(fileMissDeferred) reassigned
the same frontier at the same depth with usedBytes unchanged — every loop
variable identical on re-entry.

usedBytes lands exactly on stepCap because pin() admits an entry when
usedBytes+cost == stepCap. AdaptivePinController.OnBlockComplete makes that
reachable: as a contract approaches PerContractMaxBudgetBytes, step collapses
onto the remaining bytes and stepCap onto that fixed cap. When a wave then fills
the cap exactly and the following frontier misses dbBranches entirely, Run never
returns.

Observed on a Gnosis validator (release/3.6, 4402a1d): an FCU wedged in
SharedDomains.Commit held the execution semaphore, so NewPayload blocked while
holding the fork-choice write mutex. The CL slot ticker and ~420
getAttesterDuties readers piled up behind it, and the node stopped attesting and
producing for 17 hours while burning a core in sortAndPartitionFrontier.

Treat a wave with no room for a single file entry as a budget hit. Each
iteration now either breaks, strictly shrinks the frontier, or increments
nextDepth, so the loop always terminates; the deferred set stays in p.frontier
and resumes on the next Run.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes a livelock in execution/commitment where ContractTrunkPreloadParallel.Run can spin forever when a wave exactly fills stepCap and the next wave has only file misses with zero remaining file budget. This prevents FCU/commit paths from wedging the node under certain adaptive pin budgeting conditions.

Changes:

  • Treat “no room for even one file entry” as a budget hit, forcing the wave loop to terminate and defer work to the next Run.
  • Add regression tests covering the exact-budget-fill livelock scenario and a step-budget sweep to guard termination across edge budgets.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
execution/commitment/preload_parallel.go Adds a noFileBudget guard to convert full deferral at zero file budget into a budgetHit, guaranteeing wave-loop termination.
execution/commitment/preload_parallel_test.go Adds two regression tests to reproduce the hang scenario and validate termination across multiple step budgets.
Suppressed comments (1)

execution/commitment/preload_parallel_test.go:923

  • Same issue as above: on timeout the test fails but the goroutine running the sweep can remain stuck inside Run and continue consuming CPU. Using panic here avoids leaving a runaway goroutine alive after the failure is signaled.
	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")
	}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread execution/commitment/preload_parallel_test.go
Comment thread execution/commitment/preload_parallel_test.go Outdated
Comment thread execution/commitment/preload_parallel_test.go Outdated
@lystopad
lystopad requested a review from AskAlexSharov August 6, 2026 12:04
Abort the test binary when Run is stuck instead of only failing the test. Run
has no cancellation, so on a regression the spinning goroutine would keep
burning a core for the rest of the package run and time out unrelated tests.
SetTraceback("all") widens the dump so the spinning stack, not just the waiter,
lands in the failure output.

Derive the guaranteed-progress step budget from the costliest entry in the
synthetic tree instead of scaling the root's cost by a constant, so the
assertion survives changes to the entry-cost overhead.

Correct the sweep test's doc comment to state the invariant actually asserted:
a budget covering the costliest entry must finish the tree, not any budget that
can afford the root.
@lystopad

lystopad commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Thanks — all three Copilot comments taken, pushed in 5be5ef7.

Runaway goroutine on timeout (both tests). Agreed, and it is specific to this failure mode: Run has no cancellation, so unlike the usual channel-wait timeouts in this repo the goroutine is spinning, not parked — it would keep a core busy for the rest of the package run and could time out unrelated tests. Both sites now go through a shared panicOnStuck helper.

I also added debug.SetTraceback("all") there. Without it the panic dumps only the waiting goroutine, which is the least interesting one; with it the spinning sortAndPartitionFrontier frame is in the failure output. Verified by reverting the production fix:

panic: ContractTrunkPreloadParallel.Run did not terminate: a wave with no file budget ...
...
goroutine 39 [runnable]:
github.com/erigontech/erigon/execution/commitment.(*ContractTrunkPreloadParallel).sortAndPartitionFrontier(...)

affordable := 4 * exact bakes in a scaling assumption. Fixed properly — it is now derived from the costliest entry actually present in the synthetic tree:

affordable := 0
for path, afterMap := range tree {
	if cost := estimatedEntryCost(nibbles.HexToCompact([]byte(path)), branchVal(afterMap, valSz)); cost > affordable {
		affordable = cost
	}
}

That is also the tight threshold rather than a padded one: a step budget at least the costliest entry can always pin at least one entry per step, so progress is guaranteed. The old 4 * exact was both arbitrary and, as you noted, brittle against changes to estimatedEntryOverheadBytes.

Doc comment disagreed with the assertions. Correct — the comment claimed any budget that can afford the root must finish the tree, but depth-66+ entries cost more than the depth-64 root (34-byte compact keys vs 33), so a root-sized budget legitimately stalls. Reworded to state the invariant the test actually checks.

Re-verified after the changes: full execution/commitment/... suite green, make lint clean for the touched files, and the regression guard still fires when the production fix is reverted.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (1)

execution/commitment/preload_parallel_test.go:799

  • panicOnStuck is called from the test goroutine, so the panic will be recovered by the testing framework. That means it will not abort the test process and debug.SetTraceback("all") will not produce an all-goroutine dump as intended, leaving the runaway Run goroutine spinning and potentially timing out unrelated tests anyway. To actually crash the process (and get the full goroutine dump), trigger the panic from a separate goroutine and block this one.
// panicOnStuck aborts the test binary rather than just failing the test. Run has
// no cancellation, so a livelock regression would leave a goroutine spinning on a
// core for the rest of the package run and time out unrelated tests. The
// traceback setting widens the dump to every goroutine so the spinning one, not
// just this waiter, shows up in the failure output.
func panicOnStuck(why string) {
	debug.SetTraceback("all")
	panic("ContractTrunkPreloadParallel.Run did not terminate: " + why)
}

@lystopad
lystopad enabled auto-merge August 6, 2026 12:27
@lystopad lystopad added the go Pull requests that update go code label Aug 6, 2026
@taratorio
taratorio requested a review from yperbasis August 6, 2026 13:30
@yperbasis yperbasis removed the go Pull requests that update go code label Aug 7, 2026
…ed wave

The guard ended a step only when the whole miss set was deferred. A capped
fetch whose keys are all absent from the file layer pins nothing either, so
that wave re-entered at the same depth with the same budget, re-sorting and
re-cloning the frontier once per remaining entry.

Also check cache in the PreloadContractTrunkParallel wrapper (Run returns the
error, then the logger block dereferences it), close the BranchCaches the new
tests build (NewBranchCache bumps a live-instance counter that adaptiveTrunkDepth
reads), and add coverage for a wave that defers misses while pinning db-hits.
@awskii

awskii commented Aug 8, 2026

Copy link
Copy Markdown
Member

Reviewed. The fix is right; pushed four follow-ups to the branch (4e47f07).

Differential check on the fix. Ran this branch's Run against the pre-guard Run over 40,000 random configs / 2,310,912 steps: identical pins, queueEmpty and usedBytes at every step, no behavioural difference anywhere. The pre-guard code livelocked in 18,306 of those 40,000 configs. So the guard costs nothing and the bug is not a corner case.

Pushed:

  • preload_parallel.go — the guard fired only when the whole miss set was deferred. A capped fetch whose keys are all absent from the file layer pins nothing either, so budgetHit stayed false and the wave re-entered at the same depth with the same budget, re-sorting and re-cloning the frontier once per remaining entry. That is the normal shape at the BFS fringe, where a set afterMap bit names a leaf with no branch record. Widened to len(fileMissDeferred) > 0 && (len(fileMiss) == 0 || chunkPinned == wavePinnedBefore), a superset of the original condition. Covered by TestContractTrunkPreloadParallel_NoPinWaveEndsStep.
  • preload_parallel.goPreloadContractTrunkParallel validates resolve and ramBudgetBytes but not cache. Run returns cache is nil correctly, then the logger block dereferences it via cache.PinnedCount(), so the caller gets a panic instead of the error. Added the check. Test-only path today; serial PreloadContractTrunk has the same shape.
  • preload_parallel_test.go — the new tests build nine BranchCaches and close none. NewBranchCache does activeBranchCaches.Add(1) and adaptiveTrunkDepth drops trunk depth 4 → 2 past 10 live instances, so the leak makes later cache-routing tests order- and -run-dependent.
  • preload_parallel_test.go — neither new test exercised the guard in a wave that also pinned db-hits: dbHits is empty where it fires, so rest collapses to fileMissDeferred alone, byte-identical to the old clone path. The new mixed-depth bookkeeping (frontier at depth, pendingChildren at depth+1) was untested. Added TestContractTrunkPreloadParallel_DeferWithDbHitsInSameWave, asserting the exact split and that no prefix is pinned twice across the deferral boundary.

Separate issue, outside this diffadaptive_pin.go: c.misses entries are never removed. demoteLocked + delete(c.states, hash) drop the state but not the counter, and snapshotMisses only Swap(0)s it. One permanent sync.Map entry per distinct contract hash ever touched, and OnBlockComplete Ranges that whole accumulated set on every block — O(all contracts ever seen) per block, even when every counter is zero.

execution/commitment/... green, preload suite green under -race -count=2, make lint clean on the touched packages.

@lystopad

lystopad commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

Thanks @awskii — reviewed all four changes in 4e47f07, they all hold up. Re-verified locally: execution/commitment green under -race -count=2, CI green on the branch.

The widened guard is a strict superset of the original (the old noFileBudget case forced fileMiss = nil, which is the new len(fileMiss) == 0 disjunct), so no behavioural regression is possible, and termination stays total. Your parenthetical is the crux and it checks out exactly: when the whole miss set is deferred, fileBudget on re-entry is stepCap - (usedBytes + dbHitsBytes) — bit-identical to the value that caused the deferral.

Two notes.

The new test doesn't cover the clause it's named for. TestContractTrunkPreloadParallel_NoPinWaveEndsStep passes with chunkPinned == wavePinnedBefore removed. I ran the three combinations over the whole package:

production state result
revert only chunkPinned == wavePinnedBefore green
revert only fileBudget < minEntryBytes green
revert both (pre-4e47f07) fails

So it pins the pair, not the widening — either change alone satisfies it.

It's a one-byte budget choice. At rootCost + minEntryBytes - 1 the depth-65 wave gets fileBudget < minEntryBytes and takes the full-deferral branch (fileMiss = nil), which the narrow guard already caught. The capped-fetch path is never entered.

One more byte enters it:

-	stepBudget := estimatedEntryCost(rootKey, branchVal(0xffff, valSz)) + minEntryBytes - 1
+	stepBudget := estimatedEntryCost(rootKey, branchVal(0xffff, valSz)) + minEntryBytes

Measured at that budget (16-wide wave, maxFileFetch = 2):

  • widening removed → resolverCalls=9, queueEmpty=true
  • widening present → resolverCalls=2, queueEmpty=false, queueRemaining=14

which is the re-entry-per-chunk behaviour the test's calls > 2 assertion is written to catch. The existing comment ("a residual too small to pin any depth-65 entry") describes the deferral branch, so it'd want a word too — or keep both budgets as two cases, since the deferral path is worth holding onto as well.

Separately, fileMissStop reads as a no-op rather than a fix: sortAndPartitionFrontier accumulates dbHitsBytes as the exact sum of estimatedEntryCost over the hits, so fileBudget >= minEntryBytes guarantees pinning every db-hit lands at usedBytes <= stepCap - minEntryBytes, and budgetHit can't be set after the db-hit loop while fileMiss is non-empty. Old len(fileMiss) and new 0 always coincide. Still worth keeping — it makes the invariant local instead of resting on that argument — just noting it isn't closing a live hole, in case you had a reachable case in mind that I'm missing.

Minor: queueEmpty is now computed in two places that disagree — the stepBudgetBytes <= 0 early return omits the || p.nextDepth > maxStorageTrunkDepth clause the main path has, so a depth-exhausted preloader reports true from a normal Run and false from a zero-budget one. No live impact, since adaptive_pin.go discards Run's queueEmpty and uses QueueRemaining(). A shared p.queueEmpty() helper would remove the divergence. Also the wrapper's resolve == nil check sits after NewContractTrunkPreloadParallel while the other two guards sit before it.

Happy either way on the test budget — say the word and I'll push it, or take it yourself if you'd rather keep the follow-ups in one hand.

Agreed the c.misses growth is out of scope here. It touches the same file as #23067, so it can ride there or get its own PR — your call.

The no-pin test only reached the full-deferral branch, so reverting either the
minEntryBytes floor or the widened guard alone left the package green. Split it
into two budgets one byte apart and assert the exact resolver call count.

Fold the two queueEmpty expressions into one method: the zero-budget early
return omitted the depth-ceiling clause. Hoist the wrapper's resolve check up
with the other argument guards.
@lystopad

lystopad commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

Took this myself in 03a83f4 rather than leave it hanging — split NoPinWaveEndsStep into two budgets one byte apart, so each case now fails against exactly one of the two production changes reverted (neither is left unpinned by the other). Also folded the two queueEmpty expressions into one method and hoisted the wrapper's resolve check up with the other guards.

execution/commitment green under -race -count=2, lint clean on the touched files. PR body updated to match the final diff.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (1)

execution/commitment/preload_parallel.go:277

  • budgetHit is now used both for true budget exhaustion (inside pin) and for the no-progress early-termination guard (budgetHit = true here). That makes the name misleading and can confuse future changes (e.g., distinguishing “budget exhausted” vs “stopped to avoid livelock”). Consider renaming the flag to something like stopStep/terminateStep, or splitting it into budgetExhausted and terminateStep booleans.
		if len(fileMissDeferred) > 0 && (len(fileMiss) == 0 || chunkPinned == wavePinnedBefore) {
			budgetHit = true
		}

The flag ends a step for two reasons now: pin() overflowing the budget, and a
wave making no progress. Name it for the effect — no read site distinguishes
the cause.
@lystopad

lystopad commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

Took Copilot's naming note in 810377d: budgetHitendStep, since the flag now ends a step both on a real budget overflow in pin() and on a no-progress wave. Skipped the suggested split into two booleans — all three read sites only ask whether the step should end, none distinguishes the cause.

@awskii awskii left a comment

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.

Inline notes on the fix and test coverage.

p, err := NewContractTrunkPreloadParallel(contractHash)
if err != nil {
return 0, err
if cache == nil {

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.

This guard is what's actually fixing a real bug, worth calling out: pre-fix, calling this wrapper with cache=nil and a non-nil logger reached the unconditional logger.Info(...cache.PinnedCount()) below (line 371) and panicked — p.Run returns an error on nil cache, but nothing here checked that error before logging. No test exercises the wrapper with cache=nil, logger!=nil: TestContractTrunkPreloadParallel_NilCacheError only calls p.Run directly with logger=nil. Worth a regression test at the wrapper level for this specific fix.

endStep = true
}

if endStep {

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.

A pathKey that was a dbBranches hit but gets deferred into rest/p.frontier here loses that provenance. If a later Run call's dbBranches overlay rotates past it and the file resolver also misses, sortAndPartitionFrontier reclassifies it as fileMiss, resolve() returns nil, and it's dropped silently — indistinguishable from a genuine BFS-fringe absence. Pre-existing classification behavior, but this PR is what makes multi-call resumption of budget-deferred waves routine instead of hanging, so it's now reachable in practice (adaptive_pin.go's runExtensionLocked holds a persistent preload instance across blocks with a fresh overlay each call). No test covers a dbHit surviving a deferral into a call with a changed overlay.

// db-hits, so the resumed frontier is a concatenation (unpinned db-hits, then
// the deferred misses) at the current depth while pendingChildren already holds
// the pinned hits' children one level down.
func TestContractTrunkPreloadParallel_DeferWithDbHitsInSameWave(t *testing.T) {

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.

Ran this against the pre-PR code directly — it passes unchanged, so it isn't actually covering this PR's fix. Its budget (rootCost+r1Cost+minEntryBytes-1) makes R1 fit and R2 overflow inside the fileMiss loop itself, a shape the old code already handled via fileMissStop's in-loop assignment. Also, unlike its three siblings (ExactBudgetFillTerminates/StepBudgetSweepTerminates/NoPinWaveEndsStep), it calls p.Run synchronously with no panicOnStuck timeout guard — a livelock regression specific to this db-hit+deferred-miss shape would hang the whole test binary instead of failing fast.

// entry, so the miss set is deferred without being fetched, or it affords a
// capped fetch whose keys are all absent from the file layer — the normal shape
// at the BFS fringe, where a set afterMap bit names a leaf with no branch
// record. Neither raises endStep from pin(), so the wave used to be re-entered

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.

Traced both subtests against pre-PR code: neither actually hangs here (frontier strictly shrinks each inner iteration) — they terminate, but with the wrong result (queueEmpty=true and 2/9 resolver calls instead of 1/2) after grinding through the whole wave in one Run call. "the wave used to be re-entered ... once per chunk" reads as hang narration for something that doesn't hang at this test's scale — better to describe the wrong outcome (call count, queueEmpty) that the assertions actually pin down.

// branch (33 = shortest HexToCompact key at depth >= 64; value may be empty).
// Bounds a wave's file fetch so the budget is guaranteed exhausted inside it.
// Caps a wave's file fetch to what the remaining budget could still pin. Keys
// absent from the file layer cost nothing, so a capped fetch may leave the

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.

This rationale (capped fetch may leave budget unspent; the no-progress check is what ends the step) is restated in full again at the check itself (lines 270-277), with nothing tying the two together. Worth keeping one canonical explanation and a short pointer at the other site so a future change to the deferral threshold or no-progress condition doesn't leave one stale.

}
if stepBudgetBytes <= 0 {
return 0, len(p.frontier) == 0, nil
return 0, p.queueEmpty(), nil

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.

Extracting queueEmpty() also changes what this early return reports when stepBudgetBytes<=0 — from len(p.frontier)==0 to also checking pendingChildren and the depth ceiling. The two can only disagree when frontier is non-empty, pendingChildren is empty, and nextDepth > maxStorageTrunkDepth all at once — looks unreachable given how these three are always mutated together, but no test calls Run with stepBudgetBytes<=0 and non-empty pendingChildren to pin that down, so the behavior change rides along inside what otherwise reads as a pure refactor.

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.

DeferWithDbHitsInSameWave passed against pre-PR code — deferring a miss
unfetched and fetching it only to fail the pin leave an identical frontier, so
only the resolver call count separates them. Assert it, and add the hang guard
its siblings have.

Cover the two untested cases: the wrapper's nil-cache guard, which panicked via
the logger before returning Run's error, and the depth ceiling, where the
zero-budget early return used to disagree with a spendable one.

Sweep budgets become subtests so a regression names the failing budget. Drop the
minEntryBytes rationale that was restated in full at the no-progress check.
@lystopad

Copy link
Copy Markdown
Member Author

Thanks @awskii — all seven taken or answered, pushed in 42c4aab.

Your test doesn't cover the fix — confirmed. Reverted preload_parallel.go to the merge-base and ran each test against it:

test pre-PR covers the fix?
ExactBudgetFillTerminates fails yes
StepBudgetSweepTerminates fails yes
NoPinWaveEndsStep fails yes
DeferWithDbHitsInSameWave passes no

The budget was already right; the gap was the assertions. Deferring the miss unfetched and fetching it only to fail the pin leave an identical frontier, pendingChildren and pinned count — only the resolver call count separates them. Added calls != 1 and it now fails pre-PR with resolver called 2 times, want 1. Also added the panicOnStuck guard.

Wrapper nil-cache. Added TestPreloadContractTrunkParallel_NilCacheWithLoggerReturnsError. Removing the guard reproduces exactly what you described — panic: runtime error: invalid memory address or nil pointer dereference, not an error return.

queueEmpty extraction. You're right it rode inside a refactor, but the disagreement is reachable, not just theoretical. The loop's else-branch does frontier = pendingChildren; pendingChildren = nil; nextDepth++, so exiting at the ceiling leaves frontier non-empty, pendingChildren empty, nextDepth = 129 — your third condition, reached the ordinary way. In that state the old early return said "work remaining" while a normal Run said "done", so unifying them fixed a real inconsistency rather than changing behaviour incidentally. TestContractTrunkPreloadParallel_DepthCeilingReportsDoneOnAnyBudget pins it by setting nextDepth directly; with the old expression restored it fails with a zero budget past the depth ceiling disagreed with the spendable one.

Duplicated rationale — agreed, and it is against the repo comment policy. The minEntryBytes comment is now a one-line pointer; the explanation lives only at the check.

Sweep guard — each budget is a t.Run subtest with its own timeout, and the panic message names the budget. Also deduped the list: affordable and exact+1 both evaluate to 302 for this tree, so two subtests shared a name.

Wording — reworded; it now describes the wrong outcome (call count, queueEmpty) instead of implying a hang.

dbHit provenance — real, and your reasoning holds. Filed as #23143 rather than folded in here: the impact is a BranchCache miss and a fallback read, so performance rather than correctness, and overlay-provenance tracking is a bigger change than a livelock fix should carry. The issue notes that no test covers a db-hit surviving a deferral into a rotated overlay.

execution/commitment green under -race -count=2; lint clean on the touched files.

MoonBoi9001 pushed a commit to MoonBoi9001/erigon that referenced this pull request Aug 10, 2026
…nters (erigontech#23067)

## Problem

`commitment_trunk_preload_duration_seconds_total` and
`commitment_trunk_preload_bytes_total` are declared in
`trunk_pin_metrics.go` but never written anywhere in the tree, so both
read `0` for the life of the process.

Confirmed on a live Gnosis node with 5.9 days of uptime and 68,732
recorded preload extensions:

```
commitment_adaptive_pin_extended_total 68732
commitment_adaptive_pin_promoted_total 5137
commitment_branchcache_pinned_entries  118337
commitment_trunk_preload_bytes_total            0   <-- never written
commitment_trunk_preload_duration_seconds_total 0   <-- never written
```

The neighbouring counters work; only these two are dead. The result is
that there is no metric signal for how much work the adaptive pin
controller does or how long it spends doing it. That gap was noticed
while diagnosing erigontech#23066 — a livelock inside
`ContractTrunkPreloadParallel.Run` — where these counters would have
been the natural place to see preload time climbing.

## Change

Record both at the two places a preload actually runs, covering the
parallel and serial paths in each:

- `promoteLocked` — the initial view for a newly promoted contract.
- `runExtensionLocked` — the per-block extension step, which is the
dominant path in a running node.

A promote whose `Run` fails has its pins rolled back via `Invalidate`,
so it contributes its duration but no bytes. That is why `recordPreload`
takes the byte count as a parameter instead of reading it back off the
preloader.

No behaviour change beyond the counters; no new metric names,
cardinality, or hot-path work (both call sites already run once per
contract per block under `c.mu`).

## Testing

TDD, red → green. Both tests fail on `main` with the counters flat at
`0`:

- `TestAdaptivePin_PromoteRecordsPreloadMetrics` — both counters advance
across a promote.
- `TestAdaptivePin_ExtendRecordsPreloadMetrics` — both counters advance
across an extension, with the initial view budgeted so the queue
survives promotion and there is real work left to measure.

Each test guards against a vacuous pass by first asserting the preload
actually pinned bytes / left a non-empty queue.

Full `execution/commitment/...` suite passes. `make lint` clean for the
touched files.

## Relationship to erigontech#23066

Split out of erigontech#23066 review discussion to keep that fix minimal. The two
branches touch disjoint files (`adaptive_pin.go` /
`trunk_pin_metrics.go` here, `preload_parallel.go` there). Verified by
trial-merging the two branches: no conflicts, merged result builds and
the full package suite passes. They can land in either order.

---------

Co-authored-by: Alex Sharov <AskAlexSharov@gmail.com>
@lystopad
lystopad added this pull request to the merge queue Aug 10, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 10, 2026
@lystopad
lystopad enabled auto-merge August 10, 2026 16:23
@lystopad
lystopad added this pull request to the merge queue Aug 10, 2026
Merged via the queue into main with commit 3be2c59 Aug 10, 2026
133 checks passed
@lystopad
lystopad deleted the feature/lystopad/preload-parallel-livelock branch August 10, 2026 17:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants