@maestro-js/recurring-jobs 1.0.0-alpha.18 → 1.0.0-alpha.2
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 +253 -138
- package/dist/index.d.ts +160 -127
- package/dist/index.js +115 -185
- package/package.json +6 -12
package/README.md
CHANGED
|
@@ -1,233 +1,348 @@
|
|
|
1
|
-
#
|
|
1
|
+
# RecurringJobs Skill
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## Overview
|
|
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.
|
|
4
6
|
|
|
5
7
|
## Quick Setup
|
|
6
8
|
|
|
7
9
|
```ts
|
|
8
10
|
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
|
+
})
|
|
9
18
|
|
|
10
|
-
// Create and register with
|
|
11
|
-
const
|
|
12
|
-
|
|
19
|
+
// Create and register the recurring jobs service with a queue service
|
|
20
|
+
const recurringJobsService = RecurringJobs.Provider.create({
|
|
21
|
+
logger,
|
|
22
|
+
queueService: Queue.provider('default')
|
|
13
23
|
})
|
|
14
|
-
RecurringJobs.Provider.register('default',
|
|
24
|
+
RecurringJobs.Provider.register('default', recurringJobsService)
|
|
15
25
|
|
|
16
26
|
// Define a recurring job
|
|
17
|
-
RecurringJobs.
|
|
18
|
-
name: '
|
|
19
|
-
cron: '0
|
|
20
|
-
|
|
27
|
+
const dailyReport = RecurringJobs.createJob({
|
|
28
|
+
name: 'daily-sales-report',
|
|
29
|
+
cron: '0 6 * * *',
|
|
30
|
+
workFn: async () => {
|
|
31
|
+
const total = await calculateDailySales()
|
|
32
|
+
return { total }
|
|
33
|
+
}
|
|
21
34
|
})
|
|
22
35
|
|
|
23
|
-
// Start
|
|
24
|
-
|
|
36
|
+
// Start processing
|
|
37
|
+
dailyReport.startProcessing()
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Cron Expression Format
|
|
41
|
+
|
|
42
|
+
Uses the `cron-parser` library (v4.x) for standard 5-field cron expressions:
|
|
25
43
|
|
|
26
|
-
|
|
27
|
-
|
|
44
|
+
```
|
|
45
|
+
┌───────────── minute (0-59)
|
|
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
|
+
* * * * *
|
|
28
52
|
```
|
|
29
53
|
|
|
30
|
-
|
|
54
|
+
Common examples:
|
|
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
|
|
31
60
|
|
|
32
|
-
|
|
61
|
+
Invalid cron expressions throw immediately when passed to `createJob()`, since `cron-parser.parseExpression()` is called at creation time.
|
|
62
|
+
|
|
63
|
+
## API Reference
|
|
33
64
|
|
|
34
|
-
|
|
35
|
-
|
|
65
|
+
### Provider Setup
|
|
66
|
+
|
|
67
|
+
#### `RecurringJobs.Provider.create(config)`
|
|
68
|
+
|
|
69
|
+
Create a recurring jobs service instance.
|
|
36
70
|
|
|
37
71
|
```ts
|
|
38
|
-
RecurringJobs.
|
|
39
|
-
|
|
40
|
-
|
|
72
|
+
RecurringJobs.Provider.create(config: {
|
|
73
|
+
logger: Log.Logger<[RecurringJobs.LogMessage]>
|
|
74
|
+
queueService: Queue.QueueService
|
|
41
75
|
})
|
|
42
76
|
```
|
|
43
77
|
|
|
44
|
-
|
|
78
|
+
- `logger` -- a Log instance typed to `RecurringJobs.LogMessage` for structured job lifecycle events
|
|
79
|
+
- `queueService` -- the Queue service used to back each recurring job's dedicated queue
|
|
80
|
+
|
|
81
|
+
#### `RecurringJobs.Provider.register(name, service)`
|
|
45
82
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
);
|
|
83
|
+
Register the service instance in the named registry.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
RecurringJobs.Provider.register('default', recurringJobsService)
|
|
51
87
|
```
|
|
52
88
|
|
|
53
|
-
|
|
89
|
+
#### `RecurringJobs.provider(key)`
|
|
90
|
+
|
|
91
|
+
Resolve a named instance from the registry. Supports deferred resolution via Proxy.
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
const jobs = RecurringJobs.provider('default')
|
|
95
|
+
```
|
|
54
96
|
|
|
55
|
-
###
|
|
97
|
+
### Service Methods (on the facade or provider instance)
|
|
56
98
|
|
|
57
|
-
|
|
58
|
-
|
|
99
|
+
#### `RecurringJobs.createJob(config)`
|
|
100
|
+
|
|
101
|
+
Create a recurring job with a dedicated queue. Returns a `RecurringJob` handle.
|
|
59
102
|
|
|
60
103
|
```ts
|
|
61
|
-
RecurringJobs.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
104
|
+
RecurringJobs.createJob<T>(config: {
|
|
105
|
+
name: string
|
|
106
|
+
cron: string
|
|
107
|
+
workFn: () => Promise<T>
|
|
108
|
+
logger?: Log.LogFunctions<[RecurringJobs.LogMessage]> // optional per-job logger
|
|
109
|
+
}): RecurringJobs.RecurringJob<T>
|
|
65
110
|
```
|
|
66
111
|
|
|
67
|
-
|
|
112
|
+
Each job creates a queue named `RecurringJobs: <name>`. The cron expression is validated immediately and throws if invalid.
|
|
68
113
|
|
|
69
|
-
|
|
114
|
+
#### `RecurringJobs.startProcessing()`
|
|
70
115
|
|
|
71
|
-
|
|
72
|
-
Good for development and testing.
|
|
116
|
+
Start processing on all registered recurring jobs.
|
|
73
117
|
|
|
74
118
|
```ts
|
|
75
|
-
RecurringJobs.
|
|
119
|
+
RecurringJobs.startProcessing()
|
|
76
120
|
```
|
|
77
121
|
|
|
78
|
-
|
|
122
|
+
#### `RecurringJobs.stopProcessing()`
|
|
79
123
|
|
|
80
|
-
|
|
124
|
+
Stop processing on all registered recurring jobs.
|
|
81
125
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
| **Cache** | Yes (Redis lock) | Yes* | Production when you have Redis, no MySQL |
|
|
86
|
-
| **Local** | No | No | Development and testing |
|
|
126
|
+
```ts
|
|
127
|
+
RecurringJobs.stopProcessing()
|
|
128
|
+
```
|
|
87
129
|
|
|
88
|
-
|
|
130
|
+
#### `RecurringJobs.list()`
|
|
89
131
|
|
|
90
|
-
|
|
132
|
+
Return the names of all registered recurring jobs.
|
|
91
133
|
|
|
92
|
-
|
|
134
|
+
```ts
|
|
135
|
+
RecurringJobs.list() // ['daily-sales-report', 'session-cleanup']
|
|
136
|
+
```
|
|
93
137
|
|
|
94
|
-
|
|
138
|
+
#### `RecurringJobs.get(name)`
|
|
139
|
+
|
|
140
|
+
Return the recurring job with the given name, or `undefined` if not found.
|
|
95
141
|
|
|
96
142
|
```ts
|
|
97
|
-
RecurringJobs.
|
|
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
|
-
})
|
|
143
|
+
const job = RecurringJobs.get<{ total: number }>('daily-sales-report')
|
|
103
144
|
```
|
|
104
145
|
|
|
105
|
-
###
|
|
146
|
+
### RecurringJob Instance Methods
|
|
106
147
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
148
|
+
#### `job.startProcessing()`
|
|
149
|
+
|
|
150
|
+
Enqueue the next scheduled run and begin polling. If a waiting job already exists, it is not duplicated (deduplication).
|
|
110
151
|
|
|
111
152
|
```ts
|
|
112
|
-
|
|
153
|
+
dailyReport.startProcessing()
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
#### `job.stopProcessing()`
|
|
113
157
|
|
|
114
|
-
|
|
115
|
-
|
|
158
|
+
Stop polling for this job.
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
dailyReport.stopProcessing()
|
|
116
162
|
```
|
|
117
163
|
|
|
118
|
-
|
|
164
|
+
#### `job.getNextUp()`
|
|
119
165
|
|
|
120
|
-
|
|
166
|
+
Return the first message in `waiting` status, or `null` if none exists.
|
|
121
167
|
|
|
122
168
|
```ts
|
|
123
|
-
await
|
|
169
|
+
const next = await dailyReport.getNextUp()
|
|
170
|
+
// { id: 42, status: 'waiting', scheduledDate: '2026-02-22T06:00:00.000Z', cron: '0 6 * * *', ... }
|
|
124
171
|
```
|
|
125
172
|
|
|
126
|
-
|
|
173
|
+
#### `job.list({ pageSize, pageNumber })`
|
|
127
174
|
|
|
128
|
-
|
|
175
|
+
Return a paginated list of all messages (past runs and pending) for this job.
|
|
129
176
|
|
|
130
177
|
```ts
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
cron: '0 3 * * *', // daily at 3am
|
|
134
|
-
handle: async () => {
|
|
135
|
-
await db.update('DELETE FROM tokens WHERE expiresAt < NOW()')
|
|
136
|
-
}
|
|
137
|
-
})
|
|
178
|
+
const history = await dailyReport.list({ pageSize: 20, pageNumber: 0 })
|
|
179
|
+
// [{ id: 41, status: 'success', result: { total: 15420 }, ... }, ...]
|
|
138
180
|
```
|
|
139
181
|
|
|
140
|
-
|
|
182
|
+
#### `job.rescheduleJob({ jobId, scheduledDate })`
|
|
183
|
+
|
|
184
|
+
Change the scheduled time of a waiting job. Only the next waiting job can be rescheduled; throws if `jobId` does not match the current waiting job.
|
|
141
185
|
|
|
142
186
|
```ts
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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()
|
|
187
|
+
await dailyReport.rescheduleJob({
|
|
188
|
+
jobId: 42,
|
|
189
|
+
scheduledDate: '2026-02-22T08:00:00.000Z' as Iso.Instant
|
|
157
190
|
})
|
|
158
191
|
```
|
|
159
192
|
|
|
160
|
-
|
|
193
|
+
#### `job.name` / `job.cron`
|
|
194
|
+
|
|
195
|
+
Read-only properties exposing the job's name and cron expression.
|
|
161
196
|
|
|
162
197
|
```ts
|
|
163
|
-
|
|
198
|
+
dailyReport.name // 'daily-sales-report'
|
|
199
|
+
dailyReport.cron // '0 6 * * *'
|
|
200
|
+
```
|
|
164
201
|
|
|
165
|
-
|
|
166
|
-
await worker.abort() // waits for in-flight tick to finish
|
|
167
|
-
process.exit(0)
|
|
168
|
-
})
|
|
202
|
+
### JobMessage Interface
|
|
169
203
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
204
|
+
Each queue message returned by `getNextUp()` and `list()` has these fields:
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
interface JobMessage<Result = any> {
|
|
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
|
+
}
|
|
175
222
|
```
|
|
176
223
|
|
|
177
|
-
|
|
224
|
+
## Common Patterns
|
|
225
|
+
|
|
226
|
+
### Multiple Recurring Jobs
|
|
178
227
|
|
|
179
228
|
```ts
|
|
180
|
-
const
|
|
181
|
-
|
|
229
|
+
const report = RecurringJobs.createJob({
|
|
230
|
+
name: 'daily-report',
|
|
231
|
+
cron: '0 6 * * *',
|
|
232
|
+
workFn: async () => generateReport()
|
|
182
233
|
})
|
|
183
|
-
RecurringJobs.Provider.register('default', primary)
|
|
184
234
|
|
|
185
|
-
const
|
|
186
|
-
|
|
235
|
+
const cleanup = RecurringJobs.createJob({
|
|
236
|
+
name: 'session-cleanup',
|
|
237
|
+
cron: '0 0 * * *',
|
|
238
|
+
workFn: async () => cleanStaleSessions()
|
|
187
239
|
})
|
|
188
|
-
RecurringJobs.Provider.register('dev', secondary)
|
|
189
240
|
|
|
190
|
-
|
|
241
|
+
// Start all jobs at once
|
|
242
|
+
RecurringJobs.startProcessing()
|
|
243
|
+
|
|
244
|
+
// Or start individually
|
|
245
|
+
report.startProcessing()
|
|
246
|
+
cleanup.startProcessing()
|
|
191
247
|
```
|
|
192
248
|
|
|
193
|
-
|
|
249
|
+
### Inspecting and Rescheduling
|
|
194
250
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
251
|
+
```ts
|
|
252
|
+
const job = RecurringJobs.get('daily-report')
|
|
253
|
+
if (job) {
|
|
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
|
+
```
|
|
204
265
|
|
|
205
|
-
|
|
266
|
+
### Per-Job Logger
|
|
206
267
|
|
|
207
|
-
|
|
208
|
-
logging), `cron-parser` (cron expression parsing)
|
|
209
|
-
- **Optional drivers depend on**: `@maestro-js/db` (Db driver), `@maestro-js/cache` (Cache driver)
|
|
268
|
+
Add a dedicated logger to a specific job for fine-grained observability:
|
|
210
269
|
|
|
211
|
-
|
|
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
|
+
}
|
|
212
278
|
|
|
213
|
-
|
|
279
|
+
const criticalJob = RecurringJobs.createJob({
|
|
280
|
+
name: 'payment-reconciliation',
|
|
281
|
+
cron: '0 */4 * * *',
|
|
282
|
+
workFn: async () => reconcilePayments(),
|
|
283
|
+
logger: jobLogger
|
|
284
|
+
})
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### Execution History
|
|
214
288
|
|
|
215
289
|
```ts
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
219
297
|
}
|
|
220
298
|
```
|
|
221
299
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
300
|
+
## Log Events
|
|
301
|
+
|
|
302
|
+
The service emits structured log events of type `RecurringJobs.LogMessage`:
|
|
303
|
+
|
|
304
|
+
| Event | Level | Description |
|
|
305
|
+
|---|---|---|
|
|
306
|
+
| `jobProcessing` | info | A job message was dequeued and is about to execute |
|
|
307
|
+
| `jobSucceeded` | info | The work function completed successfully |
|
|
308
|
+
| `jobFailed` | error | The work function threw an error |
|
|
309
|
+
| `jobScheduled` | info | The next run was enqueued with its scheduled date |
|
|
310
|
+
| `jobAlreadyScheduled` | info | A waiting job already exists; skip enqueueing |
|
|
311
|
+
| `jobSchedulingFailed` | error | Failed to enqueue the next run |
|
|
312
|
+
|
|
313
|
+
## Cross-Package Integration
|
|
314
|
+
|
|
315
|
+
### Dependencies
|
|
316
|
+
|
|
317
|
+
- **@maestro-js/service-registry** -- foundation for the Provider pattern (registry, deferred Proxy resolution)
|
|
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
|
|
322
|
+
|
|
323
|
+
RecurringJobs wraps Queue to add cron scheduling semantics. When `startProcessing()` is called, the next cron-derived date is computed and a queue message is enqueued for that date. When the Queue dequeues and runs the message, the work function fires and the next run is enqueued before the work executes. This ensures schedule continuity even on failure.
|
|
324
|
+
|
|
325
|
+
Queue itself depends on Db (MySQL) for message persistence, so the full dependency chain is:
|
|
326
|
+
|
|
327
|
+
```
|
|
328
|
+
RecurringJobs -> Queue -> Db (MySQL)
|
|
329
|
+
-> Log
|
|
330
|
+
-> ServiceRegistry
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### Provider Pattern
|
|
334
|
+
|
|
335
|
+
This package follows the standard maestro Provider pattern:
|
|
225
336
|
|
|
226
|
-
|
|
337
|
+
1. `RecurringJobs.Provider.create(config)` -- factory that instantiates the service
|
|
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
|
|
227
341
|
|
|
228
|
-
##
|
|
342
|
+
## Notes
|
|
229
343
|
|
|
230
|
-
- `
|
|
231
|
-
- `
|
|
232
|
-
- `
|
|
233
|
-
- `
|
|
344
|
+
- No tests exist for this package yet (`pnpm --filter @maestro-js/recurring-jobs test` prints "No tests yet").
|
|
345
|
+
- The `cron-parser` library validates cron expressions at job creation time; invalid expressions throw immediately.
|
|
346
|
+
- Deduplication: `startProcessing()` checks for an existing waiting message before enqueueing, preventing double-fires across process restarts.
|
|
347
|
+
- The next run is enqueued inside the queue's `workFn` (before the user's `workFn` runs), so the schedule continues even if the current execution fails.
|
|
348
|
+
- Only the next waiting job can be rescheduled via `rescheduleJob()`. Attempting to reschedule a non-waiting or non-next job throws an error.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,167 +1,200 @@
|
|
|
1
1
|
import { Iso } from 'iso-fns2';
|
|
2
|
+
import { Queue } from '@maestro-js/queue';
|
|
2
3
|
import { Log } from '@maestro-js/log';
|
|
3
|
-
import { Cache } from '@maestro-js/cache';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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;
|
|
5
|
+
declare function create(serviceConfig: RecurringJobs.Provider.RecurringJobsServiceConfig): {
|
|
6
|
+
startProcessing: () => void;
|
|
7
|
+
stopProcessing: () => void;
|
|
8
|
+
list: () => string[];
|
|
9
|
+
get: <Result>(name: string) => RecurringJobs.RecurringJob<Result> | undefined;
|
|
10
|
+
createJob: <T>(jobConfig: RecurringJobs.JobConfig<T>) => RecurringJobs.RecurringJob<T>;
|
|
49
11
|
};
|
|
50
|
-
type KeysWithFallback = keyof RecurringJobs.Provider.Keys extends never ? {
|
|
51
|
-
default: unknown;
|
|
52
|
-
} : RecurringJobs.Provider.Keys;
|
|
53
12
|
declare function provider(key: RecurringJobs.Provider.Key): {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
13
|
+
startProcessing: () => void;
|
|
14
|
+
stopProcessing: () => void;
|
|
15
|
+
list: () => string[];
|
|
16
|
+
get: <Result>(name: string) => RecurringJobs.RecurringJob<Result> | undefined;
|
|
17
|
+
createJob: <T>(jobConfig: RecurringJobs.JobConfig<T>) => RecurringJobs.RecurringJob<T>;
|
|
59
18
|
};
|
|
60
19
|
/**
|
|
61
|
-
* Cron-based
|
|
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.
|
|
20
|
+
* Cron-based job scheduling backed by the Queue package.
|
|
67
21
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
22
|
+
* Each recurring job gets its own dedicated queue where every execution is a
|
|
23
|
+
* persisted message with full lifecycle tracking. The next run is enqueued
|
|
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.
|
|
70
27
|
*
|
|
71
28
|
* @example
|
|
72
29
|
* ```ts
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
30
|
+
* // Create a daily sales report job
|
|
31
|
+
* const salesReport = RecurringJobs.createJob({
|
|
32
|
+
* name: 'daily-sales-report',
|
|
33
|
+
* cron: '0 6 * * *',
|
|
34
|
+
* workFn: async () => {
|
|
35
|
+
* const total = await calculateDailySales()
|
|
36
|
+
* return { total }
|
|
37
|
+
* }
|
|
81
38
|
* })
|
|
82
39
|
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
40
|
+
* // Start processing and inspect upcoming runs
|
|
41
|
+
* salesReport.startProcessing()
|
|
42
|
+
* const next = await salesReport.getNextUp() // { status: 'waiting', scheduledDate: '...' }
|
|
43
|
+
*
|
|
44
|
+
* // List all registered jobs
|
|
45
|
+
* RecurringJobs.list() // ['daily-sales-report']
|
|
85
46
|
* ```
|
|
86
47
|
*/
|
|
87
48
|
declare namespace RecurringJobs {
|
|
88
|
-
/**
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}): Promise<boolean>;
|
|
49
|
+
/** A single queue message representing one scheduled or completed job execution */
|
|
50
|
+
interface JobMessage<Result = any> {
|
|
51
|
+
id: number;
|
|
52
|
+
batchId: number | null;
|
|
53
|
+
queue: string;
|
|
54
|
+
scheduledDate: Iso.Instant;
|
|
55
|
+
enqueuedDate: Iso.Instant;
|
|
56
|
+
startedDate: Iso.Instant | null;
|
|
57
|
+
status: 'waiting' | 'processing' | 'success' | 'error' | 'stuck';
|
|
58
|
+
result: Result;
|
|
59
|
+
recovered: number;
|
|
60
|
+
runningTimeSeconds: number | null;
|
|
61
|
+
uniqueKey: number | null;
|
|
62
|
+
priority: number | null;
|
|
63
|
+
cron: string;
|
|
104
64
|
}
|
|
105
|
-
/**
|
|
106
|
-
|
|
107
|
-
|
|
65
|
+
/**
|
|
66
|
+
* A single recurring job instance with its own dedicated queue.
|
|
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 */
|
|
108
92
|
cron: string;
|
|
109
|
-
/**
|
|
110
|
-
|
|
111
|
-
|
|
93
|
+
/** Changes the scheduled time of a waiting job */
|
|
94
|
+
rescheduleJob(job: {
|
|
95
|
+
jobId: number;
|
|
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>;
|
|
112
141
|
name: string;
|
|
113
|
-
|
|
114
|
-
middleware?: Middleware<Result>[];
|
|
142
|
+
logger?: Log.LogFunctions<[LogMessage]>;
|
|
115
143
|
}
|
|
116
144
|
/**
|
|
117
|
-
*
|
|
145
|
+
* Container that manages multiple recurring jobs sharing the same queue service.
|
|
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>`.
|
|
118
150
|
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
151
|
+
* @example
|
|
152
|
+
* ```ts
|
|
153
|
+
* const report = RecurringJobs.createJob({ name: 'daily-report', cron: '0 6 * * *', workFn: generateReport })
|
|
154
|
+
* const cleanup = RecurringJobs.createJob({ name: 'session-cleanup', cron: '0 0 * * *', workFn: cleanSessions })
|
|
155
|
+
* RecurringJobs.startProcessing() // starts both jobs
|
|
156
|
+
* ```
|
|
122
157
|
*/
|
|
123
|
-
interface
|
|
124
|
-
/**
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
|
|
158
|
+
interface RecurringJobsService {
|
|
159
|
+
/** Creates a recurring job with its own dedicated queue */
|
|
160
|
+
createJob<T>(jobConfig: RecurringJobs.JobConfig<T>): RecurringJobs.RecurringJob<T>;
|
|
161
|
+
/** Starts processing on all registered recurring jobs */
|
|
162
|
+
startProcessing: () => void;
|
|
163
|
+
/** Stops processing on all registered recurring jobs */
|
|
164
|
+
stopProcessing: () => void;
|
|
165
|
+
/** Returns the names of all registered recurring jobs */
|
|
166
|
+
list: () => string[];
|
|
167
|
+
/** Returns the recurring job with the given name, or `undefined` if not found */
|
|
168
|
+
get: <Result>(name: string) => RecurringJobs.RecurringJob<Result> | undefined;
|
|
132
169
|
}
|
|
133
170
|
namespace Provider {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
logger
|
|
171
|
+
/** Service-level configuration passed to `RecurringJobs.Provider.create()` */
|
|
172
|
+
interface RecurringJobsServiceConfig {
|
|
173
|
+
logger: Log.Logger<[RecurringJobs.LogMessage]>;
|
|
174
|
+
queueService: Queue.QueueService;
|
|
137
175
|
}
|
|
138
|
-
type Key = keyof
|
|
176
|
+
type Key = keyof Keys;
|
|
139
177
|
interface Keys {
|
|
178
|
+
default: unknown;
|
|
140
179
|
}
|
|
141
180
|
}
|
|
142
181
|
}
|
|
143
182
|
/**
|
|
144
|
-
* Cron-based
|
|
145
|
-
* Call `RecurringJobs.Provider.create()` with a
|
|
146
|
-
* and `RecurringJobs.work()` to start the 60-second polling loop. Drivers: `db`, `cache`, `local`.
|
|
183
|
+
* Cron-based job scheduling backed by the Queue package.
|
|
184
|
+
* Call `RecurringJobs.Provider.create()` with a queue service, register it, then use `RecurringJobs.createJob()` to define jobs.
|
|
147
185
|
*/
|
|
148
186
|
declare const RecurringJobs: {
|
|
149
187
|
Provider: {
|
|
150
|
-
create: typeof
|
|
151
|
-
register: (name: RecurringJobs.Provider.Key, item: RecurringJobs.
|
|
188
|
+
create: typeof create;
|
|
189
|
+
register: (name: RecurringJobs.Provider.Key, item: RecurringJobs.RecurringJobsService) => void;
|
|
152
190
|
list: () => "default"[];
|
|
153
191
|
};
|
|
154
192
|
provider: typeof provider;
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
drivers: {
|
|
161
|
-
db: typeof DbRecurringJobsDriver;
|
|
162
|
-
local: typeof localRecurringJobsDriver;
|
|
163
|
-
cache: typeof cacheRecurringJobsDriver;
|
|
164
|
-
};
|
|
193
|
+
startProcessing: () => void;
|
|
194
|
+
stopProcessing: () => void;
|
|
195
|
+
list: () => string[];
|
|
196
|
+
get: <Result>(name: string) => RecurringJobs.RecurringJob<Result> | undefined;
|
|
197
|
+
createJob: <T>(jobConfig: RecurringJobs.JobConfig<T>) => RecurringJobs.RecurringJob<T>;
|
|
165
198
|
};
|
|
166
199
|
|
|
167
200
|
export { RecurringJobs };
|
package/dist/index.js
CHANGED
|
@@ -1,222 +1,152 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import
|
|
2
|
+
import "iso-fns2";
|
|
3
3
|
import cronParser from "cron-parser";
|
|
4
|
+
import { Log } from "@maestro-js/log";
|
|
4
5
|
import { ServiceRegistry } from "@maestro-js/service-registry";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
function
|
|
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
|
-
lastRunDates.set(jobName, date);
|
|
51
|
-
return true;
|
|
52
|
-
}
|
|
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;
|
|
6
|
+
function create(serviceConfig) {
|
|
7
|
+
const queueService = serviceConfig.queueService;
|
|
8
|
+
const jobs = [];
|
|
9
|
+
function createJob(jobConfig) {
|
|
10
|
+
cronParser.parseExpression(jobConfig.cron);
|
|
11
|
+
const logger = Log.create({
|
|
12
|
+
transports: [serviceConfig.logger, ...jobConfig.logger ? [jobConfig.logger] : []]
|
|
13
|
+
});
|
|
14
|
+
const queue = queueService.create({
|
|
15
|
+
name: `RecurringJobs: ${jobConfig.name}`,
|
|
16
|
+
workFn: async () => {
|
|
17
|
+
await addJobToQueue(getNextScheduledDate());
|
|
18
|
+
return jobConfig.workFn();
|
|
19
|
+
},
|
|
20
|
+
logger: Log.create({
|
|
21
|
+
transports: [
|
|
22
|
+
{
|
|
23
|
+
write({ data: [message] }) {
|
|
24
|
+
if (message.event === "messageDequeued") {
|
|
25
|
+
logger.info({
|
|
26
|
+
event: "jobProcessing",
|
|
27
|
+
job: jobConfig.name,
|
|
28
|
+
driver: message.driver,
|
|
29
|
+
messageId: message.messageId
|
|
30
|
+
});
|
|
31
|
+
} else if (message.event === "messageProcessed") {
|
|
32
|
+
logger.info({
|
|
33
|
+
event: "jobSucceeded",
|
|
34
|
+
job: jobConfig.name,
|
|
35
|
+
driver: message.driver,
|
|
36
|
+
result: message.result,
|
|
37
|
+
messageId: message.messageId
|
|
38
|
+
});
|
|
39
|
+
} else if (message.event === "messageFailed") {
|
|
40
|
+
logger.error({
|
|
41
|
+
event: "jobFailed",
|
|
42
|
+
job: jobConfig.name,
|
|
43
|
+
driver: message.driver,
|
|
44
|
+
error: message.error,
|
|
45
|
+
messageId: message.messageId
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
]
|
|
69
51
|
})
|
|
70
|
-
);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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();
|
|
52
|
+
});
|
|
53
|
+
function getNextScheduledDate() {
|
|
54
|
+
const interval = cronParser.parseExpression(jobConfig.cron);
|
|
55
|
+
return interval.next().toDate().toISOString();
|
|
86
56
|
}
|
|
87
|
-
|
|
88
|
-
|
|
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 });
|
|
57
|
+
async function getNextUp() {
|
|
58
|
+
const messages = await queue.listMessages({ pageNumber: 0, pageSize: 100 });
|
|
59
|
+
const result = messages.find((m) => m.status === "waiting");
|
|
60
|
+
return result ? { ...result, cron: jobConfig.cron } : null;
|
|
127
61
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
lockedJobs.push(job);
|
|
135
|
-
}
|
|
136
|
-
} catch (e) {
|
|
137
|
-
logger?.error({ event: "jobLockFailed", jobName: job.name, nextRun: job.nextRun, error: e });
|
|
138
|
-
}
|
|
62
|
+
async function list2({
|
|
63
|
+
pageNumber,
|
|
64
|
+
pageSize
|
|
65
|
+
}) {
|
|
66
|
+
const messages = await queue.listMessages({ pageNumber, pageSize });
|
|
67
|
+
return messages.map((m) => ({ ...m, cron: jobConfig.cron }));
|
|
139
68
|
}
|
|
140
|
-
|
|
141
|
-
|
|
69
|
+
async function rescheduleJob({ jobId, scheduledDate }) {
|
|
70
|
+
const nextUp = await getNextUp();
|
|
71
|
+
if (!nextUp || nextUp.id !== jobId) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Cannot reschedule job ${jobId}: only the next waiting job can be rescheduled.` + (nextUp ? ` The current waiting job is ${nextUp.id}.` : " No waiting job found.")
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
await queue.updateMessage({ messageId: jobId, scheduledDate, body: {} });
|
|
142
77
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (
|
|
146
|
-
logger
|
|
147
|
-
|
|
148
|
-
jobName: lockedJobs[i].name,
|
|
149
|
-
error: results[i].reason
|
|
150
|
-
});
|
|
151
|
-
} else {
|
|
152
|
-
logger?.info({ event: "jobProcessed", jobName: lockedJobs[i].name });
|
|
78
|
+
async function addJobToQueue(nextRun) {
|
|
79
|
+
const data = await getNextUp();
|
|
80
|
+
if (data) {
|
|
81
|
+
logger.info({ event: "jobAlreadyScheduled", job: jobConfig.name, existingJobId: data.id });
|
|
82
|
+
return false;
|
|
153
83
|
}
|
|
84
|
+
await queue.addMessage({}, { scheduledDate: nextRun, tracingContext: null });
|
|
85
|
+
logger.info({ event: "jobScheduled", job: jobConfig.name, nextRun });
|
|
86
|
+
return true;
|
|
154
87
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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) => {
|
|
88
|
+
function startProcessing2() {
|
|
89
|
+
addJobToQueue(getNextScheduledDate()).catch((error) => {
|
|
90
|
+
logger.error({ event: "jobSchedulingFailed", job: jobConfig.name, error });
|
|
166
91
|
});
|
|
92
|
+
queue.startProcessing();
|
|
167
93
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
async function abort() {
|
|
171
|
-
clearInterval(interval);
|
|
172
|
-
await Promise.all(ticks);
|
|
173
|
-
abortWorkFunctions = abortWorkFunctions.filter((a) => a !== abort);
|
|
94
|
+
function stopProcessing2() {
|
|
95
|
+
queue.stopProcessing();
|
|
174
96
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
97
|
+
const jobFunctions = {
|
|
98
|
+
getNextUp,
|
|
99
|
+
list: list2,
|
|
100
|
+
startProcessing: startProcessing2,
|
|
101
|
+
stopProcessing: stopProcessing2,
|
|
102
|
+
rescheduleJob,
|
|
103
|
+
name: jobConfig.name,
|
|
104
|
+
cron: jobConfig.cron
|
|
178
105
|
};
|
|
106
|
+
jobs.push(jobFunctions);
|
|
107
|
+
return jobFunctions;
|
|
179
108
|
}
|
|
180
|
-
|
|
181
|
-
|
|
109
|
+
function startProcessing() {
|
|
110
|
+
jobs.forEach((job) => job.startProcessing());
|
|
182
111
|
}
|
|
183
|
-
function
|
|
184
|
-
|
|
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);
|
|
112
|
+
function stopProcessing() {
|
|
113
|
+
jobs.forEach((job) => job.stopProcessing());
|
|
189
114
|
}
|
|
190
|
-
|
|
115
|
+
function list() {
|
|
116
|
+
return jobs.map((job) => job.name);
|
|
117
|
+
}
|
|
118
|
+
function get(name) {
|
|
119
|
+
return jobs.find((job) => job.name === name);
|
|
120
|
+
}
|
|
121
|
+
return { startProcessing, stopProcessing, list, get, createJob };
|
|
191
122
|
}
|
|
192
123
|
var registry = ServiceRegistry.createRegistry(
|
|
193
124
|
ServiceRegistry.proxyFunctionsForObject
|
|
194
125
|
);
|
|
195
126
|
var Provider = {
|
|
196
|
-
create
|
|
127
|
+
create,
|
|
197
128
|
register: registry.register,
|
|
198
129
|
list: registry.list
|
|
199
130
|
};
|
|
200
131
|
function provider(key) {
|
|
201
132
|
const service = registry.resolve(key);
|
|
202
133
|
return {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
134
|
+
startProcessing: service.startProcessing,
|
|
135
|
+
stopProcessing: service.stopProcessing,
|
|
136
|
+
list: service.list,
|
|
137
|
+
get: service.get,
|
|
138
|
+
createJob: service.createJob
|
|
206
139
|
};
|
|
207
140
|
}
|
|
208
141
|
var facade = provider("default");
|
|
209
142
|
var RecurringJobs = {
|
|
210
143
|
Provider,
|
|
211
144
|
provider,
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
local: localRecurringJobsDriver,
|
|
218
|
-
cache: cacheRecurringJobsDriver
|
|
219
|
-
}
|
|
145
|
+
startProcessing: facade.startProcessing,
|
|
146
|
+
stopProcessing: facade.stopProcessing,
|
|
147
|
+
list: facade.list,
|
|
148
|
+
get: facade.get,
|
|
149
|
+
createJob: facade.createJob
|
|
220
150
|
};
|
|
221
151
|
export {
|
|
222
152
|
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
|
|
3
|
+
"description": "Use when working with @maestro-js/recurring-jobs. Cron-based job scheduling backed by the Queue package. Covers creating recurring jobs, managing schedules, start/stop processing, rescheduling, listing jobs, and inspecting upcoming runs. Follows the Provider pattern with ServiceRegistry. Key capabilities include cron expression parsing, deduplication across process restarts, per-job dedicated queues, structured log events, and paginated execution history.",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -11,20 +11,14 @@
|
|
|
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/
|
|
15
|
-
|
|
16
|
-
"peerDependencies": {
|
|
17
|
-
"@maestro-js/cache": "1.0.0-alpha.18",
|
|
18
|
-
"@maestro-js/log": "1.0.0-alpha.18"
|
|
14
|
+
"@maestro-js/log": "1.0.0-alpha.2",
|
|
15
|
+
"@maestro-js/service-registry": "1.0.0-alpha.2"
|
|
19
16
|
},
|
|
20
17
|
"devDependencies": {
|
|
21
18
|
"@types/node": "^22.19.11",
|
|
22
|
-
"@maestro-js/
|
|
23
|
-
"@maestro-js/log": "1.0.0-alpha.18",
|
|
24
|
-
"@maestro-js/cache": "1.0.0-alpha.18",
|
|
25
|
-
"@maestro-js/queue": "1.0.0-alpha.18"
|
|
19
|
+
"@maestro-js/queue": "1.0.0-alpha.2"
|
|
26
20
|
},
|
|
27
|
-
"version": "1.0.0-alpha.
|
|
21
|
+
"version": "1.0.0-alpha.2",
|
|
28
22
|
"publishConfig": {
|
|
29
23
|
"access": "restricted"
|
|
30
24
|
},
|
|
@@ -39,7 +33,7 @@
|
|
|
39
33
|
"scripts": {
|
|
40
34
|
"build": "tsup --config ../../tsup.config.ts",
|
|
41
35
|
"typecheck": "tsc --noEmit",
|
|
42
|
-
"test": "
|
|
36
|
+
"test": "echo 'No tests yet'",
|
|
43
37
|
"format": "prettier --write src/",
|
|
44
38
|
"lint": "prettier --check src/"
|
|
45
39
|
}
|