db: avoid Postgres fixture cache deadlock - #864
Conversation
There was a problem hiding this comment.
Code Review
This pull request refactors NewTestDBHandleFromPath in db/test_postgres.go to release the testPgFixtureMtx lock before creating a new Postgres fixture, preventing potential deadlocks or blocking on the global semaphore. It also introduces a double-check lock pattern to handle concurrent fixture creation for the same path. However, a race condition was identified in the cleanup function where sqlFixture.TearDown(t) is called before the fixture is evicted from the map, which could lead to concurrent callers accessing a stale, tearing-down fixture. It is recommended to evict the fixture from the map under lock before performing the teardown.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| t.Cleanup(func() { | ||
| sqlFixture.TearDown(t) | ||
|
|
||
| testPgFixtureMtx.Lock() | ||
| delete(testPgFixturesByPath, dbPath) | ||
| if testPgFixturesByPath[dbPath] == sqlFixture { | ||
| delete(testPgFixturesByPath, dbPath) | ||
| } | ||
| testPgFixtureMtx.Unlock() | ||
| }) |
There was a problem hiding this comment.
There is a race condition in the cleanup function: sqlFixture.TearDown(t) is called before the fixture is evicted from the testPgFixturesByPath map under the lock. Since TearDown stops and purges the Docker container (which can take several seconds), there is a window of time where a concurrent or subsequent call to NewTestDBHandleFromPath for the same dbPath will find the fixture in the map (ok == true), but the underlying container is already stopped or being stopped. This will result in connection failures.
To prevent this, we should lock the mutex and delete the fixture from the map before calling TearDown(t). This ensures that any subsequent caller will not see the stale fixture in the map and will instead spin up a new one, while the teardown of the old fixture can safely run outside the lock.
| t.Cleanup(func() { | |
| sqlFixture.TearDown(t) | |
| testPgFixtureMtx.Lock() | |
| delete(testPgFixturesByPath, dbPath) | |
| if testPgFixturesByPath[dbPath] == sqlFixture { | |
| delete(testPgFixturesByPath, dbPath) | |
| } | |
| testPgFixtureMtx.Unlock() | |
| }) | |
| t.Cleanup(func() { | |
| testPgFixtureMtx.Lock() | |
| if testPgFixturesByPath[dbPath] == sqlFixture { | |
| delete(testPgFixturesByPath, dbPath) | |
| } | |
| testPgFixtureMtx.Unlock() | |
| sqlFixture.TearDown(t) | |
| }) |
There was a problem hiding this comment.
Pull request overview
This PR updates the Postgres test DB handle helper to avoid holding the path-keyed fixture-cache mutex while potentially blocking operations (docker fixture creation / semaphore acquisition) are in progress, reducing the risk of teardown stalls in parallel test runs.
Changes:
- Release
testPgFixtureMtximmediately after the cache lookup to avoid blocking other goroutines while creating docker fixtures. - Add a second “already created” check when re-acquiring the mutex to safely handle concurrent creators and tear down redundant fixtures.
- Guard cache eviction during cleanup by only deleting the entry if it still points at the same fixture instance.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // This must happen outside testPgFixtureMtx: creating the | ||
| // fixture can block on the global Postgres fixture semaphore, | ||
| // and cleanup needs the same mutex to evict finished fixtures. | ||
| sqlFixture = NewTestPgFixture( | ||
| t, DefaultPostgresFixtureLifetime, true, |
|
@claude review please |
|
Claude finished @sputn1ck's task in 1m 59s —— View job Reviewing this PR
ReviewThe core change is sound: releasing A few notes: 1. (medium) Fixture leak if sqlFixture = NewTestPgFixture(t, DefaultPostgresFixtureLifetime, true)
store, err := NewPostgresStore(sqlFixture.GetConfig(), log)
require.NoError(t, err) // <- FailNow here leaks sqlFixture
This is pre-existing (the base code had the same shape), but the PR is right on top of it, so worth folding in: store, err := NewPostgresStore(sqlFixture.GetConfig(), log)
if err != nil {
sqlFixture.TearDown(t)
require.NoError(t, err)
}(Note the parallel branch at 2. (low / defensive) Delete from the map before Gemini's suggestion to swap the order (delete under lock, then t.Cleanup(func() {
testPgFixtureMtx.Lock()
if testPgFixturesByPath[dbPath] == sqlFixture {
delete(testPgFixturesByPath, dbPath)
}
testPgFixtureMtx.Unlock()
sqlFixture.TearDown(t)
})3. (nit) The double-check branch is effectively defensive-only. Per the function's own invariant, the same Verdict: Looks good to merge. I'd fold in #1 (the semaphore-slot leak is the same class of problem this PR targets); #2 and #3 are optional polish. |
Summary
Verification
make fmt-changedmake lint-changed-localgo test -tags="dev test_postgres nolog" ./db -run "^$"env SYSTEST_PARALLEL=1 make systest db=postgres backend=lnd case=TestOORSubDustRecipientRejectedE2E timeout=30mfrom parentdarepoParent PR: lightninglabs/darepo#643