Skip to content

Commit ef1ef2d

Browse files
committed
fix: close database pools and notification listener on shutdown
Pool closing was commented out ("@@ to be fixed, now blocks"), so the process exited with every pgx pool open and the listener's dedicated connection up: Postgres saw abrupt connection resets on each stop, and connection slots lingered until TCP teardown. Two things made Close() block: - pgxpool.Pool.Close() waits for acquired connections, and with the old 1-second shutdown window HTTP.Shutdown regularly gave up while handlers were still running and holding connections. - NotificationListener.Stop() only closed a channel that the listen loop checked between notifications, while the loop normally sits blocked inside WaitForNotification — the goroutine (and its connection) survived Stop. Now the listener's Stop cancels the loop context, which interrupts WaitForNotification, closes the connection cleanly with a bounded fresh context (so Postgres receives a Terminate message), and waits for the goroutine to exit — also when racing another Stop. Server.Shutdown then closes the engine after the HTTP drain completes — at that point no handler holds a connection — bounding the wait by the shutdown context (plus a 5s backstop when it has no deadline) so an expired drain window cannot hang the process past the supervisor's grace period.
1 parent 8a04f8c commit ef1ef2d

5 files changed

Lines changed: 106 additions & 44 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
* Session watcher no longer deadlocks when paused.
2121
* **SIGTERM** — the server now shuts down gracefully on SIGTERM (the default stop signal of Docker, Kubernetes and systemd), not only on SIGINT/Ctrl-C. A raw SIGTERM previously terminated the process immediately, cutting in-flight requests and resetting database connections.
2222
* **Exit status** — a graceful shutdown now exits with status 0; it previously exited 1, which supervisors interpret as a crash.
23+
* **Database connections on shutdown** — connection pools and the notification listener's dedicated connection are now closed after the HTTP server drains, so Postgres sees clean disconnects instead of connection resets on every stop. `NotificationListener.Stop()` now interrupts a pending `WaitForNotification` and waits for the listener goroutine to exit before returning.
2324

2425
## 0.8.1 - 2026-07-08
2526

database/notification_listener.go

Lines changed: 45 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,11 @@ type NotificationListener struct {
3535
config *ListenerConfig
3636
logger *logging.Logger
3737
connString string
38-
conn *pgx.Conn
3938
handlers map[string]NotificationHandler
4039
mu sync.RWMutex
4140
running bool
42-
stop chan struct{}
41+
cancel context.CancelFunc
42+
stopped chan struct{}
4343
}
4444

4545
// NewNotificationListener creates a new notification listener
@@ -52,7 +52,6 @@ func NewNotificationListener(connString string, config *ListenerConfig, logger *
5252
logger: logger,
5353
connString: connString,
5454
handlers: make(map[string]NotificationHandler),
55-
stop: make(chan struct{}),
5655
}
5756
}
5857

@@ -78,44 +77,52 @@ func (l *NotificationListener) Start(ctx context.Context) error {
7877
return fmt.Errorf("listener is already running")
7978
}
8079
l.running = true
80+
ctx, l.cancel = context.WithCancel(ctx)
81+
l.stopped = make(chan struct{})
82+
stopped := l.stopped
8183
l.mu.Unlock()
8284

83-
go l.listenLoop(ctx)
85+
go func() {
86+
defer close(stopped)
87+
l.listenLoop(ctx)
88+
}()
8489
return nil
8590
}
8691

87-
// Stop stops the listener
92+
// Stop stops the listener, interrupting a pending WaitForNotification, and
93+
// waits for the loop to exit, so its dedicated connection is closed when
94+
// Stop returns — also when racing another Stop. It must not be called from
95+
// a notification handler, which runs on the loop it would wait for.
8896
func (l *NotificationListener) Stop() {
8997
l.mu.Lock()
90-
defer l.mu.Unlock()
9198
if l.running {
92-
close(l.stop)
9399
l.running = false
100+
l.cancel()
101+
}
102+
stopped := l.stopped
103+
l.mu.Unlock()
104+
if stopped != nil {
105+
<-stopped
94106
}
95107
}
96108

