Skip to content

Commit b4041ac

Browse files
committed
DLQ redrive
1 parent 3f1980d commit b4041ac

14 files changed

Lines changed: 353 additions & 17 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ This will likely cater the most to teams already familiar with the simplicity of
4747
* Job dependency workflow orchestration
4848
* Cron scheduling, job deferral
4949
* Queue storage policies to support a variety of rate limiting, debouncing, and concurrency use cases
50-
* Priority queues, dead letter queues, automatic retries with exponential backoff
50+
* Priority queues, dead letter queues with redrive, automatic retries with exponential backoff
5151
* Pub/sub API for fan-out queue relationships
5252
* SQL support for non-Node.js runtimes for most operations
5353
* Serverless function compatible

docs/api/jobs.md

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,10 +319,20 @@ Returns an array of jobs from a queue
319319
blocking: boolean,
320320
deadLetter: string,
321321
policy: string,
322-
output: object
322+
output: object,
323+
sourceName: string | null,
324+
sourceId: string | null,
325+
sourceCreatedOn: Date | null,
326+
sourceRetryCount: number | null
323327
}
324328
```
325329

330+
When a job is moved into a dead letter queue, the `source*` fields record where it
331+
came from: `sourceName` is the queue it originally failed on, `sourceId` is the id
332+
of the original job, `sourceCreatedOn` is the original job's creation time (so its
333+
true age survives the move), and `sourceRetryCount` is how many retries it consumed
334+
before being dead-lettered. These are `null` for jobs that were not dead-lettered.
335+
326336

327337
**Notes**
328338

@@ -354,6 +364,37 @@ Deletes a job by id.
354364

355365
Deletes a set of jobs by id.
356366

367+
### `redrive(name, options)`
368+
369+
Moves jobs out of a dead letter queue and re-creates them as fresh jobs on their original source queue. `name` is the
370+
dead letter queue to drain. Returns the number of jobs moved.
371+
372+
Each job is routed back to the queue it originally failed on (its `sourceName`),
373+
so a single dead letter queue that collects from many source queues fans back out
374+
correctly. Re-created jobs get a new id, a reset retry count, cleared output, and
375+
the destination queue's current retry, retention, and policy configuration. Only
376+
jobs that are not currently being processed (still in the `created`/`retry` state)
377+
are moved.
378+
379+
`options`:
380+
381+
- `destination`override queue to move all matched jobs into, instead of each
382+
job's original source queue. Required to redrive jobs that have no recorded
383+
source queue (e.g. jobs dead-lettered before this feature existed); such jobs are
384+
left in place otherwise.
385+
- `sourceName`only redrive jobs that originated from this source queue.
386+
- `limit`maximum number of jobs to move in this call, oldest first (default
387+
`1000`). Loop or schedule repeated calls to drain large dead letter queues at a
388+
controlled rate.
389+
390+
```js
391+
// drain a dead letter queue back to its source queues, 500 at a time
392+
let moved
393+
do {
394+
moved = await boss.redrive('email-dlq', { limit: 500 })
395+
} while (moved > 0)
396+
```
397+
357398
### `deleteQueuedJobs(name)`
358399

359400
Deletes all queued jobs in a queue.

docs/api/queues.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Allowed policy values:
4141
4242
* **deadLetter**, string
4343
44-
When a job fails after all retries, if the queue has a `deadLetter` property, the job's payload will be copied into that queue, copying the same retention and retry configuration as the original job.
44+
When a job fails after all retries, if the queue has a `deadLetter` property, the job's payload will be copied into that queue, copying the same retention and retry configuration as the original job. The dead-lettered job also records where it came from via the `sourceName`, `sourceId`, `sourceCreatedOn`, and `sourceRetryCount` fields, which power [`redrive()`](jobs#redrivename-options) for moving jobs back to their source queue.
4545
4646
* **warningQueueSize**, int
4747

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ This will likely cater the most to teams already familiar with the simplicity of
5959
* Job dependency workflow orchestration
6060
* Cron scheduling, job deferral
6161
* Queue storage policies to support a variety of rate limiting, debouncing, and concurrency use cases
62-
* Priority queues, dead letter queues, automatic retries with exponential backoff
62+
* Priority queues, dead letter queues with redrive, automatic retries with exponential backoff
6363
* Pub/sub API for fan-out queue relationships
6464
* SQL support for non-Node.js runtimes for most operations
6565
* Serverless function compatible

package-lock.json

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

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "pg-boss",
3-
"version": "12.22.0",
3+
"version": "12.23.0",
44
"description": "Queueing jobs in Postgres from Node.js like a boss",
55
"type": "module",
66
"main": "./dist/index.js",
@@ -61,7 +61,7 @@
6161
"docs:readme": "node ./scripts/sync-readme.js"
6262
},
6363
"pgboss": {
64-
"schema": 33
64+
"schema": 34
6565
},
6666
"repository": {
6767
"type": "git",

src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,10 @@ export class PgBoss extends EventEmitter<types.PgBossEventMap> {
301301
return this.#manager.deleteJob(name, id, options)
302302
}
303303

304+
redrive (name: string, options?: types.RedriveOptions): Promise<number> {
305+
return this.#manager.redrive(name, options)
306+
}
307+
304308
deleteQueuedJobs (name: string): Promise<void> {
305309
return this.#manager.deleteQueuedJobs(name)
306310
}

src/manager.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1405,7 +1405,7 @@ class Manager extends EventEmitter implements types.EventsMixin {
14051405

14061406
// Insert to dead letter queue if failed and has dead_letter configured
14071407
if (job.dead_letter) {
1408-
await tx.executeSql(dlqSql, [job.dead_letter, job.data, jobOutput])
1408+
await tx.executeSql(dlqSql, [job.dead_letter, job.data, jobOutput, job.name, job.id, job.created_on, job.retry_count])
14091409
}
14101410
}
14111411

@@ -1425,6 +1425,28 @@ class Manager extends EventEmitter implements types.EventsMixin {
14251425
return this.mapCommandResponse(ids, result)
14261426
}
14271427

1428+
async redrive (name: string, options: types.RedriveOptions = {}): Promise<number> {
1429+
Attorney.assertQueueName(name)
1430+
1431+
const { destination, sourceName, limit = 1000 } = options
1432+
1433+
if (destination !== undefined) {
1434+
Attorney.assertQueueName(destination)
1435+
}
1436+
1437+
if (sourceName !== undefined) {
1438+
Attorney.assertQueueName(sourceName)
1439+
}
1440+
1441+
assert(Number.isInteger(limit) && limit >= 1, 'limit must be an integer >= 1')
1442+
1443+
const db = this.assertDb(options)
1444+
const { table } = await this.getQueueCache(name)
1445+
const sql = plans.redriveJobs(this.config.schema, table)
1446+
const result = await db.executeSql(sql, [name, destination ?? null, sourceName ?? null, limit])
1447+
return result.rows[0].moved as number
1448+
}
1449+
14281450
async cancel (name: string, id: string | string[], options: types.ConnectionOptions = {}) {
14291451
Attorney.assertQueueName(name)
14301452
const db = this.assertDb(options)

src/migrationStore.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,6 +1005,26 @@ function getAll (schema: string): types.Migration[] {
10051005
`SELECT ${schema}.job_table_run($cmd$DROP INDEX IF EXISTS ${schema}.job_i9$cmd$)`,
10061006
`ALTER TABLE ${schema}.version DROP COLUMN flow_on`
10071007
]
1008+
},
1009+
{
1010+
release: '12.23.0',
1011+
version: 34,
1012+
previous: 33,
1013+
// Dead-letter source provenance. Plain columns on the partitioned parent cascade to
1014+
// job_common (DEFAULT partition) and every existing/future partition, so no job_table_run
1015+
// fan-out or createQueueFn bump is needed (queue-creation/index logic is unchanged).
1016+
install: [
1017+
`ALTER TABLE ${schema}.job ADD COLUMN IF NOT EXISTS source_name text`,
1018+
`ALTER TABLE ${schema}.job ADD COLUMN IF NOT EXISTS source_id uuid`,
1019+
`ALTER TABLE ${schema}.job ADD COLUMN IF NOT EXISTS source_created_on timestamp with time zone`,
1020+
`ALTER TABLE ${schema}.job ADD COLUMN IF NOT EXISTS source_retry_count int`
1021+
],
1022+
uninstall: [
1023+
`ALTER TABLE ${schema}.job DROP COLUMN source_name`,
1024+
`ALTER TABLE ${schema}.job DROP COLUMN source_id`,
1025+
`ALTER TABLE ${schema}.job DROP COLUMN source_created_on`,
1026+
`ALTER TABLE ${schema}.job DROP COLUMN source_retry_count`
1027+
]
10081028
}
10091029
]
10101030
}

src/plans.ts

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,11 @@ function createTableJob (schema: string, noPartitioning = false) {
366366
heartbeat_seconds int,
367367
blocked boolean not null default false,
368368
blocking boolean not null default false,
369-
pending_dependencies int not null default 0
369+
pending_dependencies int not null default 0,
370+
source_name text,
371+
source_id uuid,
372+
source_created_on timestamp with time zone,
373+
source_retry_count int
370374
) ${partitionClause}
371375
`
372376
}
@@ -394,7 +398,11 @@ const JOB_COLUMNS_ALL = `${JOB_COLUMNS_MIN},
394398
blocked,
395399
blocking,
396400
pending_dependencies as "pendingDependencies",
397-
output
401+
output,
402+
source_name as "sourceName",
403+
source_id as "sourceId",
404+
source_created_on as "sourceCreatedOn",
405+
source_retry_count as "sourceRetryCount"
398406
`
399407

