Skip to content

db/state: fix Close vs external MergeLoop/BuildFiles* WaitGroup races - #22203

Merged
AskAlexSharov merged 9 commits into
mainfrom
yperbasis/agg-close-mergeloop-race
Jul 4, 2026
Merged

AskAlexSharov merged 9 commits into
mainfrom
yperbasis/agg-close-mergeloop-race

Conversation

@yperbasis

@yperbasis yperbasis commented Jul 3, 2026 •

Copy link
Copy Markdown
Member

Fixes the data race that failed the race-tests / tests-linux (other, serial) job on #22163's CI run (failing job): TestImportClosesChaindataOnInitError flagged Aggregator.Close's wg.Wait racing MergeLoop's wg.Add.

Root cause

MergeLoop, BuildFilesInBackground (on both Aggregator and ForkableAgg) and BuildFiles2 register on the aggregator's lifecycle WaitGroup from whatever goroutine calls them. For external callers nothing orders that Add against Close's Wait. An Add from a zero counter concurrent with Wait is sync.WaitGroup reuse (undefined behavior, flagged by -race), and semantically the unregistered goroutine can keep running while Close tears down the dirty files.

The CI failure hit the MergeLoop door (the background-maintenance goroutine eth.New spawns), but the same race is reachable through the sibling doors, so this PR guards all of them:

  • Aggregator.BuildFilesInBackground — live on a stock node: the fire-and-forget FCU background-prune goroutine (FcuBackgroundPrune defaults to true) ends in CollateAndPrune → BuildFilesInBackground, and its adaptive budget can reach 2/3 slot ≈ 8s on mainnet while Ethereum.Stop waits on the exec semaphore for at most 5s (WaitIdle) before proceeding to chainDB.Close → Aggregator.Close → wg.Wait. Same shape for ProcessFrozenBlocks' commit cycle, which calls BuildFilesInBackground right after a ctx-oblivious MDBX commit.
  • Aggregator.BuildFiles2 and ForkableAgg.BuildFilesInBackground — same unguarded caller-side Add; currently only reachable from tooling/tests, guarded all the same.

#21528 fixed this class for the nested spawn sites by making the already-registered parent goroutine Add before spawning; that pattern can't cover the entry points themselves — the registration has to be lifecycle-aware. The window became CI-visible when #22058 added a test that starts the import node, fails init, and immediately stops it.

Fix

A small closingWaitGroup (a sync.WaitGroup with a mutex-guarded close latch), shared by Aggregator and ForkableAgg:

  • TryAdd registers unless the latch is set. Every external entry point (MergeLoop, BuildFilesInBackground, BuildFiles2) refuses once closing and returns its usual "nothing to do" result, like the existing dbg.NoMerge() / buildingFiles-CAS early-outs (the CAS is unwound and fin closed on refusal).
  • Close latches via BeginClose before cancelling the context and waiting. The mutex gives the happens-before edge: every Add is either strictly ordered before Close's Wait, or refused.
  • BeginClose doubles as the Close-idempotency latch, replacing the previously unsynchronized ctxCancel == nil check / ctxCancel = nil write — two concurrent Close calls used to race on ctxCancel and could invoke a nil func; Close is now safe to call concurrently with itself.
  • Registration goes through a single path — TryAdd. The fire-and-forget merge spawns use it too (refused once closing: the merge is skipped and fin closed, rather than an unconditional Add), and the buildFiles errgroup children no longer touch the lifecycle wg at all (see Why dropping the wg.Add in buildFiles is safe below).

TDD

Red first, one test per door plus concurrent-Close, each reproducing the exact race signature under -race before the fix:

  • TestAggregatorCloseVsConcurrentBuildFilesInBackground — wg.Wait (aggregator.go:638) vs wg.Add (aggregator.go:2171)
  • TestAggregatorCloseVsConcurrentBuildFiles2 — wg.Wait vs wg.Add (aggregator.go:1167)
  • TestAggregatorConcurrentClose — data races on ctxCancel (reads at aggregator.go:627/633 vs the nil-write at 634)
  • TestForkableAggCloseVsConcurrentBuildFilesInBackground — forkable_agg.go:462 vs the BuildFilesInBackground Add, plus the secondary merge-internals vs closeDirtyFiles race
  • TestForkableAggConcurrentClose — ctxCancel races escalating to an actual nil-func-call panic