97109
// listenLoop maintains the connection and processes notifications
98110
func (l *NotificationListener) listenLoop(ctx context.Context) {
99111
for {
100-
select {
101-
case <-ctx.Done():
102-
return
103-
case <-l.stop:
112+
err := l.connectAndListen(ctx)
113+
if ctx.Err() != nil {
114+
// Stopped: WaitForNotification reports the canceled
115+
// context as an error, not worth logging
104116
return
105-
default:
106-
err := l.connectAndListen(ctx)
107-
if err != nil {
108-
if l.logger != nil {
109-
l.logger.Err(err).Msg("Notification listener error")
110-
}
111-
select {
112-
case <-time.After(l.config.ReconnectDelay):
113-
continue
114-
case <-ctx.Done():
115-
return
116-
case <-l.stop:
117-
return
118-
}
117+
}
118+
if err != nil {
119+
if l.logger != nil {
120+
l.logger.Err(err).Msg("Notification listener error")
121+
}
122+
select {
123+
case <-time.After(l.config.ReconnectDelay):
124+
case <-ctx.Done():
125+
return
119126
}
120127
}
121128
}
@@ -127,9 +134,13 @@ func (l *NotificationListener) connectAndListen(ctx context.Context) error {
127134
if err != nil {
128135
return fmt.Errorf("failed to connect: %w", err)
129136
}
130-
defer conn.Close(ctx)
131-
132-
l.conn = conn
137+
defer func() {
138+
// Close cleanly even when ctx is already canceled (i.e. during
139+
// Stop), so Postgres sees a Terminate message instead of a reset
140+
closeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
141+
defer cancel()
142+
conn.Close(closeCtx)
143+
}()
133144

134145
_, err = conn.Exec(ctx, fmt.Sprintf("LISTEN %s", l.config.Channel))
135146
if err != nil {
@@ -141,20 +152,12 @@ func (l *NotificationListener) connectAndListen(ctx context.Context) error {
141152
}
142153

143154
for {
144-
select {
145-
case <-ctx.Done():
146-
return ctx.Err()
147-
case <-l.stop:
148-
return nil
149-
default:
150-
notification, err := conn.WaitForNotification(ctx)
151-
if err != nil {
152-
return fmt.Errorf("error waiting for notification: %w", err)
153-
}
154-
155-
if notification != nil {
156-
l.handleNotification(ctx, notification.Payload)
157-
}
155+
notification, err := conn.WaitForNotification(ctx)
156+
if err != nil {
157+
return fmt.Errorf("error waiting for notification: %w", err)
158+
}
159+
if notification != nil {
160+
l.handleNotification(ctx, notification.Payload)
158161
}
159162
}
160163
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package database
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
)
8+
9+
// Stop must interrupt the listener even while it is blocked in
10+
// WaitForNotification, and return only once the loop has exited and its
11+
// dedicated connection is closed. It used to leave both behind: the stop
12+
// channel was only checked between notifications.
13+
func TestListenerStopInterruptsWait(t *testing.T) {
14+
l := NewNotificationListener(dbe.config.URL, nil, nil)
15+
if err := l.Start(context.Background()); err != nil {
16+
t.Fatal(err)
17+
}
18+
// Let the listener connect and block waiting for notifications
19+
time.Sleep(200 * time.Millisecond)
20+
21+
done := make(chan struct{})
22+
go func() {
23+
l.Stop()
24+
close(done)
25+
}()
26+
select {
27+
case <-done:
28+
case <-time.After(3 * time.Second):
29+
t.Fatal("Stop did not return: listener stuck in WaitForNotification")
30+
}
31+
32+
// The listener must be restartable after a stop
33+
if err := l.Start(context.Background()); err != nil {
34+
t.Fatalf("restart after Stop failed: %v", err)
35+
}
36+
l.Stop()
37+
}

server/server.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,24 @@ func (s *Server) Shutdown(ctx context.Context) {
117117
}
118118
// Close goroutines (for now just the checker in the service manager)
119119
close(s.shutdown)
120-
// Close database pools - ok, not so graceful
121-
// s.DBE.Close() @@ to be fixed, now blocks
120+
// Release database resources: the notification listener and all the
121+
// connection pools. Handlers have completed by now, so pool Close()
122+
// does not wait on acquired connections — unless the graceful window
123+
// expired with requests still running, so bound the wait (by ctx,
124+
// plus a backstop when ctx has no deadline) rather than hang past
125+
// the supervisor's grace period.
126+
closed := make(chan struct{})
127+
go func() {
128+
s.DBE.Close()
129+
close(closed)
130+
}()
131+
select {
132+
case <-closed:
133+
case <-ctx.Done():
134+
s.logger.Warn().Msg("Shutdown context expired while closing database pools")
135+
case <-time.After(5 * time.Second):
136+
s.logger.Warn().Msg("Timed out closing database pools")
137+
}
122138
close(s.shutdownCompleted)
123139
})
124140
}

server/shutdown_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ func TestSIGTERMStartsGracefulShutdown(t *testing.T) {
7979
t.Fatal(err)
8080
}
8181
waitStopped(t, done, 10*time.Second)
82+
83+
// Shutdown must also have closed the database pools
84+
if _, err := s.DBE.AcquireConnection(context.Background()); err == nil {
85+
t.Fatal("expected the main pool to be closed after shutdown")
86+
}
8287
}
8388

8489
func getStatus(t *testing.T, url string) int {

0 commit comments

Comments
 (0)