@pikku/queue-pg-boss 0.12.10 → 0.12.12
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/dist/pg-boss-queue-service.d.ts +1 -1
- package/package.json +19 -11
- package/CHANGELOG.md +0 -409
- package/run-tests.sh +0 -56
- package/src/index.ts +0 -19
- package/src/pg-boss-queue-service.test.ts +0 -83
- package/src/pg-boss-queue-service.ts +0 -113
- package/src/pg-boss-queue-worker.test.ts +0 -93
- package/src/pg-boss-queue-worker.ts +0 -231
- package/src/pg-boss-scheduler-service.ts +0 -192
- package/src/pg-boss-service-factory.ts +0 -85
- package/src/utils.ts +0 -87
- package/tsconfig.json +0 -17
- package/tsconfig.tsbuildinfo +0 -1
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import type { PgBoss, Request as PgBossRequest } from 'pg-boss'
|
|
2
|
-
import type { QueueJob, QueueService } from '@pikku/core/queue'
|
|
3
|
-
import type { JobOptions } from '@pikku/core/queue'
|
|
4
|
-
import { mapPgBossJobToQueueJob } from './utils.js'
|
|
5
|
-
|
|
6
|
-
export const mapPikkuJobToPgBoss = (
|
|
7
|
-
options?: JobOptions
|
|
8
|
-
): PgBossRequest['options'] => {
|
|
9
|
-
const pgBossOptions: any = {}
|
|
10
|
-
|
|
11
|
-
if (options?.priority !== undefined) {
|
|
12
|
-
pgBossOptions.priority = options.priority
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
if (options?.delay !== undefined) {
|
|
16
|
-
pgBossOptions.startAfter = new Date(Date.now() + options.delay)
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
if (options?.jobId !== undefined) {
|
|
20
|
-
pgBossOptions.singletonKey = options.jobId
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
if (options?.group !== undefined) {
|
|
24
|
-
pgBossOptions.group = {
|
|
25
|
-
id: options.group.id,
|
|
26
|
-
...(options.group.tier !== undefined ? { tier: options.group.tier } : {}),
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// Map retry options
|
|
31
|
-
if (options?.attempts !== undefined) {
|
|
32
|
-
pgBossOptions.retryLimit = options.attempts - 1
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
if (options?.backoff !== undefined) {
|
|
36
|
-
const type =
|
|
37
|
-
typeof options.backoff === 'string'
|
|
38
|
-
? options.backoff
|
|
39
|
-
: options.backoff.type
|
|
40
|
-
const delay =
|
|
41
|
-
typeof options.backoff === 'object' ? options.backoff.delay : undefined
|
|
42
|
-
pgBossOptions.retryBackoff = type === 'exponential'
|
|
43
|
-
if (delay !== undefined) {
|
|
44
|
-
// pg-boss uses seconds, we use milliseconds — never round a sub-second
|
|
45
|
-
// delay down to 0, which pg-boss treats as retry-immediately
|
|
46
|
-
pgBossOptions.retryDelay = Math.max(1, Math.round(delay / 1000))
|
|
47
|
-
} else if (type === 'exponential') {
|
|
48
|
-
// pg-boss computes exponential backoff as retry_delay * 2^n, and the
|
|
49
|
-
// queue-level retry_delay defaults to 0 — without an explicit base every
|
|
50
|
-
// "exponential" retry fires immediately. 1s base → ~1s, 2s, 4s, 8s…
|
|
51
|
-
pgBossOptions.retryDelay = 1
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
if (options?.removeOnComplete !== undefined) {
|
|
56
|
-
pgBossOptions.onComplete = options.removeOnComplete === 0
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (options?.removeOnFail !== undefined) {
|
|
60
|
-
// pg-boss doesn't have a direct equivalent, but we can track this for consistency
|
|
61
|
-
// Note: pg-boss keeps failed jobs by default
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return pgBossOptions as PgBossRequest['options']
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* pg-boss/PostgreSQL queue service implementation
|
|
69
|
-
* Supports job results, retries, and PostgreSQL-based queuing
|
|
70
|
-
*/
|
|
71
|
-
export class PgBossQueueService implements QueueService {
|
|
72
|
-
readonly name = 'pg-boss'
|
|
73
|
-
readonly supportsResults = true
|
|
74
|
-
protected pgBoss: PgBoss
|
|
75
|
-
|
|
76
|
-
constructor(pgBoss: PgBoss) {
|
|
77
|
-
this.pgBoss = pgBoss
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Add a job to the queue
|
|
82
|
-
*/
|
|
83
|
-
public async add<In>(
|
|
84
|
-
queueName: string,
|
|
85
|
-
data: In,
|
|
86
|
-
options?: JobOptions
|
|
87
|
-
): Promise<string> {
|
|
88
|
-
await this.pgBoss.createQueue(queueName)
|
|
89
|
-
const jobId = await this.pgBoss.send(
|
|
90
|
-
queueName,
|
|
91
|
-
data as any,
|
|
92
|
-
mapPikkuJobToPgBoss(options)
|
|
93
|
-
)
|
|
94
|
-
if (!jobId) {
|
|
95
|
-
throw new Error('Failed to create job')
|
|
96
|
-
}
|
|
97
|
-
return jobId
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Get job by ID
|
|
102
|
-
*/
|
|
103
|
-
async getJob<T, R>(
|
|
104
|
-
queueName: string,
|
|
105
|
-
jobId: string
|
|
106
|
-
): Promise<QueueJob<T, R> | null> {
|
|
107
|
-
const job = await this.pgBoss.getJobById<T>(queueName, jobId)
|
|
108
|
-
if (!job) {
|
|
109
|
-
return null
|
|
110
|
-
}
|
|
111
|
-
return mapPgBossJobToQueueJob<T, R>(job, this.pgBoss)
|
|
112
|
-
}
|
|
113
|
-
}
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import { describe, test } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
PgBossQueueWorkers,
|
|
6
|
-
mapPikkuWorkerToPgBoss,
|
|
7
|
-
} from './pg-boss-queue-worker.js'
|
|
8
|
-
|
|
9
|
-
describe('PgBossQueueWorkers', () => {
|
|
10
|
-
test('requires a logger (explicit or from singleton services)', async () => {
|
|
11
|
-
const workers = new PgBossQueueWorkers({} as any)
|
|
12
|
-
|
|
13
|
-
await assert.rejects(() => workers.registerQueues(), {
|
|
14
|
-
message:
|
|
15
|
-
'Logger is required for registerQueues — pass it explicitly or ensure singleton services are initialized first',
|
|
16
|
-
})
|
|
17
|
-
})
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
describe('mapPikkuWorkerToPgBoss', () => {
|
|
21
|
-
test('parallelism maps to independent workers (localConcurrency), never batch fetching', () => {
|
|
22
|
-
// batchSize: 1 keeps each worker single-job so a slow job can never hold its
|
|
23
|
-
// batch siblings — head-of-line blocking is impossible.
|
|
24
|
-
assert.deepEqual(mapPikkuWorkerToPgBoss(), {
|
|
25
|
-
localConcurrency: 10,
|
|
26
|
-
batchSize: 1,
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
assert.deepEqual(mapPikkuWorkerToPgBoss({ batchSize: 25 }), {
|
|
30
|
-
localConcurrency: 25,
|
|
31
|
-
batchSize: 1,
|
|
32
|
-
})
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
test('pollInterval is converted from ms to seconds', () => {
|
|
36
|
-
assert.deepEqual(mapPikkuWorkerToPgBoss({ pollInterval: 3000 }), {
|
|
37
|
-
localConcurrency: 10,
|
|
38
|
-
batchSize: 1,
|
|
39
|
-
pollingIntervalSeconds: 3,
|
|
40
|
-
})
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
test('groupConcurrency caps how much of the worker one group can take', () => {
|
|
44
|
-
assert.deepEqual(mapPikkuWorkerToPgBoss({ groupConcurrency: 2 }), {
|
|
45
|
-
localConcurrency: 10,
|
|
46
|
-
batchSize: 1,
|
|
47
|
-
localGroupConcurrency: 2,
|
|
48
|
-
})
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
test('tiered groupConcurrency passes tiers through', () => {
|
|
52
|
-
assert.deepEqual(
|
|
53
|
-
mapPikkuWorkerToPgBoss({
|
|
54
|
-
batchSize: 20,
|
|
55
|
-
groupConcurrency: { default: 2, tiers: { slowWorkflow: 1 } },
|
|
56
|
-
}),
|
|
57
|
-
{
|
|
58
|
-
localConcurrency: 20,
|
|
59
|
-
batchSize: 1,
|
|
60
|
-
localGroupConcurrency: { default: 2, tiers: { slowWorkflow: 1 } },
|
|
61
|
-
}
|
|
62
|
-
)
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
test('group limits are clamped to the worker concurrency', () => {
|
|
66
|
-
// pg-boss asserts every group limit is <= localConcurrency and throws at
|
|
67
|
-
// worker start otherwise — clamp so an over-eager config degrades to "this
|
|
68
|
-
// group may use the whole worker" instead of failing to start.
|
|
69
|
-
assert.deepEqual(
|
|
70
|
-
mapPikkuWorkerToPgBoss({
|
|
71
|
-
batchSize: 4,
|
|
72
|
-
groupConcurrency: { default: 10, tiers: { greedy: 99 } },
|
|
73
|
-
}),
|
|
74
|
-
{
|
|
75
|
-
localConcurrency: 4,
|
|
76
|
-
batchSize: 1,
|
|
77
|
-
localGroupConcurrency: { default: 4, tiers: { greedy: 4 } },
|
|
78
|
-
}
|
|
79
|
-
)
|
|
80
|
-
|
|
81
|
-
assert.deepEqual(
|
|
82
|
-
mapPikkuWorkerToPgBoss({ batchSize: 3, groupConcurrency: 8 }),
|
|
83
|
-
{ localConcurrency: 3, batchSize: 1, localGroupConcurrency: 3 }
|
|
84
|
-
)
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
test('no group config means no group limits', () => {
|
|
88
|
-
assert.equal(
|
|
89
|
-
'localGroupConcurrency' in mapPikkuWorkerToPgBoss({ batchSize: 5 }),
|
|
90
|
-
false
|
|
91
|
-
)
|
|
92
|
-
})
|
|
93
|
-
})
|
|
@@ -1,231 +0,0 @@
|
|
|
1
|
-
import type { PgBoss, WorkOptions } from 'pg-boss'
|
|
2
|
-
import type {
|
|
3
|
-
QueueWorkers,
|
|
4
|
-
PikkuWorkerConfig,
|
|
5
|
-
QueueConfigMapping,
|
|
6
|
-
ConfigValidationResult,
|
|
7
|
-
} from '@pikku/core/queue'
|
|
8
|
-
import { runQueueJob } from '@pikku/core/queue'
|
|
9
|
-
import type { Logger } from '@pikku/core/services'
|
|
10
|
-
import {
|
|
11
|
-
registerQueueWorkers,
|
|
12
|
-
QueueJobFailedError,
|
|
13
|
-
QueueJobDiscardedError,
|
|
14
|
-
} from '@pikku/core/queue'
|
|
15
|
-
import { pikkuState } from '@pikku/core/state'
|
|
16
|
-
import { mapPgBossJobToQueueJob } from './utils.js'
|
|
17
|
-
|
|
18
|
-
export const mapPikkuWorkerToPgBoss = (
|
|
19
|
-
workerConfig?: PikkuWorkerConfig
|
|
20
|
-
): WorkOptions => {
|
|
21
|
-
// Map the requested parallelism to pg-boss `localConcurrency` (independent
|
|
22
|
-
// per-job workers), NOT `batchSize`. A batch worker hands the whole fetched
|
|
23
|
-
// batch to one handler and only fetches again once that handler resolves — so
|
|
24
|
-
// a single slow job blocks every sibling in its batch AND stalls the next
|
|
25
|
-
// fetch. On a shared queue that is head-of-line blocking: one long job can
|
|
26
|
-
// freeze the entire worker and starve everything queued behind it.
|
|
27
|
-
// `localConcurrency` spawns N pollers that each fetch+process a single job and
|
|
28
|
-
// refill the instant they finish, so a slow job never holds up the others.
|
|
29
|
-
const localConcurrency = workerConfig?.batchSize ?? 10
|
|
30
|
-
const workerOptions: WorkOptions = {
|
|
31
|
-
localConcurrency,
|
|
32
|
-
batchSize: 1,
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
if (workerConfig?.pollInterval !== undefined) {
|
|
36
|
-
workerOptions.pollingIntervalSeconds = Math.round(
|
|
37
|
-
workerConfig.pollInterval / 1000
|
|
38
|
-
)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Per-group fairness. `localGroupConcurrency` is in-memory and free — groups
|
|
42
|
-
// already at capacity are excluded from the fetch itself, so an at-capacity
|
|
43
|
-
// group costs nothing rather than being fetched and restored. pg-boss asserts
|
|
44
|
-
// every limit is <= localConcurrency, so clamp instead of letting a config
|
|
45
|
-
// that reads as "let this tier use more" throw at worker start.
|
|
46
|
-
if (workerConfig?.groupConcurrency !== undefined) {
|
|
47
|
-
const clamp = (limit: number) => Math.min(limit, localConcurrency)
|
|
48
|
-
workerOptions.localGroupConcurrency =
|
|
49
|
-
typeof workerConfig.groupConcurrency === 'number'
|
|
50
|
-
? clamp(workerConfig.groupConcurrency)
|
|
51
|
-
: {
|
|
52
|
-
default: clamp(workerConfig.groupConcurrency.default),
|
|
53
|
-
...(workerConfig.groupConcurrency.tiers
|
|
54
|
-
? {
|
|
55
|
-
tiers: Object.fromEntries(
|
|
56
|
-
Object.entries(workerConfig.groupConcurrency.tiers).map(
|
|
57
|
-
([tier, limit]) => [tier, clamp(limit)]
|
|
58
|
-
)
|
|
59
|
-
),
|
|
60
|
-
}
|
|
61
|
-
: {}),
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return workerOptions
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* pg-boss/PostgreSQL queue worker implementation
|
|
70
|
-
* Supports job results, retries, and PostgreSQL-based queuing
|
|
71
|
-
*/
|
|
72
|
-
export class PgBossQueueWorkers implements QueueWorkers {
|
|
73
|
-
readonly name = 'pg-boss'
|
|
74
|
-
readonly supportsResults = true
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Configuration mapping rules for pg-boss
|
|
78
|
-
* This defines how Pikku worker configs map to pg-boss configurations
|
|
79
|
-
*/
|
|
80
|
-
readonly configMappings: QueueConfigMapping = {
|
|
81
|
-
// Configurations that are directly supported
|
|
82
|
-
supported: {
|
|
83
|
-
batchSize: {
|
|
84
|
-
queueProperty: 'localConcurrency',
|
|
85
|
-
description: 'Number of jobs to process concurrently',
|
|
86
|
-
},
|
|
87
|
-
groupConcurrency: {
|
|
88
|
-
queueProperty: 'localGroupConcurrency',
|
|
89
|
-
description:
|
|
90
|
-
'Maximum concurrent jobs per group, keeping one shared queue fair across groups',
|
|
91
|
-
},
|
|
92
|
-
pollInterval: {
|
|
93
|
-
queueProperty: 'pollingIntervalSeconds',
|
|
94
|
-
transform: (value: number) => Math.round(value / 1000),
|
|
95
|
-
description:
|
|
96
|
-
'How often to poll for new jobs (converted from ms to seconds)',
|
|
97
|
-
},
|
|
98
|
-
},
|
|
99
|
-
|
|
100
|
-
// Configurations that are not supported but have reasonable explanations
|
|
101
|
-
unsupported: {
|
|
102
|
-
name: {
|
|
103
|
-
reason: 'Worker names are not supported in pg-boss',
|
|
104
|
-
explanation:
|
|
105
|
-
'pg-boss identifies workers by their queue name and process, not custom names',
|
|
106
|
-
},
|
|
107
|
-
autorun: {
|
|
108
|
-
reason: 'Autorun is not configurable in pg-boss (always enabled)',
|
|
109
|
-
explanation:
|
|
110
|
-
'pg-boss workers automatically start processing when created',
|
|
111
|
-
},
|
|
112
|
-
lockDuration: {
|
|
113
|
-
reason: 'Lock duration is managed differently in pg-boss',
|
|
114
|
-
explanation:
|
|
115
|
-
'pg-boss uses job-level expiration instead of worker-level locks',
|
|
116
|
-
},
|
|
117
|
-
drainDelay: {
|
|
118
|
-
reason: 'Drain delay is not configurable in pg-boss',
|
|
119
|
-
explanation: 'pg-boss handles graceful shutdown internally',
|
|
120
|
-
},
|
|
121
|
-
maxStalledCount: {
|
|
122
|
-
reason: 'Max stalled count is not configurable in pg-boss',
|
|
123
|
-
explanation:
|
|
124
|
-
'pg-boss handles stalled jobs through its built-in retry mechanism',
|
|
125
|
-
},
|
|
126
|
-
prefetch: {
|
|
127
|
-
reason: 'Prefetch is managed internally by pg-boss',
|
|
128
|
-
explanation:
|
|
129
|
-
'pg-boss optimizes job fetching automatically based on batch size',
|
|
130
|
-
},
|
|
131
|
-
visibilityTimeout: {
|
|
132
|
-
reason: "Visibility timeout concept doesn't apply to pg-boss",
|
|
133
|
-
explanation:
|
|
134
|
-
'pg-boss uses PostgreSQL locks and job expiration instead of visibility timeout',
|
|
135
|
-
},
|
|
136
|
-
},
|
|
137
|
-
|
|
138
|
-
// Configurations that have partial support or workarounds
|
|
139
|
-
fallbacks: {
|
|
140
|
-
removeOnComplete: {
|
|
141
|
-
reason: 'Job retention is managed through pg-boss archival system',
|
|
142
|
-
explanation:
|
|
143
|
-
'pg-boss automatically archives completed jobs based on database settings',
|
|
144
|
-
fallbackValue: 'Managed by pg-boss database archival',
|
|
145
|
-
},
|
|
146
|
-
removeOnFail: {
|
|
147
|
-
reason:
|
|
148
|
-
'Failed job retention is managed through pg-boss archival system',
|
|
149
|
-
explanation:
|
|
150
|
-
'pg-boss automatically archives failed jobs based on database settings',
|
|
151
|
-
fallbackValue: 'Managed by pg-boss database archival',
|
|
152
|
-
},
|
|
153
|
-
},
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
private pgBoss: PgBoss
|
|
157
|
-
private activeWorkers = new Map<string, string>()
|
|
158
|
-
|
|
159
|
-
constructor(pgBoss: PgBoss) {
|
|
160
|
-
this.pgBoss = pgBoss
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Scan state and register all compatible processors
|
|
165
|
-
*/
|
|
166
|
-
async registerQueues(
|
|
167
|
-
logger?: Logger
|
|
168
|
-
): Promise<Record<string, ConfigValidationResult[]>> {
|
|
169
|
-
if (!logger) {
|
|
170
|
-
logger = pikkuState(null, 'package', 'singletonServices')?.logger
|
|
171
|
-
}
|
|
172
|
-
if (!logger) {
|
|
173
|
-
throw new Error(
|
|
174
|
-
'Logger is required for registerQueues — pass it explicitly or ensure singleton services are initialized first'
|
|
175
|
-
)
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
return await registerQueueWorkers(
|
|
179
|
-
this.configMappings,
|
|
180
|
-
logger,
|
|
181
|
-
async (queueName, processor) => {
|
|
182
|
-
await this.pgBoss.createQueue(queueName)
|
|
183
|
-
const workerId = await this.pgBoss.work(
|
|
184
|
-
processor.name,
|
|
185
|
-
{
|
|
186
|
-
...mapPikkuWorkerToPgBoss(processor.config),
|
|
187
|
-
includeMetadata: true,
|
|
188
|
-
},
|
|
189
|
-
async (jobs) => {
|
|
190
|
-
if (!jobs || jobs.length === 0) {
|
|
191
|
-
logger.warn(`No jobs received for queue ${queueName}`)
|
|
192
|
-
return
|
|
193
|
-
}
|
|
194
|
-
// Process all jobs in parallel
|
|
195
|
-
await Promise.all(
|
|
196
|
-
jobs.map(async (job) => {
|
|
197
|
-
try {
|
|
198
|
-
await runQueueJob({
|
|
199
|
-
job: mapPgBossJobToQueueJob(job, this.pgBoss),
|
|
200
|
-
})
|
|
201
|
-
} catch (error: unknown) {
|
|
202
|
-
if (error instanceof QueueJobFailedError) {
|
|
203
|
-
// Mark this specific job as failed
|
|
204
|
-
await this.pgBoss.fail(queueName, job.id, {
|
|
205
|
-
message: error.message,
|
|
206
|
-
})
|
|
207
|
-
} else if (error instanceof QueueJobDiscardedError) {
|
|
208
|
-
// For pg-boss, complete the job successfully to discard it
|
|
209
|
-
logger.info(
|
|
210
|
-
`PgBoss job ${job.id} discarded: ${error.message}`
|
|
211
|
-
)
|
|
212
|
-
await this.pgBoss.complete(queueName, job.id)
|
|
213
|
-
} else {
|
|
214
|
-
// For other errors, fail the job
|
|
215
|
-
await this.pgBoss.fail(queueName, job.id, {
|
|
216
|
-
message:
|
|
217
|
-
error instanceof Error
|
|
218
|
-
? error.message
|
|
219
|
-
: 'Unknown error',
|
|
220
|
-
})
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
})
|
|
224
|
-
)
|
|
225
|
-
}
|
|
226
|
-
)
|
|
227
|
-
this.activeWorkers.set(queueName, workerId)
|
|
228
|
-
}
|
|
229
|
-
)
|
|
230
|
-
}
|
|
231
|
-
}
|
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
import type { PgBoss } from 'pg-boss'
|
|
2
|
-
import { runScheduledTask } from '@pikku/core/scheduler'
|
|
3
|
-
import { SchedulerService } from '@pikku/core/services'
|
|
4
|
-
import type { CoreUserSession } from '@pikku/core/types'
|
|
5
|
-
import type {
|
|
6
|
-
ScheduledTaskInfo,
|
|
7
|
-
ScheduledTaskSummary,
|
|
8
|
-
} from '@pikku/core/services'
|
|
9
|
-
import { parseDurationString } from '@pikku/core/utils'
|
|
10
|
-
import { pikkuState } from '@pikku/core/state'
|
|
11
|
-
import { getScheduledTasks } from '@pikku/core/scheduler'
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Data stored in scheduled job
|
|
15
|
-
*/
|
|
16
|
-
interface ScheduledJobData {
|
|
17
|
-
rpcName: string
|
|
18
|
-
data?: any
|
|
19
|
-
session?: CoreUserSession
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Prefix for pg-boss queues/schedules backing recurring scheduled tasks. */
|
|
23
|
-
const RECURRING_TASK_PREFIX = 'pikku-recurring-scheduled-task_'
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* pg-boss scheduler service implementation
|
|
27
|
-
* Uses pg-boss's schedule() API to store recurring jobs in PostgreSQL.
|
|
28
|
-
* Handles both one-off delayed RPCs and recurring cron-scheduled tasks.
|
|
29
|
-
*/
|
|
30
|
-
export class PgBossSchedulerService extends SchedulerService {
|
|
31
|
-
private scheduledCronNames: string[] = []
|
|
32
|
-
|
|
33
|
-
constructor(private pgBoss: PgBoss) {
|
|
34
|
-
super()
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Initialize - no-op as pg-boss is already initialized by factory
|
|
39
|
-
*/
|
|
40
|
-
async init(): Promise<void> {
|
|
41
|
-
// No-op - pg-boss is already initialized by factory
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Schedule a one-off delayed RPC call
|
|
46
|
-
* Uses pg-boss send() with startAfter for one-time execution
|
|
47
|
-
*/
|
|
48
|
-
async scheduleRPC(
|
|
49
|
-
delay: number | string,
|
|
50
|
-
rpcName: string,
|
|
51
|
-
data?: any,
|
|
52
|
-
session?: CoreUserSession
|
|
53
|
-
): Promise<string> {
|
|
54
|
-
// Parse delay if it's a string
|
|
55
|
-
const delayMs =
|
|
56
|
-
typeof delay === 'string' ? parseDurationString(delay) : delay
|
|
57
|
-
|
|
58
|
-
const jobData: ScheduledJobData = {
|
|
59
|
-
rpcName,
|
|
60
|
-
data,
|
|
61
|
-
session,
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Use send() with startAfter for one-off delayed execution
|
|
65
|
-
const taskId = await this.pgBoss.send(
|
|
66
|
-
'pikku-remote-internal-rpc',
|
|
67
|
-
jobData,
|
|
68
|
-
{
|
|
69
|
-
startAfter: new Date(Date.now() + delayMs),
|
|
70
|
-
singletonKey: `${rpcName}-${Date.now()}`, // Ensure uniqueness
|
|
71
|
-
}
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
if (!taskId) {
|
|
75
|
-
throw new Error('Failed to schedule RPC')
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
return taskId
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Unschedule (cancel) a task by ID
|
|
83
|
-
*/
|
|
84
|
-
async unschedule(taskId: string): Promise<boolean> {
|
|
85
|
-
await this.pgBoss.cancel('pikku-remote-internal-rpc', taskId)
|
|
86
|
-
return true
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Get a scheduled task by ID with full details
|
|
91
|
-
*/
|
|
92
|
-
async getTask(taskId: string): Promise<ScheduledTaskInfo | null> {
|
|
93
|
-
const job = await this.pgBoss.getJobById(
|
|
94
|
-
'pikku-remote-internal-rpc',
|
|
95
|
-
taskId
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
if (!job) {
|
|
99
|
-
return null
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const jobData = job.data as ScheduledJobData
|
|
103
|
-
|
|
104
|
-
return {
|
|
105
|
-
taskId: job.id,
|
|
106
|
-
rpcName: jobData.rpcName,
|
|
107
|
-
scheduledFor: job.startAfter || job.createdOn,
|
|
108
|
-
data: jobData.data,
|
|
109
|
-
session: jobData.session,
|
|
110
|
-
status: job.state as any,
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Get all scheduled tasks with minimal info
|
|
116
|
-
*/
|
|
117
|
-
async getAllTasks(): Promise<ScheduledTaskSummary[]> {
|
|
118
|
-
// pg-boss doesn't have a simple way to list all pending jobs
|
|
119
|
-
// This would require querying the database directly or maintaining a separate index
|
|
120
|
-
// For now, return empty array - can be implemented if needed
|
|
121
|
-
return []
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Close - no-op as pg-boss lifecycle is managed by factory
|
|
126
|
-
*/
|
|
127
|
-
async close(): Promise<void> {
|
|
128
|
-
// No-op - pg-boss lifecycle is managed by factory
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Start recurring scheduled tasks.
|
|
133
|
-
* Registers a pg-boss worker to process recurring jobs via runScheduledTask.
|
|
134
|
-
*/
|
|
135
|
-
async start(): Promise<void> {
|
|
136
|
-
const logger = pikkuState(null, 'package', 'singletonServices')!.logger
|
|
137
|
-
const scheduledTasks = getScheduledTasks()
|
|
138
|
-
|
|
139
|
-
const expectedCronNames = new Set<string>()
|
|
140
|
-
for (const [name, task] of scheduledTasks) {
|
|
141
|
-
const cronName = `${RECURRING_TASK_PREFIX}${name.replace(/[^a-zA-Z0-9_\-.]/g, '_')}`
|
|
142
|
-
expectedCronNames.add(cronName)
|
|
143
|
-
await this.pgBoss.createQueue(cronName)
|
|
144
|
-
await this.pgBoss.schedule(cronName, task.schedule, {
|
|
145
|
-
rpcName: name,
|
|
146
|
-
} as ScheduledJobData)
|
|
147
|
-
this.scheduledCronNames.push(cronName)
|
|
148
|
-
|
|
149
|
-
// Register a worker to process the scheduled jobs
|
|
150
|
-
await this.pgBoss.work<ScheduledJobData>(cronName, async (jobs) => {
|
|
151
|
-
for (const job of jobs) {
|
|
152
|
-
const { rpcName } = job.data
|
|
153
|
-
logger.info(`Running scheduled task: ${rpcName}`)
|
|
154
|
-
await runScheduledTask({ name: rpcName })
|
|
155
|
-
}
|
|
156
|
-
})
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Prune orphaned schedules. A task removed from code leaves its pg-boss
|
|
160
|
-
// schedule behind; with no worker registered it keeps producing jobs that
|
|
161
|
-
// pile up as 'created' forever (this is how a removed task flooded a queue
|
|
162
|
-
// with thousands of stuck jobs). Drop any recurring schedule + queue that's
|
|
163
|
-
// no longer registered so task removals self-heal on the next boot.
|
|
164
|
-
try {
|
|
165
|
-
const existing = await this.pgBoss.getSchedules()
|
|
166
|
-
for (const schedule of existing) {
|
|
167
|
-
if (
|
|
168
|
-
schedule.name.startsWith(RECURRING_TASK_PREFIX) &&
|
|
169
|
-
!expectedCronNames.has(schedule.name)
|
|
170
|
-
) {
|
|
171
|
-
await this.pgBoss.unschedule(schedule.name)
|
|
172
|
-
await this.pgBoss.deleteQueue(schedule.name)
|
|
173
|
-
logger.warn(`Pruned orphaned scheduled task queue: ${schedule.name}`)
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
} catch (err) {
|
|
177
|
-
logger.warn(
|
|
178
|
-
`Failed to prune orphaned scheduled tasks: ${err instanceof Error ? err.message : String(err)}`
|
|
179
|
-
)
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Stop recurring scheduled tasks by unscheduling them from pg-boss.
|
|
185
|
-
*/
|
|
186
|
-
async stop(): Promise<void> {
|
|
187
|
-
for (const cronName of this.scheduledCronNames) {
|
|
188
|
-
await this.pgBoss.unschedule(cronName)
|
|
189
|
-
}
|
|
190
|
-
this.scheduledCronNames = []
|
|
191
|
-
}
|
|
192
|
-
}
|