400408
function createTableJobCommon (schema: string) {
@@ -1691,16 +1699,21 @@ function failJobsBody (schema: string, table: string, where: string, output: str
16911699
SELECT * FROM failed_jobs
16921700
),
16931701
dlq_jobs as (
1694-
INSERT INTO ${schema}.job (name, data, output, retry_limit, retry_backoff, retry_delay, keep_until, deletion_seconds)
1702+
INSERT INTO ${schema}.job (name, data, output, retry_limit, retry_backoff, retry_delay, keep_until, deletion_seconds,
1703+
source_name, source_id, source_created_on, source_retry_count)
16951704
SELECT
16961705
r.dead_letter,
1697-
data,
1698-
output,
1706+
r.data,
1707+
r.output,
16991708
q.retry_limit,
17001709
q.retry_backoff,
17011710
q.retry_delay,
17021711
now() + q.retention_seconds * interval '1s',
1703-
q.deletion_seconds
1712+
q.deletion_seconds,
1713+
r.name,
1714+
r.id,
1715+
r.created_on,
1716+
r.retry_count
17041717
FROM results r
17051718
JOIN ${schema}.queue q ON q.name = r.dead_letter
17061719
WHERE state = '${JOB_STATES.failed}'
@@ -1911,12 +1924,52 @@ export function insertRetryJob (schema: string, table: string): string {
19111924

19121925
export function insertDeadLetterJob (schema: string): string {
19131926
return `
1914-
INSERT INTO ${schema}.job (name, data, output, retry_limit, retry_backoff, retry_delay, keep_until, deletion_seconds)
1915-
SELECT $1, $2, $3, q.retry_limit, q.retry_backoff, q.retry_delay, now() + q.retention_seconds * interval '1s', q.deletion_seconds
1927+
INSERT INTO ${schema}.job (name, data, output, retry_limit, retry_backoff, retry_delay, keep_until, deletion_seconds,
1928+
source_name, source_id, source_created_on, source_retry_count)
1929+
SELECT $1, $2, $3, q.retry_limit, q.retry_backoff, q.retry_delay, now() + q.retention_seconds * interval '1s', q.deletion_seconds,
1930+
$4, $5, $6, $7
19161931
FROM ${schema}.queue q WHERE q.name = $1
19171932
`
19181933
}
19191934

1935+
// Dead-letter redrive. Moves un-started jobs out of a dead-letter queue and
1936+
// re-creates them as fresh jobs on their original source queue (or $2 destination override),
1937+
// oldest-first, capped at $4. The JOIN in `candidates` only matches jobs whose destination queue
1938+
// exists, so legacy/orphaned jobs (NULL source_name, no override) are never deleted — they stay
1939+
// in the DLQ rather than being lost. Re-created jobs get a new id, `created` state, retry_count 0,
1940+
// cleared output, NULL source_*, and the destination queue's current retry/retention/policy config.
1941+
export function redriveJobs (schema: string, table: string): string {
1942+
return `
1943+
WITH candidates AS (
1944+
SELECT j.id
1945+
FROM ${schema}.${table} j
1946+
JOIN ${schema}.queue q ON q.name = COALESCE($2, j.source_name)
1947+
WHERE j.name = $1
1948+
AND j.state < '${JOB_STATES.active}'
1949+
AND ($3::text IS NULL OR j.source_name = $3)
1950+
ORDER BY j.created_on
1951+
LIMIT $4
1952+
FOR UPDATE OF j SKIP LOCKED
1953+
),
1954+
moved AS (
1955+
DELETE FROM ${schema}.${table}
1956+
WHERE id IN (SELECT id FROM candidates)
1957+
RETURNING *
1958+
),
1959+
ins AS (
1960+
INSERT INTO ${schema}.job
1961+
(name, data, priority, retry_limit, retry_backoff, retry_delay, retry_delay_max,
1962+
expire_seconds, keep_until, deletion_seconds, policy)
1963+
SELECT COALESCE($2, m.source_name), m.data, m.priority, q.retry_limit, q.retry_backoff,
1964+
q.retry_delay, q.retry_delay_max, q.expire_seconds,
1965+
now() + q.retention_seconds * interval '1s', q.deletion_seconds, q.policy
1966+
FROM moved m JOIN ${schema}.queue q ON q.name = COALESCE($2, m.source_name)
1967+
RETURNING 1
1968+
)
1969+
SELECT count(*)::int AS moved FROM ins
1970+
`
1971+
}
1972+
19201973
export function deletion (schema: string, table: string, queues: string[], noAdvisoryLocks?: boolean): string {
19211974
const sql = `
19221975
DELETE FROM ${schema}.${table}

0 commit comments

Comments
 (0)