Skip to content

Commit a5a3e71

Browse files
committed
added LISTEN tuning config and docs, versioning, deps
1 parent 1d1443b commit a5a3e71

7 files changed

Lines changed: 1043 additions & 640 deletions

File tree

docs/api/constructor.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@ The following options can be set as properties in an object for additional confi
4040

4141
Number of milliseconds to wait before timing out when acquiring a new client from the pool. Set to `0` to disable the timeout and wait indefinitely.
4242

43+
* **notifyHeartbeatIntervalMs** - int, defaults to 10000
44+
45+
Interval between heartbeat checks on the dedicated LISTEN/NOTIFY connection. Lower values detect silent connection drops faster at the cost of more heartbeat queries.
46+
47+
* **notifyHeartbeatTimeoutMs** - int, defaults to 5000
48+
49+
Timeout for each LISTEN/NOTIFY heartbeat query. If a heartbeat does not complete within this window the listener is torn down and reconnected. Raise this on a loaded database where the default is too aggressive.
50+
51+
* **notifyKeepAliveInitialDelayMs** - int, defaults to 10000
52+
53+
TCP keepalive initial delay for the dedicated LISTEN/NOTIFY connection.
54+
4355
* **db** - object
4456

4557
Passing an object named db allows you "bring your own database connection". This option may be beneficial if you'd like to use an existing database service with its own connection pool. Setting this option will bypass the above configuration.

package-lock.json

