Skip to content

Commit d26d5e2

Browse files
committed
Add PostgreSQL listen and notify
The client already knows the host, credentials, and TLS settings. Listen opens a dedicated connection from those settings so a notification listener does not need a second configuration. Notify publishes on a pooled connection. Signed-off-by: Bryan Frimin <bryan@probo.com>
1 parent 7fae5fb commit d26d5e2

6 files changed

Lines changed: 204 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.14.0] - 2026-10-22
11+
12+
### Added
13+
14+
- **pg**: `Client.Listen` subscribes to a PostgreSQL channel on a dedicated connection that reuses the client's address, credentials, TLS, and tracing. `Client.Notify` publishes a payload on a pooled connection.
15+
16+
### Changed
17+
18+
- **log**: `ErrorCtx` logs a canceled context at warn level. A client disconnect is not a server failure. Deadline exceeded stays at error.
19+
20+
## [0.13.1] - 2026-06-18
21+
22+
### Fixed
23+
24+
- **unit**: Keep the signal context separate from the run context, so a shutdown signal cancels the process without replacing the run context's cause.
25+
26+
## [0.13.0] - 2026-06-18
27+
28+
### Changed
29+
30+
- **unit**: `RunContext` returns `context.Canceled` after a shutdown signal.
31+
32+
## [0.12.0] - 2026-06-18
33+
34+
### Added
35+
36+
- **worker**: Recover panics while claiming and processing tasks, and while recovering stale tasks, and record them instead of crashing the process.
37+
1038
## [0.11.0] - 2026-05-25
1139

1240
### Added

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Kit provides foundational packages for building production-grade Go services wit
1616
|---------|-------------|
1717
| `unit` | Application lifecycle, configuration, signal handling, and graceful shutdown |
1818
| `log` | Structured logging with `slog`, trace correlation, and multiple output formats |
19-
| `pg` | PostgreSQL client with connection pooling, tracing, and metrics |
19+
| `pg` | PostgreSQL client with connection pooling, LISTEN/NOTIFY, tracing, and metrics |
2020
| `migrator` | Database schema migrations with advisory locking |
2121
| `httpserver` | HTTP server with request tracing and metrics |
2222
| `httpclient` | HTTP client with telemetry and connection management |