Green after the fix. Verified additionally:

  • all Close-related tests (the five above plus TestAggregatorCloseVsConcurrentMergeLoop, TestForkableAggCloseVsConcurrentMergeLoop, both db/state: fix Aggregator & ForkableAgg Close vs background MergeLoop WaitGroup race #21528 regression tests, and TestAggregatorCloseReleasesBranchCache) pass under -race with -count=2, zero race reports
  • TestAggregatorCloseVsConcurrentMergeLoop also got cheaper: it burned ~42s under -race (~97% idle in WaitForFiles' 3-second poll ticker whenever a merge attempt overlapped Close); with 4 iterations and t.Parallel it runs in 6–9s and the whole Close suite finishes in ~19s under -race -count=2
  • go test -short ./db/state/... ./db/kv/temporal/... passes
  • make lint clean (two consecutive runs), make erigon integration builds
  • TestImportClosesChaindataOnInitError (the original CI failure) passes locally without -race; it cannot run under -race on darwin/arm64 at all (fatal error: too many address space collisions for -race mode at startup — a Go runtime limitation with this test's MDBX mappings, unrelated to this change), so the Linux race-tests job on this PR is the definitive check for it

Why dropping the wg.Add in buildFiles is safe

buildFiles (and forkable buildFile) build their per-domain / per-II files on an errgroup.Group and block on g.Wait() before returning. Two facts make a separate lifecycle-wg registration of those children redundant:

  1. buildFiles is only ever called synchronously from the background goroutine that already registered on the lifecycle wg via TryAdd (the buildFilesInBackground / BuildFiles2 goroutine).
  2. That goroutine cannot reach its own defer wg.Done() until buildFiles returns — i.e. until g.Wait() has joined every child.

So Close's wg.Wait() already blocks on those children transitively, through the still-held count of the entry goroutine. Registering them on the lifecycle wg as well was pure double-counting — it changed the counter value but not the set of goroutines Close waits for. Removing it keeps Close's guarantee intact while getting rid of an Add whose safety depended on the caller's context (which Go code can't observe — a function doesn't know whether it runs inline or in a goroutine).

MergeLoop registers on the aggregator's WaitGroup from goroutines the
aggregator did not spawn (e.g. the node's background-maintenance
goroutine), so its Add was unordered against Close's Wait — WaitGroup
reuse from zero, flagged by -race, and an unregistered merge loop could
keep running through Close's file teardown. Guard the registration with
a closing flag so every Add is either ordered before the Wait or
refused. Same fix for ForkableAgg.

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 sync.WaitGroup data race between Aggregator.Close()/ForkableAgg.Close() (wg.Wait) and externally-spawned MergeLoop() calls (wg.Add), by making MergeLoop lifecycle-aware and refusing to register once shutdown begins.

Changes:

  • Add closing + closingMu to Aggregator and ForkableAgg; Close sets closing before waiting, and MergeLoop registers under the same mutex (or returns early if closing).
  • Add race-regression tests that invoke concurrent MergeLoop calls across the Close() window for both Aggregator and ForkableAgg.

Reviewed changes

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

File Description
db/state/aggregator.go Adds shutdown-aware guard around MergeLoop registration to prevent wg.Add racing Close’s wg.Wait.
db/state/forkable_agg.go Mirrors the same shutdown-aware guard for ForkableAgg.MergeLoop vs ForkableAgg.Close.
db/state/aggregator_close_test.go Adds regression test reproducing the Close-vs-concurrent-MergeLoop race scenario.
db/state/forkable_agg_test.go Adds regression test reproducing the Close-vs-concurrent-MergeLoop race scenario for forkable aggregator.

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

Extract the closing flag into closingWaitGroup (TryAdd/BeginClose) shared
by Aggregator and ForkableAgg; apply it to BuildFilesInBackground (both
types) and BuildFiles2 alongside MergeLoop. BeginClose also replaces the
unsynchronized ctxCancel idempotency latch, making concurrent Close safe.
Red-first tests per door plus concurrent-Close; trim the MergeLoop race
test (t.Parallel, 4 iterations) from 42s to 6-9s under -race.
@yperbasis yperbasis changed the title db/state: fix Close vs external MergeLoop WaitGroup race db/state: fix Close vs external MergeLoop/BuildFiles* WaitGroup races Jul 3, 2026
@yperbasis
yperbasis requested a review from Copilot July 3, 2026 10:47

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 5 out of 5 changed files in this pull request and generated 8 comments.

Comment thread db/state/aggregator_close_test.go
Comment thread db/state/aggregator_close_test.go
Comment thread db/state/aggregator_close_test.go
Comment thread db/state/aggregator_close_test.go
Comment thread db/state/forkable_agg_test.go
Comment thread db/state/forkable_agg_test.go
Comment thread db/state/forkable_agg_test.go
Comment thread db/state/aggregator.go Outdated
@yperbasis
yperbasis requested a review from Copilot July 3, 2026 10:55
@yperbasis
yperbasis marked this pull request as ready for review July 3, 2026 10:55

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 5 out of 5 changed files in this pull request and generated no new comments.

@AskAlexSharov

AskAlexSharov commented Jul 3, 2026 •

Copy link
Copy Markdown
Collaborator

Code review

The core fix is sound. The closingWaitGroup (mutex-guarded TryAdd/BeginClose latch) correctly eliminates the WaitGroup-reuse race: every externally-reachable entry (MergeLoop, BuildFiles2, BuildFilesInBackground on both Aggregator and ForkableAgg) now registers via TryAdd, and each remaining plain wg.Add(1) is provably spawned from an already-registered goroutine so the counter can't be zero. The mutex orders every successful Add before Close's Wait, dropping the ctxCancel == nil guard is safe (both constructors set ctxCancel unconditionally), and no fin channel is left unclosed on any path. No correctness bug found.

A few quality / test-coverage notes, ranked:

1. The ForkableAgg race twins drop the barrier + stagger their Aggregator counterparts rely on.
TestForkableAggCloseVsConcurrentMergeLoop and TestForkableAggCloseVsConcurrentBuildFilesInBackground launch workers with loops.Go(...) and then call agg.Close() on the very next line — no start channel / <-start / staggered time.Sleep like the Aggregator twins in aggregator_close_test.go. Under common scheduling Close latches BeginClose and drains wg.Wait() (counter 0) before any worker reaches TryAdd, so every worker sees closing==true and returns without ever calling WaitGroup.Add. The Add-during-Wait interleaving these tests exist to catch is then never produced, and a regression that removed the latch could still pass under -race. Suggest mirroring the Aggregator twins' start-barrier + per-goroutine stagger so both sides of the latch are exercised.

2. closingWaitGroup embeds sync.WaitGroup, re-promoting an unguarded Add.
Embedding exposes a public wg.Add() that bypasses the close latch — the exact operation the type exists to forbid from unregistered goroutines. A future caller (or a refactor that moves one of the plain wg.Add(1) sites to an unregistered goroutine) silently reintroduces the reuse race, with nothing at the type level stopping it. Un-embedding (named field, exposing only Wait/Done/TryAdd plus a distinctly-named add-from-registered method) would make the misuse uncompilable — matches the CLAUDE.md preference for a type the caller can't misuse over a comment describing the rule.

Nits:

  • The three new ForkableAgg tests omit t.Parallel(), inconsistent with all four Aggregator twins.
  • TestAggregatorCloseVsConcurrentBuildFiles2 calls BuildFiles2(ctx, 0, 0, false) — with fromStep==toStep==0 and doMerge=false the goroutine body is a no-op, so it only covers the TryAdd/CAS gate. The trailing agg.wg.Wait() is also dead (Close already drained it and no TryAdd can register post-BeginClose).

- closingWaitGroup no longer embeds sync.WaitGroup: a named field plus
  TryAdd / AddFromRegistered / Done / Wait / BeginClose keeps a bare,
  latch-bypassing Add off the type's API.
- Concurrent Close now blocks every caller until teardown completes: the
  caller that loses the BeginClose latch waits on a channel the winner
  closes via MarkClosed after closeDirtyFiles.
- ForkableAgg Close-vs-* tests gain the start-barrier + per-goroutine
  stagger their Aggregator twins use, so the Add-during-Wait interleaving
  is actually produced. They stay serial (not t.Parallel) because setup
  registers into the global forkable Registry whose reads aren't
  lock-guarded, so parallel setups race there.
- TestAggregatorCloseVsConcurrentBuildFiles2 exercises the merge-spawn
  path (doMerge=true) and drops a dead trailing wg.Wait.
Replace the hand-rolled closed-channel + BeginClose/MarkClosed protocol
with a sync.Once-backed RunClose(teardown func()): Once.Do already runs
teardown exactly once and blocks concurrent/later callers until it
returns, which is the same block-until-torn-down, idempotent-Close
guarantee — and matches the sibling Collector.Stop idiom called two lines
into Aggregator.Close. Removes the forgettable "winner must MarkClosed"
obligation; the closing latch that TryAdd checks is set inside the Once.

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 5 out of 5 changed files in this pull request and generated 1 comment.

Comment thread db/state/aggregator.go Outdated

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 5 out of 5 changed files in this pull request and generated no new comments.

The function promises a channel closed when aggregation is done, but the
doMerge==false branch returned without closing fin. Not a live bug — both
callers pass doMerge=true — but the latent contract violation would
deadlock a future no-merge caller waiting on the channel.
AddFromRegistered's contract was "the caller is an already-registered
goroutine" — a precondition Go code can't observe, since any function may
run inline or in a goroutine at the caller's choosing. Remove it and route
every registration through the latched TryAdd, which depends only on the
close latch, not on caller context:

- The buildFiles / buildFile errgroup children dropped their wg registration
  entirely — g.Wait() already joins them before the (registered) caller
  returns, so Close covers them transitively; the extra count was redundant.
- The fire-and-forget merge spawns now TryAdd: refused once closing, in
  which case the merge is skipped (and fin closed) instead of started
  during shutdown.
@AskAlexSharov
AskAlexSharov requested a review from Copilot July 4, 2026 01:59

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 5 out of 5 changed files in this pull request and generated no new comments.

@AskAlexSharov AskAlexSharov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

i a bit modified to addresss Copilot's things.

@yperbasis feel free to review.

merging to fix main ci.

@AskAlexSharov
AskAlexSharov added this pull request to the merge queue Jul 4, 2026
Merged via the queue into main with commit 6fc6b52 Jul 4, 2026
93 checks passed
@AskAlexSharov
AskAlexSharov deleted the yperbasis/agg-close-mergeloop-race branch July 4, 2026 03:56
github-merge-queue Bot pushed a commit that referenced this pull request Jul 5, 2026
…nt data race on shutdown (#22244)

**[SharovBot]**

## Problem

A data race was detected in `TestImportClosesChaindataOnInitError`
(race-tests CI job, 2026-07-03):

```
WARNING: DATA RACE
Write at 0x00c0653acb90 by goroutine 321:
  github.com/erigontech/erigon/db/state.(*Aggregator).Close()
      db/state/aggregator.go:630

Previous read at 0x00c0653acb90 by goroutine 373:
  github.com/erigontech/erigon/db/state.(*Aggregator).MergeLoop()
      db/state/aggregator.go:1228
  github.com/erigontech/erigon/node/eth.New.func16()
      node/eth/backend.go:1133
```

## Context

PR #22203 (merged 2026-07-04) addressed this race by replacing
`sync.WaitGroup` with a `closingWaitGroup` latch in `Aggregator`, making
`MergeLoop`'s `TryAdd()` properly ordered against `Close()`'s
`BeginClose()+Wait()`.

## This PR

This PR provides an additional, complementary fix: track the MergeLoop
goroutine in `bgComponentsEg` so `Stop()` → `bgComponentsEg.Wait()`
explicitly waits for the MergeLoop goroutine to exit before
`chainDB.Close()` is called.

Without this, `bgComponentsEg.Wait()` in `Stop()` returns without
waiting for the MergeLoop goroutine (since it was launched as a bare `go
func()`), meaning the goroutine could theoretically still be running
when `chainDB.Close()` begins. The `closingWaitGroup` handles the
WaitGroup reuse race, but this PR makes the shutdown ordering explicit
and unambiguous.

**Changes:**
- Moves the MergeLoop goroutine from a bare `go func()` to
`backend.bgComponentsEg.Go()`
- Fixes the typo: `"snapashot"` → `"snapshot"` in the error message
- Filters context cancellation errors (expected on shutdown) from
`logger.Error`

## Testing

- `go test -race -count=10 ./cmd/utils/app/ -run
TestImportClosesChaindataOnInitError` — all 10 runs PASS, no data race
- `go build ./...` — succeeds
- No test files modified

Fixes CI:
https://github.com/erigontech/erigon/actions/runs/28674562917/job/85045122311

Co-authored-by: SharovBot <sharovbot@erigon.tech>
Co-authored-by: Giulio Rebuffo <giulio.rebuffo@gmail.com>
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.

3 participants