Skip to content

loopdb: honor postgres.maxconnections - #1262

Open
starius wants to merge 3 commits into
lightninglabs:masterfrom
starius:fix-max-connections
Open

starius wants to merge 3 commits into
lightninglabs:masterfrom
starius:fix-max-connections

Conversation

@starius

@starius starius commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

The postgres.maxconnections option (PostgresConfig.MaxOpenConnections) has been a no-op since the Postgres store was added: NewPostgresStore never applied it, so the connection pool was unbounded and concurrent queries opened as many connections as they needed.

This matters for the static address loop-in manager, which handles up to 128 sweep requests concurrently (maxConcurrentSweepRequests). A burst of co-signing requests can open more connections than the Postgres server accepts (100 by default). This showed up in the Loop server's integration tests with a Postgres-backed client: sweep requests failed with FATAL: sorry, too many clients already (SQLSTATE 53300), the server's co-signing requests timed out, and its sweep batcher had to split the swaps into separate batches.

Changes

  • Release the migration connection. golang-migrate's Postgres driver created by WithInstance checks out a dedicated connection that only the driver's Close returns, and Close also closes the store's database, so the store never gave that connection back. Migrations now run on a connection taken from the pool (WithConnection), which is returned once they are done. The driver only holds session-level advisory locks while migrating, so the connection is safe to reuse.
  • Apply the limit. NewPostgresStore now calls SetMaxOpenConns with the configured value, or 25 if the option is unset, the same default as lnd and tapd. The flag description, sample-loopd.conf and the release notes are updated.
  • Test. TestPostgresConnectionPool (built with the test_db_postgres tag, so it runs in the Postgres unit test CI job) opens a store with the default limit, a configured limit and a single connection, and checks the pool's limit and that no connection is still checked out afterwards.

Notes

  • Postgres users who never set the option go from an unbounded pool to at most 25 open connections. The idle connection settings are unchanged (Go's default of 2 idle connections).
  • The limit can't deadlock the store: no transaction body acquires a second connection while it holds one, FixFaultyTimestamps drains its rows before it opens its transaction, and migrations no longer keep a connection checked out. A single connection is enough to open the store, which the test covers.
  • SQLite stores are unaffected.

Pull Request Checklist

  • Add an entry to docs/release-notes/release-notes-next.md, or apply the
    no-changelog label (required by CI)

@starius
starius requested a review from hieblmi October 23, 2026 20:36

@hieblmi hieblmi 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.

Found one migration lock-timeout regression. The regular and Postgres loopdb test suites pass; a focused contention test reproduces the issue described inline.

Comment thread loopdb/postgres.go Outdated
return err
}
defer func() {
_ = conn.Close()

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.

[P2] Preserve the migration lock timeout during connection cleanup

If another migrator acquires the advisory lock between WithConnection's ensureVersionTable check and Up(), the migration library returns ErrLockTimeout after its default 15 seconds while its background pg_advisory_lock query remains blocked. sql.Conn.Close() waits for in-flight queries, so this new deferred close prevents the timeout from reaching the caller until the competing lock is released, potentially hanging startup indefinitely.

I reproduced this by acquiring the competing advisory lock when the migration filesystem is opened, after ensureVersionTable has released it. With the library timeout shortened to 500 ms, the previous WithInstance path returned ErrLockTimeout after 501 ms; this helper was still blocked after 1.512 seconds and returned only after I explicitly released the competing lock.

Please ensure the pending advisory-lock query is bounded or cancelled before synchronously returning the connection to the pool, and add a contention regression test.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed

@starius
starius force-pushed the fix-max-connections branch from 6c7fa05 to 2aaf7e6 Compare October 29, 2026 00:15
@starius
starius requested a review from hieblmi October 29, 2026 00:15
@litbot-9000

Copy link
Copy Markdown
Collaborator

@hieblmi: review reminder

@hieblmi hieblmi 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.

LGTM, pending rebase!

postgres_migrate.WithInstance checks out a dedicated connection that
only the driver's Close method gives back, and Close also closes the
database that the store keeps using. The store therefore never returned
that connection to the pool. That's harmless while the pool is
unbounded, but it would permanently take a slot from a bounded one.

Run the migrations on a connection obtained from the pool and return it
once they are done. The driver only holds session-level advisory locks
while it migrates, so the connection can be reused afterwards.

If the migrations give up waiting for the migration lock, their request
for the lock stays pending on the connection. Closing the connection
would wait for that request, so the lock timeout would only reach the
caller once the competing migrator releases the lock. Hand the
connection back in the background in that case, once the request is
done and the lock it may have got is released.
NewPostgresStore never applied PostgresConfig.MaxOpenConnections, the
postgres.maxconnections option, so the connection pool was unbounded
and concurrent queries opened as many connections as they needed. The
static address loop-in manager handles up to 128 sweep requests at
once, and a burst of co-signing requests exceeded the 100 connections
that a default Postgres server accepts. The requests failed with
"sorry, too many clients already".

Limit the pool to the configured number of connections, or to 25 if
the option is unset, the same default that lnd and tapd use. No
transaction waits for a second connection while it holds one, and the
migrations no longer keep a connection checked out, so the limit can't
deadlock the store, even with a single connection.
Open a Postgres store with the default limit, a configured limit and a
single connection, and check the limit of its connection pool and that
no connection is still checked out afterwards. Without the limit, the
pool is unbounded. Without handing the migration connection back, one
connection stays in use, and a single connection isn't enough to open
the store.

Also let the migrations time out on the migration lock while a
competing migrator holds it, and check that they report the timeout
while the lock is still held and hand their connection back without the
lock once the competing migrator releases it. Closing the connection
right away delays the timeout until the competing migrator releases the
lock, and a connection handed back with the lock keeps blocking other
migrators.
@starius
starius force-pushed the fix-max-connections branch from 2aaf7e6 to f0814ce Compare November 5, 2026 20:31
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