@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.
@@ -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
- }