@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,273 +0,0 @@
1
- import Queue, { hydrateJob } from './queue.ts'
2
- import Emitter from '@strav/kernel/events/emitter'
3
- import { checkBreaker, recordFailure, recordSuccess } from './circuit_breaker.ts'
4
- import type { JobRecord, JobMeta } from './queue.ts'
5
-
6
- export interface WorkerOptions {
7
- queue?: string
8
- sleep?: number
9
- }
10
-
11
- /**
12
- * Processes jobs from the queue.
13
- *
14
- * Uses `SELECT ... FOR UPDATE SKIP LOCKED` for safe concurrent polling.
15
- * Supports job timeouts, exponential/linear backoff for retries,
16
- * and graceful shutdown on SIGINT/SIGTERM.
17
- *
18
- * @example
19
- * const worker = new Worker({ queue: 'emails', sleep: 500 })
20
- * await worker.start() // blocks until worker.stop() is called
21
- */
22
- export default class Worker {
23
- private running = false
24
- private processing = false
25
- private queue: string
26
- private sleep: number
27
-
28
- constructor(options: WorkerOptions = {}) {
29
- this.queue = options.queue ?? Queue.config.default
30
- this.sleep = options.sleep ?? Queue.config.sleep
31
- }
32
-
33
- /** Start the worker loop. Blocks until stop() is called. */
34
- async start(): Promise<void> {
35
- this.running = true
36
-
37
- const onSignal = () => this.stop()
38
- process.on('SIGINT', onSignal)
39
- process.on('SIGTERM', onSignal)
40
-
41
- let pollCount = 0
42
-
43
- try {
44
- while (this.running) {
45
- // Periodically release stale jobs (every 60 cycles)
46
- if (pollCount % 60 === 0) {
47
- await this.releaseStaleJobs()
48
- }
49
- pollCount++
50
-
51
- const job = await this.fetchNext()
52
- if (job) {
53
- this.processing = true
54
- await this.process(job)
55
- this.processing = false
56
- } else {
57
- await Bun.sleep(this.sleep)
58
- }
59
- }
60
- } finally {
61
- process.off('SIGINT', onSignal)
62
- process.off('SIGTERM', onSignal)
63
- }
64
- }
65
-
66
- /** Signal the worker to stop after the current job completes. */
67
- stop(): void {
68
- this.running = false
69
- }
70
-
71
- /** Whether the worker is currently processing a job. */
72
- get busy(): boolean {
73
- return this.processing
74
- }
75
-
76
- // ---------------------------------------------------------------------------
77
- // Internal
78
- // ---------------------------------------------------------------------------
79
-
80
- /**
81
- * Fetch the next available job using FOR UPDATE SKIP LOCKED.
82
- * Atomically reserves it by setting reserved_at and incrementing attempts.
83
- */
84
- private async fetchNext(): Promise<JobRecord | null> {
85
- const sql = Queue.db.sql
86
-
87
- const rows = await sql.begin(async (tx: any) => {
88
- const result = await tx`
89
- SELECT * FROM "_strav_jobs"
90
- WHERE "queue" = ${this.queue}
91
- AND "available_at" <= NOW()
92
- AND "reserved_at" IS NULL
93
- ORDER BY "available_at" ASC
94
- LIMIT 1
95
- FOR UPDATE SKIP LOCKED
96
- `
97
- if (result.length === 0) return []
98
-
99
- const job = result[0] as Record<string, unknown>
100
- await tx`
101
- UPDATE "_strav_jobs"
102
- SET "reserved_at" = NOW(), "attempts" = "attempts" + 1
103
- WHERE "id" = ${job.id}
104
- `
105
- return [{ ...job, attempts: (job.attempts as number) + 1 }]
106
- })
107
-
108
- return rows.length > 0 ? hydrateJob(rows[0] as Record<string, unknown>) : null
109
- }
110
-
111
- /** Process a single job: run handler, handle success/failure. */
112
- private async process(job: JobRecord): Promise<void> {
113
- const registration = Queue.handlers.get(job.job)
114
-
115
- if (!registration) {
116
- await this.fail(job, new Error(`No handler registered for job "${job.job}"`))
117
- return
118
- }
119
-
120
- // Q-1: per-handler circuit breaker. If the handler has tripped its
121
- // breaker (too many failures in the configured window), defer this
122
- // job rather than running it. Push it back to the queue with
123
- // `available_at = now + cooldown` so it retries AFTER the breaker
124
- // resets — this clears the worker to drain unrelated jobs from the
125
- // queue instead of compounding the failure storm.
126
- const cooldownRemaining = checkBreaker(job.job)
127
- if (cooldownRemaining !== null) {
128
- await this.deferForCooldown(job, cooldownRemaining)
129
- return
130
- }
131
-
132
- const meta: JobMeta = {
133
- id: job.id,
134
- queue: job.queue,
135
- job: job.job,
136
- attempts: job.attempts,
137
- maxAttempts: job.maxAttempts,
138
- progress: (value: number, message?: string) => Queue.reportProgress(job.id, value, message),
139
- }
140
-
141
- // Re-parse the payload through the registered schema (CC-5). Catches
142
- // payloads that drifted from the handler's expected shape — older
143
- // enqueues, manual DB edits, malicious tampering. A parse failure
144
- // is fatal: the job goes straight to failed_jobs without retry,
145
- // because retrying with the same bad payload won't help.
146
- let payload = job.payload
147
- if (registration.schema) {
148
- try {
149
- payload = registration.schema.parse(job.payload)
150
- } catch (err) {
151
- const detail = err instanceof Error ? err.message : String(err)
152
- await this.fail(job, new Error(`Job "${job.job}" payload failed validation: ${detail}`))
153
- return
154
- }
155
- }
156
-
157
- const start = performance.now()
158
-
159
- try {
160
- await Promise.race([
161
- Promise.resolve(registration.handler(payload, meta)),
162
- new Promise<never>((_, reject) =>
163
- setTimeout(
164
- () => reject(new Error(`Job "${job.job}" timed out after ${job.timeout}ms`)),
165
- job.timeout
166
- )
167
- ),
168
- ])
169
- await this.complete(job)
170
- recordSuccess(job.job)
171
-
172
- if (Emitter.listenerCount('queue:processed') > 0) {
173
- const duration = performance.now() - start
174
- Emitter.emit('queue:processed', {
175
- job: job.job,
176
- id: job.id,
177
- queue: job.queue,
178
- duration,
179
- }).catch(() => {})
180
- }
181
- } catch (error) {
182
- const err = error instanceof Error ? error : new Error(String(error))
183
- // Update breaker state regardless of retry decision so a job that
184
- // exhausts its retries still counts toward the trip threshold.
185
- recordFailure(job.job)
186
- if (job.attempts >= job.maxAttempts) {
187
- await this.fail(job, err)
188
-
189
- if (Emitter.listenerCount('queue:failed') > 0) {
190
- const duration = performance.now() - start
191
- Emitter.emit('queue:failed', {
192
- job: job.job,
193
- id: job.id,
194
- queue: job.queue,
195
- error: err.message,
196
- duration,
197
- }).catch(() => {})
198
- }
199
- } else {
200
- await this.release(job)
201
- }
202
- }
203
- }
204
-
205
- /** Delete a completed job. */
206
- private async complete(job: JobRecord): Promise<void> {
207
- await Queue.db.sql`DELETE FROM "_strav_jobs" WHERE "id" = ${job.id}`
208
- }
209
-
210
- /** Move a job to the failed_jobs table and delete from jobs. */
211
- private async fail(job: JobRecord, error: Error): Promise<void> {
212
- const sql = Queue.db.sql
213
- await sql.begin(async (tx: any) => {
214
- await tx`
215
- INSERT INTO "_strav_failed_jobs" ("queue", "job", "payload", "error")
216
- VALUES (${job.queue}, ${job.job}, ${JSON.stringify(job.payload)}, ${error.message})
217
- `
218
- await tx`DELETE FROM "_strav_jobs" WHERE "id" = ${job.id}`
219
- })
220
- }
221
-
222
- /** Release a job back to the queue with incremented backoff delay. */
223
- private async release(job: JobRecord): Promise<void> {
224
- const delay = this.backoffDelay(job.attempts)
225
- const availableAt = new Date(Date.now() + delay)
226
-
227
- await Queue.db.sql`
228
- UPDATE "_strav_jobs"
229
- SET "reserved_at" = NULL, "available_at" = ${availableAt}
230
- WHERE "id" = ${job.id}
231
- `
232
- }
233
-
234
- /**
235
- * Push a tripped-circuit job back to the queue with `available_at`
236
- * scheduled past the cooldown. Also rolls back the attempts counter
237
- * the fetcher incremented so a circuit trip doesn't eat retry
238
- * budget — the job genuinely never executed.
239
- */
240
- private async deferForCooldown(job: JobRecord, cooldownMs: number): Promise<void> {
241
- const availableAt = new Date(Date.now() + Math.max(cooldownMs, 1_000))
242
-
243
- await Queue.db.sql`
244
- UPDATE "_strav_jobs"
245
- SET "reserved_at" = NULL,
246
- "available_at" = ${availableAt},
247
- "attempts" = GREATEST("attempts" - 1, 0)
248
- WHERE "id" = ${job.id}
249
- `
250
- }
251
-
252
- /** Calculate backoff delay in ms based on attempt number. */
253
- backoffDelay(attempts: number): number {
254
- if (Queue.config.retryBackoff === 'linear') {
255
- return attempts * 5_000
256
- }
257
- // Exponential: 2^attempts * 1000, with jitter
258
- const base = Math.pow(2, attempts) * 1000
259
- const jitter = Math.random() * 1000
260
- return base + jitter
261
- }
262
-
263
- /** Release jobs that have been reserved for too long (crashed workers). */
264
- private async releaseStaleJobs(): Promise<void> {
265
- await Queue.db.sql`
266
- UPDATE "_strav_jobs"
267
- SET "reserved_at" = NULL
268
- WHERE "reserved_at" IS NOT NULL
269
- AND "queue" = ${this.queue}
270
- AND "reserved_at" < NOW() - MAKE_INTERVAL(secs => "timeout" * 2.0 / 1000)
271
- `
272
- }
273
- }
@@ -1,146 +0,0 @@
1
- /**
2
- * Minimal 5-field cron expression parser.
3
- *
4
- * Supports: `*`, exact (`5`), range (`1-5`), list (`1,3,5`),
5
- * step (`*​/10`), and range+step (`1-30/5`).
6
- *
7
- * Fields: minute (0–59), hour (0–23), day-of-month (1–31),
8
- * month (1–12), day-of-week (0–6, 0 = Sunday).
9
- */
10
-
11
- export interface CronExpression {
12
- minute: number[]
13
- hour: number[]
14
- dayOfMonth: number[]
15
- month: number[]
16
- dayOfWeek: number[]
17
- }
18
-
19
- const FIELD_RANGES: [number, number][] = [
20
- [0, 59], // minute
21
- [0, 23], // hour
22
- [1, 31], // day of month
23
- [1, 12], // month
24
- [0, 6], // day of week
25
- ]
26
-
27
- // Parse a 5-field cron string into expanded numeric arrays.
28
- export function parseCron(expression: string): CronExpression {
29
- const parts = expression.trim().split(/\s+/)
30
- if (parts.length !== 5) {
31
- throw new Error(
32
- `Invalid cron expression "${expression}": expected 5 fields, got ${parts.length}`
33
- )
34
- }
35
-
36
- const [minute, hour, dayOfMonth, month, dayOfWeek] = parts.map((part, i) =>
37
- parseField(part, FIELD_RANGES[i]![0], FIELD_RANGES[i]![1])
38
- )
39
-
40
- return {
41
- minute: minute!,
42
- hour: hour!,
43
- dayOfMonth: dayOfMonth!,
44
- month: month!,
45
- dayOfWeek: dayOfWeek!,
46
- }
47
- }
48
-
49
- /**
50
- * Check if a Date matches a parsed cron expression.
51
- *
52
- * Standard cron rule: if both day-of-month and day-of-week are restricted
53
- * (not wildcards), either match satisfies the condition.
54
- */
55
- export function cronMatches(cron: CronExpression, date: Date): boolean {
56
- const minute = date.getUTCMinutes()
57
- const hour = date.getUTCHours()
58
- const dayOfMonth = date.getUTCDate()
59
- const month = date.getUTCMonth() + 1
60
- const dayOfWeek = date.getUTCDay()
61
-
62
- if (!cron.minute.includes(minute)) return false
63
- if (!cron.hour.includes(hour)) return false
64
- if (!cron.month.includes(month)) return false
65
-
66
- // Standard cron: day-of-month and day-of-week are OR'd when both are restricted
67
- const domRestricted = cron.dayOfMonth.length < 31
68
- const dowRestricted = cron.dayOfWeek.length < 7
69
-
70
- if (domRestricted && dowRestricted) {
71
- return cron.dayOfMonth.includes(dayOfMonth) || cron.dayOfWeek.includes(dayOfWeek)
72
- }
73
-
74
- if (!cron.dayOfMonth.includes(dayOfMonth)) return false
75
- if (!cron.dayOfWeek.includes(dayOfWeek)) return false
76
-
77
- return true
78
- }
79
-
80
- /**
81
- * Compute the next Date (UTC) after `after` that matches the expression.
82
- * Searches up to 2 years ahead to prevent infinite loops.
83
- */
84
- export function nextCronDate(cron: CronExpression, after: Date): Date {
85
- const date = new Date(after.getTime())
86
- // Advance to the next whole minute
87
- date.setUTCSeconds(0, 0)
88
- date.setUTCMinutes(date.getUTCMinutes() + 1)
89
-
90
- const limit = after.getTime() + 2 * 365 * 24 * 60 * 60 * 1000
91
-
92
- while (date.getTime() <= limit) {
93
- if (cronMatches(cron, date)) return date
94
- date.setUTCMinutes(date.getUTCMinutes() + 1)
95
- }
96
-
97
- throw new Error('Could not find next matching date within 2 years')
98
- }
99
-
100
- // ---------------------------------------------------------------------------
101
- // Field parsing
102
- // ---------------------------------------------------------------------------
103
-
104
- function parseField(field: string, min: number, max: number): number[] {
105
- const values = new Set<number>()
106
-
107
- for (const part of field.split(',')) {
108
- const stepMatch = part.match(/^(.+)\/(\d+)$/)
109
-
110
- if (stepMatch) {
111
- const [, rangePart, stepStr] = stepMatch
112
- const step = parseInt(stepStr!, 10)
113
- if (step <= 0) throw new Error(`Invalid step value: ${stepStr}`)
114
-
115
- const [start, end] = rangePart === '*' ? [min, max] : parseRange(rangePart!, min, max)
116
- for (let i = start; i <= end; i += step) {
117
- values.add(i)
118
- }
119
- } else if (part === '*') {
120
- for (let i = min; i <= max; i++) values.add(i)
121
- } else if (part.includes('-')) {
122
- const [start, end] = parseRange(part, min, max)
123
- for (let i = start; i <= end; i++) values.add(i)
124
- } else {
125
- const n = parseInt(part, 10)
126
- if (isNaN(n) || n < min || n > max) {
127
- throw new Error(`Invalid cron value "${part}": must be ${min}–${max}`)
128
- }
129
- values.add(n)
130
- }
131
- }
132
-
133
- return [...values].sort((a, b) => a - b)
134
- }
135
-
136
- function parseRange(part: string, min: number, max: number): [number, number] {
137
- const [startStr, endStr] = part.split('-')
138
- const start = parseInt(startStr!, 10)
139
- const end = parseInt(endStr!, 10)
140
-
141
- if (isNaN(start) || isNaN(end) || start < min || end > max || start > end) {
142
- throw new Error(`Invalid cron range "${part}": must be within ${min}–${max}`)
143
- }
144
-
145
- return [start, end]
146
- }
@@ -1,8 +0,0 @@
1
- export { default as Scheduler } from './scheduler.ts'
2
- export { Schedule } from './schedule.ts'
3
- export { default as SchedulerRunner } from './runner.ts'
4
- export { parseCron, cronMatches, nextCronDate } from './cron.ts'
5
- export type { CronExpression } from './cron.ts'
6
- export { TimeUnit } from './schedule.ts'
7
- export type { TaskHandler } from './schedule.ts'
8
- export type { RunnerOptions } from './runner.ts'
@@ -1,116 +0,0 @@
1
- import Scheduler from './scheduler.ts'
2
- import type { Schedule } from './schedule.ts'
3
-
4
- export interface RunnerOptions {
5
- /** Timezone for schedule evaluation. @default 'UTC' */
6
- timezone?: string
7
- }
8
-
9
- /**
10
- * Long-running scheduler loop.
11
- *
12
- * Aligns to minute boundaries (standard cron behaviour), checks which
13
- * tasks are due, and executes them concurrently. Supports graceful
14
- * shutdown via SIGINT/SIGTERM and per-task overlap prevention.
15
- *
16
- * @example
17
- * const runner = new SchedulerRunner()
18
- * await runner.start() // blocks until runner.stop()
19
- */
20
- export default class SchedulerRunner {
21
- private running = false
22
- private active = new Set<string>()
23
-
24
- /** Start the scheduler loop. Blocks until stop() is called. */
25
- async start(): Promise<void> {
26
- this.running = true
27
-
28
- const onSignal = () => this.stop()
29
- process.on('SIGINT', onSignal)
30
- process.on('SIGTERM', onSignal)
31
-
32
- try {
33
- while (this.running) {
34
- await this.sleepUntilNextMinute()
35
- if (!this.running) break
36
-
37
- const now = new Date()
38
- const due = Scheduler.due(now)
39
-
40
- if (due.length > 0) {
41
- await this.runAll(due)
42
- }
43
- }
44
-
45
- // Wait for active tasks to finish before exiting
46
- if (this.active.size > 0) {
47
- await this.waitForActive()
48
- }
49
- } finally {
50
- process.off('SIGINT', onSignal)
51
- process.off('SIGTERM', onSignal)
52
- }
53
- }
54
-
55
- /** Signal the runner to stop after the current tick completes. */
56
- stop(): void {
57
- this.running = false
58
- }
59
-
60
- /** Number of tasks currently executing. */
61
- get activeCount(): number {
62
- return this.active.size
63
- }
64
-
65
- // ---------------------------------------------------------------------------
66
- // Internal
67
- // ---------------------------------------------------------------------------
68
-
69
- /** Sleep until the start of the next minute (XX:XX:00.000). */
70
- private async sleepUntilNextMinute(): Promise<void> {
71
- const now = Date.now()
72
- const nextMinute = Math.ceil(now / 60_000) * 60_000
73
- const delay = nextMinute - now
74
-
75
- // Minimum 1s, maximum 60s — avoid sleeping 0ms on exact boundaries
76
- const ms = Math.max(1_000, Math.min(delay, 60_000))
77
- await Bun.sleep(ms)
78
- }
79
-
80
- /** Execute all due tasks concurrently. */
81
- private async runAll(tasks: Schedule[]): Promise<void> {
82
- const promises: Promise<void>[] = []
83
-
84
- for (const task of tasks) {
85
- if (task.preventsOverlap && this.active.has(task.name)) {
86
- continue
87
- }
88
-
89
- promises.push(this.runTask(task))
90
- }
91
-
92
- await Promise.allSettled(promises)
93
- }
94
-
95
- /** Execute a single task with overlap tracking and error handling. */
96
- private async runTask(task: Schedule): Promise<void> {
97
- this.active.add(task.name)
98
- try {
99
- await task.handler()
100
- } catch (error) {
101
- const message = error instanceof Error ? error.message : String(error)
102
- console.error(`[scheduler] Task "${task.name}" failed: ${message}`)
103
- } finally {
104
- this.active.delete(task.name)
105
- }
106
- }
107
-
108
- /** Wait for all active tasks to complete (for graceful shutdown). */
109
- private async waitForActive(): Promise<void> {
110
- const maxWait = 30_000
111
- const start = Date.now()
112
- while (this.active.size > 0 && Date.now() - start < maxWait) {
113
- await Bun.sleep(100)
114
- }
115
- }
116
- }