‎pg/client.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ type (
7272

7373
tlsConfig *tls.Config
7474

75-
pool *pgxpool.Pool
75+
pool *pgxpool.Pool
76+
connConfig *pgx.ConnConfig
7677

7778
tracerProvider trace.TracerProvider
7879
tracer trace.Tracer
@@ -476,6 +477,10 @@ func NewClient(options ...Option) (*Client, error) {
476477

477478
config.ConnConfig.Tracer = multitracer.New(tracers...)
478479

480+
// LISTEN holds a connection for its whole lifetime, so it cannot come
481+
// from the pool. Keep a copy of the same connection settings.
482+
c.connConfig = config.ConnConfig.Copy()
483+
479484
pool, err := pgxpool.NewWithConfig(context.Background(), config)
480485
if err != nil {
481486
return nil, fmt.Errorf("cannot create connection pool from config: %w", err)

‎pg/doc.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
// Package pg is a wrapper around the pgx PostgreSQL driver, providing
22
// a higher-level client with features for connection pooling,
3-
// tracing, and metrics.
3+
// tracing, metrics, and LISTEN/NOTIFY.
44
package pg

‎pg/listen.go‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright (c) 2024 Bryan Frimin <bryan@frimin.fr>.
2+
//
3+
// Permission to use, copy, modify, and/or distribute this software
4+
// for any purpose with or without fee is hereby granted, provided
5+
// that the above copyright notice and this permission notice appear
6+
// in all copies.
7+
//
8+
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL
9+
// WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED
10+
// WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE
11+
// AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR
12+
// CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
13+
// OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
14+
// NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
15+
// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
16+
17+
package pg
18+
19+
import (
20+
"context"
21+
"fmt"
22+
23+
"github.com/jackc/pgx/v5"
24+
)
25+
26+
// Listen opens a dedicated connection and calls handler for each
27+
// notification delivered on channel. It returns when ctx is canceled
28+
// or the connection fails. The caller decides whether to reconnect.
29+
//
30+
// The connection is not taken from the pool. LISTEN holds it until
31+
// the call returns, and the pool would otherwise recycle it.
32+
func (c *Client) Listen(
33+
ctx context.Context,
34+
channel string,
35+
handler func(payload string),
36+
) error {
37+
if channel == "" {
38+
return fmt.Errorf("cannot listen for PostgreSQL notifications: channel is empty")
39+
}
40+
41+
if handler == nil {
42+
return fmt.Errorf("cannot listen for PostgreSQL notifications: handler is nil")
43+
}
44+
45+
conn, err := pgx.ConnectConfig(ctx, c.connConfig)
46+
if err != nil {
47+
return fmt.Errorf("cannot connect PostgreSQL notification listener: %w", err)
48+
}
49+
50+
defer func() { _ = conn.Close(context.Background()) }()
51+
52+
listen := "LISTEN " + pgx.Identifier{channel}.Sanitize()
53+
if _, err := conn.Exec(ctx, listen); err != nil {
54+
return fmt.Errorf("cannot listen for PostgreSQL notifications: %w", err)
55+
}
56+
57+
for {
58+
notification, err := conn.WaitForNotification(ctx)
59+
if err != nil {
60+
return fmt.Errorf("cannot wait for PostgreSQL notification: %w", err)
61+
}
62+
63+
handler(notification.Payload)
64+
}
65+
}
66+
67+
// Notify publishes payload on channel using a pooled connection.
68+
//
69+
// A notification that must commit together with a write has to run
70+
// inside that transaction, with SELECT pg_notify($1, $2). This method
71+
// publishes immediately on its own connection.
72+
func (c *Client) Notify(
73+
ctx context.Context,
74+
channel string,
75+
payload string,
76+
) error {
77+
if channel == "" {
78+
return fmt.Errorf("cannot notify PostgreSQL channel: channel is empty")
79+
}
80+
81+
return c.WithConn(
82+
ctx,
83+
func(ctx context.Context, conn Querier) error {
84+
_, err := conn.Exec(ctx, "SELECT pg_notify($1, $2)", channel, payload)
85+
if err != nil {
86+
return fmt.Errorf("cannot notify PostgreSQL channel: %w", err)
87+
}
88+
89+
return nil
90+
},
91+
)
92+
}

‎pg/listen_test.go‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package pg_test
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"testing"
7+
"time"
8+
9+
"github.com/prometheus/client_golang/prometheus"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
"go.gearno.de/kit/pg"
13+
)
14+
15+
func TestListenRejectsEmptyChannel(t *testing.T) {
16+
client, err := pg.NewClient(
17+
pg.WithRegisterer(prometheus.NewRegistry()),
18+
)
19+
require.NoError(t, err)
20+
t.Cleanup(client.Close)
21+
22+
err = client.Listen(context.Background(), "", func(string) {})
23+
require.Error(t, err)
24+
25+
err = client.Notify(context.Background(), "", "payload")
26+
require.Error(t, err)
27+
}
28+
29+
func TestListenReceivesNotification(t *testing.T) {
30+
client := newTestClient(t)
31+
32+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
33+
defer cancel()
34+
35+
channel := fmt.Sprintf("kit_listen_%d", time.Now().UnixNano())
36+
received := make(chan string, 1)
37+
done := make(chan error, 1)
38+
39+
go func() {
40+
done <- client.Listen(
41+
ctx,
42+
channel,
43+
func(payload string) {
44+
received <- payload
45+
cancel()
46+
},
47+
)
48+
}()
49+
50+
require.Eventually(
51+
t,
52+
func() bool {
53+
err := client.Notify(ctx, channel, "hello")
54+
if err != nil {
55+
return false
56+
}
57+
58+
select {
59+
case payload := <-received:
60+
assert.Equal(t, "hello", payload)
61+
return true
62+
case <-time.After(20 * time.Millisecond):
63+
return false
64+
}
65+
},
66+
4*time.Second,
67+
20*time.Millisecond,
68+
)
69+
70+
select {
71+
case err := <-done:
72+
require.Error(t, err)
73+
case <-time.After(time.Second):
74+
t.Fatal("listener did not return after cancellation")
75+
}
76+
}

0 commit comments

Comments
 (0)