@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.
- package/README.md +243 -0
- package/package.json +20 -29
- package/src/cron.ts +194 -0
- package/src/database_queue.ts +177 -0
- package/src/failed_jobs_schema.ts +37 -0
- package/src/index.ts +52 -3
- package/src/job.ts +153 -0
- package/src/job_registry.ts +135 -0
- package/src/job_schema.ts +49 -0
- package/src/queue.ts +69 -0
- package/src/scheduler.ts +242 -0
- package/src/scheduler_runs_schema.ts +33 -0
- package/src/sync_queue.ts +126 -0
- package/src/worker.ts +351 -0
- package/src/providers/index.ts +0 -3
- package/src/providers/queue_provider.ts +0 -29
- package/src/queue/circuit_breaker.ts +0 -135
- package/src/queue/index.ts +0 -22
- package/src/queue/queue.ts +0 -493
- package/src/queue/worker.ts +0 -273
- package/src/scheduler/cron.ts +0 -146
- package/src/scheduler/index.ts +0 -8
- package/src/scheduler/runner.ts +0 -116
- package/src/scheduler/schedule.ts +0 -292
- package/src/scheduler/scheduler.ts +0 -71
- package/tsconfig.json +0 -5
package/src/queue/worker.ts
DELETED
|
@@ -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
|
-
}
|
package/src/scheduler/cron.ts
DELETED
|
@@ -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
|
-
}
|
package/src/scheduler/index.ts
DELETED
|
@@ -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'
|
package/src/scheduler/runner.ts
DELETED
|
@@ -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
|
-
}
|