@strav/queue 0.1.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 +38 -0
- package/src/index.ts +3 -0
- package/src/providers/index.ts +3 -0
- package/src/providers/queue_provider.ts +29 -0
- package/src/queue/index.ts +11 -0
- package/src/queue/queue.ts +347 -0
- package/src/queue/worker.ts +221 -0
- package/src/scheduler/cron.ts +146 -0
- package/src/scheduler/index.ts +8 -0
- package/src/scheduler/runner.ts +116 -0
- package/src/scheduler/schedule.ts +262 -0
- package/src/scheduler/scheduler.ts +47 -0
- package/tsconfig.json +5 -0
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@strav/queue",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Background job processing and task scheduling for the Strav framework",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"bun",
|
|
9
|
+
"framework",
|
|
10
|
+
"typescript",
|
|
11
|
+
"strav",
|
|
12
|
+
"queue",
|
|
13
|
+
"scheduler"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
"src/",
|
|
17
|
+
"package.json",
|
|
18
|
+
"tsconfig.json",
|
|
19
|
+
"CHANGELOG.md"
|
|
20
|
+
],
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./src/index.ts",
|
|
23
|
+
"./queue": "./src/queue/index.ts",
|
|
24
|
+
"./queue/*": "./src/queue/*.ts",
|
|
25
|
+
"./scheduler": "./src/scheduler/index.ts",
|
|
26
|
+
"./scheduler/*": "./src/scheduler/*.ts",
|
|
27
|
+
"./providers": "./src/providers/index.ts",
|
|
28
|
+
"./providers/*": "./src/providers/*.ts"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@strav/kernel": "0.1.0",
|
|
32
|
+
"@strav/database": "0.1.0"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "bun test tests/",
|
|
36
|
+
"typecheck": "tsc --noEmit"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import ServiceProvider from '@stravigor/kernel/core/service_provider'
|
|
2
|
+
import type Application from '@stravigor/kernel/core/application'
|
|
3
|
+
import Queue from '../queue/queue.ts'
|
|
4
|
+
|
|
5
|
+
export interface QueueProviderOptions {
|
|
6
|
+
/** Whether to auto-create the jobs tables. Default: `true` */
|
|
7
|
+
ensureTables?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default class QueueProvider extends ServiceProvider {
|
|
11
|
+
readonly name = 'queue'
|
|
12
|
+
override readonly dependencies = ['database']
|
|
13
|
+
|
|
14
|
+
constructor(private options?: QueueProviderOptions) {
|
|
15
|
+
super()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
override register(app: Application): void {
|
|
19
|
+
app.singleton(Queue)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
override async boot(app: Application): Promise<void> {
|
|
23
|
+
app.resolve(Queue)
|
|
24
|
+
|
|
25
|
+
if (this.options?.ensureTables !== false) {
|
|
26
|
+
await Queue.ensureTables()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { default as Queue } from './queue.ts'
|
|
2
|
+
export { default as Worker } from './worker.ts'
|
|
3
|
+
export type {
|
|
4
|
+
JobOptions,
|
|
5
|
+
QueueConfig,
|
|
6
|
+
JobMeta,
|
|
7
|
+
JobRecord,
|
|
8
|
+
FailedJobRecord,
|
|
9
|
+
JobHandler,
|
|
10
|
+
} from './queue.ts'
|
|
11
|
+
export type { WorkerOptions } from './worker.ts'
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { inject } from '@stravigor/kernel/core/inject'
|
|
2
|
+
import Configuration from '@stravigor/kernel/config/configuration'
|
|
3
|
+
import Database from '@stravigor/database/database/database'
|
|
4
|
+
import Emitter from '@stravigor/kernel/events/emitter'
|
|
5
|
+
import { ConfigurationError } from '@stravigor/kernel/exceptions/errors'
|
|
6
|
+
|
|
7
|
+
export interface JobOptions {
|
|
8
|
+
queue?: string
|
|
9
|
+
delay?: number
|
|
10
|
+
attempts?: number
|
|
11
|
+
timeout?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface QueueConfig {
|
|
15
|
+
default: string
|
|
16
|
+
maxAttempts: number
|
|
17
|
+
timeout: number
|
|
18
|
+
retryBackoff: 'exponential' | 'linear'
|
|
19
|
+
sleep: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Metadata passed to job handlers alongside the payload. */
|
|
23
|
+
export interface JobMeta {
|
|
24
|
+
id: number
|
|
25
|
+
queue: string
|
|
26
|
+
job: string
|
|
27
|
+
attempts: number
|
|
28
|
+
maxAttempts: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A raw job row from the _strav_jobs table. */
|
|
32
|
+
export interface JobRecord {
|
|
33
|
+
id: number
|
|
34
|
+
queue: string
|
|
35
|
+
job: string
|
|
36
|
+
payload: unknown
|
|
37
|
+
attempts: number
|
|
38
|
+
maxAttempts: number
|
|
39
|
+
timeout: number
|
|
40
|
+
availableAt: Date
|
|
41
|
+
reservedAt: Date | null
|
|
42
|
+
createdAt: Date
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A raw row from the _strav_failed_jobs table. */
|
|
46
|
+
export interface FailedJobRecord {
|
|
47
|
+
id: number
|
|
48
|
+
queue: string
|
|
49
|
+
job: string
|
|
50
|
+
payload: unknown
|
|
51
|
+
error: string
|
|
52
|
+
failedAt: Date
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type JobHandler<T = any> = (payload: T, meta: JobMeta) => void | Promise<void>
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* PostgreSQL-backed job queue.
|
|
59
|
+
*
|
|
60
|
+
* Resolved once via the DI container — stores the database reference
|
|
61
|
+
* and parsed config for Worker and all static methods.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* app.singleton(Queue)
|
|
65
|
+
* app.resolve(Queue)
|
|
66
|
+
* await Queue.ensureTables()
|
|
67
|
+
*
|
|
68
|
+
* Queue.handle('send-email', async (payload) => { ... })
|
|
69
|
+
* await Queue.push('send-email', { to: 'user@example.com' })
|
|
70
|
+
*/
|
|
71
|
+
@inject
|
|
72
|
+
export default class Queue {
|
|
73
|
+
private static _db: Database
|
|
74
|
+
private static _config: QueueConfig
|
|
75
|
+
private static _handlers = new Map<string, JobHandler>()
|
|
76
|
+
|
|
77
|
+
constructor(db: Database, config: Configuration) {
|
|
78
|
+
Queue._db = db
|
|
79
|
+
Queue._config = {
|
|
80
|
+
default: config.get('queue.default', 'default') as string,
|
|
81
|
+
maxAttempts: config.get('queue.maxAttempts', 3) as number,
|
|
82
|
+
timeout: config.get('queue.timeout', 60_000) as number,
|
|
83
|
+
retryBackoff: config.get('queue.retryBackoff', 'exponential') as 'exponential' | 'linear',
|
|
84
|
+
sleep: config.get('queue.sleep', 1000) as number,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
static get db(): Database {
|
|
89
|
+
if (!Queue._db)
|
|
90
|
+
throw new ConfigurationError(
|
|
91
|
+
'Queue not configured. Resolve Queue through the container first.'
|
|
92
|
+
)
|
|
93
|
+
return Queue._db
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
static get config(): QueueConfig {
|
|
97
|
+
return Queue._config
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
static get handlers(): Map<string, JobHandler> {
|
|
101
|
+
return Queue._handlers
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Create the internal jobs and failed_jobs tables if they don't exist. */
|
|
105
|
+
static async ensureTables(): Promise<void> {
|
|
106
|
+
const sql = Queue.db.sql
|
|
107
|
+
|
|
108
|
+
await sql`
|
|
109
|
+
CREATE TABLE IF NOT EXISTS "_strav_jobs" (
|
|
110
|
+
"id" BIGSERIAL PRIMARY KEY,
|
|
111
|
+
"queue" VARCHAR(255) NOT NULL DEFAULT 'default',
|
|
112
|
+
"job" VARCHAR(255) NOT NULL,
|
|
113
|
+
"payload" JSONB NOT NULL DEFAULT '{}',
|
|
114
|
+
"attempts" INT NOT NULL DEFAULT 0,
|
|
115
|
+
"max_attempts" INT NOT NULL DEFAULT 3,
|
|
116
|
+
"timeout" INT NOT NULL DEFAULT 60000,
|
|
117
|
+
"available_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
118
|
+
"reserved_at" TIMESTAMPTZ,
|
|
119
|
+
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
120
|
+
)
|
|
121
|
+
`
|
|
122
|
+
|
|
123
|
+
await sql`
|
|
124
|
+
CREATE INDEX IF NOT EXISTS "idx_strav_jobs_queue_available"
|
|
125
|
+
ON "_strav_jobs" ("queue", "available_at")
|
|
126
|
+
WHERE "reserved_at" IS NULL
|
|
127
|
+
`
|
|
128
|
+
|
|
129
|
+
await sql`
|
|
130
|
+
CREATE TABLE IF NOT EXISTS "_strav_failed_jobs" (
|
|
131
|
+
"id" BIGSERIAL PRIMARY KEY,
|
|
132
|
+
"queue" VARCHAR(255) NOT NULL,
|
|
133
|
+
"job" VARCHAR(255) NOT NULL,
|
|
134
|
+
"payload" JSONB NOT NULL DEFAULT '{}',
|
|
135
|
+
"error" TEXT NOT NULL,
|
|
136
|
+
"failed_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
137
|
+
)
|
|
138
|
+
`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Register a handler for a named job. */
|
|
142
|
+
static handle<T = any>(name: string, handler: JobHandler<T>): void {
|
|
143
|
+
Queue._handlers.set(name, handler)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Push a job onto the queue. Returns the job ID.
|
|
148
|
+
*/
|
|
149
|
+
static async push<T = any>(name: string, payload: T, options?: JobOptions): Promise<number> {
|
|
150
|
+
const sql = Queue.db.sql
|
|
151
|
+
const queue = options?.queue ?? Queue._config.default
|
|
152
|
+
const maxAttempts = options?.attempts ?? Queue._config.maxAttempts
|
|
153
|
+
const timeout = options?.timeout ?? Queue._config.timeout
|
|
154
|
+
const availableAt = options?.delay ? new Date(Date.now() + options.delay) : new Date()
|
|
155
|
+
|
|
156
|
+
const rows = await sql`
|
|
157
|
+
INSERT INTO "_strav_jobs" ("queue", "job", "payload", "max_attempts", "timeout", "available_at")
|
|
158
|
+
VALUES (${queue}, ${name}, ${JSON.stringify(payload)}, ${maxAttempts}, ${timeout}, ${availableAt})
|
|
159
|
+
RETURNING "id"
|
|
160
|
+
`
|
|
161
|
+
|
|
162
|
+
const id = Number((rows[0] as Record<string, unknown>).id)
|
|
163
|
+
|
|
164
|
+
if (Emitter.listenerCount('queue:dispatched') > 0) {
|
|
165
|
+
Emitter.emit('queue:dispatched', { id, name, queue, payload }).catch(() => {})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return id
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Create a listener function suitable for Emitter.on().
|
|
173
|
+
* When the event fires, the payload is pushed onto the queue.
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* Emitter.on('user.registered', Queue.listener('send-welcome-email'))
|
|
177
|
+
*/
|
|
178
|
+
static listener(jobName: string, options?: JobOptions): (payload: any) => Promise<void> {
|
|
179
|
+
return async (payload: any) => {
|
|
180
|
+
await Queue.push(jobName, payload, options)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Introspection / Management
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
/** Count of pending (unreserved) jobs in a queue. */
|
|
189
|
+
static async size(queue?: string): Promise<number> {
|
|
190
|
+
const sql = Queue.db.sql
|
|
191
|
+
const q = queue ?? Queue._config.default
|
|
192
|
+
|
|
193
|
+
const rows = await sql`
|
|
194
|
+
SELECT COUNT(*)::int AS count FROM "_strav_jobs"
|
|
195
|
+
WHERE "queue" = ${q} AND "reserved_at" IS NULL
|
|
196
|
+
`
|
|
197
|
+
return (rows[0] as Record<string, unknown>).count as number
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** List pending jobs, most recent first. */
|
|
201
|
+
static async pending(queue?: string, limit = 25): Promise<JobRecord[]> {
|
|
202
|
+
const sql = Queue.db.sql
|
|
203
|
+
const q = queue ?? Queue._config.default
|
|
204
|
+
|
|
205
|
+
const rows = await sql`
|
|
206
|
+
SELECT * FROM "_strav_jobs"
|
|
207
|
+
WHERE "queue" = ${q}
|
|
208
|
+
ORDER BY "available_at" ASC
|
|
209
|
+
LIMIT ${limit}
|
|
210
|
+
`
|
|
211
|
+
return rows.map(hydrateJob)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** List failed jobs, most recent first. */
|
|
215
|
+
static async failed(queue?: string, limit = 25): Promise<FailedJobRecord[]> {
|
|
216
|
+
const sql = Queue.db.sql
|
|
217
|
+
|
|
218
|
+
if (queue) {
|
|
219
|
+
const rows = await sql`
|
|
220
|
+
SELECT * FROM "_strav_failed_jobs"
|
|
221
|
+
WHERE "queue" = ${queue}
|
|
222
|
+
ORDER BY "failed_at" DESC
|
|
223
|
+
LIMIT ${limit}
|
|
224
|
+
`
|
|
225
|
+
return rows.map(hydrateFailedJob)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const rows = await sql`
|
|
229
|
+
SELECT * FROM "_strav_failed_jobs"
|
|
230
|
+
ORDER BY "failed_at" DESC
|
|
231
|
+
LIMIT ${limit}
|
|
232
|
+
`
|
|
233
|
+
return rows.map(hydrateFailedJob)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Delete all pending jobs in a queue. Returns the number deleted. */
|
|
237
|
+
static async clear(queue?: string): Promise<number> {
|
|
238
|
+
const sql = Queue.db.sql
|
|
239
|
+
const q = queue ?? Queue._config.default
|
|
240
|
+
|
|
241
|
+
const rows = await sql`
|
|
242
|
+
DELETE FROM "_strav_jobs" WHERE "queue" = ${q}
|
|
243
|
+
`
|
|
244
|
+
return rows.count
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Move all failed jobs back to the jobs table. Returns the number retried. */
|
|
248
|
+
static async retryFailed(queue?: string): Promise<number> {
|
|
249
|
+
const sql = Queue.db.sql
|
|
250
|
+
|
|
251
|
+
if (queue) {
|
|
252
|
+
const failed = await sql`
|
|
253
|
+
DELETE FROM "_strav_failed_jobs" WHERE "queue" = ${queue} RETURNING *
|
|
254
|
+
`
|
|
255
|
+
for (const row of failed) {
|
|
256
|
+
const r = row as Record<string, unknown>
|
|
257
|
+
await sql`
|
|
258
|
+
INSERT INTO "_strav_jobs" ("queue", "job", "payload", "max_attempts")
|
|
259
|
+
VALUES (${r.queue}, ${r.job}, ${typeof r.payload === 'string' ? r.payload : JSON.stringify(r.payload)}, ${Queue._config.maxAttempts})
|
|
260
|
+
`
|
|
261
|
+
}
|
|
262
|
+
return failed.length
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const failed = await sql`
|
|
266
|
+
DELETE FROM "_strav_failed_jobs" RETURNING *
|
|
267
|
+
`
|
|
268
|
+
for (const row of failed) {
|
|
269
|
+
const r = row as Record<string, unknown>
|
|
270
|
+
await sql`
|
|
271
|
+
INSERT INTO "_strav_jobs" ("queue", "job", "payload", "max_attempts")
|
|
272
|
+
VALUES (${r.queue}, ${r.job}, ${typeof r.payload === 'string' ? r.payload : JSON.stringify(r.payload)}, ${Queue._config.maxAttempts})
|
|
273
|
+
`
|
|
274
|
+
}
|
|
275
|
+
return failed.length
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Delete all failed jobs for a queue (or all queues). Returns the number deleted. */
|
|
279
|
+
static async clearFailed(queue?: string): Promise<number> {
|
|
280
|
+
const sql = Queue.db.sql
|
|
281
|
+
|
|
282
|
+
if (queue) {
|
|
283
|
+
const rows = await sql`
|
|
284
|
+
DELETE FROM "_strav_failed_jobs" WHERE "queue" = ${queue}
|
|
285
|
+
`
|
|
286
|
+
return rows.count
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const rows = await sql`
|
|
290
|
+
DELETE FROM "_strav_failed_jobs"
|
|
291
|
+
`
|
|
292
|
+
return rows.count
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Delete all jobs and failed jobs across all queues. For dev/test only. */
|
|
296
|
+
static async flush(): Promise<void> {
|
|
297
|
+
const sql = Queue.db.sql
|
|
298
|
+
await sql`DELETE FROM "_strav_jobs"`
|
|
299
|
+
await sql`DELETE FROM "_strav_failed_jobs"`
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Reset static state. For testing only. */
|
|
303
|
+
static reset(): void {
|
|
304
|
+
Queue._handlers.clear()
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// Hydration helpers
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
function parsePayload(raw: unknown): unknown {
|
|
313
|
+
if (typeof raw === 'string') {
|
|
314
|
+
try {
|
|
315
|
+
return JSON.parse(raw)
|
|
316
|
+
} catch {
|
|
317
|
+
return raw
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return raw
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function hydrateJob(row: Record<string, unknown>): JobRecord {
|
|
324
|
+
return {
|
|
325
|
+
id: Number(row.id),
|
|
326
|
+
queue: row.queue as string,
|
|
327
|
+
job: row.job as string,
|
|
328
|
+
payload: parsePayload(row.payload),
|
|
329
|
+
attempts: row.attempts as number,
|
|
330
|
+
maxAttempts: row.max_attempts as number,
|
|
331
|
+
timeout: row.timeout as number,
|
|
332
|
+
availableAt: row.available_at as Date,
|
|
333
|
+
reservedAt: (row.reserved_at as Date) ?? null,
|
|
334
|
+
createdAt: row.created_at as Date,
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function hydrateFailedJob(row: Record<string, unknown>): FailedJobRecord {
|
|
339
|
+
return {
|
|
340
|
+
id: Number(row.id),
|
|
341
|
+
queue: row.queue as string,
|
|
342
|
+
job: row.job as string,
|
|
343
|
+
payload: parsePayload(row.payload),
|
|
344
|
+
error: row.error as string,
|
|
345
|
+
failedAt: row.failed_at as Date,
|
|
346
|
+
}
|
|
347
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import Queue, { hydrateJob } from './queue.ts'
|
|
2
|
+
import Emitter from '@stravigor/kernel/events/emitter'
|
|
3
|
+
import type { JobRecord, JobMeta } from './queue.ts'
|
|
4
|
+
|
|
5
|
+
export interface WorkerOptions {
|
|
6
|
+
queue?: string
|
|
7
|
+
sleep?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Processes jobs from the queue.
|
|
12
|
+
*
|
|
13
|
+
* Uses `SELECT ... FOR UPDATE SKIP LOCKED` for safe concurrent polling.
|
|
14
|
+
* Supports job timeouts, exponential/linear backoff for retries,
|
|
15
|
+
* and graceful shutdown on SIGINT/SIGTERM.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* const worker = new Worker({ queue: 'emails', sleep: 500 })
|
|
19
|
+
* await worker.start() // blocks until worker.stop() is called
|
|
20
|
+
*/
|
|
21
|
+
export default class Worker {
|
|
22
|
+
private running = false
|
|
23
|
+
private processing = false
|
|
24
|
+
private queue: string
|
|
25
|
+
private sleep: number
|
|
26
|
+
|
|
27
|
+
constructor(options: WorkerOptions = {}) {
|
|
28
|
+
this.queue = options.queue ?? Queue.config.default
|
|
29
|
+
this.sleep = options.sleep ?? Queue.config.sleep
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Start the worker loop. Blocks until stop() is called. */
|
|
33
|
+
async start(): Promise<void> {
|
|
34
|
+
this.running = true
|
|
35
|
+
|
|
36
|
+
const onSignal = () => this.stop()
|
|
37
|
+
process.on('SIGINT', onSignal)
|
|
38
|
+
process.on('SIGTERM', onSignal)
|
|
39
|
+
|
|
40
|
+
let pollCount = 0
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
while (this.running) {
|
|
44
|
+
// Periodically release stale jobs (every 60 cycles)
|
|
45
|
+
if (pollCount % 60 === 0) {
|
|
46
|
+
await this.releaseStaleJobs()
|
|
47
|
+
}
|
|
48
|
+
pollCount++
|
|
49
|
+
|
|
50
|
+
const job = await this.fetchNext()
|
|
51
|
+
if (job) {
|
|
52
|
+
this.processing = true
|
|
53
|
+
await this.process(job)
|
|
54
|
+
this.processing = false
|
|
55
|
+
} else {
|
|
56
|
+
await Bun.sleep(this.sleep)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} finally {
|
|
60
|
+
process.off('SIGINT', onSignal)
|
|
61
|
+
process.off('SIGTERM', onSignal)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Signal the worker to stop after the current job completes. */
|
|
66
|
+
stop(): void {
|
|
67
|
+
this.running = false
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Whether the worker is currently processing a job. */
|
|
71
|
+
get busy(): boolean {
|
|
72
|
+
return this.processing
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Internal
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Fetch the next available job using FOR UPDATE SKIP LOCKED.
|
|
81
|
+
* Atomically reserves it by setting reserved_at and incrementing attempts.
|
|
82
|
+
*/
|
|
83
|
+
private async fetchNext(): Promise<JobRecord | null> {
|
|
84
|
+
const sql = Queue.db.sql
|
|
85
|
+
|
|
86
|
+
const rows = await sql.begin(async (tx: any) => {
|
|
87
|
+
const result = await tx`
|
|
88
|
+
SELECT * FROM "_strav_jobs"
|
|
89
|
+
WHERE "queue" = ${this.queue}
|
|
90
|
+
AND "available_at" <= NOW()
|
|
91
|
+
AND "reserved_at" IS NULL
|
|
92
|
+
ORDER BY "available_at" ASC
|
|
93
|
+
LIMIT 1
|
|
94
|
+
FOR UPDATE SKIP LOCKED
|
|
95
|
+
`
|
|
96
|
+
if (result.length === 0) return []
|
|
97
|
+
|
|
98
|
+
const job = result[0] as Record<string, unknown>
|
|
99
|
+
await tx`
|
|
100
|
+
UPDATE "_strav_jobs"
|
|
101
|
+
SET "reserved_at" = NOW(), "attempts" = "attempts" + 1
|
|
102
|
+
WHERE "id" = ${job.id}
|
|
103
|
+
`
|
|
104
|
+
return [{ ...job, attempts: (job.attempts as number) + 1 }]
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
return rows.length > 0 ? hydrateJob(rows[0] as Record<string, unknown>) : null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Process a single job: run handler, handle success/failure. */
|
|
111
|
+
private async process(job: JobRecord): Promise<void> {
|
|
112
|
+
const handler = Queue.handlers.get(job.job)
|
|
113
|
+
|
|
114
|
+
if (!handler) {
|
|
115
|
+
await this.fail(job, new Error(`No handler registered for job "${job.job}"`))
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const meta: JobMeta = {
|
|
120
|
+
id: job.id,
|
|
121
|
+
queue: job.queue,
|
|
122
|
+
job: job.job,
|
|
123
|
+
attempts: job.attempts,
|
|
124
|
+
maxAttempts: job.maxAttempts,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const start = performance.now()
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await Promise.race([
|
|
131
|
+
Promise.resolve(handler(job.payload, meta)),
|
|
132
|
+
new Promise<never>((_, reject) =>
|
|
133
|
+
setTimeout(
|
|
134
|
+
() => reject(new Error(`Job "${job.job}" timed out after ${job.timeout}ms`)),
|
|
135
|
+
job.timeout
|
|
136
|
+
)
|
|
137
|
+
),
|
|
138
|
+
])
|
|
139
|
+
await this.complete(job)
|
|
140
|
+
|
|
141
|
+
if (Emitter.listenerCount('queue:processed') > 0) {
|
|
142
|
+
const duration = performance.now() - start
|
|
143
|
+
Emitter.emit('queue:processed', {
|
|
144
|
+
job: job.job,
|
|
145
|
+
id: job.id,
|
|
146
|
+
queue: job.queue,
|
|
147
|
+
duration,
|
|
148
|
+
}).catch(() => {})
|
|
149
|
+
}
|
|
150
|
+
} catch (error) {
|
|
151
|
+
const err = error instanceof Error ? error : new Error(String(error))
|
|
152
|
+
if (job.attempts >= job.maxAttempts) {
|
|
153
|
+
await this.fail(job, err)
|
|
154
|
+
|
|
155
|
+
if (Emitter.listenerCount('queue:failed') > 0) {
|
|
156
|
+
const duration = performance.now() - start
|
|
157
|
+
Emitter.emit('queue:failed', {
|
|
158
|
+
job: job.job,
|
|
159
|
+
id: job.id,
|
|
160
|
+
queue: job.queue,
|
|
161
|
+
error: err.message,
|
|
162
|
+
duration,
|
|
163
|
+
}).catch(() => {})
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
await this.release(job)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Delete a completed job. */
|
|
172
|
+
private async complete(job: JobRecord): Promise<void> {
|
|
173
|
+
await Queue.db.sql`DELETE FROM "_strav_jobs" WHERE "id" = ${job.id}`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Move a job to the failed_jobs table and delete from jobs. */
|
|
177
|
+
private async fail(job: JobRecord, error: Error): Promise<void> {
|
|
178
|
+
const sql = Queue.db.sql
|
|
179
|
+
await sql.begin(async (tx: any) => {
|
|
180
|
+
await tx`
|
|
181
|
+
INSERT INTO "_strav_failed_jobs" ("queue", "job", "payload", "error")
|
|
182
|
+
VALUES (${job.queue}, ${job.job}, ${JSON.stringify(job.payload)}, ${error.message})
|
|
183
|
+
`
|
|
184
|
+
await tx`DELETE FROM "_strav_jobs" WHERE "id" = ${job.id}`
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Release a job back to the queue with incremented backoff delay. */
|
|
189
|
+
private async release(job: JobRecord): Promise<void> {
|
|
190
|
+
const delay = this.backoffDelay(job.attempts)
|
|
191
|
+
const availableAt = new Date(Date.now() + delay)
|
|
192
|
+
|
|
193
|
+
await Queue.db.sql`
|
|
194
|
+
UPDATE "_strav_jobs"
|
|
195
|
+
SET "reserved_at" = NULL, "available_at" = ${availableAt}
|
|
196
|
+
WHERE "id" = ${job.id}
|
|
197
|
+
`
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Calculate backoff delay in ms based on attempt number. */
|
|
201
|
+
backoffDelay(attempts: number): number {
|
|
202
|
+
if (Queue.config.retryBackoff === 'linear') {
|
|
203
|
+
return attempts * 5_000
|
|
204
|
+
}
|
|
205
|
+
// Exponential: 2^attempts * 1000, with jitter
|
|
206
|
+
const base = Math.pow(2, attempts) * 1000
|
|
207
|
+
const jitter = Math.random() * 1000
|
|
208
|
+
return base + jitter
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Release jobs that have been reserved for too long (crashed workers). */
|
|
212
|
+
private async releaseStaleJobs(): Promise<void> {
|
|
213
|
+
await Queue.db.sql`
|
|
214
|
+
UPDATE "_strav_jobs"
|
|
215
|
+
SET "reserved_at" = NULL
|
|
216
|
+
WHERE "reserved_at" IS NOT NULL
|
|
217
|
+
AND "queue" = ${this.queue}
|
|
218
|
+
AND "reserved_at" < NOW() - MAKE_INTERVAL(secs => "timeout" * 2.0 / 1000)
|
|
219
|
+
`
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
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'
|
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { parseCron, cronMatches } from './cron.ts'
|
|
2
|
+
import type { CronExpression } from './cron.ts'
|
|
3
|
+
|
|
4
|
+
export type TaskHandler = () => void | Promise<void>
|
|
5
|
+
|
|
6
|
+
export enum TimeUnit {
|
|
7
|
+
Minutes = 'minutes',
|
|
8
|
+
Hours = 'hours',
|
|
9
|
+
Days = 'days',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const DAY_NAMES: Record<string, number> = {
|
|
13
|
+
sunday: 0,
|
|
14
|
+
sun: 0,
|
|
15
|
+
monday: 1,
|
|
16
|
+
mon: 1,
|
|
17
|
+
tuesday: 2,
|
|
18
|
+
tue: 2,
|
|
19
|
+
wednesday: 3,
|
|
20
|
+
wed: 3,
|
|
21
|
+
thursday: 4,
|
|
22
|
+
thu: 4,
|
|
23
|
+
friday: 5,
|
|
24
|
+
fri: 5,
|
|
25
|
+
saturday: 6,
|
|
26
|
+
sat: 6,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A scheduled task definition with a fluent configuration API.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* new Schedule('cleanup', handler).dailyAt('02:30').withoutOverlapping()
|
|
34
|
+
*/
|
|
35
|
+
export class Schedule {
|
|
36
|
+
readonly name: string
|
|
37
|
+
readonly handler: TaskHandler
|
|
38
|
+
|
|
39
|
+
private _cron: CronExpression | null = null
|
|
40
|
+
private _noOverlap = false
|
|
41
|
+
|
|
42
|
+
private _sporadicMin: number | null = null
|
|
43
|
+
private _sporadicMax: number | null = null
|
|
44
|
+
private _sporadicUnit: TimeUnit | null = null
|
|
45
|
+
private _nextRunAt: Date | null = null
|
|
46
|
+
|
|
47
|
+
constructor(name: string, handler: TaskHandler) {
|
|
48
|
+
this.name = name
|
|
49
|
+
this.handler = handler
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Raw cron ──────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/** Set a raw 5-field cron expression. */
|
|
55
|
+
cron(expression: string): this {
|
|
56
|
+
this._cron = parseCron(expression)
|
|
57
|
+
return this
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Minute-based ──────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/** Run every minute. */
|
|
63
|
+
everyMinute(): this {
|
|
64
|
+
return this.cron('* * * * *')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Run every 2 minutes. */
|
|
68
|
+
everyTwoMinutes(): this {
|
|
69
|
+
return this.cron('*/2 * * * *')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Run every 5 minutes. */
|
|
73
|
+
everyFiveMinutes(): this {
|
|
74
|
+
return this.cron('*/5 * * * *')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Run every 10 minutes. */
|
|
78
|
+
everyTenMinutes(): this {
|
|
79
|
+
return this.cron('*/10 * * * *')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Run every 15 minutes. */
|
|
83
|
+
everyFifteenMinutes(): this {
|
|
84
|
+
return this.cron('*/15 * * * *')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Run every 30 minutes. */
|
|
88
|
+
everyThirtyMinutes(): this {
|
|
89
|
+
return this.cron('*/30 * * * *')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Hourly ────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/** Run once per hour at minute 0. */
|
|
95
|
+
hourly(): this {
|
|
96
|
+
return this.cron('0 * * * *')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Run once per hour at the given minute. */
|
|
100
|
+
hourlyAt(minute: number): this {
|
|
101
|
+
return this.cron(`${minute} * * * *`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Daily ─────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/** Run once per day at midnight. */
|
|
107
|
+
daily(): this {
|
|
108
|
+
return this.cron('0 0 * * *')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Run once per day at the given time (HH:MM). */
|
|
112
|
+
dailyAt(time: string): this {
|
|
113
|
+
const [hour, minute] = parseTime(time)
|
|
114
|
+
return this.cron(`${minute} ${hour} * * *`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Run twice per day at the given hours (minute 0). */
|
|
118
|
+
twiceDaily(hour1: number, hour2: number): this {
|
|
119
|
+
return this.cron(`0 ${hour1},${hour2} * * *`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Weekly ────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/** Run once per week on Sunday at midnight. */
|
|
125
|
+
weekly(): this {
|
|
126
|
+
return this.cron('0 0 * * 0')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Run once per week on the given day and optional time. */
|
|
130
|
+
weeklyOn(day: string | number, time?: string): this {
|
|
131
|
+
const dow = typeof day === 'string' ? dayToNumber(day) : day
|
|
132
|
+
const [hour, minute] = time ? parseTime(time) : [0, 0]
|
|
133
|
+
return this.cron(`${minute} ${hour} * * ${dow}`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Monthly ───────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/** Run once per month on the 1st at midnight. */
|
|
139
|
+
monthly(): this {
|
|
140
|
+
return this.cron('0 0 1 * *')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Run once per month on the given day and optional time. */
|
|
144
|
+
monthlyOn(day: number, time?: string): this {
|
|
145
|
+
const [hour, minute] = time ? parseTime(time) : [0, 0]
|
|
146
|
+
return this.cron(`${minute} ${hour} ${day} * *`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Sporadic ──────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Run at random intervals between `min` and `max` in the given unit.
|
|
153
|
+
* Simulates human-like, non-periodic scheduling.
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* Scheduler.task('scrape', handler).sporadically(5, 30, TimeUnit.Minutes)
|
|
157
|
+
*/
|
|
158
|
+
sporadically(min: number, max: number, unit: TimeUnit): this {
|
|
159
|
+
if (min < 0 || max < 0) {
|
|
160
|
+
throw new Error('sporadically: min and max must be non-negative')
|
|
161
|
+
}
|
|
162
|
+
if (min >= max) {
|
|
163
|
+
throw new Error('sporadically: min must be less than max')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
this._sporadicMin = min
|
|
167
|
+
this._sporadicMax = max
|
|
168
|
+
this._sporadicUnit = unit
|
|
169
|
+
this._cron = null
|
|
170
|
+
this._nextRunAt = this.computeNextRun(new Date())
|
|
171
|
+
return this
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Options ───────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/** Prevent overlapping runs within this process. */
|
|
177
|
+
withoutOverlapping(): this {
|
|
178
|
+
this._noOverlap = true
|
|
179
|
+
return this
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Internal ──────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
/** Check if this task is due at the given Date (evaluated in UTC). */
|
|
185
|
+
isDue(now: Date): boolean {
|
|
186
|
+
if (this._sporadicMin !== null) {
|
|
187
|
+
if (!this._nextRunAt) return false
|
|
188
|
+
if (now >= this._nextRunAt) {
|
|
189
|
+
this._nextRunAt = this.computeNextRun(now)
|
|
190
|
+
return true
|
|
191
|
+
}
|
|
192
|
+
return false
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!this._cron) return false
|
|
196
|
+
return cronMatches(this._cron, now)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Whether overlap prevention is enabled. */
|
|
200
|
+
get preventsOverlap(): boolean {
|
|
201
|
+
return this._noOverlap
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The parsed cron expression (for testing/debugging). */
|
|
205
|
+
get expression(): CronExpression | null {
|
|
206
|
+
return this._cron
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The next scheduled run time (for sporadic schedules). */
|
|
210
|
+
get nextRunAt(): Date | null {
|
|
211
|
+
return this._nextRunAt
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Compute the next random run time from a reference point. */
|
|
215
|
+
private computeNextRun(from: Date): Date {
|
|
216
|
+
const ms = unitToMs(this._sporadicUnit!)
|
|
217
|
+
const minMs = this._sporadicMin! * ms
|
|
218
|
+
const maxMs = this._sporadicMax! * ms
|
|
219
|
+
const delay = minMs + Math.random() * (maxMs - minMs)
|
|
220
|
+
return new Date(from.getTime() + delay)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Helpers
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
function parseTime(time: string): [number, number] {
|
|
229
|
+
const parts = time.split(':')
|
|
230
|
+
if (parts.length !== 2) {
|
|
231
|
+
throw new Error(`Invalid time format "${time}": expected HH:MM`)
|
|
232
|
+
}
|
|
233
|
+
const hour = parseInt(parts[0]!, 10)
|
|
234
|
+
const minute = parseInt(parts[1]!, 10)
|
|
235
|
+
if (isNaN(hour) || isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
|
236
|
+
throw new Error(`Invalid time "${time}": hour must be 0–23, minute must be 0–59`)
|
|
237
|
+
}
|
|
238
|
+
return [hour, minute]
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function unitToMs(unit: TimeUnit): number {
|
|
242
|
+
switch (unit) {
|
|
243
|
+
case TimeUnit.Minutes:
|
|
244
|
+
return 60_000
|
|
245
|
+
case TimeUnit.Hours:
|
|
246
|
+
return 3_600_000
|
|
247
|
+
case TimeUnit.Days:
|
|
248
|
+
return 86_400_000
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function dayToNumber(day: string): number {
|
|
253
|
+
const n = DAY_NAMES[day.toLowerCase()]
|
|
254
|
+
if (n === undefined) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`Invalid day name "${day}": expected one of ${Object.keys(DAY_NAMES)
|
|
257
|
+
.filter((_, i) => i % 2 === 0)
|
|
258
|
+
.join(', ')}`
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
return n
|
|
262
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Schedule } from './schedule.ts'
|
|
2
|
+
import type { TaskHandler } from './schedule.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Static task registry for periodic jobs.
|
|
6
|
+
*
|
|
7
|
+
* No DI, no database — tasks are registered in code and evaluated
|
|
8
|
+
* in-memory by the {@link SchedulerRunner}.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* Scheduler.task('cleanup:sessions', async () => {
|
|
12
|
+
* await db.sql`DELETE FROM "_strav_sessions" WHERE "expires_at" < NOW()`
|
|
13
|
+
* }).hourly()
|
|
14
|
+
*
|
|
15
|
+
* Scheduler.task('reports:daily', () => generateDailyReport()).dailyAt('02:00')
|
|
16
|
+
*/
|
|
17
|
+
export default class Scheduler {
|
|
18
|
+
private static _tasks: Schedule[] = []
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Register a periodic task. Returns the {@link Schedule} for fluent configuration.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* Scheduler.task('prune-cache', () => cache.flush()).everyFifteenMinutes()
|
|
25
|
+
*/
|
|
26
|
+
static task(name: string, handler: TaskHandler): Schedule {
|
|
27
|
+
const schedule = new Schedule(name, handler)
|
|
28
|
+
Scheduler._tasks.push(schedule)
|
|
29
|
+
return schedule
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** All registered tasks. */
|
|
33
|
+
static get tasks(): readonly Schedule[] {
|
|
34
|
+
return Scheduler._tasks
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Return tasks that are due at the given time (defaults to now, UTC). */
|
|
38
|
+
static due(now?: Date): Schedule[] {
|
|
39
|
+
const date = now ?? new Date()
|
|
40
|
+
return Scheduler._tasks.filter(t => t.isDue(date))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Clear all registered tasks. For testing. */
|
|
44
|
+
static reset(): void {
|
|
45
|
+
Scheduler._tasks = []
|
|
46
|
+
}
|
|
47
|
+
}
|