@strav/queue 0.4.31 → 1.0.0-alpha.3

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,493 +0,0 @@
1
- import { inject } from '@strav/kernel/core/inject'
2
- import Configuration from '@strav/kernel/config/configuration'
3
- import Database from '@strav/database/database/database'
4
- import Emitter from '@strav/kernel/events/emitter'
5
- import { ConfigurationError } from '@strav/kernel/exceptions/errors'
6
- import { configureBreaker, type CircuitBreakerOptions } from './circuit_breaker.ts'
7
-
8
- export interface JobOptions {
9
- queue?: string
10
- delay?: number
11
- attempts?: number
12
- timeout?: number
13
- }
14
-
15
- export interface QueueConfig {
16
- default: string
17
- maxAttempts: number
18
- timeout: number
19
- retryBackoff: 'exponential' | 'linear'
20
- sleep: number
21
- }
22
-
23
- /** Metadata passed to job handlers alongside the payload. */
24
- export interface JobMeta {
25
- id: number
26
- queue: string
27
- job: string
28
- attempts: number
29
- maxAttempts: number
30
- /**
31
- * Report progress for a long-running job. `value` is `0..1`. The reported
32
- * value is persisted to the job row so external consumers can poll via
33
- * {@link Queue.progressOf}, and a `queue:progress` event is emitted for
34
- * live consumers (e.g. SSE).
35
- *
36
- * Returns immediately after persisting; safe to call from a tight loop
37
- * but throttle to avoid hammering the database (e.g. every N rows or
38
- * every 1 s).
39
- */
40
- progress: (value: number, message?: string) => Promise<void>
41
- }
42
-
43
- /** Snapshot of a job's current progress, returned by {@link Queue.progressOf}. */
44
- export interface JobProgress {
45
- /** Job id. */
46
- id: number
47
- /** 0..1, last reported by the handler. */
48
- value: number
49
- /** Optional human-readable message attached to the last update. */
50
- message: string | null
51
- /** Current attempt count. */
52
- attempts: number
53
- }
54
-
55
- /** A raw job row from the _strav_jobs table. */
56
- export interface JobRecord {
57
- id: number
58
- queue: string
59
- job: string
60
- payload: unknown
61
- attempts: number
62
- maxAttempts: number
63
- timeout: number
64
- availableAt: Date
65
- reservedAt: Date | null
66
- createdAt: Date
67
- }
68
-
69
- /** A raw row from the _strav_failed_jobs table. */
70
- export interface FailedJobRecord {
71
- id: number
72
- queue: string
73
- job: string
74
- payload: unknown
75
- error: string
76
- failedAt: Date
77
- }
78
-
79
- export type JobHandler<T = any> = (payload: T, meta: JobMeta) => void | Promise<void>
80
-
81
- /**
82
- * Minimal "schema-like" shape — anything that exposes `parse(input)`
83
- * (Zod, ArkType, Valibot, hand-written validators) works. The schema
84
- * is invoked at dequeue time, BEFORE the handler runs, so a tampered
85
- * row in the DB or a payload from an older code revision is rejected
86
- * loudly instead of executing with a half-formed shape.
87
- */
88
- export interface JobPayloadSchema<T = unknown> {
89
- parse(input: unknown): T
90
- }
91
-
92
- /** Per-handler registration options. */
93
- export interface JobHandlerOptions<T = any> {
94
- /**
95
- * Optional payload schema. When set, the worker calls `schema.parse(payload)`
96
- * before invoking the handler; a parse failure routes the job to
97
- * `_strav_failed_jobs` with the validation error message.
98
- *
99
- * Recommended for any handler whose payload comes from an external
100
- * source (HTTP webhook, customer upload) or whose code has churned
101
- * since older jobs were enqueued — the parse is a fail-fast invariant
102
- * that catches drift before the handler corrupts state.
103
- */
104
- schema?: JobPayloadSchema<T>
105
- /**
106
- * Per-handler circuit breaker. Trips when the failure count within
107
- * `windowMs` reaches `threshold`, pausing dispatch for `cooldownMs`.
108
- * Defends against retry storms — a stale-schema or downed-dependency
109
- * handler shouldn't keep eating worker cycles. Defaults: threshold
110
- * 10, windowMs 60_000, cooldownMs 30_000.
111
- */
112
- circuitBreaker?: CircuitBreakerOptions
113
- }
114
-
115
- /** Internal registration record stored in Queue._handlers. */
116
- export interface JobHandlerRegistration<T = any> {
117
- handler: JobHandler<T>
118
- schema?: JobPayloadSchema<T>
119
- }
120
-
121
- /**
122
- * PostgreSQL-backed job queue.
123
- *
124
- * Resolved once via the DI container — stores the database reference
125
- * and parsed config for Worker and all static methods.
126
- *
127
- * @example
128
- * app.singleton(Queue)
129
- * app.resolve(Queue)
130
- * await Queue.ensureTables()
131
- *
132
- * Queue.handle('send-email', async (payload) => { ... })
133
- * await Queue.push('send-email', { to: 'user@example.com' })
134
- */
135
- @inject
136
- export default class Queue {
137
- private static _db: Database
138
- private static _config: QueueConfig
139
- private static _handlers = new Map<string, JobHandlerRegistration>()
140
-
141
- constructor(db: Database, config: Configuration) {
142
- Queue._db = db
143
- Queue._config = {
144
- default: config.get('queue.default', 'default') as string,
145
- maxAttempts: config.get('queue.maxAttempts', 3) as number,
146
- timeout: config.get('queue.timeout', 60_000) as number,
147
- retryBackoff: config.get('queue.retryBackoff', 'exponential') as 'exponential' | 'linear',
148
- sleep: config.get('queue.sleep', 1000) as number,
149
- }
150
- }
151
-
152
- static get db(): Database {
153
- if (!Queue._db)
154
- throw new ConfigurationError(
155
- 'Queue not configured. Resolve Queue through the container first.'
156
- )
157
- return Queue._db
158
- }
159
-
160
- static get config(): QueueConfig {
161
- return Queue._config
162
- }
163
-
164
- static get handlers(): Map<string, JobHandlerRegistration> {
165
- return Queue._handlers
166
- }
167
-
168
- /** Create the internal jobs and failed_jobs tables if they don't exist. */
169
- static async ensureTables(): Promise<void> {
170
- const sql = Queue.db.sql
171
-
172
- await sql`
173
- CREATE TABLE IF NOT EXISTS "_strav_jobs" (
174
- "id" BIGSERIAL PRIMARY KEY,
175
- "queue" VARCHAR(255) NOT NULL DEFAULT 'default',
176
- "job" VARCHAR(255) NOT NULL,
177
- "payload" JSONB NOT NULL DEFAULT '{}',
178
- "attempts" INT NOT NULL DEFAULT 0,
179
- "max_attempts" INT NOT NULL DEFAULT 3,
180
- "timeout" INT NOT NULL DEFAULT 60000,
181
- "available_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
182
- "reserved_at" TIMESTAMPTZ,
183
- "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
184
- "progress" NUMERIC NOT NULL DEFAULT 0,
185
- "progress_message" TEXT
186
- )
187
- `
188
-
189
- // Additive migrations for progress columns — for tables that existed
190
- // before progress reporting was introduced.
191
- await sql`
192
- ALTER TABLE "_strav_jobs"
193
- ADD COLUMN IF NOT EXISTS "progress" NUMERIC NOT NULL DEFAULT 0
194
- `
195
- await sql`
196
- ALTER TABLE "_strav_jobs"
197
- ADD COLUMN IF NOT EXISTS "progress_message" TEXT
198
- `
199
-
200
- await sql`
201
- CREATE INDEX IF NOT EXISTS "idx_strav_jobs_queue_available"
202
- ON "_strav_jobs" ("queue", "available_at")
203
- WHERE "reserved_at" IS NULL
204
- `
205
-
206
- await sql`
207
- CREATE TABLE IF NOT EXISTS "_strav_failed_jobs" (
208
- "id" BIGSERIAL PRIMARY KEY,
209
- "queue" VARCHAR(255) NOT NULL,
210
- "job" VARCHAR(255) NOT NULL,
211
- "payload" JSONB NOT NULL DEFAULT '{}',
212
- "error" TEXT NOT NULL,
213
- "failed_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
214
- )
215
- `
216
- }
217
-
218
- /**
219
- * Register a handler for a named job. Pass `options.schema` to have
220
- * the worker validate the payload (Zod / ArkType / etc.) before
221
- * invoking the handler — a parse failure routes the job to
222
- * `_strav_failed_jobs` instead of running the handler with bad data.
223
- *
224
- * @example
225
- * import { z } from 'zod'
226
- * Queue.handle('send-email', async (payload) => { ... }, {
227
- * schema: z.object({ to: z.string().email(), subject: z.string() }),
228
- * })
229
- */
230
- static handle<T = any>(
231
- name: string,
232
- handler: JobHandler<T>,
233
- options?: JobHandlerOptions<T>
234
- ): void {
235
- Queue._handlers.set(name, { handler, schema: options?.schema })
236
- if (options?.circuitBreaker) {
237
- configureBreaker(name, options.circuitBreaker)
238
- }
239
- }
240
-
241
- /**
242
- * Push a job onto the queue. Returns the job ID.
243
- */
244
- static async push<T = any>(name: string, payload: T, options?: JobOptions): Promise<number> {
245
- const sql = Queue.db.sql
246
- const queue = options?.queue ?? Queue._config.default
247
- const maxAttempts = options?.attempts ?? Queue._config.maxAttempts
248
- const timeout = options?.timeout ?? Queue._config.timeout
249
- const availableAt = options?.delay ? new Date(Date.now() + options.delay) : new Date()
250
-
251
- const rows = await sql`
252
- INSERT INTO "_strav_jobs" ("queue", "job", "payload", "max_attempts", "timeout", "available_at")
253
- VALUES (${queue}, ${name}, ${JSON.stringify(payload)}, ${maxAttempts}, ${timeout}, ${availableAt})
254
- RETURNING "id"
255
- `
256
-
257
- const id = Number((rows[0] as Record<string, unknown>).id)
258
-
259
- if (Emitter.listenerCount('queue:dispatched') > 0) {
260
- Emitter.emit('queue:dispatched', { id, name, queue, payload }).catch(() => {})
261
- }
262
-
263
- return id
264
- }
265
-
266
- /**
267
- * Persist progress for an in-flight job and emit a `queue:progress` event.
268
- * Called by the `JobMeta.progress` callback that workers hand to handlers,
269
- * but exposed statically so other code (e.g. retry replay tools) can update
270
- * progress directly. `value` is clamped to `[0, 1]`.
271
- */
272
- static async reportProgress(
273
- id: number,
274
- value: number,
275
- message?: string
276
- ): Promise<void> {
277
- const sql = Queue.db.sql
278
- const clamped = Math.max(0, Math.min(1, value))
279
- const msg = message ?? null
280
- await sql`
281
- UPDATE "_strav_jobs"
282
- SET "progress" = ${clamped}, "progress_message" = ${msg}
283
- WHERE "id" = ${id}
284
- `
285
- if (Emitter.listenerCount('queue:progress') > 0) {
286
- Emitter.emit('queue:progress', {
287
- id,
288
- value: clamped,
289
- message: msg,
290
- }).catch(() => {})
291
- }
292
- }
293
-
294
- /**
295
- * Read the latest progress snapshot for a job. Returns `null` once the
296
- * job has completed (the row is deleted on success) or if the id is
297
- * unknown.
298
- */
299
- static async progressOf(id: number): Promise<JobProgress | null> {
300
- const sql = Queue.db.sql
301
- const rows = await sql`
302
- SELECT "id", "progress", "progress_message", "attempts"
303
- FROM "_strav_jobs"
304
- WHERE "id" = ${id}
305
- LIMIT 1
306
- `
307
- if (rows.length === 0) return null
308
- const row = rows[0] as Record<string, unknown>
309
- return {
310
- id: Number(row.id),
311
- value: Number(row.progress ?? 0),
312
- message: (row.progress_message as string | null) ?? null,
313
- attempts: Number(row.attempts ?? 0),
314
- }
315
- }
316
-
317
- /**
318
- * Create a listener function suitable for Emitter.on().
319
- * When the event fires, the payload is pushed onto the queue.
320
- *
321
- * @example
322
- * Emitter.on('user.registered', Queue.listener('send-welcome-email'))
323
- */
324
- static listener(jobName: string, options?: JobOptions): (payload: any) => Promise<void> {
325
- return async (payload: any) => {
326
- await Queue.push(jobName, payload, options)
327
- }
328
- }
329
-
330
- // ---------------------------------------------------------------------------
331
- // Introspection / Management
332
- // ---------------------------------------------------------------------------
333
-
334
- /** Count of pending (unreserved) jobs in a queue. */
335
- static async size(queue?: string): Promise<number> {
336
- const sql = Queue.db.sql
337
- const q = queue ?? Queue._config.default
338
-
339
- const rows = await sql`
340
- SELECT COUNT(*)::int AS count FROM "_strav_jobs"
341
- WHERE "queue" = ${q} AND "reserved_at" IS NULL
342
- `
343
- return (rows[0] as Record<string, unknown>).count as number
344
- }
345
-
346
- /** List pending jobs, most recent first. */
347
- static async pending(queue?: string, limit = 25): Promise<JobRecord[]> {
348
- const sql = Queue.db.sql
349
- const q = queue ?? Queue._config.default
350
-
351
- const rows = await sql`
352
- SELECT * FROM "_strav_jobs"
353
- WHERE "queue" = ${q}
354
- ORDER BY "available_at" ASC
355
- LIMIT ${limit}
356
- `
357
- return rows.map(hydrateJob)
358
- }
359
-
360
- /** List failed jobs, most recent first. */
361
- static async failed(queue?: string, limit = 25): Promise<FailedJobRecord[]> {
362
- const sql = Queue.db.sql
363
-
364
- if (queue) {
365
- const rows = await sql`
366
- SELECT * FROM "_strav_failed_jobs"
367
- WHERE "queue" = ${queue}
368
- ORDER BY "failed_at" DESC
369
- LIMIT ${limit}
370
- `
371
- return rows.map(hydrateFailedJob)
372
- }
373
-
374
- const rows = await sql`
375
- SELECT * FROM "_strav_failed_jobs"
376
- ORDER BY "failed_at" DESC
377
- LIMIT ${limit}
378
- `
379
- return rows.map(hydrateFailedJob)
380
- }
381
-
382
- /** Delete all pending jobs in a queue. Returns the number deleted. */
383
- static async clear(queue?: string): Promise<number> {
384
- const sql = Queue.db.sql
385
- const q = queue ?? Queue._config.default
386
-
387
- const rows = await sql`
388
- DELETE FROM "_strav_jobs" WHERE "queue" = ${q}
389
- `
390
- return rows.count
391
- }
392
-
393
- /** Move all failed jobs back to the jobs table. Returns the number retried. */
394
- static async retryFailed(queue?: string): Promise<number> {
395
- const sql = Queue.db.sql
396
-
397
- if (queue) {
398
- const failed = await sql`
399
- DELETE FROM "_strav_failed_jobs" WHERE "queue" = ${queue} RETURNING *
400
- `
401
- for (const row of failed) {
402
- const r = row as Record<string, unknown>
403
- await sql`
404
- INSERT INTO "_strav_jobs" ("queue", "job", "payload", "max_attempts")
405
- VALUES (${r.queue}, ${r.job}, ${typeof r.payload === 'string' ? r.payload : JSON.stringify(r.payload)}, ${Queue._config.maxAttempts})
406
- `
407
- }
408
- return failed.length
409
- }
410
-
411
- const failed = await sql`
412
- DELETE FROM "_strav_failed_jobs" RETURNING *
413
- `
414
- for (const row of failed) {
415
- const r = row as Record<string, unknown>
416
- await sql`
417
- INSERT INTO "_strav_jobs" ("queue", "job", "payload", "max_attempts")
418
- VALUES (${r.queue}, ${r.job}, ${typeof r.payload === 'string' ? r.payload : JSON.stringify(r.payload)}, ${Queue._config.maxAttempts})
419
- `
420
- }
421
- return failed.length
422
- }
423
-
424
- /** Delete all failed jobs for a queue (or all queues). Returns the number deleted. */
425
- static async clearFailed(queue?: string): Promise<number> {
426
- const sql = Queue.db.sql
427
-
428
- if (queue) {
429
- const rows = await sql`
430
- DELETE FROM "_strav_failed_jobs" WHERE "queue" = ${queue}
431
- `
432
- return rows.count
433
- }
434
-
435
- const rows = await sql`
436
- DELETE FROM "_strav_failed_jobs"
437
- `
438
- return rows.count
439
- }
440
-
441
- /** Delete all jobs and failed jobs across all queues. For dev/test only. */
442
- static async flush(): Promise<void> {
443
- const sql = Queue.db.sql
444
- await sql`DELETE FROM "_strav_jobs"`
445
- await sql`DELETE FROM "_strav_failed_jobs"`
446
- }
447
-
448
- /** Reset static state. For testing only. */
449
- static reset(): void {
450
- Queue._handlers.clear()
451
- }
452
- }
453
-
454
- // ---------------------------------------------------------------------------
455
- // Hydration helpers
456
- // ---------------------------------------------------------------------------
457
-
458
- function parsePayload(raw: unknown): unknown {
459
- if (typeof raw === 'string') {
460
- try {
461
- return JSON.parse(raw)
462
- } catch {
463
- return raw
464
- }
465
- }
466
- return raw
467
- }
468
-
469
- export function hydrateJob(row: Record<string, unknown>): JobRecord {
470
- return {
471
- id: Number(row.id),
472
- queue: row.queue as string,
473
- job: row.job as string,
474
- payload: parsePayload(row.payload),
475
- attempts: row.attempts as number,
476
- maxAttempts: row.max_attempts as number,
477
- timeout: row.timeout as number,
478
- availableAt: row.available_at as Date,
479
- reservedAt: (row.reserved_at as Date) ?? null,
480
- createdAt: row.created_at as Date,
481
- }
482
- }
483
-
484
- function hydrateFailedJob(row: Record<string, unknown>): FailedJobRecord {
485
- return {
486
- id: Number(row.id),
487
- queue: row.queue as string,
488
- job: row.job as string,
489
- payload: parsePayload(row.payload),
490
- error: row.error as string,
491
- failedAt: row.failed_at as Date,
492
- }
493
- }