@maestro-js/recurring-jobs 1.0.0-alpha.0 → 1.0.0-alpha.10
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 +138 -253
- package/dist/index.d.ts +127 -160
- package/dist/index.js +185 -115
- package/package.json +12 -6
package/README.md
CHANGED
|
@@ -1,348 +1,233 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @maestro-js/recurring-jobs
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
RecurringJobs provides cron-based job scheduling backed by the Queue package. Each recurring job gets its own dedicated queue where every execution is a persisted message with full lifecycle tracking. The next run is enqueued before the work function executes, guaranteeing the schedule continues even if the current run fails.
|
|
3
|
+
Cron-based recurring job scheduling with distributed lock-based deduplication.
|
|
6
4
|
|
|
7
5
|
## Quick Setup
|
|
8
6
|
|
|
9
7
|
```ts
|
|
10
8
|
import { RecurringJobs } from '@maestro-js/recurring-jobs'
|
|
11
|
-
import { Queue } from '@maestro-js/queue'
|
|
12
|
-
import { Log } from '@maestro-js/log'
|
|
13
|
-
|
|
14
|
-
// Create a logger for recurring job events
|
|
15
|
-
const logger = Log.create<[RecurringJobs.LogMessage]>({
|
|
16
|
-
transports: [{ write: ({ data: [msg] }) => console.log(msg) }]
|
|
17
|
-
})
|
|
18
9
|
|
|
19
|
-
// Create and register the
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
queueService: Queue.provider('default')
|
|
10
|
+
// Create and register with the local (in-memory) driver
|
|
11
|
+
const service = RecurringJobs.Provider.create({
|
|
12
|
+
driver: RecurringJobs.drivers.local()
|
|
23
13
|
})
|
|
24
|
-
RecurringJobs.Provider.register('default',
|
|
14
|
+
RecurringJobs.Provider.register('default', service)
|
|
25
15
|
|
|
26
16
|
// Define a recurring job
|
|
27
|
-
|
|
28
|
-
name: '
|
|
29
|
-
cron: '0
|
|
30
|
-
|
|
31
|
-
const total = await calculateDailySales()
|
|
32
|
-
return { total }
|
|
33
|
-
}
|
|
17
|
+
RecurringJobs.create({
|
|
18
|
+
name: 'send-weekly-digest',
|
|
19
|
+
cron: '0 9 * * MON',
|
|
20
|
+
handle: async () => sendDigest()
|
|
34
21
|
})
|
|
35
22
|
|
|
36
|
-
// Start
|
|
37
|
-
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
## Cron Expression Format
|
|
41
|
-
|
|
42
|
-
Uses the `cron-parser` library (v4.x) for standard 5-field cron expressions:
|
|
23
|
+
// Start polling -- fires immediately then every 60 seconds
|
|
24
|
+
const worker = RecurringJobs.work()
|
|
43
25
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
│ ┌───────────── hour (0-23)
|
|
47
|
-
│ │ ┌───────────── day of month (1-31)
|
|
48
|
-
│ │ │ ┌───────────── month (1-12)
|
|
49
|
-
│ │ │ │ ┌───────────── day of week (0-7, 0 and 7 are Sunday)
|
|
50
|
-
│ │ │ │ │
|
|
51
|
-
* * * * *
|
|
26
|
+
// Graceful shutdown
|
|
27
|
+
await worker.abort()
|
|
52
28
|
```
|
|
53
29
|
|
|
54
|
-
|
|
55
|
-
- `0 6 * * *` -- every day at 6:00 AM
|
|
56
|
-
- `*/15 * * * *` -- every 15 minutes
|
|
57
|
-
- `0 0 * * 0` -- every Sunday at midnight
|
|
58
|
-
- `0 9 1 * *` -- first day of every month at 9:00 AM
|
|
59
|
-
- `30 */2 * * *` -- every 2 hours at the 30-minute mark
|
|
30
|
+
## Drivers
|
|
60
31
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
## API Reference
|
|
32
|
+
### Db
|
|
64
33
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
#### `RecurringJobs.Provider.create(config)`
|
|
68
|
-
|
|
69
|
-
Create a recurring jobs service instance.
|
|
34
|
+
MySQL-backed, for multi-process production deployments. Uses `UPDATE ... WHERE lastRunDate < ?`
|
|
35
|
+
and `INSERT IGNORE` for atomic distributed locking.
|
|
70
36
|
|
|
71
37
|
```ts
|
|
72
|
-
RecurringJobs.
|
|
73
|
-
|
|
74
|
-
|
|
38
|
+
RecurringJobs.drivers.db({
|
|
39
|
+
db: Db, // Db service instance
|
|
40
|
+
databaseTable: 'recurringJobs'
|
|
75
41
|
})
|
|
76
42
|
```
|
|
77
43
|
|
|
78
|
-
|
|
79
|
-
- `queueService` -- the Queue service used to back each recurring job's dedicated queue
|
|
80
|
-
|
|
81
|
-
#### `RecurringJobs.Provider.register(name, service)`
|
|
44
|
+
Required table schema:
|
|
82
45
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
46
|
+
```sql
|
|
47
|
+
CREATE TABLE recurringJobs (
|
|
48
|
+
name VARCHAR(255) NOT NULL PRIMARY KEY,
|
|
49
|
+
lastRunDate DATETIME DEFAULT NULL
|
|
50
|
+
);
|
|
87
51
|
```
|
|
88
52
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
Resolve a named instance from the registry. Supports deferred resolution via Proxy.
|
|
92
|
-
|
|
93
|
-
```ts
|
|
94
|
-
const jobs = RecurringJobs.provider('default')
|
|
95
|
-
```
|
|
53
|
+
Source: `packages/recurring-jobs/src/db-recurring-jobs-driver.ts`
|
|
96
54
|
|
|
97
|
-
###
|
|
55
|
+
### Cache
|
|
98
56
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
Create a recurring job with a dedicated queue. Returns a `RecurringJob` handle.
|
|
57
|
+
Redis-backed via `@maestro-js/cache` with distributed locking (`cache.lock()`). Use when you
|
|
58
|
+
already have Redis and don't need MySQL.
|
|
102
59
|
|
|
103
60
|
```ts
|
|
104
|
-
RecurringJobs.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
logger?: Log.LogFunctions<[RecurringJobs.LogMessage]> // optional per-job logger
|
|
109
|
-
}): RecurringJobs.RecurringJob<T>
|
|
61
|
+
RecurringJobs.drivers.cache({
|
|
62
|
+
cache: cacheService, // Cache.CacheService instance
|
|
63
|
+
keyPrefix: 'recurring-jobs:' // optional, default 'recurring-jobs:'
|
|
64
|
+
})
|
|
110
65
|
```
|
|
111
66
|
|
|
112
|
-
|
|
67
|
+
Source: `packages/recurring-jobs/src/cache-recurring-jobs-driver.ts`
|
|
113
68
|
|
|
114
|
-
|
|
69
|
+
### Local
|
|
115
70
|
|
|
116
|
-
|
|
71
|
+
In-memory, single-process only. No distributed locking -- every process will run every due job.
|
|
72
|
+
Good for development and testing.
|
|
117
73
|
|
|
118
74
|
```ts
|
|
119
|
-
RecurringJobs.
|
|
75
|
+
RecurringJobs.drivers.local()
|
|
120
76
|
```
|
|
121
77
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
Stop processing on all registered recurring jobs.
|
|
125
|
-
|
|
126
|
-
```ts
|
|
127
|
-
RecurringJobs.stopProcessing()
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
#### `RecurringJobs.list()`
|
|
131
|
-
|
|
132
|
-
Return the names of all registered recurring jobs.
|
|
133
|
-
|
|
134
|
-
```ts
|
|
135
|
-
RecurringJobs.list() // ['daily-sales-report', 'session-cleanup']
|
|
136
|
-
```
|
|
78
|
+
Source: `packages/recurring-jobs/src/local-recurring-jobs-driver.ts`
|
|
137
79
|
|
|
138
|
-
|
|
80
|
+
### Choosing a driver
|
|
139
81
|
|
|
140
|
-
|
|
82
|
+
| Driver | Distributed locking | Persistence | Best for |
|
|
83
|
+
|---------|---------------------|-------------|---------------------------------------|
|
|
84
|
+
| **Db** | Yes (MySQL row) | Yes | Multi-process production |
|
|
85
|
+
| **Cache** | Yes (Redis lock) | Yes* | Production when you have Redis, no MySQL |
|
|
86
|
+
| **Local** | No | No | Development and testing |
|
|
141
87
|
|
|
142
|
-
|
|
143
|
-
const job = RecurringJobs.get<{ total: number }>('daily-sales-report')
|
|
144
|
-
```
|
|
88
|
+
\* Cache persistence depends on Redis persistence settings.
|
|
145
89
|
|
|
146
|
-
|
|
90
|
+
## API Reference
|
|
147
91
|
|
|
148
|
-
|
|
92
|
+
### `RecurringJobs.create(config)`
|
|
149
93
|
|
|
150
|
-
|
|
94
|
+
Register a recurring job. Throws immediately on invalid cron expression or duplicate job name.
|
|
151
95
|
|
|
152
96
|
```ts
|
|
153
|
-
|
|
97
|
+
RecurringJobs.create({
|
|
98
|
+
name: string, // unique job identifier
|
|
99
|
+
cron: string, // cron expression (e.g. '0 9 * * MON')
|
|
100
|
+
handle: () => Promise<Result>, // async handler
|
|
101
|
+
middleware?: Middleware<Result>[] // optional middleware chain
|
|
102
|
+
})
|
|
154
103
|
```
|
|
155
104
|
|
|
156
|
-
|
|
105
|
+
### `RecurringJobs.work()`
|
|
157
106
|
|
|
158
|
-
|
|
107
|
+
Start a 60-second polling loop that checks for due jobs, acquires distributed locks via the
|
|
108
|
+
driver, and executes handlers. Returns an object with `abort()` to stop the loop and wait for
|
|
109
|
+
in-flight ticks.
|
|
159
110
|
|
|
160
111
|
```ts
|
|
161
|
-
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
#### `job.getNextUp()`
|
|
165
|
-
|
|
166
|
-
Return the first message in `waiting` status, or `null` if none exists.
|
|
112
|
+
const worker = RecurringJobs.work()
|
|
167
113
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
// { id: 42, status: 'waiting', scheduledDate: '2026-02-22T06:00:00.000Z', cron: '0 6 * * *', ... }
|
|
114
|
+
// Later, to stop:
|
|
115
|
+
await worker.abort()
|
|
171
116
|
```
|
|
172
117
|
|
|
173
|
-
|
|
118
|
+
### `RecurringJobs.stopAllWork()`
|
|
174
119
|
|
|
175
|
-
|
|
120
|
+
Abort all active work loops and wait for in-flight ticks to finish.
|
|
176
121
|
|
|
177
122
|
```ts
|
|
178
|
-
|
|
179
|
-
// [{ id: 41, status: 'success', result: { total: 15420 }, ... }, ...]
|
|
123
|
+
await RecurringJobs.stopAllWork()
|
|
180
124
|
```
|
|
181
125
|
|
|
182
|
-
|
|
126
|
+
## Common Patterns
|
|
183
127
|
|
|
184
|
-
|
|
128
|
+
### Basic cron job
|
|
185
129
|
|
|
186
130
|
```ts
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
131
|
+
RecurringJobs.create({
|
|
132
|
+
name: 'cleanup-expired-tokens',
|
|
133
|
+
cron: '0 3 * * *', // daily at 3am
|
|
134
|
+
handle: async () => {
|
|
135
|
+
await db.update('DELETE FROM tokens WHERE expiresAt < NOW()')
|
|
136
|
+
}
|
|
190
137
|
})
|
|
191
138
|
```
|
|
192
139
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
Read-only properties exposing the job's name and cron expression.
|
|
140
|
+
### Middleware wrapping
|
|
196
141
|
|
|
197
142
|
```ts
|
|
198
|
-
|
|
199
|
-
|
|
143
|
+
RecurringJobs.create({
|
|
144
|
+
name: 'generate-report',
|
|
145
|
+
cron: '0 6 * * MON',
|
|
146
|
+
middleware: [
|
|
147
|
+
async (next) => {
|
|
148
|
+
try {
|
|
149
|
+
return await next()
|
|
150
|
+
} catch (e) {
|
|
151
|
+
await errorReporter.capture(e)
|
|
152
|
+
throw e
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
],
|
|
156
|
+
handle: async () => generateWeeklyReport()
|
|
157
|
+
})
|
|
200
158
|
```
|
|
201
159
|
|
|
202
|
-
###
|
|
203
|
-
|
|
204
|
-
Each queue message returned by `getNextUp()` and `list()` has these fields:
|
|
160
|
+
### Graceful shutdown
|
|
205
161
|
|
|
206
162
|
```ts
|
|
207
|
-
|
|
208
|
-
id: number
|
|
209
|
-
batchId: number | null
|
|
210
|
-
queue: string
|
|
211
|
-
scheduledDate: Iso.Instant
|
|
212
|
-
enqueuedDate: Iso.Instant
|
|
213
|
-
startedDate: Iso.Instant | null
|
|
214
|
-
status: 'waiting' | 'processing' | 'success' | 'error' | 'stuck'
|
|
215
|
-
result: Result
|
|
216
|
-
recovered: number
|
|
217
|
-
runningTime: number | null
|
|
218
|
-
uniqueKey: number | null
|
|
219
|
-
priority: number | null
|
|
220
|
-
cron: string
|
|
221
|
-
}
|
|
222
|
-
```
|
|
163
|
+
const worker = RecurringJobs.work()
|
|
223
164
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
```ts
|
|
229
|
-
const report = RecurringJobs.createJob({
|
|
230
|
-
name: 'daily-report',
|
|
231
|
-
cron: '0 6 * * *',
|
|
232
|
-
workFn: async () => generateReport()
|
|
165
|
+
process.on('SIGTERM', async () => {
|
|
166
|
+
await worker.abort() // waits for in-flight tick to finish
|
|
167
|
+
process.exit(0)
|
|
233
168
|
})
|
|
234
169
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
170
|
+
// Or abort all workers at once:
|
|
171
|
+
process.on('SIGTERM', async () => {
|
|
172
|
+
await RecurringJobs.stopAllWork()
|
|
173
|
+
process.exit(0)
|
|
239
174
|
})
|
|
240
|
-
|
|
241
|
-
// Start all jobs at once
|
|
242
|
-
RecurringJobs.startProcessing()
|
|
243
|
-
|
|
244
|
-
// Or start individually
|
|
245
|
-
report.startProcessing()
|
|
246
|
-
cleanup.startProcessing()
|
|
247
175
|
```
|
|
248
176
|
|
|
249
|
-
###
|
|
177
|
+
### Named provider instances
|
|
250
178
|
|
|
251
179
|
```ts
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
const next = await job.getNextUp()
|
|
255
|
-
if (next) {
|
|
256
|
-
// Delay the next run by 2 hours
|
|
257
|
-
const delayed = new Date(new Date(next.scheduledDate).getTime() + 2 * 60 * 60 * 1000)
|
|
258
|
-
await job.rescheduleJob({
|
|
259
|
-
jobId: next.id,
|
|
260
|
-
scheduledDate: delayed.toISOString() as Iso.Instant
|
|
261
|
-
})
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
```
|
|
265
|
-
|
|
266
|
-
### Per-Job Logger
|
|
267
|
-
|
|
268
|
-
Add a dedicated logger to a specific job for fine-grained observability:
|
|
269
|
-
|
|
270
|
-
```ts
|
|
271
|
-
const jobLogger: Log.LogFunctions<[RecurringJobs.LogMessage]> = {
|
|
272
|
-
write({ data: [msg] }) {
|
|
273
|
-
if (msg.event === 'jobFailed') {
|
|
274
|
-
alertOpsTeam(msg.job, msg.error)
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const criticalJob = RecurringJobs.createJob({
|
|
280
|
-
name: 'payment-reconciliation',
|
|
281
|
-
cron: '0 */4 * * *',
|
|
282
|
-
workFn: async () => reconcilePayments(),
|
|
283
|
-
logger: jobLogger
|
|
180
|
+
const primary = RecurringJobs.Provider.create({
|
|
181
|
+
driver: RecurringJobs.drivers.db({ db: Db, databaseTable: 'recurringJobs' })
|
|
284
182
|
})
|
|
285
|
-
|
|
183
|
+
RecurringJobs.Provider.register('default', primary)
|
|
286
184
|
|
|
287
|
-
|
|
185
|
+
const secondary = RecurringJobs.Provider.create({
|
|
186
|
+
driver: RecurringJobs.drivers.local()
|
|
187
|
+
})
|
|
188
|
+
RecurringJobs.Provider.register('dev', secondary)
|
|
288
189
|
|
|
289
|
-
|
|
290
|
-
const job = RecurringJobs.get('daily-report')
|
|
291
|
-
if (job) {
|
|
292
|
-
const runs = await job.list({ pageSize: 50, pageNumber: 0 })
|
|
293
|
-
const failures = runs.filter(r => r.status === 'error')
|
|
294
|
-
const avgRuntime = runs
|
|
295
|
-
.filter(r => r.runningTime !== null)
|
|
296
|
-
.reduce((sum, r) => sum + r.runningTime!, 0) / runs.length
|
|
297
|
-
}
|
|
190
|
+
RecurringJobs.provider('dev').create({ ... })
|
|
298
191
|
```
|
|
299
192
|
|
|
300
|
-
##
|
|
301
|
-
|
|
302
|
-
The service emits structured log events of type `RecurringJobs.LogMessage`:
|
|
193
|
+
## Warnings and Gotchas
|
|
303
194
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
195
|
+
- **60-second polling**: Jobs won't fire more precisely than ~1 minute.
|
|
196
|
+
- **Unique job names**: Duplicate names within a service instance throw at `create()` time.
|
|
197
|
+
- **Eager cron validation**: Invalid cron expressions throw at `create()` time, not at poll time.
|
|
198
|
+
- **First-time jobs**: A never-before-run job doesn't fire immediately on `work()` -- it waits
|
|
199
|
+
until its cron expression's next occurrence from now.
|
|
200
|
+
- **Handler errors don't crash the loop**: Failed handlers are logged as `jobFailed` and other
|
|
201
|
+
jobs continue normally.
|
|
202
|
+
- **Db driver requires a table**: The `recurringJobs` table must exist with the schema shown above.
|
|
203
|
+
- **Local driver has no distributed locking**: All processes will independently run the same job.
|
|
312
204
|
|
|
313
205
|
## Cross-Package Integration
|
|
314
206
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
-
|
|
318
|
-
- **@maestro-js/queue** -- each recurring job creates a dedicated queue via `queueService.create()`. Queue handles message persistence, polling, dequeuing, and lifecycle transitions
|
|
319
|
-
- **@maestro-js/log** -- structured logging with pluggable transports for job lifecycle events
|
|
320
|
-
|
|
321
|
-
### How It Connects
|
|
207
|
+
- **Depends on**: `@maestro-js/service-registry` (Provider pattern), `@maestro-js/log` (structured
|
|
208
|
+
logging), `cron-parser` (cron expression parsing)
|
|
209
|
+
- **Optional drivers depend on**: `@maestro-js/db` (Db driver), `@maestro-js/cache` (Cache driver)
|
|
322
210
|
|
|
323
|
-
|
|
211
|
+
## Driver Interface
|
|
324
212
|
|
|
325
|
-
|
|
213
|
+
Implement `RecurringJobs.Driver` to create a custom backend:
|
|
326
214
|
|
|
215
|
+
```ts
|
|
216
|
+
interface Driver {
|
|
217
|
+
listLastRunDates(jobNames: string[]): Promise<{ jobName: string; date: Iso.Instant }[]>
|
|
218
|
+
pullJob(options: { jobName: string; date: Iso.Instant }): Promise<boolean>
|
|
219
|
+
}
|
|
327
220
|
```
|
|
328
|
-
RecurringJobs -> Queue -> Db (MySQL)
|
|
329
|
-
-> Log
|
|
330
|
-
-> ServiceRegistry
|
|
331
|
-
```
|
|
332
|
-
|
|
333
|
-
### Provider Pattern
|
|
334
221
|
|
|
335
|
-
|
|
222
|
+
`listLastRunDates` returns the last run date for each known job name. `pullJob` attempts to
|
|
223
|
+
acquire a lock for the given job and date -- returns `true` if the lock was acquired (this process
|
|
224
|
+
should run the job), `false` otherwise.
|
|
336
225
|
|
|
337
|
-
|
|
338
|
-
2. `RecurringJobs.Provider.register(name, service)` -- registers the instance by name
|
|
339
|
-
3. `RecurringJobs.provider(key)` -- resolves a named instance (deferred via Proxy)
|
|
340
|
-
4. `RecurringJobs.*` -- facade that spreads `provider('default')` for direct method access
|
|
226
|
+
Source: `packages/recurring-jobs/src/index.ts`
|
|
341
227
|
|
|
342
|
-
##
|
|
228
|
+
## Key Source Files
|
|
343
229
|
|
|
344
|
-
-
|
|
345
|
-
-
|
|
346
|
-
-
|
|
347
|
-
-
|
|
348
|
-
- Only the next waiting job can be rescheduled via `rescheduleJob()`. Attempting to reschedule a non-waiting or non-next job throws an error.
|
|
230
|
+
- `packages/recurring-jobs/src/index.ts` -- main export, Provider pattern, polling loop, middleware
|
|
231
|
+
- `packages/recurring-jobs/src/db-recurring-jobs-driver.ts` -- MySQL driver with row-level locking
|
|
232
|
+
- `packages/recurring-jobs/src/cache-recurring-jobs-driver.ts` -- Redis driver via @maestro-js/cache
|
|
233
|
+
- `packages/recurring-jobs/src/local-recurring-jobs-driver.ts` -- in-memory driver for dev/testing
|
package/dist/index.d.ts
CHANGED
|
@@ -1,200 +1,167 @@
|
|
|
1
1
|
import { Iso } from 'iso-fns2';
|
|
2
|
-
import { Queue } from '@maestro-js/queue';
|
|
3
2
|
import { Log } from '@maestro-js/log';
|
|
3
|
+
import { Cache } from '@maestro-js/cache';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
interface DbRecurringJobsDriverConfig {
|
|
6
|
+
db: {
|
|
7
|
+
update(query: string, params?: any[]): Promise<{
|
|
8
|
+
affectedRows: number;
|
|
9
|
+
changedRows: number;
|
|
10
|
+
}>;
|
|
11
|
+
select(query: string, params?: any[]): Promise<Array<Record<string, any>>>;
|
|
12
|
+
insert(query: string, params?: any[]): Promise<{
|
|
13
|
+
insertId: number;
|
|
14
|
+
}>;
|
|
15
|
+
};
|
|
16
|
+
databaseTable: string;
|
|
17
|
+
}
|
|
18
|
+
declare function DbRecurringJobsDriver({ db, databaseTable }: DbRecurringJobsDriverConfig): RecurringJobs.Driver;
|
|
19
|
+
|
|
20
|
+
declare function localRecurringJobsDriver(): RecurringJobs.Driver;
|
|
21
|
+
|
|
22
|
+
interface CacheRecurringJobsDriverConfig {
|
|
23
|
+
cache: Cache.CacheService;
|
|
24
|
+
keyPrefix?: string;
|
|
25
|
+
}
|
|
26
|
+
declare function cacheRecurringJobsDriver({ cache, keyPrefix }: CacheRecurringJobsDriverConfig): RecurringJobs.Driver;
|
|
27
|
+
|
|
28
|
+
declare function createService({ driver, logger: serviceLogger }: RecurringJobs.Provider.ServiceConfig): RecurringJobs.Service;
|
|
29
|
+
type RecurringJobsLogMessage = {
|
|
30
|
+
event: 'jobDue';
|
|
31
|
+
jobName: string;
|
|
32
|
+
nextRun: string;
|
|
33
|
+
} | {
|
|
34
|
+
event: 'jobLockAcquired';
|
|
35
|
+
jobName: string;
|
|
36
|
+
nextRun: string;
|
|
37
|
+
} | {
|
|
38
|
+
event: 'jobLockFailed';
|
|
39
|
+
jobName: string;
|
|
40
|
+
nextRun: string;
|
|
41
|
+
error: unknown;
|
|
42
|
+
} | {
|
|
43
|
+
event: 'jobProcessed';
|
|
44
|
+
jobName: string;
|
|
45
|
+
} | {
|
|
46
|
+
event: 'jobFailed';
|
|
47
|
+
jobName: string;
|
|
48
|
+
error: unknown;
|
|
11
49
|
};
|
|
50
|
+
type KeysWithFallback = keyof RecurringJobs.Provider.Keys extends never ? {
|
|
51
|
+
default: unknown;
|
|
52
|
+
} : RecurringJobs.Provider.Keys;
|
|
12
53
|
declare function provider(key: RecurringJobs.Provider.Key): {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
54
|
+
work: () => {
|
|
55
|
+
abort(): Promise<void>;
|
|
56
|
+
};
|
|
57
|
+
stopAllWork: () => Promise<void>;
|
|
58
|
+
create: <Result = any>(config: RecurringJobs.JobConfig<Result>) => void;
|
|
18
59
|
};
|
|
19
60
|
/**
|
|
20
|
-
* Cron-based job scheduling
|
|
61
|
+
* Cron-based recurring job scheduling with distributed lock-based deduplication.
|
|
62
|
+
*
|
|
63
|
+
* Register a service with `RecurringJobs.Provider.register('default', RecurringJobs.Provider.create({ driver }))`,
|
|
64
|
+
* then define jobs with `RecurringJobs.create({ name, cron, handle })`. Call `RecurringJobs.work()` to start
|
|
65
|
+
* a 60-second polling loop that checks for due jobs, acquires distributed locks via the driver, and executes
|
|
66
|
+
* handlers with optional middleware.
|
|
21
67
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* *before* the work function executes, guaranteeing the schedule continues
|
|
25
|
-
* even if the current run fails. Deduplication prevents double-fires across
|
|
26
|
-
* process restarts.
|
|
68
|
+
* Three built-in drivers: `db` (MySQL row-level locking), `cache` (Redis via @maestro-js/cache),
|
|
69
|
+
* `local` (in-memory, no distributed locking).
|
|
27
70
|
*
|
|
28
71
|
* @example
|
|
29
72
|
* ```ts
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* cron: '0 6 * * *',
|
|
34
|
-
* workFn: async () => {
|
|
35
|
-
* const total = await calculateDailySales()
|
|
36
|
-
* return { total }
|
|
37
|
-
* }
|
|
38
|
-
* })
|
|
73
|
+
* RecurringJobs.Provider.register('default', RecurringJobs.Provider.create({
|
|
74
|
+
* driver: RecurringJobs.drivers.db({ db: Db, databaseTable: 'recurringJobs' })
|
|
75
|
+
* }))
|
|
39
76
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
77
|
+
* RecurringJobs.create({
|
|
78
|
+
* name: 'send-weekly-digest',
|
|
79
|
+
* cron: '0 9 * * MON',
|
|
80
|
+
* handle: async () => sendDigest()
|
|
81
|
+
* })
|
|
43
82
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
83
|
+
* const worker = RecurringJobs.work()
|
|
84
|
+
* // Later: await worker.abort()
|
|
46
85
|
* ```
|
|
47
86
|
*/
|
|
48
87
|
declare namespace RecurringJobs {
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
88
|
+
/** Union of all recurring jobs lifecycle log event shapes */
|
|
89
|
+
type LogMessage = RecurringJobsLogMessage;
|
|
90
|
+
/** Middleware function that wraps recurring job execution */
|
|
91
|
+
type Middleware<Result = any> = (next: () => Promise<Result>) => Promise<Result>;
|
|
92
|
+
/** Pluggable storage backend for tracking job run dates and acquiring distributed locks */
|
|
93
|
+
interface Driver {
|
|
94
|
+
/** Returns the last run date for each of the given job names */
|
|
95
|
+
listLastRunDates(jobNames: string[]): Promise<{
|
|
96
|
+
jobName: string;
|
|
97
|
+
date: Iso.Instant;
|
|
98
|
+
}[]>;
|
|
99
|
+
/** Attempts to acquire a lock for the given job and date — returns true if the lock was acquired */
|
|
100
|
+
pullJob(options: {
|
|
101
|
+
jobName: string;
|
|
102
|
+
date: Iso.Instant;
|
|
103
|
+
}): Promise<boolean>;
|
|
64
104
|
}
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
* Each job has independent start/stop lifecycle, execution history, and
|
|
69
|
-
* rescheduling capability. The queue stores one message per scheduled run —
|
|
70
|
-
* call {@link list} to page through past and pending executions, or
|
|
71
|
-
* {@link getNextUp} to peek at the next waiting run.
|
|
72
|
-
*
|
|
73
|
-
* @example
|
|
74
|
-
* ```ts
|
|
75
|
-
* const job = RecurringJobs.createJob({
|
|
76
|
-
* name: 'stale-session-cleanup',
|
|
77
|
-
* cron: '0 0 * * *',
|
|
78
|
-
* workFn: async () => { await cleanStaleSessions() }
|
|
79
|
-
* })
|
|
80
|
-
* job.startProcessing()
|
|
81
|
-
* const next = await job.getNextUp() // { status: 'waiting', ... }
|
|
82
|
-
* ```
|
|
83
|
-
*/
|
|
84
|
-
interface RecurringJob<T = unknown> {
|
|
85
|
-
/** Enqueues the next scheduled run and begins polling */
|
|
86
|
-
startProcessing(): void;
|
|
87
|
-
/** Stops polling for this job */
|
|
88
|
-
stopProcessing(): void;
|
|
89
|
-
/** The name of the recurring job as specified in the config */
|
|
90
|
-
name: string;
|
|
91
|
-
/** The cron expression defining this job's schedule */
|
|
105
|
+
/** Per-job configuration passed to {@link Service.create} */
|
|
106
|
+
interface JobConfig<Result = any> {
|
|
107
|
+
/** Cron expression defining the schedule (e.g. '0 9 * * MON') */
|
|
92
108
|
cron: string;
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
scheduledDate: Iso.Instant;
|
|
97
|
-
}): Promise<void>;
|
|
98
|
-
/** Returns the first message in `waiting` status, or `null` if none exists */
|
|
99
|
-
getNextUp(): Promise<JobMessage<T> | null>;
|
|
100
|
-
/** Returns a paginated list of all messages (past runs and pending) for this job */
|
|
101
|
-
list(options: {
|
|
102
|
-
pageSize: number;
|
|
103
|
-
pageNumber: number;
|
|
104
|
-
}): Promise<JobMessage<unknown>[]>;
|
|
105
|
-
}
|
|
106
|
-
/** Union of all recurring job lifecycle log event shapes */
|
|
107
|
-
type LogMessage = {
|
|
108
|
-
event: 'jobProcessing';
|
|
109
|
-
job: string;
|
|
110
|
-
driver: Queue.Driver;
|
|
111
|
-
messageId: number;
|
|
112
|
-
} | {
|
|
113
|
-
event: 'jobSucceeded';
|
|
114
|
-
job: string;
|
|
115
|
-
driver: Queue.Driver;
|
|
116
|
-
result: unknown;
|
|
117
|
-
messageId: number;
|
|
118
|
-
} | {
|
|
119
|
-
event: 'jobFailed';
|
|
120
|
-
job: string;
|
|
121
|
-
driver: Queue.Driver;
|
|
122
|
-
error: unknown;
|
|
123
|
-
messageId: number;
|
|
124
|
-
} | {
|
|
125
|
-
event: 'jobScheduled';
|
|
126
|
-
job: string;
|
|
127
|
-
nextRun: Iso.Instant;
|
|
128
|
-
} | {
|
|
129
|
-
event: 'jobAlreadyScheduled';
|
|
130
|
-
job: string;
|
|
131
|
-
existingJobId: number;
|
|
132
|
-
} | {
|
|
133
|
-
event: 'jobSchedulingFailed';
|
|
134
|
-
job: string;
|
|
135
|
-
error: unknown;
|
|
136
|
-
};
|
|
137
|
-
/** Configuration for a single recurring job passed to {@link RecurringJobsService.createJob} */
|
|
138
|
-
interface JobConfig<T = unknown> {
|
|
139
|
-
cron: string;
|
|
140
|
-
workFn(): Promise<T>;
|
|
109
|
+
/** Async handler invoked when the job is due */
|
|
110
|
+
handle(): Promise<Result>;
|
|
111
|
+
/** Unique name identifying this job across processes */
|
|
141
112
|
name: string;
|
|
142
|
-
|
|
113
|
+
/** Optional middleware chain wrapping the handler */
|
|
114
|
+
middleware?: Middleware<Result>[];
|
|
143
115
|
}
|
|
144
116
|
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
* Create individual jobs with {@link createJob}, then start or stop processing
|
|
148
|
-
* across all jobs at once or individually. Each job gets its own dedicated queue
|
|
149
|
-
* named `RecurringJobs: <jobName>`.
|
|
117
|
+
* Core recurring jobs service returned by {@link RecurringJobs.Provider.create}.
|
|
150
118
|
*
|
|
151
|
-
* @
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
* const cleanup = RecurringJobs.createJob({ name: 'session-cleanup', cron: '0 0 * * *', workFn: cleanSessions })
|
|
155
|
-
* RecurringJobs.startProcessing() // starts both jobs
|
|
156
|
-
* ```
|
|
119
|
+
* Use {@link Service.create create} to register jobs, then call {@link Service.work work}
|
|
120
|
+
* to start polling. The service checks every 60 seconds for due jobs, acquires distributed
|
|
121
|
+
* locks via the driver, and executes handlers with middleware.
|
|
157
122
|
*/
|
|
158
|
-
interface
|
|
159
|
-
/**
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
|
|
167
|
-
/** Returns the recurring job with the given name, or `undefined` if not found */
|
|
168
|
-
get: <Result>(name: string) => RecurringJobs.RecurringJob<Result> | undefined;
|
|
123
|
+
interface Service {
|
|
124
|
+
/** Starts a polling loop that checks for and executes due jobs every 60 seconds — returns an object with an `abort()` method to stop */
|
|
125
|
+
work(): {
|
|
126
|
+
abort(): Promise<void>;
|
|
127
|
+
};
|
|
128
|
+
/** Aborts all active work loops and waits for in-flight ticks to finish */
|
|
129
|
+
stopAllWork(): Promise<void>;
|
|
130
|
+
/** Registers a new recurring job with the given cron schedule and handler */
|
|
131
|
+
create<Result = any>(config: JobConfig<Result>): void;
|
|
169
132
|
}
|
|
170
133
|
namespace Provider {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
logger
|
|
174
|
-
queueService: Queue.QueueService;
|
|
134
|
+
interface ServiceConfig {
|
|
135
|
+
driver: Driver;
|
|
136
|
+
logger?: Log.Logger<[LogMessage]>;
|
|
175
137
|
}
|
|
176
|
-
type Key = keyof
|
|
138
|
+
type Key = keyof KeysWithFallback;
|
|
177
139
|
interface Keys {
|
|
178
|
-
default: unknown;
|
|
179
140
|
}
|
|
180
141
|
}
|
|
181
142
|
}
|
|
182
143
|
/**
|
|
183
|
-
* Cron-based job scheduling
|
|
184
|
-
* Call `RecurringJobs.Provider.create()` with a
|
|
144
|
+
* Cron-based recurring job scheduling with distributed lock-based deduplication.
|
|
145
|
+
* Call `RecurringJobs.Provider.create()` with a driver, register it, then use `RecurringJobs.create()` to define jobs
|
|
146
|
+
* and `RecurringJobs.work()` to start the 60-second polling loop. Drivers: `db`, `cache`, `local`.
|
|
185
147
|
*/
|
|
186
148
|
declare const RecurringJobs: {
|
|
187
149
|
Provider: {
|
|
188
|
-
create: typeof
|
|
189
|
-
register: (name: RecurringJobs.Provider.Key, item: RecurringJobs.
|
|
150
|
+
create: typeof createService;
|
|
151
|
+
register: (name: RecurringJobs.Provider.Key, item: RecurringJobs.Service) => void;
|
|
190
152
|
list: () => "default"[];
|
|
191
153
|
};
|
|
192
154
|
provider: typeof provider;
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
155
|
+
work: () => {
|
|
156
|
+
abort(): Promise<void>;
|
|
157
|
+
};
|
|
158
|
+
stopAllWork: () => Promise<void>;
|
|
159
|
+
create: <Result = any>(config: RecurringJobs.JobConfig<Result>) => void;
|
|
160
|
+
drivers: {
|
|
161
|
+
db: typeof DbRecurringJobsDriver;
|
|
162
|
+
local: typeof localRecurringJobsDriver;
|
|
163
|
+
cache: typeof cacheRecurringJobsDriver;
|
|
164
|
+
};
|
|
198
165
|
};
|
|
199
166
|
|
|
200
167
|
export { RecurringJobs };
|
package/dist/index.js
CHANGED
|
@@ -1,152 +1,222 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import "iso-fns2";
|
|
2
|
+
import { instantFns as instantFns2 } from "iso-fns2";
|
|
3
3
|
import cronParser from "cron-parser";
|
|
4
|
-
import { Log } from "@maestro-js/log";
|
|
5
4
|
import { ServiceRegistry } from "@maestro-js/service-registry";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
function getNextScheduledDate() {
|
|
54
|
-
const interval = cronParser.parseExpression(jobConfig.cron);
|
|
55
|
-
return interval.next().toDate().toISOString();
|
|
5
|
+
|
|
6
|
+
// src/db-recurring-jobs-driver.ts
|
|
7
|
+
import { instantFns } from "iso-fns2";
|
|
8
|
+
function DbRecurringJobsDriver({ db, databaseTable }) {
|
|
9
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(databaseTable)) {
|
|
10
|
+
throw new Error(`Invalid databaseTable name: ${databaseTable}`);
|
|
11
|
+
}
|
|
12
|
+
async function listLastRunDates(jobNames) {
|
|
13
|
+
if (jobNames.length === 0) return [];
|
|
14
|
+
const placeholders = jobNames.map(() => "?").join(", ");
|
|
15
|
+
const rows = await db.select(`SELECT name, lastRunDate FROM ${databaseTable} WHERE name IN (${placeholders})`, jobNames);
|
|
16
|
+
return rows.filter((row) => row["lastRunDate"] != null).map((row) => ({
|
|
17
|
+
jobName: row["name"],
|
|
18
|
+
date: row["lastRunDate"]
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
async function pullJob({ jobName, date }) {
|
|
22
|
+
const formattedDate = instantFns.formatISO9075(date);
|
|
23
|
+
const { changedRows } = await db.update(
|
|
24
|
+
`UPDATE ${databaseTable} SET lastRunDate = ? WHERE name = ? AND (lastRunDate IS NULL OR lastRunDate < ?)`,
|
|
25
|
+
[formattedDate, jobName, formattedDate]
|
|
26
|
+
);
|
|
27
|
+
if (changedRows > 0) return true;
|
|
28
|
+
const insert = await db.update(`INSERT IGNORE INTO ${databaseTable} (name, lastRunDate) VALUES (?, ?)`, [
|
|
29
|
+
jobName,
|
|
30
|
+
formattedDate
|
|
31
|
+
]);
|
|
32
|
+
return insert.affectedRows > 0;
|
|
33
|
+
}
|
|
34
|
+
return { listLastRunDates, pullJob };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// src/local-recurring-jobs-driver.ts
|
|
38
|
+
function localRecurringJobsDriver() {
|
|
39
|
+
const lastRunDates = /* @__PURE__ */ new Map();
|
|
40
|
+
async function listLastRunDates(jobNames) {
|
|
41
|
+
if (jobNames.length === 0) return [];
|
|
42
|
+
return jobNames.filter((name) => lastRunDates.has(name)).map((name) => ({
|
|
43
|
+
jobName: name,
|
|
44
|
+
date: lastRunDates.get(name)
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
async function pullJob({ jobName, date }) {
|
|
48
|
+
const current = lastRunDates.get(jobName);
|
|
49
|
+
if (current == null || current < date) {
|
|
50
|
+
lastRunDates.set(jobName, date);
|
|
51
|
+
return true;
|
|
56
52
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
return { listLastRunDates, pullJob };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/cache-recurring-jobs-driver.ts
|
|
59
|
+
function cacheRecurringJobsDriver({
|
|
60
|
+
cache,
|
|
61
|
+
keyPrefix = "recurring-jobs:"
|
|
62
|
+
}) {
|
|
63
|
+
async function listLastRunDates(jobNames) {
|
|
64
|
+
if (jobNames.length === 0) return [];
|
|
65
|
+
const results = await Promise.all(
|
|
66
|
+
jobNames.map(async (name) => {
|
|
67
|
+
const date = await cache.get(keyPrefix + name);
|
|
68
|
+
return date != null ? { jobName: name, date } : null;
|
|
69
|
+
})
|
|
70
|
+
);
|
|
71
|
+
return results.filter((r) => r != null);
|
|
72
|
+
}
|
|
73
|
+
async function pullJob({ jobName, date }) {
|
|
74
|
+
const lock = cache.lock(keyPrefix + "lock:" + jobName, 10);
|
|
75
|
+
const acquired = await lock.get();
|
|
76
|
+
if (!acquired) return false;
|
|
77
|
+
try {
|
|
78
|
+
const current = await cache.get(keyPrefix + jobName);
|
|
79
|
+
if (current == null || current < date) {
|
|
80
|
+
await cache.set(keyPrefix + jobName, date, null);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
} finally {
|
|
85
|
+
await lock.release();
|
|
61
86
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
87
|
+
}
|
|
88
|
+
return { listLastRunDates, pullJob };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/index.ts
|
|
92
|
+
function createService({ driver, logger: serviceLogger }) {
|
|
93
|
+
const allJobs = /* @__PURE__ */ new Map();
|
|
94
|
+
const logger = serviceLogger;
|
|
95
|
+
let lastRunDatesCached = null;
|
|
96
|
+
async function refreshLastRunDates() {
|
|
97
|
+
const rows = await driver.listLastRunDates([...allJobs.keys()]);
|
|
98
|
+
const map = /* @__PURE__ */ new Map();
|
|
99
|
+
for (const row of rows) map.set(row.jobName, row.date);
|
|
100
|
+
lastRunDatesCached = map;
|
|
101
|
+
return map;
|
|
102
|
+
}
|
|
103
|
+
async function executeJob(job) {
|
|
104
|
+
const middleware = job.middleware ?? [];
|
|
105
|
+
const executeFn = middleware.reduceRight(
|
|
106
|
+
(acc, cur) => {
|
|
107
|
+
return () => cur(acc);
|
|
108
|
+
},
|
|
109
|
+
() => job.handle()
|
|
110
|
+
);
|
|
111
|
+
return executeFn();
|
|
112
|
+
}
|
|
113
|
+
async function tick() {
|
|
114
|
+
const now = instantFns2.now();
|
|
115
|
+
const lastRunDates = lastRunDatesCached ?? await refreshLastRunDates();
|
|
116
|
+
const jobsToRun = [...allJobs.values()].map((jobConfig) => {
|
|
117
|
+
const lastRun = lastRunDates.get(jobConfig.name);
|
|
118
|
+
const cron = cronParser.parseExpression(jobConfig.cron);
|
|
119
|
+
if (lastRun) {
|
|
120
|
+
cron.reset(lastRun);
|
|
121
|
+
}
|
|
122
|
+
const nextRun = cron.next().toDate().toISOString();
|
|
123
|
+
return { ...jobConfig, nextRun };
|
|
124
|
+
}).filter((c) => c.nextRun <= now);
|
|
125
|
+
for (const job of jobsToRun) {
|
|
126
|
+
logger?.info({ event: "jobDue", jobName: job.name, nextRun: job.nextRun });
|
|
68
127
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
128
|
+
const lockedJobs = [];
|
|
129
|
+
for (const job of jobsToRun) {
|
|
130
|
+
try {
|
|
131
|
+
const haveLock = await driver.pullJob({ jobName: job.name, date: job.nextRun });
|
|
132
|
+
if (haveLock) {
|
|
133
|
+
logger?.info({ event: "jobLockAcquired", jobName: job.name, nextRun: job.nextRun });
|
|
134
|
+
lockedJobs.push(job);
|
|
135
|
+
}
|
|
136
|
+
} catch (e) {
|
|
137
|
+
logger?.error({ event: "jobLockFailed", jobName: job.name, nextRun: job.nextRun, error: e });
|
|
75
138
|
}
|
|
76
|
-
await queue.updateMessage({ messageId: jobId, scheduledDate, body: {} });
|
|
77
139
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
140
|
+
if (jobsToRun.length) {
|
|
141
|
+
lastRunDatesCached = null;
|
|
142
|
+
}
|
|
143
|
+
const results = await Promise.allSettled(lockedJobs.map(executeJob));
|
|
144
|
+
for (let i = 0; i < results.length; i++) {
|
|
145
|
+
if (results[i].status === "rejected") {
|
|
146
|
+
logger?.error({
|
|
147
|
+
event: "jobFailed",
|
|
148
|
+
jobName: lockedJobs[i].name,
|
|
149
|
+
error: results[i].reason
|
|
150
|
+
});
|
|
151
|
+
} else {
|
|
152
|
+
logger?.info({ event: "jobProcessed", jobName: lockedJobs[i].name });
|
|
83
153
|
}
|
|
84
|
-
await queue.addMessage({}, { scheduledDate: nextRun, tracingContext: null });
|
|
85
|
-
logger.info({ event: "jobScheduled", job: jobConfig.name, nextRun });
|
|
86
|
-
return true;
|
|
87
154
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
155
|
+
}
|
|
156
|
+
let abortWorkFunctions = [];
|
|
157
|
+
function work() {
|
|
158
|
+
let ticks = [];
|
|
159
|
+
function doTick() {
|
|
160
|
+
const promise = tick();
|
|
161
|
+
ticks.push(promise);
|
|
162
|
+
promise.finally(() => {
|
|
163
|
+
ticks = ticks.filter((t) => t !== promise);
|
|
164
|
+
});
|
|
165
|
+
return promise.catch((e) => {
|
|
91
166
|
});
|
|
92
|
-
queue.startProcessing();
|
|
93
167
|
}
|
|
94
|
-
|
|
95
|
-
|
|
168
|
+
doTick();
|
|
169
|
+
const interval = setInterval(doTick, 6e4);
|
|
170
|
+
async function abort() {
|
|
171
|
+
clearInterval(interval);
|
|
172
|
+
await Promise.all(ticks);
|
|
173
|
+
abortWorkFunctions = abortWorkFunctions.filter((a) => a !== abort);
|
|
96
174
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
startProcessing: startProcessing2,
|
|
101
|
-
stopProcessing: stopProcessing2,
|
|
102
|
-
rescheduleJob,
|
|
103
|
-
name: jobConfig.name,
|
|
104
|
-
cron: jobConfig.cron
|
|
175
|
+
abortWorkFunctions.push(abort);
|
|
176
|
+
return {
|
|
177
|
+
abort
|
|
105
178
|
};
|
|
106
|
-
jobs.push(jobFunctions);
|
|
107
|
-
return jobFunctions;
|
|
108
|
-
}
|
|
109
|
-
function startProcessing() {
|
|
110
|
-
jobs.forEach((job) => job.startProcessing());
|
|
111
179
|
}
|
|
112
|
-
function
|
|
113
|
-
|
|
180
|
+
async function stopAllWork() {
|
|
181
|
+
await Promise.all(abortWorkFunctions.map((w) => w()));
|
|
114
182
|
}
|
|
115
|
-
function
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
183
|
+
function create(config) {
|
|
184
|
+
cronParser.parseExpression(config.cron);
|
|
185
|
+
if (allJobs.has(config.name)) {
|
|
186
|
+
throw new Error(`Cannot have multiple recurring jobs with name "${config.name}"`);
|
|
187
|
+
}
|
|
188
|
+
allJobs.set(config.name, config);
|
|
120
189
|
}
|
|
121
|
-
return {
|
|
190
|
+
return { work, create, stopAllWork };
|
|
122
191
|
}
|
|
123
192
|
var registry = ServiceRegistry.createRegistry(
|
|
124
193
|
ServiceRegistry.proxyFunctionsForObject
|
|
125
194
|
);
|
|
126
195
|
var Provider = {
|
|
127
|
-
create,
|
|
196
|
+
create: createService,
|
|
128
197
|
register: registry.register,
|
|
129
198
|
list: registry.list
|
|
130
199
|
};
|
|
131
200
|
function provider(key) {
|
|
132
201
|
const service = registry.resolve(key);
|
|
133
202
|
return {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
get: service.get,
|
|
138
|
-
createJob: service.createJob
|
|
203
|
+
work: service.work,
|
|
204
|
+
stopAllWork: service.stopAllWork,
|
|
205
|
+
create: service.create
|
|
139
206
|
};
|
|
140
207
|
}
|
|
141
208
|
var facade = provider("default");
|
|
142
209
|
var RecurringJobs = {
|
|
143
210
|
Provider,
|
|
144
211
|
provider,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
212
|
+
work: facade.work,
|
|
213
|
+
stopAllWork: facade.stopAllWork,
|
|
214
|
+
create: facade.create,
|
|
215
|
+
drivers: {
|
|
216
|
+
db: DbRecurringJobsDriver,
|
|
217
|
+
local: localRecurringJobsDriver,
|
|
218
|
+
cache: cacheRecurringJobsDriver
|
|
219
|
+
}
|
|
150
220
|
};
|
|
151
221
|
export {
|
|
152
222
|
RecurringJobs
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maestro-js/recurring-jobs",
|
|
3
|
-
"description": "Use when working with @maestro-js/recurring-jobs. Cron-based job scheduling
|
|
3
|
+
"description": "Use when working with @maestro-js/recurring-jobs. Cron-based recurring job scheduling with distributed lock-based deduplication. Use for cron scheduling, periodic background tasks, and recurring jobs that must run exactly once across processes. Drivers: Db (MySQL), Cache (Redis via @maestro-js/cache), Local (in-memory). Follows the Provider pattern with ServiceRegistry. Key capabilities include cron expression parsing, 60-second polling loop, per-job distributed locking, optional middleware wrapping, eager cron validation, structured lifecycle log events (jobDue, jobLockAcquired, jobFailed, jobProcessed), and graceful shutdown via abort/stopAllWork.",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -11,14 +11,20 @@
|
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"cron-parser": "^4.9.0",
|
|
13
13
|
"iso-fns2": "npm:iso-fns@2.0.0-alpha.26",
|
|
14
|
-
"@maestro-js/service-registry": "1.0.0-alpha.
|
|
15
|
-
|
|
14
|
+
"@maestro-js/service-registry": "1.0.0-alpha.10"
|
|
15
|
+
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"@maestro-js/cache": "1.0.0-alpha.10",
|
|
18
|
+
"@maestro-js/log": "1.0.0-alpha.10"
|
|
16
19
|
},
|
|
17
20
|
"devDependencies": {
|
|
18
21
|
"@types/node": "^22.19.11",
|
|
19
|
-
"@maestro-js/
|
|
22
|
+
"@maestro-js/cache": "1.0.0-alpha.10",
|
|
23
|
+
"@maestro-js/db": "1.0.0-alpha.10",
|
|
24
|
+
"@maestro-js/log": "1.0.0-alpha.10",
|
|
25
|
+
"@maestro-js/queue": "1.0.0-alpha.10"
|
|
20
26
|
},
|
|
21
|
-
"version": "1.0.0-alpha.
|
|
27
|
+
"version": "1.0.0-alpha.10",
|
|
22
28
|
"publishConfig": {
|
|
23
29
|
"access": "restricted"
|
|
24
30
|
},
|
|
@@ -33,7 +39,7 @@
|
|
|
33
39
|
"scripts": {
|
|
34
40
|
"build": "tsup --config ../../tsup.config.ts",
|
|
35
41
|
"typecheck": "tsc --noEmit",
|
|
36
|
-
"test": "
|
|
42
|
+
"test": "beartest ./tests/**/*",
|
|
37
43
|
"format": "prettier --write src/",
|
|
38
44
|
"lint": "prettier --check src/"
|
|
39
45
|
}
|