Lines changed: 987 additions & 619 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "pg-boss",
3-
"version": "12.26.1",
3+
"version": "12.26.2",
44
"description": "Queueing jobs in Postgres from Node.js like a boss",
55
"type": "module",
66
"main": "./dist/index.js",
@@ -18,8 +18,8 @@
1818
},
1919
"devDependencies": {
2020
"@electric-sql/pglite": "^0.5.4",
21-
"@prisma/adapter-pg": "^7.8.0",
22-
"@prisma/client": "^7.8.0",
21+
"@prisma/adapter-pg": "^7.9.0",
22+
"@prisma/client": "^7.9.0",
2323
"@tsconfig/node-ts": "^23.6.4",
2424
"@tsconfig/node22": "^22.0.5",
2525
"@types/luxon": "^3.7.2",
@@ -31,10 +31,10 @@
3131
"drizzle-orm": "^1.0.0-rc.4",
3232
"eslint": "^9.39.5",
3333
"knex": "^3.3.0",
34-
"kysely": "^0.29.3",
34+
"kysely": "^0.29.4",
3535
"luxon": "^3.7.2",
3636
"neostandard": "^0.13.0",
37-
"prisma": "^7.8.0",
37+
"prisma": "^7.9.0",
3838
"tsx": "^4.23.1",
3939
"typescript": "^6.0.3",
4040
"vitest": "^4.0.18"

src/db.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import type * as types from './types.ts'
66
// Keep silent network failures below the default 30-second notify polling backstop: in the
77
// worst case a failure happens immediately after a successful check, then takes one interval,
88
// one query timeout, and the existing first reconnect backoff (1s) to restore LISTEN.
9-
const LISTEN_HEARTBEAT_INTERVAL_MS = 10000
10-
const LISTEN_HEARTBEAT_TIMEOUT_MS = 5000
11-
const LISTEN_KEEP_ALIVE_INITIAL_DELAY_MS = 10000
9+
const DEFAULT_LISTEN_HEARTBEAT_INTERVAL_MS = 10000
10+
const DEFAULT_LISTEN_HEARTBEAT_TIMEOUT_MS = 5000
11+
const DEFAULT_LISTEN_KEEP_ALIVE_INITIAL_DELAY_MS = 10000
1212

1313
class Db extends EventEmitter implements types.IDatabase, types.EventsMixin {
1414
private pool!: pg.Pool
@@ -79,8 +79,9 @@ class Db extends EventEmitter implements types.IDatabase, types.EventsMixin {
7979
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
8080
let heartbeatTimer: ReturnType<typeof setTimeout> | null = null
8181
let attempt = 0
82-
const heartbeatInterval = this.config.__test__listenHeartbeatIntervalMs ?? LISTEN_HEARTBEAT_INTERVAL_MS
83-
const heartbeatTimeout = this.config.__test__listenHeartbeatTimeoutMs ?? LISTEN_HEARTBEAT_TIMEOUT_MS
82+
const heartbeatInterval = this.config.notifyHeartbeatIntervalMs ?? DEFAULT_LISTEN_HEARTBEAT_INTERVAL_MS
83+
const heartbeatTimeout = this.config.notifyHeartbeatTimeoutMs ?? DEFAULT_LISTEN_HEARTBEAT_TIMEOUT_MS
84+
const keepAliveInitialDelay = this.config.notifyKeepAliveInitialDelayMs ?? DEFAULT_LISTEN_KEEP_ALIVE_INITIAL_DELAY_MS
8485
// Only self-heal once the listener has been established at least once. If the INITIAL connect
8586
// fails, the rejection propagates to the caller (Notifier.start), which falls back to
8687
// polling-only and discards this subscription's close handle — so a reconnect scheduled from
@@ -161,7 +162,7 @@ class Db extends EventEmitter implements types.IDatabase, types.EventsMixin {
161162
const next = new pg.Client({
162163
...this.config,
163164
keepAlive: true,
164-
keepAliveInitialDelayMillis: LISTEN_KEEP_ALIVE_INITIAL_DELAY_MS
165+
keepAliveInitialDelayMillis: keepAliveInitialDelay
165166
})
166167

167168
next.on('error', error => {

src/types.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,25 @@ export interface DatabaseOptions {
4545
max?: number;
4646
db?: IDatabase;
4747
connectionTimeoutMillis?: number;
48+
/**
49+
* Interval in milliseconds between LISTEN/NOTIFY heartbeat checks on the dedicated
50+
* listener connection. Lower values detect silent connection drops faster at the cost
51+
* of more heartbeat queries. Defaults to 10000.
52+
*/
53+
notifyHeartbeatIntervalMs?: number;
54+
/**
55+
* Timeout in milliseconds for each LISTEN/NOTIFY heartbeat query. If a heartbeat does
56+
* not complete within this window the listener is torn down and reconnected. Raise this
57+
* on a loaded database where the default is too aggressive. Defaults to 5000.
58+
*/
59+
notifyHeartbeatTimeoutMs?: number;
60+
/**
61+
* TCP keepalive initial delay in milliseconds for the dedicated LISTEN/NOTIFY connection.
62+
* Defaults to 10000.
63+
*/
64+
notifyKeepAliveInitialDelayMs?: number;
4865
/** @internal */
4966
debug?: boolean;
50-
/** @internal */
51-
__test__listenHeartbeatIntervalMs?: number;
52-
/** @internal */
53-
__test__listenHeartbeatTimeoutMs?: number;
5467
}
5568

5669
export interface SchedulingOptions {

test/dbListenTest.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ helper.describePglite('db listen/notify', function () {
3232
application_name: 'pgboss_half_open_test',
3333
host: '127.0.0.1',
3434
port: proxyPort,
35-
__test__listenHeartbeatIntervalMs: 100,
36-
__test__listenHeartbeatTimeoutMs: 100
35+
notifyHeartbeatIntervalMs: 100,
36+
notifyHeartbeatTimeoutMs: 100
3737
})
3838
const channel = 'pgboss_db_half_open_test'
3939
const payloads: string[] = []
@@ -77,8 +77,8 @@ helper.describePglite('db listen/notify', function () {
7777
const db = new Db({
7878
...helper.getConfig(),
7979
application_name: 'pgboss_lost_subscription_test',
80-
__test__listenHeartbeatIntervalMs: 100,
81-
__test__listenHeartbeatTimeoutMs: 100
80+
notifyHeartbeatIntervalMs: 100,
81+
notifyHeartbeatTimeoutMs: 100
8282
})
8383
const channel = 'pgboss_db_lost_subscription_test'
8484
const payloads: string[] = []

test/workLifecycleTest.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ describe('work lifecycle', function () {
221221
const localConcurrency = 3
222222
const abortedJobs: string[] = []
223223
const jobIds: (string | null)[] = []
224+
let started = 0
224225

225226
// Send 3 jobs
226227
for (let i = 0; i < 3; i++) {
@@ -229,6 +230,7 @@ describe('work lifecycle', function () {
229230
}
230231

231232
await ctx.boss.work(ctx.schema, { localConcurrency, pollingIntervalSeconds: 0.5 }, async ([job]) => {
233+
started++
232234
// All jobs check for abort signal
233235
for (let i = 0; i < 100; i++) {
234236
if (job.signal.aborted) {
@@ -239,8 +241,15 @@ describe('work lifecycle', function () {
239241
}
240242
})
241243

242-
// Wait for all workers to start
243-
await delay(500)
244+
// Wait until all 3 workers have actually picked up a job before stopping. A fixed
245+
// delay races the last fetch under load: a worker that hasn't fetched when stop()
246+
// halts fetching never runs its handler, so its signal is never checked (abortedJobs
247+
// ends at 2 instead of 3). Gate on the real in-flight count instead.
248+
for (let i = 0; i < 50; i++) {
249+
if (started >= localConcurrency) break
250+
await delay(100)
251+
}
252+
expect(started).toBe(localConcurrency)
244253

245254
// Stop with short timeout - jobs take 10s, so timeout will expire
246255
await ctx.boss.stop({ timeout: 1000 })

0 commit comments

Comments
 (0)