Skip to content

Commit bed0ed3

Browse files
authored
add pgx listeners to database/sql (#1366)
The `database/sql` driver currently operates in poll-only mode because it cannot acquire the dedicated connection required by Postgres `LISTEN`. Cross-process actions like cancelling a running job therefore wait for a poll even when the underlying SQL driver is Pgx. Add `NewWithPgxListener`, which keeps all query and transaction execution on the supplied `*sql.DB` while delegating listener creation to a Pgx driver backed by a separately supplied pool. Preserve `New` as the poll-only constructor and reject a nil listener pool so configuration errors cannot silently disable notifications. Document the database, schema, connection ownership, and pool sizing requirements. Exercise the hybrid driver through the shared listener suite, including notification-driven cancellation and transactional job completion through an ordinary `*sql.Tx`.
1 parent eced75d commit bed0ed3

6 files changed

Lines changed: 177 additions & 7 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Added
11+
12+
- Added `riverdatabasesql.NewWithPgxListener` for using a dedicated Pgx pool to receive Postgres notifications while continuing to execute jobs and transactions through `database/sql`. [PR #1366](https://github.com/riverqueue/river/pull/1366).
13+
1014
## [0.45.0] - 2026-08-25
1115

1216
### Changed

riverdriver/riverdatabasesql/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ require (
99
github.com/lib/pq v1.12.3
1010
github.com/riverqueue/river v0.45.0
1111
github.com/riverqueue/river/riverdriver v0.45.0
12+
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.45.0
1213
github.com/riverqueue/river/rivershared v0.45.0
1314
github.com/riverqueue/river/rivertype v0.45.0
1415
github.com/stretchr/testify v1.12.1
@@ -18,7 +19,6 @@ require (
1819
github.com/jackc/pgpassfile v1.0.0 // indirect
1920
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
2021
github.com/jackc/puddle/v2 v2.2.2 // indirect
21-
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.45.0 // indirect
2222
github.com/tidwall/gjson v1.19.0 // indirect
2323
github.com/tidwall/match v1.2.0 // indirect
2424
github.com/tidwall/pretty v1.2.1 // indirect

riverdriver/riverdatabasesql/river_database_sql_driver.go

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ import (
1818
"strings"
1919
"time"
2020

21+
"github.com/jackc/pgx/v5/pgxpool"
2122
"github.com/lib/pq"
2223

2324
"github.com/riverqueue/river/riverdriver"
2425
"github.com/riverqueue/river/riverdriver/riverdatabasesql/internal/dbsqlc"
26+
"github.com/riverqueue/river/riverdriver/riverpgxv5"
2527
"github.com/riverqueue/river/rivershared/sqlctemplate"
2628
"github.com/riverqueue/river/rivershared/uniquestates"
2729
"github.com/riverqueue/river/rivershared/util/dbutil"
@@ -36,8 +38,9 @@ var migrationFS embed.FS
3638

3739
// Driver is an implementation of riverdriver.Driver for database/sql.
3840
type Driver struct {
39-
dbPool *sql.DB
40-
replacer sqlctemplate.Replacer
41+
dbPool *sql.DB
42+
listenerDriver *riverpgxv5.Driver
43+
replacer sqlctemplate.Replacer
4144
}
4245

4346
// New returns a new database/sql River driver for use with River.
@@ -51,6 +54,38 @@ func New(dbPool *sql.DB) *Driver {
5154
}
5255
}
5356

57+
// NewWithPgxListener returns a new database/sql River driver with a Pgx-backed
58+
// listener. The database/sql pool continues to be used for all database
59+
// operations other than listening for notifications. The Pgx pool is used only
60+
// to acquire dedicated connections for Postgres LISTEN commands. It panics if
61+
// listenerPool is nil; use New for a poll-only driver.
62+
//
63+
// Both pools are owned by the caller, must connect to the same database, and
64+
// must resolve the same schema. When the River client has no explicit schema,
65+
// both pools' search paths must produce the same current schema. Neither pool
66+
// may be closed while associated River objects are running.
67+
//
68+
// Listener connections are hijacked from listenerPool and never returned. A
69+
// pool dedicated to one River client can generally set MinConns to zero and
70+
// MaxConns to one. Each concurrently running client still needs its own listener
71+
// connection. Because hijacked connections no longer count
72+
// against the pool's maximum, sharing a listener pool between clients may cause
73+
// total live connections to exceed that maximum. Closing listenerPool does not
74+
// close hijacked connections; stopping the associated River clients does.
75+
//
76+
// Applications using PgBouncer must configure the listener pool to use session
77+
// pooling or connect it directly to Postgres.
78+
func NewWithPgxListener(dbPool *sql.DB, listenerPool *pgxpool.Pool) *Driver {
79+
if listenerPool == nil {
80+
panic("riverdatabasesql: listener pool must not be nil")
81+
}
82+
83+
return &Driver{
84+
dbPool: dbPool,
85+
listenerDriver: riverpgxv5.New(listenerPool),
86+
}
87+
}
88+
5489
const argPlaceholder = "$"
5590

5691
func (d *Driver) ArgPlaceholder() string { return argPlaceholder }
@@ -61,7 +96,11 @@ func (d *Driver) GetExecutor() riverdriver.Executor {
6196
}
6297

6398
func (d *Driver) GetListener(params *riverdriver.GetListenenerParams) riverdriver.Listener {
64-
panic(riverdriver.ErrNotImplemented)
99+
if d.listenerDriver == nil {
100+
panic(riverdriver.ErrNotImplemented)
101+
}
102+
103+
return d.listenerDriver.GetListener(params)
65104
}
66105

67106
func (d *Driver) GetMigrationDefaultLines() []string { return []string{riverdriver.MigrationLineMain} }
@@ -96,7 +135,7 @@ func (d *Driver) SQLFragmentColumnIn(column string, values any) (string, any, er
96135
return fmt.Sprintf("%s = any(@%s)", column, column), pq.Array(values), nil
97136
}
98137

99-
func (d *Driver) SupportsListener() bool { return false }
138+
func (d *Driver) SupportsListener() bool { return d.listenerDriver != nil }
100139
func (d *Driver) SupportsListenNotify() bool { return true }
101140
func (d *Driver) TimePrecision() time.Duration { return time.Microsecond }
102141

riverdriver/riverdatabasesql/river_database_sql_driver_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"errors"
77
"testing"
88

9+
"github.com/jackc/pgx/v5/pgxpool"
910
"github.com/stretchr/testify/require"
1011

1112
"github.com/riverqueue/river/riverdriver"
@@ -35,6 +36,37 @@ func TestNew(t *testing.T) {
3536
})
3637
}
3738

39+
func TestNewWithPgxListener(t *testing.T) {
40+
t.Parallel()
41+
42+
t.Run("PanicsOnNilListenerPool", func(t *testing.T) {
43+
t.Parallel()
44+
45+
require.PanicsWithValue(t, "riverdatabasesql: listener pool must not be nil", func() {
46+
NewWithPgxListener(&sql.DB{}, nil)
47+
})
48+
})
49+
50+
t.Run("UsesSeparateListenerPool", func(t *testing.T) {
51+
t.Parallel()
52+
53+
dbPool := &sql.DB{}
54+
listenerPool := &pgxpool.Pool{}
55+
driver := NewWithPgxListener(dbPool, listenerPool)
56+
57+
require.Equal(t, dbPool, driver.dbPool)
58+
require.NotNil(t, driver.listenerDriver)
59+
require.True(t, driver.SupportsListener())
60+
require.Equal(t, dbPool, driver.GetExecutor().(*Executor).dbPool) //nolint:forcetypeassert
61+
62+
listener1 := driver.GetListener(&riverdriver.GetListenenerParams{Schema: "schema_one"})
63+
listener2 := driver.GetListener(&riverdriver.GetListenenerParams{Schema: "schema_two"})
64+
require.NotSame(t, listener1, listener2)
65+
require.Equal(t, "schema_one", listener1.Schema())
66+
require.Equal(t, "schema_two", listener2.Schema())
67+
})
68+
}
69+
3870
func TestBitIntegerToBits(t *testing.T) {
3971
t.Parallel()
4072

riverdriver/riverdrivertest/driver_client_test.go

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/riverqueue/river/riverdriver/riversqlite"
2424
"github.com/riverqueue/river/rivershared/riversharedtest"
2525
"github.com/riverqueue/river/rivershared/testfactory"
26+
"github.com/riverqueue/river/rivershared/testsignal"
2627
"github.com/riverqueue/river/rivershared/util/testutil"
2728
"github.com/riverqueue/river/rivershared/util/urlutil"
2829
"github.com/riverqueue/river/rivertype"
@@ -57,7 +58,7 @@ func TestClientWithDriverRiverDatabaseSQLPgx(t *testing.T) {
5758
ctx = context.Background()
5859
dbPool = riversharedtest.DBPool(ctx, t)
5960
stdPool = stdlib.OpenDBFromPool(dbPool)
60-
driver = riverdatabasesql.New(stdPool)
61+
driver = riverdatabasesql.NewWithPgxListener(stdPool, dbPool)
6162
)
6263
t.Cleanup(func() { require.NoError(t, stdPool.Close()) })
6364

@@ -70,6 +71,59 @@ func TestClientWithDriverRiverDatabaseSQLPgx(t *testing.T) {
7071
)
7172
}
7273

74+
func TestClientWithDriverRiverDatabaseSQLPgxJobCompleteTx(t *testing.T) {
75+
t.Parallel()
76+
77+
var (
78+
ctx = context.Background()
79+
dbPool = riversharedtest.DBPool(ctx, t)
80+
stdPool = stdlib.OpenDBFromPool(dbPool)
81+
driver = riverdatabasesql.NewWithPgxListener(stdPool, dbPool)
82+
schema = riverdbtest.TestSchema(ctx, t, driver, nil)
83+
)
84+
t.Cleanup(func() { require.NoError(t, stdPool.Close()) })
85+
86+
var jobCompleted testsignal.TestSignal[int64]
87+
jobCompleted.Init(t)
88+
89+
type JobArgs struct {
90+
testutil.JobArgsReflectKind[JobArgs]
91+
}
92+
93+
config := newTestConfig(t, schema)
94+
config.FetchPollInterval = time.Minute
95+
river.AddWorker(config.Workers, river.WorkFunc(func(ctx context.Context, job *river.Job[JobArgs]) error {
96+
tx, err := stdPool.BeginTx(ctx, nil)
97+
if err != nil {
98+
return err
99+
}
100+
defer tx.Rollback()
101+
102+
updatedJob, err := river.JobCompleteTx[*riverdatabasesql.Driver](ctx, tx, job)
103+
if err != nil {
104+
return err
105+
}
106+
if err := tx.Commit(); err != nil {
107+
return err
108+
}
109+
110+
jobCompleted.Signal(updatedJob.ID)
111+
return nil
112+
}))
113+
114+
client, err := river.NewClient(driver, config)
115+
require.NoError(t, err)
116+
startClient(ctx, t, client)
117+
118+
insertRes, err := client.Insert(ctx, &JobArgs{}, nil)
119+
require.NoError(t, err)
120+
require.Equal(t, insertRes.Job.ID, jobCompleted.WaitOrTimeout())
121+
122+
completedJob, err := client.JobGet(ctx, insertRes.Job.ID)
123+
require.NoError(t, err)
124+
require.Equal(t, rivertype.JobStateCompleted, completedJob.State)
125+
}
126+
73127
func TestClientWithDriverRiverPgxV5(t *testing.T) {
74128
t.Parallel()
75129

@@ -301,6 +355,47 @@ func ExerciseClient[TTx any](ctx context.Context, t *testing.T,
301355
require.Equal(t, insertRes.Job.Kind, event.Job.Kind)
302356
})
303357

358+
t.Run("CancelRunningJobWithListener", func(t *testing.T) {
359+
t.Parallel()
360+
361+
config, bundle := setupConfig(t)
362+
if bundle.driver.DatabaseName() != riverdriver.DatabaseNamePostgres || !bundle.driver.SupportsListener() {
363+
t.Skip("requires a Postgres listener")
364+
}
365+
config.FetchPollInterval = time.Minute
366+
367+
client, err := river.NewClient(bundle.driver, config)
368+
require.NoError(t, err)
369+
370+
var jobStarted testsignal.TestSignal[int64]
371+
jobStarted.Init(t)
372+
373+
type JobArgs struct {
374+
testutil.JobArgsReflectKind[JobArgs]
375+
}
376+
377+
river.AddWorker(bundle.config.Workers, river.WorkFunc(func(ctx context.Context, job *river.Job[JobArgs]) error {
378+
jobStarted.Signal(job.ID)
379+
<-ctx.Done()
380+
return ctx.Err()
381+
}))
382+
383+
subscribeChan := subscribe(t, client)
384+
startClient(ctx, t, client)
385+
386+
insertRes, err := client.Insert(ctx, &JobArgs{}, nil)
387+
require.NoError(t, err)
388+
require.Equal(t, insertRes.Job.ID, jobStarted.WaitOrTimeout())
389+
390+
updatedJob, err := client.JobCancel(ctx, insertRes.Job.ID)
391+
require.NoError(t, err)
392+
require.Equal(t, rivertype.JobStateRunning, updatedJob.State)
393+
394+
event := riversharedtest.WaitOrTimeout(t, subscribeChan)
395+
require.Equal(t, river.EventKindJobCancelled, event.Kind)
396+
require.Equal(t, rivertype.JobStateCancelled, event.Job.State)
397+
})
398+
304399
t.Run("JobDelete", func(t *testing.T) {
305400
t.Parallel()
306401

riverdriver/riverdrivertest/driver_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ func TestDriverRiverDatabaseSQLPgx(t *testing.T) {
6868
ctx = context.Background()
6969
dbPool = riversharedtest.DBPool(ctx, t)
7070
stdPool = stdlib.OpenDBFromPool(dbPool)
71-
driver = riverdatabasesql.New(stdPool)
71+
driver = riverdatabasesql.NewWithPgxListener(stdPool, dbPool)
7272
)
7373
t.Cleanup(func() { require.NoError(t, stdPool.Close()) })
7474

0 commit comments

Comments
 (0)