@pikku/queue-pg-boss 0.7.0
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/package.json +24 -0
- package/run-tests.sh +3 -0
- package/src/index.ts +18 -0
- package/src/pg-boss-queue-service.ts +88 -0
- package/src/pg-boss-queue-worker.ts +186 -0
- package/src/utils.ts +87 -0
- package/tsconfig.json +17 -0
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pikku/queue-pg-boss",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"author": "yasser.fadl@gmail.com",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"module": "dist/index.js",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"tsc": "tsc",
|
|
11
|
+
"ncu": "npx npm-check-updates",
|
|
12
|
+
"build": "tsc -b",
|
|
13
|
+
"release": "npm run build && npm test"
|
|
14
|
+
},
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"@pikku/core": "^0.7.0"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"pg-boss": "^10.3.2"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"typescript": "^5.6"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/run-tests.sh
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @pikku/queue-pg-boss
|
|
3
|
+
*
|
|
4
|
+
* PostgreSQL queue service for Pikku using pg-boss
|
|
5
|
+
* Provides job results, retries, priorities, and PostgreSQL-based queuing
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { PgBossQueueService } from './pg-boss-queue-service.js'
|
|
9
|
+
export { PgBossQueueWorkers } from './pg-boss-queue-worker.js'
|
|
10
|
+
|
|
11
|
+
// Re-export core queue types for convenience
|
|
12
|
+
export type {
|
|
13
|
+
QueueService,
|
|
14
|
+
PikkuWorkerConfig,
|
|
15
|
+
PikkuJobConfig,
|
|
16
|
+
QueueJob,
|
|
17
|
+
JobOptions,
|
|
18
|
+
} from '@pikku/core/queue'
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import PgBoss from 'pg-boss'
|
|
2
|
+
import type { QueueService, QueueJob, JobOptions } from '@pikku/core/queue'
|
|
3
|
+
import { mapPgBossJobToQueueJob } from './utils.js'
|
|
4
|
+
|
|
5
|
+
export const mapPikkuJobToPgBoss = (
|
|
6
|
+
options?: JobOptions
|
|
7
|
+
): PgBoss.JobOptions => {
|
|
8
|
+
const pgBossOptions: PgBoss.JobOptions = {}
|
|
9
|
+
|
|
10
|
+
if (options?.priority !== undefined) {
|
|
11
|
+
pgBossOptions.priority = options.priority
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (options?.delay !== undefined) {
|
|
15
|
+
pgBossOptions.startAfter = new Date(Date.now() + options.delay)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (options?.jobId !== undefined) {
|
|
19
|
+
pgBossOptions.singletonKey = options.jobId
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return pgBossOptions
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* pg-boss/PostgreSQL queue service implementation
|
|
27
|
+
* Supports job results, retries, and PostgreSQL-based queuing
|
|
28
|
+
*/
|
|
29
|
+
export class PgBossQueueService implements QueueService {
|
|
30
|
+
readonly name = 'pg-boss'
|
|
31
|
+
readonly supportsResults = true
|
|
32
|
+
protected pgBoss: PgBoss
|
|
33
|
+
|
|
34
|
+
constructor(optionsOrUrl: PgBoss.ConstructorOptions | string) {
|
|
35
|
+
if (typeof optionsOrUrl === 'string') {
|
|
36
|
+
// If a string is provided, treat it as the connection URL
|
|
37
|
+
optionsOrUrl = { connectionString: optionsOrUrl }
|
|
38
|
+
}
|
|
39
|
+
this.pgBoss = new PgBoss(optionsOrUrl)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Initialize the pg-boss instance
|
|
44
|
+
*/
|
|
45
|
+
async init(): Promise<void> {
|
|
46
|
+
await this.pgBoss.start()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Add a job to the queue
|
|
51
|
+
*/
|
|
52
|
+
public async add<In>(
|
|
53
|
+
queueName: string,
|
|
54
|
+
data: In,
|
|
55
|
+
options?: JobOptions
|
|
56
|
+
): Promise<string> {
|
|
57
|
+
const jobId = await this.pgBoss.send(
|
|
58
|
+
queueName,
|
|
59
|
+
data as any,
|
|
60
|
+
mapPikkuJobToPgBoss(options)
|
|
61
|
+
)
|
|
62
|
+
if (!jobId) {
|
|
63
|
+
throw new Error('Failed to create job')
|
|
64
|
+
}
|
|
65
|
+
return jobId
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get job by ID
|
|
70
|
+
*/
|
|
71
|
+
async getJob<T, R>(
|
|
72
|
+
queueName: string,
|
|
73
|
+
jobId: string
|
|
74
|
+
): Promise<QueueJob<T, R> | null> {
|
|
75
|
+
const job = await this.pgBoss.getJobById<T>(queueName, jobId)
|
|
76
|
+
if (!job) {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
return mapPgBossJobToQueueJob<T, R>(job, this.pgBoss)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Close the pg-boss connection
|
|
84
|
+
*/
|
|
85
|
+
async close(): Promise<void> {
|
|
86
|
+
await this.pgBoss.stop()
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import PgBoss from 'pg-boss'
|
|
2
|
+
import type {
|
|
3
|
+
QueueWorkers,
|
|
4
|
+
PikkuWorkerConfig,
|
|
5
|
+
QueueConfigMapping,
|
|
6
|
+
ConfigValidationResult,
|
|
7
|
+
} from '@pikku/core/queue'
|
|
8
|
+
import { runQueueJob, registerqueueWorkers } from '@pikku/core/queue'
|
|
9
|
+
import {
|
|
10
|
+
CoreServices,
|
|
11
|
+
CoreSingletonServices,
|
|
12
|
+
CreateSessionServices,
|
|
13
|
+
} from '@pikku/core'
|
|
14
|
+
import { mapPgBossJobToQueueJob } from './utils.js'
|
|
15
|
+
|
|
16
|
+
export const mapPikkuWorkerToPgBoss = (
|
|
17
|
+
workerConfig?: PikkuWorkerConfig
|
|
18
|
+
): PgBoss.WorkOptions => {
|
|
19
|
+
const workerOptions: PgBoss.WorkOptions = {}
|
|
20
|
+
|
|
21
|
+
if (workerConfig?.batchSize !== undefined) {
|
|
22
|
+
workerOptions.batchSize = workerConfig.batchSize
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (workerConfig?.pollInterval !== undefined) {
|
|
26
|
+
workerOptions.pollingIntervalSeconds = Math.round(
|
|
27
|
+
workerConfig.pollInterval / 1000
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return workerOptions
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* pg-boss/PostgreSQL queue worker implementation
|
|
36
|
+
* Supports job results, retries, and PostgreSQL-based queuing
|
|
37
|
+
*/
|
|
38
|
+
export class PgBossQueueWorkers implements QueueWorkers {
|
|
39
|
+
readonly name = 'pg-boss'
|
|
40
|
+
readonly supportsResults = true
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Configuration mapping rules for pg-boss
|
|
44
|
+
* This defines how Pikku worker configs map to pg-boss configurations
|
|
45
|
+
*/
|
|
46
|
+
readonly configMappings: QueueConfigMapping = {
|
|
47
|
+
// Configurations that are directly supported
|
|
48
|
+
supported: {
|
|
49
|
+
batchSize: {
|
|
50
|
+
queueProperty: 'batchSize',
|
|
51
|
+
description: 'Number of jobs to process in a single batch',
|
|
52
|
+
},
|
|
53
|
+
pollInterval: {
|
|
54
|
+
queueProperty: 'pollingIntervalSeconds',
|
|
55
|
+
transform: (value: number) => Math.round(value / 1000),
|
|
56
|
+
description:
|
|
57
|
+
'How often to poll for new jobs (converted from ms to seconds)',
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
// Configurations that are not supported but have reasonable explanations
|
|
62
|
+
unsupported: {
|
|
63
|
+
name: {
|
|
64
|
+
reason: 'Worker names are not supported in pg-boss',
|
|
65
|
+
explanation:
|
|
66
|
+
'pg-boss identifies workers by their queue name and process, not custom names',
|
|
67
|
+
},
|
|
68
|
+
autorun: {
|
|
69
|
+
reason: 'Autorun is not configurable in pg-boss (always enabled)',
|
|
70
|
+
explanation:
|
|
71
|
+
'pg-boss workers automatically start processing when created',
|
|
72
|
+
},
|
|
73
|
+
lockDuration: {
|
|
74
|
+
reason: 'Lock duration is managed differently in pg-boss',
|
|
75
|
+
explanation:
|
|
76
|
+
'pg-boss uses job-level expiration instead of worker-level locks',
|
|
77
|
+
},
|
|
78
|
+
drainDelay: {
|
|
79
|
+
reason: 'Drain delay is not configurable in pg-boss',
|
|
80
|
+
explanation: 'pg-boss handles graceful shutdown internally',
|
|
81
|
+
},
|
|
82
|
+
maxStalledCount: {
|
|
83
|
+
reason: 'Max stalled count is not configurable in pg-boss',
|
|
84
|
+
explanation:
|
|
85
|
+
'pg-boss handles stalled jobs through its built-in retry mechanism',
|
|
86
|
+
},
|
|
87
|
+
prefetch: {
|
|
88
|
+
reason: 'Prefetch is managed internally by pg-boss',
|
|
89
|
+
explanation:
|
|
90
|
+
'pg-boss optimizes job fetching automatically based on batch size',
|
|
91
|
+
},
|
|
92
|
+
visibilityTimeout: {
|
|
93
|
+
reason: "Visibility timeout concept doesn't apply to pg-boss",
|
|
94
|
+
explanation:
|
|
95
|
+
'pg-boss uses PostgreSQL locks and job expiration instead of visibility timeout',
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
// Configurations that have partial support or workarounds
|
|
100
|
+
fallbacks: {
|
|
101
|
+
removeOnComplete: {
|
|
102
|
+
reason: 'Job retention is managed through pg-boss archival system',
|
|
103
|
+
explanation:
|
|
104
|
+
'pg-boss automatically archives completed jobs based on database settings',
|
|
105
|
+
fallbackValue: 'Managed by pg-boss database archival',
|
|
106
|
+
},
|
|
107
|
+
removeOnFail: {
|
|
108
|
+
reason:
|
|
109
|
+
'Failed job retention is managed through pg-boss archival system',
|
|
110
|
+
explanation:
|
|
111
|
+
'pg-boss automatically archives failed jobs based on database settings',
|
|
112
|
+
fallbackValue: 'Managed by pg-boss database archival',
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private pgBoss: PgBoss
|
|
118
|
+
private activeWorkers = new Map<string, string>()
|
|
119
|
+
|
|
120
|
+
constructor(
|
|
121
|
+
options: PgBoss.ConstructorOptions | string,
|
|
122
|
+
private singletonServices: CoreSingletonServices,
|
|
123
|
+
private createSessionServices?: CreateSessionServices<
|
|
124
|
+
CoreSingletonServices,
|
|
125
|
+
CoreServices,
|
|
126
|
+
any
|
|
127
|
+
>
|
|
128
|
+
) {
|
|
129
|
+
if (typeof options === 'string') {
|
|
130
|
+
// If a string is provided, treat it as the connection string
|
|
131
|
+
options = { connectionString: options }
|
|
132
|
+
}
|
|
133
|
+
this.pgBoss = new PgBoss(options)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Initialize pg-boss
|
|
138
|
+
*/
|
|
139
|
+
async init(): Promise<void> {
|
|
140
|
+
await this.pgBoss.start()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Scan state and register all compatible processors
|
|
145
|
+
*/
|
|
146
|
+
async registerQueues(): Promise<Record<string, ConfigValidationResult[]>> {
|
|
147
|
+
return await registerqueueWorkers(
|
|
148
|
+
this.configMappings,
|
|
149
|
+
this.singletonServices.logger,
|
|
150
|
+
async (queueName, processor) => {
|
|
151
|
+
await this.pgBoss.createQueue(queueName)
|
|
152
|
+
const workerId = await this.pgBoss.work<any>(
|
|
153
|
+
processor.queueName,
|
|
154
|
+
{
|
|
155
|
+
...mapPikkuWorkerToPgBoss(processor.config),
|
|
156
|
+
includeMetadata: true,
|
|
157
|
+
},
|
|
158
|
+
async ([job]) => {
|
|
159
|
+
if (!this.singletonServices) {
|
|
160
|
+
throw new Error('Singleton services not available')
|
|
161
|
+
}
|
|
162
|
+
if (!job) {
|
|
163
|
+
this.singletonServices.logger.warn(
|
|
164
|
+
`No job received for queue ${queueName}`
|
|
165
|
+
)
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
await runQueueJob({
|
|
169
|
+
singletonServices: this.singletonServices,
|
|
170
|
+
createSessionServices: this.createSessionServices,
|
|
171
|
+
job: mapPgBossJobToQueueJob(job, this.pgBoss),
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
this.activeWorkers.set(queueName, workerId)
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Close all workers and connections
|
|
182
|
+
*/
|
|
183
|
+
async close(): Promise<void> {
|
|
184
|
+
await this.pgBoss.stop()
|
|
185
|
+
}
|
|
186
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { QueueJob, QueueJobStatus } from '@pikku/core/queue'
|
|
2
|
+
import PgBoss from 'pg-boss'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Map pg-boss job state to our queue job status
|
|
6
|
+
*/
|
|
7
|
+
const mapPgBossStateToStatus = (state: string): QueueJobStatus => {
|
|
8
|
+
switch (state) {
|
|
9
|
+
case 'created':
|
|
10
|
+
case 'retry':
|
|
11
|
+
return 'waiting'
|
|
12
|
+
case 'active':
|
|
13
|
+
return 'active'
|
|
14
|
+
case 'completed':
|
|
15
|
+
return 'completed'
|
|
16
|
+
case 'failed':
|
|
17
|
+
return 'failed'
|
|
18
|
+
case 'cancelled':
|
|
19
|
+
return 'failed'
|
|
20
|
+
default:
|
|
21
|
+
return 'waiting'
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Map pg-boss job to QueueJob interface
|
|
27
|
+
*/
|
|
28
|
+
export const mapPgBossJobToQueueJob = <In, Out>(
|
|
29
|
+
pgBossJob: PgBoss.JobWithMetadata<In>,
|
|
30
|
+
pgBossInstance: PgBoss
|
|
31
|
+
): QueueJob<In, Out> => {
|
|
32
|
+
return {
|
|
33
|
+
queueName: pgBossJob.name,
|
|
34
|
+
id: pgBossJob.id || '',
|
|
35
|
+
data: pgBossJob.data,
|
|
36
|
+
status: async () => {
|
|
37
|
+
const jobWithState = await pgBossInstance.getJobById(
|
|
38
|
+
pgBossJob.name,
|
|
39
|
+
pgBossJob.id
|
|
40
|
+
)
|
|
41
|
+
return mapPgBossStateToStatus(jobWithState?.state || 'created')
|
|
42
|
+
},
|
|
43
|
+
waitForCompletion: async (ttl?: number): Promise<Out> => {
|
|
44
|
+
// pg-boss doesn't have a built-in waitForCompletion, so we'll poll
|
|
45
|
+
const startTime = Date.now()
|
|
46
|
+
const timeout = ttl || 30000 // 30 seconds default
|
|
47
|
+
|
|
48
|
+
while (Date.now() - startTime < timeout) {
|
|
49
|
+
const job = await pgBossInstance.getJobById(
|
|
50
|
+
pgBossJob.name,
|
|
51
|
+
pgBossJob.id
|
|
52
|
+
)
|
|
53
|
+
if (job?.state === 'completed') {
|
|
54
|
+
return job.output as Out
|
|
55
|
+
}
|
|
56
|
+
if (job?.state === 'failed' || job?.state === 'cancelled') {
|
|
57
|
+
throw new Error(`Job failed: ${JSON.stringify(job.output)}`)
|
|
58
|
+
}
|
|
59
|
+
// Wait 100ms before polling again
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
throw new Error('Job did not complete within timeout')
|
|
64
|
+
},
|
|
65
|
+
metadata: () => {
|
|
66
|
+
return {
|
|
67
|
+
progress: 0, // pg-boss doesn't have built-in progress tracking
|
|
68
|
+
attemptsMade: pgBossJob.retryCount || 0,
|
|
69
|
+
maxAttempts: pgBossJob.retryLimit || 1,
|
|
70
|
+
createdAt: pgBossJob.createdOn
|
|
71
|
+
? new Date(pgBossJob.createdOn)
|
|
72
|
+
: new Date(),
|
|
73
|
+
processedAt: pgBossJob.startedOn
|
|
74
|
+
? new Date(pgBossJob.startedOn)
|
|
75
|
+
: undefined,
|
|
76
|
+
completedAt: pgBossJob.completedOn
|
|
77
|
+
? new Date(pgBossJob.completedOn)
|
|
78
|
+
: undefined,
|
|
79
|
+
failedAt:
|
|
80
|
+
pgBossJob.state === 'failed' && pgBossJob.completedOn
|
|
81
|
+
? new Date(pgBossJob.completedOn)
|
|
82
|
+
: undefined,
|
|
83
|
+
error: (pgBossJob.data as any).error,
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"rootDir": "./src",
|
|
5
|
+
"module": "Node16",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"target": "esnext",
|
|
8
|
+
"declaration": true
|
|
9
|
+
},
|
|
10
|
+
"include": ["src/**/*.ts"],
|
|
11
|
+
"exclude": ["**/*.test.ts", "node_modules"],
|
|
12
|
+
"references": [
|
|
13
|
+
{
|
|
14
|
+
"path": "../../core/tsconfig.json"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|