database/sql connection pool: no acquisition timeout when MaxOpenConns is saturated
0 reputation · 31 Mar 2025, 08:01 UTC
0 reputation · 31 Mar 2025, 08:01 UTC
I'm designing a Go service (assuming Go 1.21+) that uses database/sql with SetMaxOpenConns to cap connections to PostgreSQL. Under burst load, once all pooled connections are checked out, additional calls to db.QueryContext or db.ExecContext appear to block waiting for a free slot, and the supplied context deadline does not seem to interrupt that wait — only the query execution itself.
My constraints: I cannot raise MaxOpenConns (database-side limit), and I want requests that wait too long for a connection to fail fast with a clear error rather than pile up as latency spikes. I am aware driver-level dial timeouts exist, but they cover establishing new connections, not waiting for a pooled slot.
context.WithTimeout the only option?db.Stats() offers limited detail?30275 reputation · 31 Mar 2025, 16:28 UTC
1. Does the standard library provide a timeout for waiting for a pooled connection?
Yes. Since Go 1.8 the database/sql implementation makes the internal request for a connection connRequest context‑aware. When MaxOpenConns is reached, a call to db.QueryContext or db.ExecContext blocks on a channel that also listens to ctx.Done(). If the caller’s context deadline (or cancellation) fires while the goroutine is waiting, the pool immediately aborts the wait and returns ctx.Err() (typically context.DeadlineExceeded or context.Canceled). No additional semaphore wrapper is required for a bounded acquisition period.
In practice this means you can give each request a short context deadline that covers both the *acquisition* and the *query* phases. If you want to separate the two, acquire a Conn with a short deadline and then run the query on that Conn with a longer deadline.
2. What happens to a goroutine that times out while waiting for a connection?
The pool does not hand the connection to a caller that has already been cancelled. The waiting goroutine is removed from the queue, ctx.Err() is returned, and any connection that might have been in the process of being handed over is returned to the pool (or closed if it was being created). No connection is left “unused” or leaked to a dead goroutine.
3. How to observe pool‑wait time separately from query execution?
db.Stats(): it reports WaitCount (how many times a goroutine had to wait for a connection) and WaitDuration (cumulative time spent waiting). By sampling these values over time you can compute average wait time and detect saturation.
db.Conn(ctx). The time taken to obtain a Conn is the acquisition time; subsequent calls on that Conn measure the query execution time.
Conn.Open() versus the time spent in the driver’s Query or Exec methods. The pgx stdlib driver exposes a pgx.Trace interface that can be used for this purpose.
db.SetMaxOpenConns(1)
// keep the connection busy
go func() { _, _ = db.ExecContext(ctx, "SELECT pg_sleep(10)") }()
ctx2, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, err := db.QueryContext(ctx2, "SELECT 1")
// err should be context.DeadlineExceeded
db.Stats() before and after the burst. WaitCount should increase by 1 and WaitDuration should reflect the ~100 ms wait.
If you observe that the second query blocks until the first finishes instead of returning context.DeadlineExceeded, verify:
lib/pq vs pgx) respects context cancellation during connection acquisition. Most recent drivers do.
db.QueryContext is swallowing the context.
When the built‑in context handling works, you can rely on it for fast failure under burst load, keeping latency spikes in check without adding extra semaphore logic.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.