@pikku/queue-pg-boss 0.12.3 → 0.12.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,81 @@
|
|
|
1
|
+
## 0.12.5
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 05aba57: fix(scheduler): prune orphaned recurring-task schedules on start
|
|
6
|
+
|
|
7
|
+
When a scheduled task is removed from code, its pg-boss schedule row survived and
|
|
8
|
+
kept producing jobs into a queue with no registered worker — the jobs piled up as
|
|
9
|
+
'created' forever and could flood the queue (thousands of stuck jobs). `start()`
|
|
10
|
+
now drops any `pikku-recurring-scheduled-task_*` schedule and queue that is no
|
|
11
|
+
longer registered, so task removals self-heal on the next boot.
|
|
12
|
+
|
|
13
|
+
- Updated dependencies [a8022e5]
|
|
14
|
+
- @pikku/core@0.12.43
|
|
15
|
+
|
|
16
|
+
## 0.12.4
|
|
17
|
+
|
|
18
|
+
### Patch Changes
|
|
19
|
+
|
|
20
|
+
- 92cd5b1: feat(workflow): workflow-owned step retries + stable invocationId
|
|
21
|
+
|
|
22
|
+
The workflow — not the queue — now owns step retry policy, and each step
|
|
23
|
+
invocation gets a stable idempotency key.
|
|
24
|
+
- **Default `retries: 5` with exponential backoff.** A step with no `retries`
|
|
25
|
+
previously inherited the queue's bare default (e.g. pg-boss `retry_limit 2`,
|
|
26
|
+
no backoff) so retries fired instantly and couldn't outlast a transient
|
|
27
|
+
outage. Retries now default to 5 with backoff, resolved at the workflow layer.
|
|
28
|
+
- **`retries: 0` is honored.** Dispatch previously passed `undefined` options
|
|
29
|
+
for `retries: 0`, letting the queue re-run a non-idempotent step up to its own
|
|
30
|
+
default. The resolved policy now always sets `attempts` (`retries: 0` →
|
|
31
|
+
`attempts: 1`), so the queue never second-guesses the workflow. The persisted
|
|
32
|
+
step retries and the dispatched `attempts` are resolved together so
|
|
33
|
+
"retries exhausted" and "no more redeliveries" are the same event.
|
|
34
|
+
- **`workflowStep.invocationId`** — a deterministic, dependency-free
|
|
35
|
+
`uuidv5(runId:stepName)` handed to every step. Unlike `stepId` (minted per
|
|
36
|
+
attempt), it is identical across retries, so a step can dedupe on it
|
|
37
|
+
(`ON CONFLICT (invocationId)`, Stripe idempotency keys, etc.).
|
|
38
|
+
- **queue-bullmq**: `mapPikkuJobToBull` now maps `backoff` (previously dropped,
|
|
39
|
+
so a step's backoff silently never applied on Redis), and `registerQueues`
|
|
40
|
+
throws a clear error when no logger is available (matching queue-pg-boss).
|
|
41
|
+
- **Dispatch failures are recoverable, not fatal.** A step is now marked
|
|
42
|
+
`scheduled` only _after_ it is successfully handed to its transport (queue or
|
|
43
|
+
scheduler) — a failed hand-off leaves it `pending` so a replay re-dispatches
|
|
44
|
+
it, instead of stranding it in `scheduled` (replay would pause forever on a
|
|
45
|
+
job that was never enqueued). A transport outage (e.g. pg-boss momentarily
|
|
46
|
+
down) is surfaced as a new `WorkflowDispatchException`, which the orchestrator
|
|
47
|
+
treats as transient: the run is left running and the orchestrator job is
|
|
48
|
+
rethrown for redelivery (it replays idempotently from the snapshot) rather
|
|
49
|
+
than the whole run being marked `failed`. The orchestrator job now also
|
|
50
|
+
carries its own retry policy, so this holds even when the orchestrator queue
|
|
51
|
+
is configured `retry_limit 0`. A genuine step error still fails the run.
|
|
52
|
+
- **Same step name can be invoked multiple times in one run.** Step rows are now
|
|
53
|
+
keyed per _invocation_: the Nth reach of a step name in a replay resolves to a
|
|
54
|
+
physical key (`name` for the first, `name#N` for repeats), so a literal
|
|
55
|
+
duplicate name no longer clobbers the earlier step's state. The first reach
|
|
56
|
+
keeps the bare name, so existing rows, graph-node matching and `invocationId`s
|
|
57
|
+
are unchanged. Ordinals are derived deterministically from DSL execution order
|
|
58
|
+
and reset each replay.
|
|
59
|
+
- **Step provenance (`fromStepName`) + graph cycles.** Every step now records
|
|
60
|
+
the predecessor it was scheduled from (`fromStepName`; entry steps have none),
|
|
61
|
+
persisted on the step row across all stores (in-memory, kysely, redis,
|
|
62
|
+
mongodb, cloudflare DO) and carried in the queued payload. The DSL wire
|
|
63
|
+
exposes the derived `fromInvocationId` (`uuidv5(runId:fromStepName)`) so
|
|
64
|
+
consumers get the stable predecessor key without a second persisted id —
|
|
65
|
+
`fromStepName` is the source of truth (it is replay-deterministic; `stepId`,
|
|
66
|
+
minted per row, is not). This makes the walked path reconstructable even when
|
|
67
|
+
a node is reached more than once: in `a → b → a → c` the second `a` is a
|
|
68
|
+
distinct ordinal instance (`a#1`) whose `fromStepName` is `b`.
|
|
69
|
+
The graph runner now supports **cycles**: a forward edge into an
|
|
70
|
+
already-started node still collapses to a single run (joins/diamonds are
|
|
71
|
+
unchanged), but a _back-edge_ — one whose target can reach its source — fires
|
|
72
|
+
a fresh ordinal instance, so a node can loop back to itself. Termination is
|
|
73
|
+
the graph's responsibility (branch routing must converge); the engine enforces
|
|
74
|
+
no visit cap.
|
|
75
|
+
|
|
76
|
+
- Updated dependencies [92cd5b1]
|
|
77
|
+
- @pikku/core@0.12.38
|
|
78
|
+
|
|
1
79
|
## 0.12.0
|
|
2
80
|
|
|
3
81
|
## 0.12.3
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { SchedulerService, parseDurationString } from '@pikku/core';
|
|
2
2
|
import { pikkuState } from '@pikku/core/internal';
|
|
3
3
|
import { runScheduledTask, getScheduledTasks } from '@pikku/core/scheduler';
|
|
4
|
+
/** Prefix for pg-boss queues/schedules backing recurring scheduled tasks. */
|
|
5
|
+
const RECURRING_TASK_PREFIX = 'pikku-recurring-scheduled-task_';
|
|
4
6
|
/**
|
|
5
7
|
* pg-boss scheduler service implementation
|
|
6
8
|
* Uses pg-boss's schedule() API to store recurring jobs in PostgreSQL.
|
|
@@ -88,8 +90,10 @@ export class PgBossSchedulerService extends SchedulerService {
|
|
|
88
90
|
async start() {
|
|
89
91
|
const logger = pikkuState(null, 'package', 'singletonServices').logger;
|
|
90
92
|
const scheduledTasks = getScheduledTasks();
|
|
93
|
+
const expectedCronNames = new Set();
|
|
91
94
|
for (const [name, task] of scheduledTasks) {
|
|
92
|
-
const cronName =
|
|
95
|
+
const cronName = `${RECURRING_TASK_PREFIX}${name.replace(/[^a-zA-Z0-9_\-.]/g, '_')}`;
|
|
96
|
+
expectedCronNames.add(cronName);
|
|
93
97
|
await this.pgBoss.createQueue(cronName);
|
|
94
98
|
await this.pgBoss.schedule(cronName, task.schedule, {
|
|
95
99
|
rpcName: name,
|
|
@@ -104,6 +108,25 @@ export class PgBossSchedulerService extends SchedulerService {
|
|
|
104
108
|
}
|
|
105
109
|
});
|
|
106
110
|
}
|
|
111
|
+
// Prune orphaned schedules. A task removed from code leaves its pg-boss
|
|
112
|
+
// schedule behind; with no worker registered it keeps producing jobs that
|
|
113
|
+
// pile up as 'created' forever (this is how a removed task flooded a queue
|
|
114
|
+
// with thousands of stuck jobs). Drop any recurring schedule + queue that's
|
|
115
|
+
// no longer registered so task removals self-heal on the next boot.
|
|
116
|
+
try {
|
|
117
|
+
const existing = await this.pgBoss.getSchedules();
|
|
118
|
+
for (const schedule of existing) {
|
|
119
|
+
if (schedule.name.startsWith(RECURRING_TASK_PREFIX) &&
|
|
120
|
+
!expectedCronNames.has(schedule.name)) {
|
|
121
|
+
await this.pgBoss.unschedule(schedule.name);
|
|
122
|
+
await this.pgBoss.deleteQueue(schedule.name);
|
|
123
|
+
logger.warn(`Pruned orphaned scheduled task queue: ${schedule.name}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
logger.warn(`Failed to prune orphaned scheduled tasks: ${err instanceof Error ? err.message : String(err)}`);
|
|
129
|
+
}
|
|
107
130
|
}
|
|
108
131
|
/**
|
|
109
132
|
* Stop recurring scheduled tasks by unscheduling them from pg-boss.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/queue-pg-boss",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.5",
|
|
4
4
|
"author": "yasser.fadl@gmail.com",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"module": "dist/index.js",
|
|
@@ -8,18 +8,22 @@
|
|
|
8
8
|
"type": "module",
|
|
9
9
|
"scripts": {
|
|
10
10
|
"tsc": "tsc",
|
|
11
|
+
"test": "bash run-tests.sh",
|
|
11
12
|
"ncu": "npx npm-check-updates",
|
|
12
13
|
"build": "tsc -b",
|
|
13
14
|
"release": "npm run build && npm test",
|
|
14
15
|
"prepublishOnly": "yarn build"
|
|
15
16
|
},
|
|
16
17
|
"peerDependencies": {
|
|
17
|
-
"@pikku/core": "^0.12.
|
|
18
|
+
"@pikku/core": "^0.12.43"
|
|
18
19
|
},
|
|
19
20
|
"dependencies": {
|
|
20
21
|
"pg-boss": "^12.14.0"
|
|
21
22
|
},
|
|
22
23
|
"devDependencies": {
|
|
23
24
|
"typescript": "^5.9"
|
|
25
|
+
},
|
|
26
|
+
"exports": {
|
|
27
|
+
".": "./dist/index.js"
|
|
24
28
|
}
|
|
25
29
|
}
|
package/run-tests.sh
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
cd "$(dirname "$0")"
|
|
3
|
-
|
|
3
|
+
files=($(find src -type f -name "*.test.ts"))
|
|
4
|
+
if [ ${#files[@]} -eq 0 ]; then echo "No test files found"; exit 0; fi
|
|
5
|
+
node --import tsx --test "$@" "${files[@]}"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { mapPikkuJobToPgBoss } from './pg-boss-queue-service.js'
|
|
5
|
+
|
|
6
|
+
// These guard the contract the workflow engine relies on: a step's resolved
|
|
7
|
+
// retry policy (attempts + backoff) must survive translation into pg-boss
|
|
8
|
+
// terms, so "workflow owns retries" actually reaches the queue.
|
|
9
|
+
describe('mapPikkuJobToPgBoss — retry mapping', () => {
|
|
10
|
+
test('attempts → retryLimit (attempts - 1)', () => {
|
|
11
|
+
// Default policy: 5 retries → 6 attempts → retryLimit 5.
|
|
12
|
+
assert.equal(mapPikkuJobToPgBoss({ attempts: 6 }).retryLimit, 5)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('attempts: 1 (retries: 0) → retryLimit 0 (pg-boss never re-runs it)', () => {
|
|
16
|
+
assert.equal(mapPikkuJobToPgBoss({ attempts: 1 }).retryLimit, 0)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test("backoff 'exponential' → retryBackoff true", () => {
|
|
20
|
+
assert.equal(
|
|
21
|
+
mapPikkuJobToPgBoss({ attempts: 6, backoff: 'exponential' })
|
|
22
|
+
.retryBackoff,
|
|
23
|
+
true
|
|
24
|
+
)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('fixed backoff → retryBackoff false + retryDelay in seconds', () => {
|
|
28
|
+
const opts = mapPikkuJobToPgBoss({
|
|
29
|
+
attempts: 4,
|
|
30
|
+
backoff: { type: 'fixed', delay: 5000 },
|
|
31
|
+
})
|
|
32
|
+
assert.equal(opts.retryBackoff, false)
|
|
33
|
+
// pg-boss takes seconds; our delay is milliseconds.
|
|
34
|
+
assert.equal(opts.retryDelay, 5)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('exponential object backoff → retryBackoff true + retryDelay in seconds', () => {
|
|
38
|
+
const opts = mapPikkuJobToPgBoss({
|
|
39
|
+
attempts: 4,
|
|
40
|
+
backoff: { type: 'exponential', delay: 2000 },
|
|
41
|
+
})
|
|
42
|
+
assert.equal(opts.retryBackoff, true)
|
|
43
|
+
assert.equal(opts.retryDelay, 2)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('no options → no retry fields set', () => {
|
|
47
|
+
const opts = mapPikkuJobToPgBoss(undefined)
|
|
48
|
+
assert.equal(opts.retryLimit, undefined)
|
|
49
|
+
assert.equal(opts.retryBackoff, undefined)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -4,12 +4,12 @@ import assert from 'node:assert/strict'
|
|
|
4
4
|
import { PgBossQueueWorkers } from './pg-boss-queue-worker.js'
|
|
5
5
|
|
|
6
6
|
describe('PgBossQueueWorkers', () => {
|
|
7
|
-
test('requires
|
|
7
|
+
test('requires a logger (explicit or from singleton services)', async () => {
|
|
8
8
|
const workers = new PgBossQueueWorkers({} as any)
|
|
9
9
|
|
|
10
10
|
await assert.rejects(() => workers.registerQueues(), {
|
|
11
11
|
message:
|
|
12
|
-
'
|
|
12
|
+
'Logger is required for registerQueues — pass it explicitly or ensure singleton services are initialized first',
|
|
13
13
|
})
|
|
14
14
|
})
|
|
15
15
|
})
|
|
@@ -17,6 +17,9 @@ interface ScheduledJobData {
|
|
|
17
17
|
session?: CoreUserSession
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** Prefix for pg-boss queues/schedules backing recurring scheduled tasks. */
|
|
21
|
+
const RECURRING_TASK_PREFIX = 'pikku-recurring-scheduled-task_'
|
|
22
|
+
|
|
20
23
|
/**
|
|
21
24
|
* pg-boss scheduler service implementation
|
|
22
25
|
* Uses pg-boss's schedule() API to store recurring jobs in PostgreSQL.
|
|
@@ -131,8 +134,10 @@ export class PgBossSchedulerService extends SchedulerService {
|
|
|
131
134
|
const logger = pikkuState(null, 'package', 'singletonServices')!.logger
|
|
132
135
|
const scheduledTasks = getScheduledTasks()
|
|
133
136
|
|
|
137
|
+
const expectedCronNames = new Set<string>()
|
|
134
138
|
for (const [name, task] of scheduledTasks) {
|
|
135
|
-
const cronName =
|
|
139
|
+
const cronName = `${RECURRING_TASK_PREFIX}${name.replace(/[^a-zA-Z0-9_\-.]/g, '_')}`
|
|
140
|
+
expectedCronNames.add(cronName)
|
|
136
141
|
await this.pgBoss.createQueue(cronName)
|
|
137
142
|
await this.pgBoss.schedule(cronName, task.schedule, {
|
|
138
143
|
rpcName: name,
|
|
@@ -148,6 +153,29 @@ export class PgBossSchedulerService extends SchedulerService {
|
|
|
148
153
|
}
|
|
149
154
|
})
|
|
150
155
|
}
|
|
156
|
+
|
|
157
|
+
// Prune orphaned schedules. A task removed from code leaves its pg-boss
|
|
158
|
+
// schedule behind; with no worker registered it keeps producing jobs that
|
|
159
|
+
// pile up as 'created' forever (this is how a removed task flooded a queue
|
|
160
|
+
// with thousands of stuck jobs). Drop any recurring schedule + queue that's
|
|
161
|
+
// no longer registered so task removals self-heal on the next boot.
|
|
162
|
+
try {
|
|
163
|
+
const existing = await this.pgBoss.getSchedules()
|
|
164
|
+
for (const schedule of existing) {
|
|
165
|
+
if (
|
|
166
|
+
schedule.name.startsWith(RECURRING_TASK_PREFIX) &&
|
|
167
|
+
!expectedCronNames.has(schedule.name)
|
|
168
|
+
) {
|
|
169
|
+
await this.pgBoss.unschedule(schedule.name)
|
|
170
|
+
await this.pgBoss.deleteQueue(schedule.name)
|
|
171
|
+
logger.warn(`Pruned orphaned scheduled task queue: ${schedule.name}`)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} catch (err) {
|
|
175
|
+
logger.warn(
|
|
176
|
+
`Failed to prune orphaned scheduled tasks: ${err instanceof Error ? err.message : String(err)}`
|
|
177
|
+
)
|
|
178
|
+
}
|
|
151
179
|
}
|
|
152
180
|
|
|
153
181
|
/**
|