@cocreate/cron-jobs 1.4.3 → 1.4.4
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 +367 -47
- package/package.json +2 -5
- package/src/index.js +362 -135
package/README.md
CHANGED
|
@@ -1,91 +1,411 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @cocreate/cron-jobs
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A high-performance, multi-tenant, zero-dependency background cron engine and task scheduler built for distributed cluster orchestration and automated event execution across the CoCreate ecosystem.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-

|
|
7
|
-

|
|
8
|
-

|
|
9
|
-

|
|
10
|
-

|
|
5
|
+
---
|
|
11
6
|
|
|
12
|
-
|
|
7
|
+
## Documentation
|
|
13
8
|
|
|
14
|
-
|
|
9
|
+
For complete API references, deployment guides, scheduling examples, and architecture documentation, visit the CoCreate Cron Jobs documentation:
|
|
15
10
|
|
|
16
|
-
|
|
11
|
+
https://cocreatejs.com/docs/cron-jobs
|
|
17
12
|
|
|
18
|
-
|
|
13
|
+
---
|
|
19
14
|
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
## Table of Contents
|
|
16
|
+
|
|
17
|
+
- [Overview](#overview)
|
|
18
|
+
- [Key Features](#key-features)
|
|
19
|
+
- [Architecture & Execution Pipeline](#architecture--execution-pipeline)
|
|
20
|
+
- [Installation](#installation)
|
|
21
|
+
- [Quick Start](#quick-start)
|
|
22
|
+
- [Schedule Configuration & Formats](#schedule-configuration--formats)
|
|
23
|
+
- [Standard 5-Part Cron Expressions](#1-standard-5-part-cron-expressions)
|
|
24
|
+
- [Rich JSON Schedule Objects](#2-rich-json-schedule-objects)
|
|
25
|
+
- [Supported Schedule Attributes](#supported-schedule-attributes)
|
|
26
|
+
- [Cron Expression Parsing Engine](#cron-expression-parsing-engine)
|
|
27
|
+
- [Cluster Coordination & Job Assignment](#cluster-coordination--job-assignment)
|
|
28
|
+
- [Programmatic API Reference](#programmatic-api-reference)
|
|
29
|
+
- [Database Integration & CRUD Events](#database-integration--crud-events)
|
|
30
|
+
- [Environment Variables](#environment-variables)
|
|
31
|
+
- [Announcements](#announcements)
|
|
32
|
+
- [Roadmap](#roadmap)
|
|
33
|
+
- [How to Contribute](#how-to-contribute)
|
|
34
|
+
- [About](#about)
|
|
35
|
+
- [License](#license)
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
# Overview
|
|
40
|
+
|
|
41
|
+
`@cocreate/cron-jobs` delivers an enterprise-grade background scheduling engine for `@cocreate/server`.
|
|
42
|
+
|
|
43
|
+
It supports both traditional 5-part Unix cron expressions and rich JSON schedule objects with advanced scheduling capabilities including time zones, skip dates, end-of-month execution, date ranges, and human-readable scheduling.
|
|
44
|
+
|
|
45
|
+
Built as a native, zero-dependency service, it combines real-time CRUD event listeners with a resilient polling engine to coordinate scheduled jobs safely across multi-server and multi-worker deployments using atomic job assignment.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
# Key Features
|
|
50
|
+
|
|
51
|
+
- **Zero Dependencies** — Built entirely with native Node.js APIs including `Date`, `Intl.DateTimeFormat`, and standard language features.
|
|
52
|
+
- **Dual Scheduling Syntax** — Supports traditional cron expressions and structured JSON schedule objects.
|
|
53
|
+
- **UTC-First Scheduling** — Performs scheduling calculations in UTC while supporting localized execution using IANA time zones.
|
|
54
|
+
- **End-of-Month Support** — Supports `"L"` and `"end-of-month"` keywords, including leap-year awareness.
|
|
55
|
+
- **Hybrid Scheduler** — Combines database polling with real-time CRUD event listeners for reliable execution.
|
|
56
|
+
- **Cluster Safe** — Coordinates execution using `organization_id`, `clusterId`, `serverId`, and `workerId` ownership to prevent duplicate execution.
|
|
57
|
+
- **Integrated Action Routing** — Executes internal server actions, webhooks, or WebSocket broadcasts through the CoCreate platform.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
# Architecture & Execution Pipeline
|
|
62
|
+
|
|
63
|
+
The scheduler continuously polls the platform database looking for jobs scheduled within the next five-minute execution window. Eligible jobs are atomically claimed before being scheduled in memory using precise `setTimeout()` timers.
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
Database / CRUD Event
|
|
67
|
+
│
|
|
68
|
+
▼
|
|
69
|
+
Evaluate Next UTC Execution
|
|
70
|
+
│
|
|
71
|
+
▼
|
|
72
|
+
Is execution within 5 minutes?
|
|
73
|
+
│
|
|
74
|
+
┌────┴────┐
|
|
75
|
+
│ │
|
|
76
|
+
Yes No
|
|
77
|
+
│ │
|
|
78
|
+
Claim Job Persist
|
|
79
|
+
Schedule State
|
|
80
|
+
setTimeout()
|
|
22
81
|
```
|
|
23
82
|
|
|
24
|
-
|
|
25
|
-
|
|
83
|
+
When a scheduled timer executes:
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
Cron Job Triggered
|
|
87
|
+
│
|
|
88
|
+
▼
|
|
89
|
+
Execute Action
|
|
90
|
+
(Webhook / WS / Internal Action)
|
|
91
|
+
│
|
|
92
|
+
▼
|
|
93
|
+
Calculate Next Execution Time
|
|
94
|
+
│
|
|
95
|
+
┌────┴────┐
|
|
96
|
+
│ │
|
|
97
|
+
Next Run Expired
|
|
98
|
+
│ │
|
|
99
|
+
Scheduled Completed
|
|
26
100
|
```
|
|
27
101
|
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
# Installation
|
|
105
|
+
|
|
28
106
|
## NPM
|
|
29
107
|
|
|
30
|
-
```
|
|
31
|
-
|
|
108
|
+
```bash
|
|
109
|
+
npm install @cocreate/cron-jobs
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Yarn
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
yarn add @cocreate/cron-jobs
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
> **Note**
|
|
119
|
+
>
|
|
120
|
+
> When used alongside `@cocreate/server`, the cron service initializes automatically.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
# Quick Start
|
|
125
|
+
|
|
126
|
+
## Calculate the Next Execution Time
|
|
127
|
+
|
|
128
|
+
```javascript
|
|
129
|
+
import cronJobs from "@cocreate/cron-jobs";
|
|
130
|
+
|
|
131
|
+
const nextRun = cronJobs.getNextExecutionTime({
|
|
132
|
+
cronExpression: "0 8 * * 1-5",
|
|
133
|
+
timezone: "UTC"
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
console.log(nextRun);
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Create a Scheduled Job
|
|
140
|
+
|
|
141
|
+
```javascript
|
|
142
|
+
await crud.send({
|
|
143
|
+
method: "object.create",
|
|
144
|
+
array: "cron-job",
|
|
145
|
+
organization_id: "org_102938",
|
|
146
|
+
object: [
|
|
147
|
+
{
|
|
148
|
+
organization_id: "org_102938",
|
|
149
|
+
action: "notifications.sendDigest",
|
|
150
|
+
active: true,
|
|
151
|
+
schedule: {
|
|
152
|
+
time: "09:00:00",
|
|
153
|
+
daysOfWeek: ["Monday", "Wednesday", "Friday"],
|
|
154
|
+
skipDates: ["2026-12-25"],
|
|
155
|
+
timezone: "America/New_York"
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
]
|
|
159
|
+
});
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
# Schedule Configuration & Formats
|
|
165
|
+
|
|
166
|
+
The scheduler accepts either a standard cron expression or a structured JSON schedule object.
|
|
167
|
+
|
|
168
|
+
## 1. Standard 5-Part Cron Expressions
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
{
|
|
172
|
+
"cronExpression": "*/15 8-17 * * MON-FRI",
|
|
173
|
+
"startTime": "2026-01-01T00:00:00.000Z",
|
|
174
|
+
"endTime": "2026-12-31T23:59:59.000Z",
|
|
175
|
+
"skipDates": ["2026-07-04"]
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## 2. Rich JSON Schedule Objects
|
|
182
|
+
|
|
183
|
+
```json
|
|
184
|
+
{
|
|
185
|
+
"startTime": "2026-01-01T00:00:00.000Z",
|
|
186
|
+
"endTime": "2026-12-31T23:59:59.000Z",
|
|
187
|
+
"time": "14:30:00",
|
|
188
|
+
"daysOfWeek": ["Tuesday", "Thursday"],
|
|
189
|
+
"daysOfMonth": [1, 15, "L"],
|
|
190
|
+
"months": ["January", "June", "December"],
|
|
191
|
+
"skipDates": ["2026-11-26"],
|
|
192
|
+
"timezone": "UTC"
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## Supported Schedule Attributes
|
|
199
|
+
|
|
200
|
+
| Property | Type | Description |
|
|
201
|
+
|----------|------|-------------|
|
|
202
|
+
| `cronExpression` | String | Standard 5-part cron expression. |
|
|
203
|
+
| `startTime` | String | Earliest execution time. |
|
|
204
|
+
| `endTime` | String | Expiration time. |
|
|
205
|
+
| `time` | String | Time of day (`HH:MM` or `HH:MM:SS`). |
|
|
206
|
+
| `daysOfWeek` | Array | Weekday names. |
|
|
207
|
+
| `daysOfMonth` | Array | Month days including `"L"` or `"end-of-month"`. |
|
|
208
|
+
| `endOfMonth` | Boolean | Execute only on the last day of the month. |
|
|
209
|
+
| `months` | Array | Month names. |
|
|
210
|
+
| `skipDates` | Array | ISO dates excluded from execution. |
|
|
211
|
+
| `timezone` | String | IANA timezone identifier. Defaults to `UTC`. |
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
# Cron Expression Parsing Engine
|
|
216
|
+
|
|
217
|
+
Supported fields:
|
|
218
|
+
|
|
219
|
+
```text
|
|
220
|
+
Minute Hour Day-of-Month Month Day-of-Week
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Supported syntax:
|
|
224
|
+
|
|
225
|
+
| Syntax | Example | Description |
|
|
226
|
+
|---------|---------|-------------|
|
|
227
|
+
| Wildcard | `*` | Every value |
|
|
228
|
+
| Value | `15` | Specific value |
|
|
229
|
+
| List | `1,15,30` | Multiple values |
|
|
230
|
+
| Range | `1-5` | Inclusive range |
|
|
231
|
+
| Step | `*/15` | Every N values |
|
|
232
|
+
| Aliases | `JAN`, `MON` | Month and weekday aliases |
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
# Cluster Coordination & Job Assignment
|
|
237
|
+
|
|
238
|
+
To prevent duplicate execution, scheduled jobs are atomically assigned to a server and worker before execution.
|
|
239
|
+
|
|
240
|
+
Example assignment:
|
|
241
|
+
|
|
242
|
+
```json
|
|
243
|
+
{
|
|
244
|
+
"status": "assigned",
|
|
245
|
+
"clusterId": "default-cluster",
|
|
246
|
+
"serverId": "srv_instance_84f92a",
|
|
247
|
+
"workerId": 2,
|
|
248
|
+
"organization_id": "org_30192"
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
If a worker exits before execution completes, polling automatically detects stale assignments and safely reassigns work to another available worker.
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
# Programmatic API Reference
|
|
257
|
+
|
|
258
|
+
## getNextExecutionTime(schedule)
|
|
259
|
+
|
|
260
|
+
Calculates the next execution time.
|
|
261
|
+
|
|
262
|
+
```javascript
|
|
263
|
+
import cronJobs from "@cocreate/cron-jobs";
|
|
264
|
+
|
|
265
|
+
const next = cronJobs.getNextExecutionTime({
|
|
266
|
+
cronExpression: "0 0 * * *"
|
|
267
|
+
});
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## scheduleCronJob(job)
|
|
273
|
+
|
|
274
|
+
Schedules a job in memory.
|
|
275
|
+
|
|
276
|
+
```javascript
|
|
277
|
+
cronJobs.scheduleCronJob({
|
|
278
|
+
_id: "job_88a91b",
|
|
279
|
+
organization_id: "org_102",
|
|
280
|
+
nextExecutionTime: "2026-08-01T12:00:00.000Z",
|
|
281
|
+
schedule: {}
|
|
282
|
+
});
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## executeCronJob(job)
|
|
288
|
+
|
|
289
|
+
Immediately executes a scheduled job.
|
|
290
|
+
|
|
291
|
+
```javascript
|
|
292
|
+
await cronJobs.executeCronJob(jobObject);
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## pollForCronJobs()
|
|
298
|
+
|
|
299
|
+
Scans the database for pending work.
|
|
300
|
+
|
|
301
|
+
```javascript
|
|
302
|
+
await cronJobs.pollForCronJobs();
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## handleCrudEvent(data)
|
|
308
|
+
|
|
309
|
+
Updates in-memory schedules after CRUD operations.
|
|
310
|
+
|
|
311
|
+
```javascript
|
|
312
|
+
process.emit("crud-event", {
|
|
313
|
+
array: "cron-job",
|
|
314
|
+
method: "object.update",
|
|
315
|
+
object: [updatedJob]
|
|
316
|
+
});
|
|
32
317
|
```
|
|
33
318
|
|
|
34
|
-
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
# Database Integration & CRUD Events
|
|
35
322
|
|
|
36
|
-
|
|
37
|
-
|
|
323
|
+
`@cocreate/cron-jobs` automatically listens for platform CRUD events using:
|
|
324
|
+
|
|
325
|
+
```javascript
|
|
326
|
+
process.on("crud-event");
|
|
38
327
|
```
|
|
39
328
|
|
|
40
|
-
|
|
329
|
+
### Supported Events
|
|
330
|
+
|
|
331
|
+
| Event | Behavior |
|
|
332
|
+
|--------|----------|
|
|
333
|
+
| `object.create` | Calculates next execution time and schedules the job. |
|
|
334
|
+
| `object.update` | Recalculates the schedule and updates timers. |
|
|
335
|
+
| `object.delete` | Cancels timers and releases resources. |
|
|
336
|
+
| `active: false` | Stops scheduling and marks the job inactive. |
|
|
337
|
+
|
|
338
|
+
Organization updates also synchronize job summaries into the parent `organizations` collection.
|
|
41
339
|
|
|
42
|
-
|
|
43
|
-
- [Announcements](#announcements)
|
|
44
|
-
- [Roadmap](#roadmap)
|
|
45
|
-
- [How to Contribute](#how-to-contribute)
|
|
46
|
-
- [About](#about)
|
|
47
|
-
- [License](#license)
|
|
340
|
+
---
|
|
48
341
|
|
|
49
|
-
|
|
342
|
+
# Environment Variables
|
|
343
|
+
|
|
344
|
+
| Variable | Default | Description |
|
|
345
|
+
|----------|----------|-------------|
|
|
346
|
+
| `organization_id` | — | Platform administrative organization. |
|
|
347
|
+
| `SERVER_ID` | `default-server` | Unique server identifier. |
|
|
348
|
+
| `WORKER_ID` | `standalone` | Worker process identifier. |
|
|
349
|
+
|
|
350
|
+
---
|
|
50
351
|
|
|
51
352
|
# Announcements
|
|
52
353
|
|
|
53
|
-
|
|
354
|
+
Release notes, performance improvements, scheduling enhancements, and compatibility updates are available on the GitHub Releases page:
|
|
355
|
+
|
|
356
|
+
https://github.com/CoCreate-app/CoCreate-cron-jobs/releases
|
|
54
357
|
|
|
55
|
-
|
|
358
|
+
---
|
|
56
359
|
|
|
57
360
|
# Roadmap
|
|
58
361
|
|
|
59
|
-
|
|
362
|
+
Upcoming improvements include:
|
|
60
363
|
|
|
61
|
-
|
|
364
|
+
- Runtime AST execution rules
|
|
365
|
+
- Distributed lock providers
|
|
366
|
+
- Built-in execution telemetry
|
|
367
|
+
- Retry policies with exponential backoff
|
|
368
|
+
- Enhanced monitoring and reporting
|
|
62
369
|
|
|
63
|
-
|
|
370
|
+
---
|
|
64
371
|
|
|
65
|
-
|
|
372
|
+
# How to Contribute
|
|
66
373
|
|
|
67
|
-
|
|
374
|
+
We welcome bug reports, feature requests, documentation improvements, and pull requests.
|
|
68
375
|
|
|
69
|
-
|
|
376
|
+
Please review the contributing guide before submitting changes:
|
|
70
377
|
|
|
71
|
-
|
|
378
|
+
- **Contributing Guide:** https://github.com/CoCreate-app/CoCreate-cron-jobs/blob/master/CONTRIBUTING.md
|
|
379
|
+
- **Issue Tracker:** https://github.com/CoCreate-app/CoCreate-cron-jobs/issues
|
|
380
|
+
- **Discussions:** https://github.com/CoCreate-app/CoCreate-cron-jobs/discussions
|
|
72
381
|
|
|
73
|
-
|
|
382
|
+
---
|
|
74
383
|
|
|
75
|
-
|
|
384
|
+
# About
|
|
385
|
+
|
|
386
|
+
`@cocreate/cron-jobs` is designed, built, and maintained by the CoCreate Developer Experience Team.
|
|
387
|
+
|
|
388
|
+
It provides the distributed scheduling and recurring task execution backbone for the CoCreate platform.
|
|
76
389
|
|
|
77
|
-
|
|
390
|
+
Learn more:
|
|
78
391
|
|
|
79
|
-
|
|
392
|
+
- Documentation: https://cocreatejs.com/docs/cron-jobs
|
|
393
|
+
- Website: https://cocreatejs.com
|
|
80
394
|
|
|
81
|
-
|
|
395
|
+
---
|
|
82
396
|
|
|
83
397
|
# License
|
|
84
398
|
|
|
85
|
-
This software is dual-licensed under the GNU Affero General Public License
|
|
399
|
+
This software is dual-licensed under the GNU Affero General Public License v3 (AGPLv3) and a commercial license.
|
|
400
|
+
|
|
401
|
+
### Open Source
|
|
402
|
+
|
|
403
|
+
For open-source and non-commercial projects, this software is available under the AGPLv3.
|
|
404
|
+
|
|
405
|
+
See the `LICENSE` file for details.
|
|
86
406
|
|
|
87
|
-
|
|
407
|
+
### Commercial
|
|
88
408
|
|
|
89
|
-
|
|
409
|
+
Organizations requiring proprietary use without AGPL obligations may obtain a commercial license from CoCreate.
|
|
90
410
|
|
|
91
|
-
|
|
411
|
+
https://cocreatejs.com/licenses
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cocreate/cron-jobs",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.4",
|
|
4
4
|
"description": "A simple cron-jobs component in vanilla javascript. Easily configured using HTML5 data-attributes and/or JavaScript API.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cron-jobs",
|
|
@@ -35,8 +35,5 @@
|
|
|
35
35
|
"url": "https://github.com/sponsors/CoCreate-app"
|
|
36
36
|
},
|
|
37
37
|
"type": "module",
|
|
38
|
-
"main": "./src/index.js"
|
|
39
|
-
"dependencies": {
|
|
40
|
-
"cron-parser": "^4.9.0"
|
|
41
|
-
}
|
|
38
|
+
"main": "./src/index.js"
|
|
42
39
|
}
|
package/src/index.js
CHANGED
|
@@ -21,87 +21,299 @@
|
|
|
21
21
|
// you must obtain a commercial license from CoCreate LLC.
|
|
22
22
|
// For details, visit <https://cocreate.app/licenses/> or contact us at sales@cocreate.app.
|
|
23
23
|
|
|
24
|
-
import cronParser from 'cron-parser';
|
|
25
24
|
import server from '@cocreate/server';
|
|
26
25
|
|
|
27
|
-
// ==========================================
|
|
28
|
-
// Module-Level Sandbox State (ESM Singleton)
|
|
29
|
-
// ==========================================
|
|
30
26
|
let crud = null;
|
|
31
27
|
let wsManager = null;
|
|
32
28
|
const scheduledJobs = {};
|
|
33
29
|
|
|
34
30
|
/**
|
|
35
|
-
*
|
|
36
|
-
* Takes into account cronExpression, startTime, skipDates, time, daysOfWeek, daysOfMonth, months, endTime, etc.
|
|
37
|
-
* @param {Object} schedule - The schedule object containing cronExpression, startTime, etc.
|
|
38
|
-
* @returns {string|null} nextExecutionTime - Calculated execution timestamp in ISO format, or null if endTime has passed.
|
|
31
|
+
* Safe helper to retrieve localized or UTC date part names natively.
|
|
39
32
|
*/
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
|
|
33
|
+
function getFormattedDatePart(date, type, timezone = 'UTC') {
|
|
34
|
+
return date.toLocaleString('en-US', { [type]: 'long', timeZone: timezone });
|
|
35
|
+
}
|
|
43
36
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Calculates the last day of the month for any given UTC year/month.
|
|
39
|
+
*/
|
|
40
|
+
function getLastDayOfMonthUTC(year, monthIndex) {
|
|
41
|
+
// Setting day to 0 of monthIndex + 1 yields the last day of monthIndex
|
|
42
|
+
return new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Custom 5-Part Cron Field Parser (Zero external dependencies).
|
|
47
|
+
* Handles ranges (1-5), lists (1,2,3), wildcards (*), step values (* /15, 1-30/5), and string aliases.
|
|
48
|
+
*/
|
|
49
|
+
function parseCronField(field, min, max, nameMap = {}) {
|
|
50
|
+
field = field.trim().toUpperCase();
|
|
51
|
+
|
|
52
|
+
// Replace string aliases if present (e.g., JAN-DEC or SUN-SAT)
|
|
53
|
+
for (const [name, val] of Object.entries(nameMap)) {
|
|
54
|
+
field = field.replace(new RegExp(name, 'g'), val);
|
|
47
55
|
}
|
|
48
56
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
57
|
+
const allowed = new Set();
|
|
58
|
+
const parts = field.split(',');
|
|
59
|
+
|
|
60
|
+
for (const part of parts) {
|
|
61
|
+
if (part.includes('/')) {
|
|
62
|
+
const [rangeStr, stepStr] = part.split('/');
|
|
63
|
+
const step = parseInt(stepStr, 10) || 1;
|
|
64
|
+
let start = min;
|
|
65
|
+
let end = max;
|
|
66
|
+
|
|
67
|
+
if (rangeStr !== '*') {
|
|
68
|
+
if (rangeStr.includes('-')) {
|
|
69
|
+
const [s, e] = rangeStr.split('-').map(Number);
|
|
70
|
+
start = s;
|
|
71
|
+
end = e;
|
|
72
|
+
} else {
|
|
73
|
+
start = parseInt(rangeStr, 10);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (let i = start; i <= end; i += step) {
|
|
77
|
+
if (i >= min && i <= max) allowed.add(i);
|
|
78
|
+
}
|
|
79
|
+
} else if (part.includes('-')) {
|
|
80
|
+
const [start, end] = part.split('-').map(Number);
|
|
81
|
+
for (let i = start; i <= end; i++) {
|
|
82
|
+
if (i >= min && i <= max) allowed.add(i);
|
|
83
|
+
}
|
|
84
|
+
} else if (part === '*') {
|
|
85
|
+
for (let i = min; i <= max; i++) allowed.add(i);
|
|
86
|
+
} else {
|
|
87
|
+
const val = parseInt(part, 10);
|
|
88
|
+
if (!isNaN(val) && val >= min && val <= max) {
|
|
89
|
+
allowed.add(val);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
52
92
|
}
|
|
93
|
+
return allowed;
|
|
94
|
+
}
|
|
53
95
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Tokenizes standard 5-part cron expressions into match sets.
|
|
98
|
+
*/
|
|
99
|
+
function parseCronExpression(expression) {
|
|
100
|
+
if (!expression || typeof expression !== 'string') return null;
|
|
101
|
+
const tokens = expression.trim().split(/\s+/);
|
|
102
|
+
if (tokens.length !== 5) return null;
|
|
103
|
+
|
|
104
|
+
const monthNames = { JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6, JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12 };
|
|
105
|
+
const dayNames = { SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6 };
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
minutes: parseCronField(tokens[0], 0, 59),
|
|
109
|
+
hours: parseCronField(tokens[1], 0, 23),
|
|
110
|
+
daysOfMonth: parseCronField(tokens[2], 1, 31),
|
|
111
|
+
months: parseCronField(tokens[3], 1, 12, monthNames),
|
|
112
|
+
daysOfWeek: parseCronField(tokens[4], 0, 6, dayNames)
|
|
113
|
+
};
|
|
114
|
+
}
|
|
57
115
|
|
|
58
|
-
|
|
59
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Checks if a specific UTC date instance matches a parsed 5-part cron object.
|
|
118
|
+
*/
|
|
119
|
+
function matchesCron(parsedCron, date) {
|
|
120
|
+
const month = date.getUTCMonth() + 1; // 1-12
|
|
121
|
+
const dayOfMonth = date.getUTCDate(); // 1-31
|
|
122
|
+
const dayOfWeek = date.getUTCDay(); // 0-6
|
|
123
|
+
const hours = date.getUTCHours();
|
|
124
|
+
const minutes = date.getUTCMinutes();
|
|
125
|
+
|
|
126
|
+
if (!parsedCron.minutes.has(minutes)) return false;
|
|
127
|
+
if (!parsedCron.hours.has(hours)) return false;
|
|
128
|
+
if (!parsedCron.months.has(month)) return false;
|
|
129
|
+
|
|
130
|
+
const domHasAll = parsedCron.daysOfMonth.size === 31;
|
|
131
|
+
const dowHasAll = parsedCron.daysOfWeek.size === 7;
|
|
132
|
+
|
|
133
|
+
// Standard Cron rule: If both DOM and DOW are specified (neither is *), either matching triggers execution (OR logic).
|
|
134
|
+
if (!domHasAll && !dowHasAll) {
|
|
135
|
+
if (!parsedCron.daysOfMonth.has(dayOfMonth) && !parsedCron.daysOfWeek.has(dayOfWeek)) {
|
|
136
|
+
return false;
|
|
60
137
|
}
|
|
138
|
+
} else {
|
|
139
|
+
if (!parsedCron.daysOfMonth.has(dayOfMonth)) return false;
|
|
140
|
+
if (!parsedCron.daysOfWeek.has(dayOfWeek)) return false;
|
|
61
141
|
}
|
|
62
142
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Calculates the next execution time for standard 5-part cron strings natively.
|
|
148
|
+
*/
|
|
149
|
+
function getNextCronExecution(cronExpression, startFromDate, skipDates = []) {
|
|
150
|
+
const parsed = parseCronExpression(cronExpression);
|
|
151
|
+
if (!parsed) return null;
|
|
152
|
+
|
|
153
|
+
let current = new Date(startFromDate.getTime());
|
|
154
|
+
current.setUTCSeconds(0, 0);
|
|
155
|
+
current.setUTCMinutes(current.getUTCMinutes() + 1); // Step forward to next minute
|
|
156
|
+
|
|
157
|
+
const maxSearchMinutes = 525600; // 1-year upper bound protection
|
|
158
|
+
let counter = 0;
|
|
159
|
+
|
|
160
|
+
while (counter < maxSearchMinutes) {
|
|
161
|
+
counter++;
|
|
162
|
+
const isoDateStr = current.toISOString().split('T')[0];
|
|
163
|
+
|
|
164
|
+
// Evaluate skipDates
|
|
165
|
+
if (skipDates && skipDates.includes(isoDateStr)) {
|
|
166
|
+
current.setUTCDate(current.getUTCDate() + 1);
|
|
167
|
+
current.setUTCHours(0, 0, 0, 0);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (matchesCron(parsed, current)) {
|
|
172
|
+
return current.toISOString();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Fast-forward month mismatch
|
|
176
|
+
const month = current.getUTCMonth() + 1;
|
|
177
|
+
if (!parsed.months.has(month)) {
|
|
178
|
+
current.setUTCMonth(current.getUTCMonth() + 1);
|
|
179
|
+
current.setUTCDate(1);
|
|
180
|
+
current.setUTCHours(0, 0, 0, 0);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Fast-forward day mismatch if DOW is wildcard
|
|
185
|
+
const dom = current.getUTCDate();
|
|
186
|
+
if (parsed.daysOfWeek.size === 7 && !parsed.daysOfMonth.has(dom)) {
|
|
187
|
+
current.setUTCDate(current.getUTCDate() + 1);
|
|
188
|
+
current.setUTCHours(0, 0, 0, 0);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Fast-forward hour mismatch
|
|
193
|
+
const hour = current.getUTCHours();
|
|
194
|
+
if (!parsed.hours.has(hour)) {
|
|
195
|
+
current.setUTCHours(current.getUTCHours() + 1);
|
|
196
|
+
current.setUTCMinutes(0, 0, 0);
|
|
197
|
+
continue;
|
|
67
198
|
}
|
|
199
|
+
|
|
200
|
+
current.setUTCMinutes(current.getUTCMinutes() + 1);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Parses the schedule object and calculates the next execution time natively in UTC.
|
|
208
|
+
* Supports cronExpression, startTime, skipDates, time, daysOfWeek, daysOfMonth, endOfMonth, months, endTime, etc.
|
|
209
|
+
* @param {Object} schedule - The schedule object containing custom fields or cronExpression.
|
|
210
|
+
* @returns {string|null} nextExecutionTime - Calculated ISO timestamp in UTC, or null if endTime has passed.
|
|
211
|
+
*/
|
|
212
|
+
function getNextExecutionTime(schedule) {
|
|
213
|
+
if (!schedule) return null;
|
|
214
|
+
|
|
215
|
+
const now = new Date();
|
|
216
|
+
let currentDateTime = new Date();
|
|
217
|
+
|
|
218
|
+
// 1. Establish UTC start baseline
|
|
219
|
+
if (schedule.startTime && new Date(schedule.startTime) > now) {
|
|
220
|
+
currentDateTime = new Date(schedule.startTime);
|
|
68
221
|
}
|
|
69
222
|
|
|
70
|
-
|
|
71
|
-
if (schedule.months) {
|
|
72
|
-
const monthName = currentDateTime.toLocaleString('en-US', { month: 'long', timeZone: schedule.timezone || 'UTC' });
|
|
223
|
+
const timezone = schedule.timezone || 'UTC';
|
|
73
224
|
|
|
74
|
-
|
|
75
|
-
|
|
225
|
+
// 2. If standard 5-part cron expression exists, use native parser
|
|
226
|
+
if (schedule.cronExpression) {
|
|
227
|
+
const nextCronRun = getNextCronExecution(schedule.cronExpression, currentDateTime, schedule.skipDates);
|
|
228
|
+
if (nextCronRun && schedule.endTime && new Date(nextCronRun) > new Date(schedule.endTime)) {
|
|
229
|
+
return null;
|
|
76
230
|
}
|
|
231
|
+
return nextCronRun;
|
|
77
232
|
}
|
|
78
233
|
|
|
79
|
-
//
|
|
234
|
+
// 3. Object-based custom schedule evaluation with safety bounds
|
|
235
|
+
let safetyCounter = 0;
|
|
236
|
+
const maxSafetyIterations = 1000;
|
|
237
|
+
|
|
238
|
+
// Apply time of day if set (formatted HH:MM or HH:MM:SS)
|
|
80
239
|
if (schedule.time) {
|
|
81
|
-
const [hours, minutes, seconds] = schedule.time.split(':');
|
|
82
|
-
currentDateTime.
|
|
83
|
-
}
|
|
240
|
+
const [hours, minutes, seconds] = schedule.time.split(':').map(Number);
|
|
241
|
+
currentDateTime.setUTCHours(hours || 0, minutes || 0, seconds || 0, 0);
|
|
84
242
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
243
|
+
if (currentDateTime < now) {
|
|
244
|
+
currentDateTime.setUTCDate(currentDateTime.getUTCDate() + 1);
|
|
245
|
+
}
|
|
88
246
|
}
|
|
89
247
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
248
|
+
while (safetyCounter < maxSafetyIterations) {
|
|
249
|
+
safetyCounter++;
|
|
250
|
+
|
|
251
|
+
const isoDateStr = currentDateTime.toISOString().split('T')[0];
|
|
252
|
+
|
|
253
|
+
// Check skipDates
|
|
254
|
+
if (schedule.skipDates && schedule.skipDates.includes(isoDateStr)) {
|
|
255
|
+
currentDateTime.setUTCDate(currentDateTime.getUTCDate() + 1);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Check months
|
|
260
|
+
if (schedule.months && schedule.months.length > 0) {
|
|
261
|
+
const currentMonth = getFormattedDatePart(currentDateTime, 'month', timezone);
|
|
262
|
+
if (!schedule.months.includes(currentMonth)) {
|
|
263
|
+
currentDateTime.setUTCMonth(currentDateTime.getUTCMonth() + 1);
|
|
264
|
+
currentDateTime.setUTCDate(1);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Check daysOfMonth and end-of-month / "L" keyword
|
|
270
|
+
if (schedule.daysOfMonth && schedule.daysOfMonth.length > 0) {
|
|
271
|
+
const currentDom = currentDateTime.getUTCDate();
|
|
272
|
+
const year = currentDateTime.getUTCFullYear();
|
|
273
|
+
const monthIdx = currentDateTime.getUTCMonth();
|
|
274
|
+
const lastDay = getLastDayOfMonthUTC(year, monthIdx);
|
|
275
|
+
|
|
276
|
+
const isAllowedDom = schedule.daysOfMonth.some((day) => {
|
|
277
|
+
if (day === 'L' || day === 'end-of-month') return currentDom === lastDay;
|
|
278
|
+
return Number(day) === currentDom;
|
|
96
279
|
});
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
280
|
+
|
|
281
|
+
if (!isAllowedDom) {
|
|
282
|
+
currentDateTime.setUTCDate(currentDateTime.getUTCDate() + 1);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
} else if (schedule.endOfMonth) {
|
|
286
|
+
// Direct object property for end-of-month scheduling
|
|
287
|
+
const currentDom = currentDateTime.getUTCDate();
|
|
288
|
+
const year = currentDateTime.getUTCFullYear();
|
|
289
|
+
const monthIdx = currentDateTime.getUTCMonth();
|
|
290
|
+
const lastDay = getLastDayOfMonthUTC(year, monthIdx);
|
|
291
|
+
|
|
292
|
+
if (currentDom !== lastDay) {
|
|
293
|
+
currentDateTime.setUTCDate(currentDateTime.getUTCDate() + 1);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Check daysOfWeek
|
|
299
|
+
if (schedule.daysOfWeek && schedule.daysOfWeek.length > 0) {
|
|
300
|
+
const currentDayOfWeek = getFormattedDatePart(currentDateTime, 'weekday', timezone);
|
|
301
|
+
if (!schedule.daysOfWeek.includes(currentDayOfWeek)) {
|
|
302
|
+
currentDateTime.setUTCDate(currentDateTime.getUTCDate() + 1);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Verify Expiration / EndTime
|
|
308
|
+
if (schedule.endTime && currentDateTime > new Date(schedule.endTime)) {
|
|
100
309
|
return null;
|
|
101
310
|
}
|
|
311
|
+
|
|
312
|
+
return currentDateTime.toISOString();
|
|
102
313
|
}
|
|
103
314
|
|
|
104
|
-
|
|
315
|
+
console.warn('[@cocreate/cron-jobs] Max safety iteration reached for schedule:', schedule);
|
|
316
|
+
return null;
|
|
105
317
|
}
|
|
106
318
|
|
|
107
319
|
/**
|
|
@@ -112,6 +324,7 @@ async function updateCronJob(object) {
|
|
|
112
324
|
if (!crud) return;
|
|
113
325
|
try {
|
|
114
326
|
const cronJobs = Array.isArray(object) ? object : [object];
|
|
327
|
+
if (cronJobs.length === 0) return;
|
|
115
328
|
|
|
116
329
|
for (const job of cronJobs) {
|
|
117
330
|
await crud.send({
|
|
@@ -121,10 +334,10 @@ async function updateCronJob(object) {
|
|
|
121
334
|
upsert: true,
|
|
122
335
|
organization_id: job.organization_id
|
|
123
336
|
});
|
|
124
|
-
console.log(`[@cocreate/cron-jobs] Cron job for org ${job.organization_id} updated.`);
|
|
337
|
+
console.log(`[@cocreate/cron-jobs] Cron job ${job._id} for org ${job.organization_id} updated.`);
|
|
125
338
|
}
|
|
126
339
|
|
|
127
|
-
if (cronJobs
|
|
340
|
+
if (cronJobs[0].organization_id) {
|
|
128
341
|
const firstJob = cronJobs[0];
|
|
129
342
|
await crud.send({
|
|
130
343
|
method: 'object.update',
|
|
@@ -142,12 +355,36 @@ async function updateCronJob(object) {
|
|
|
142
355
|
}
|
|
143
356
|
}
|
|
144
357
|
|
|
358
|
+
/**
|
|
359
|
+
* Triggers the defined job action or payload webhook.
|
|
360
|
+
*/
|
|
361
|
+
async function triggerJobAction(job) {
|
|
362
|
+
console.log(`[@cocreate/cron-jobs] Triggering pipeline action for job ${job._id}`);
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
if (server?.lazyloader?.webhook) {
|
|
366
|
+
await server.lazyloader.webhook(job);
|
|
367
|
+
} else if (wsManager && job.action) {
|
|
368
|
+
await wsManager.broadcast({
|
|
369
|
+
target: job.action,
|
|
370
|
+
data: job
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
} catch (err) {
|
|
374
|
+
console.error(`[@cocreate/cron-jobs] Action execution failed for job ${job._id}:`, err);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
145
378
|
/**
|
|
146
379
|
* Executes the scheduled cron job.
|
|
147
380
|
* @param {Object} object - The cron-job object containing job details, schedule parameters, etc.
|
|
148
381
|
*/
|
|
149
382
|
async function executeCronJob(object) {
|
|
150
|
-
console.log(`[@cocreate/cron-jobs] Executing cron job for org ${object.organization_id}`);
|
|
383
|
+
console.log(`[@cocreate/cron-jobs] Executing cron job ${object._id} for org ${object.organization_id}`);
|
|
384
|
+
|
|
385
|
+
if (scheduledJobs[object._id]) {
|
|
386
|
+
delete scheduledJobs[object._id];
|
|
387
|
+
}
|
|
151
388
|
|
|
152
389
|
try {
|
|
153
390
|
if (!object.job && crud) {
|
|
@@ -158,20 +395,19 @@ async function executeCronJob(object) {
|
|
|
158
395
|
organization_id: object.organization_id
|
|
159
396
|
});
|
|
160
397
|
if (data.object && data.object[0]) {
|
|
161
|
-
object = data.object[0];
|
|
398
|
+
object = { ...data.object[0], ...object };
|
|
162
399
|
}
|
|
163
|
-
console.log('[@cocreate/cron-jobs] Fetched cron job data:', object);
|
|
164
400
|
}
|
|
165
401
|
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
402
|
+
// Execute target payload / AST action
|
|
403
|
+
await triggerJobAction(object);
|
|
404
|
+
|
|
405
|
+
// Recalculate next execution window in UTC
|
|
169
406
|
object.nextExecutionTime = getNextExecutionTime(object.schedule);
|
|
170
407
|
if (!object.nextExecutionTime) {
|
|
171
408
|
object.active = false;
|
|
172
409
|
object.status = 'completed';
|
|
173
410
|
} else {
|
|
174
|
-
// Re-queue and release the lock for the next interval run
|
|
175
411
|
object.status = 'scheduled';
|
|
176
412
|
object.serverId = null;
|
|
177
413
|
object.workerId = null;
|
|
@@ -184,10 +420,17 @@ async function executeCronJob(object) {
|
|
|
184
420
|
}
|
|
185
421
|
|
|
186
422
|
/**
|
|
187
|
-
* Schedules a cron job to be executed at a specific datetime.
|
|
423
|
+
* Schedules a cron job in-memory to be executed at a specific UTC datetime.
|
|
188
424
|
* @param {Object} object - The cron-job configuration footprint.
|
|
189
425
|
*/
|
|
190
426
|
function scheduleCronJob(object) {
|
|
427
|
+
if (!object || !object._id || !object.nextExecutionTime) return;
|
|
428
|
+
|
|
429
|
+
if (scheduledJobs[object._id]) {
|
|
430
|
+
clearTimeout(scheduledJobs[object._id]);
|
|
431
|
+
delete scheduledJobs[object._id];
|
|
432
|
+
}
|
|
433
|
+
|
|
191
434
|
const delay = new Date(object.nextExecutionTime) - new Date();
|
|
192
435
|
|
|
193
436
|
if (delay <= 0) {
|
|
@@ -195,17 +438,9 @@ function scheduleCronJob(object) {
|
|
|
195
438
|
return;
|
|
196
439
|
}
|
|
197
440
|
|
|
198
|
-
console.log(`[@cocreate/cron-jobs] Scheduling
|
|
441
|
+
console.log(`[@cocreate/cron-jobs] Scheduling job ${object._id} for org ${object.organization_id} in ${(delay / 1000).toFixed(1)} seconds`);
|
|
199
442
|
|
|
200
|
-
const scheduledObj = {
|
|
201
|
-
_id: object._id,
|
|
202
|
-
nextExecutionTime: object.nextExecutionTime,
|
|
203
|
-
status: object.status,
|
|
204
|
-
clusterId: 'default-cluster',
|
|
205
|
-
serverId: server?.instanceId || server?.serverId || 'default-server',
|
|
206
|
-
workerId: server?.workerId !== undefined ? server.workerId : 'standalone',
|
|
207
|
-
organization_id: object.organization_id
|
|
208
|
-
};
|
|
443
|
+
const scheduledObj = { ...object };
|
|
209
444
|
|
|
210
445
|
scheduledJobs[object._id] = setTimeout(() => {
|
|
211
446
|
executeCronJob(scheduledObj);
|
|
@@ -213,71 +448,71 @@ function scheduleCronJob(object) {
|
|
|
213
448
|
}
|
|
214
449
|
|
|
215
450
|
/**
|
|
216
|
-
* Handles CRUD events to schedule cron jobs dynamically.
|
|
451
|
+
* Handles CRUD events to schedule cron jobs dynamically without array mutation bugs.
|
|
217
452
|
* @param {Object} data - The data object emitted from CRUD operations.
|
|
218
453
|
*/
|
|
219
454
|
async function handleCrudEvent(data) {
|
|
220
|
-
if (data.array !== 'cron-job') {
|
|
221
|
-
return;
|
|
455
|
+
if (!data || data.array !== 'cron-job' || !Array.isArray(data.object)) {
|
|
456
|
+
return;
|
|
222
457
|
}
|
|
223
458
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
459
|
+
const updatedJobs = [];
|
|
460
|
+
|
|
461
|
+
for (const job of data.object) {
|
|
462
|
+
if (!job || !job._id) continue;
|
|
228
463
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
464
|
+
if (data.method === 'object.delete' || job.active === false) {
|
|
465
|
+
if (scheduledJobs[job._id]) {
|
|
466
|
+
clearTimeout(scheduledJobs[job._id]);
|
|
467
|
+
delete scheduledJobs[job._id];
|
|
232
468
|
}
|
|
233
469
|
|
|
234
|
-
data.object.
|
|
235
|
-
|
|
470
|
+
job.status = data.method === 'object.delete' ? 'deleted' : 'inactive';
|
|
471
|
+
updatedJobs.push(job);
|
|
236
472
|
continue;
|
|
237
473
|
}
|
|
238
474
|
|
|
239
|
-
const nextExecutionTime = getNextExecutionTime(
|
|
475
|
+
const nextExecutionTime = getNextExecutionTime(job.schedule);
|
|
240
476
|
|
|
241
477
|
if (nextExecutionTime) {
|
|
242
|
-
// If already scheduled for the exact same block, skip duplicate scheduling
|
|
243
|
-
if (scheduledJobs[data.object[i]._id]) {
|
|
244
|
-
continue;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
478
|
const timeUntilExecution = new Date(nextExecutionTime) - new Date();
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
479
|
+
job.nextExecutionTime = nextExecutionTime;
|
|
480
|
+
|
|
481
|
+
if (timeUntilExecution <= 5 * 60 * 1000 && timeUntilExecution >= 0) {
|
|
482
|
+
if (!scheduledJobs[job._id]) {
|
|
483
|
+
job.status = 'assigned';
|
|
484
|
+
job.serverId = server?.instanceId || server?.serverId || 'default-server';
|
|
485
|
+
job.workerId = server?.workerId !== undefined ? server.workerId : 'standalone';
|
|
486
|
+
scheduleCronJob(job);
|
|
487
|
+
}
|
|
488
|
+
} else {
|
|
489
|
+
job.status = 'scheduled';
|
|
257
490
|
}
|
|
491
|
+
updatedJobs.push(job);
|
|
258
492
|
} else {
|
|
259
|
-
|
|
260
|
-
|
|
493
|
+
job.active = false;
|
|
494
|
+
job.status = 'completed';
|
|
495
|
+
updatedJobs.push(job);
|
|
261
496
|
}
|
|
262
497
|
}
|
|
263
498
|
|
|
264
|
-
|
|
499
|
+
if (updatedJobs.length > 0) {
|
|
500
|
+
await updateCronJob(updatedJobs);
|
|
501
|
+
}
|
|
265
502
|
}
|
|
266
503
|
|
|
267
504
|
/**
|
|
268
505
|
* Polls the platform DB for cron jobs that are scheduled or potentially failed.
|
|
269
|
-
* Any worker process hosting active sockets/procedures for an organization can safely execute this.
|
|
270
506
|
*/
|
|
271
507
|
async function pollForCronJobs() {
|
|
272
508
|
if (!crud) return;
|
|
273
|
-
console.log(`[@cocreate/cron-jobs] [
|
|
509
|
+
console.log(`[@cocreate/cron-jobs] [WORKER ${server?.workerId ?? 'standalone'}] Polling for active organizations' cron jobs...`);
|
|
274
510
|
|
|
275
511
|
const now = new Date();
|
|
276
512
|
const fiveMinutesFromNow = new Date(now.getTime() + 5 * 60 * 1000);
|
|
277
|
-
const oneMinuteAgo = new Date(now.getTime() - 1 * 60 * 1000);
|
|
513
|
+
const oneMinuteAgo = new Date(now.getTime() - 1 * 60 * 1000);
|
|
278
514
|
|
|
279
515
|
try {
|
|
280
|
-
// Query organizations.
|
|
281
516
|
const data = await crud.send({
|
|
282
517
|
method: 'object.read',
|
|
283
518
|
array: 'organizations',
|
|
@@ -286,16 +521,16 @@ async function pollForCronJobs() {
|
|
|
286
521
|
$or: [
|
|
287
522
|
{
|
|
288
523
|
$and: [
|
|
289
|
-
{ 'cron-jobs.nextExecutionTime': { $elemMatch: { $lte: fiveMinutesFromNow, $gte: now } } },
|
|
290
|
-
{ 'cron-jobs.status': { $elemMatch: { $ne: 'assigned' } } },
|
|
291
|
-
{ 'cron-jobs.active': { $elemMatch: { $ne: false } } }
|
|
524
|
+
{ 'cron-jobs.nextExecutionTime': { $elemMatch: { $lte: fiveMinutesFromNow, $gte: now } } },
|
|
525
|
+
{ 'cron-jobs.status': { $elemMatch: { $ne: 'assigned' } } },
|
|
526
|
+
{ 'cron-jobs.active': { $elemMatch: { $ne: false } } }
|
|
292
527
|
]
|
|
293
528
|
},
|
|
294
529
|
{
|
|
295
530
|
$and: [
|
|
296
|
-
{ 'cron-jobs.nextExecutionTime': { $elemMatch: { $lt: oneMinuteAgo } } },
|
|
297
|
-
{ 'cron-jobs.status': { $elemMatch: { $eq: 'assigned' } } },
|
|
298
|
-
{ 'cron-jobs.active': { $elemMatch: { $ne: false } } }
|
|
531
|
+
{ 'cron-jobs.nextExecutionTime': { $elemMatch: { $lt: oneMinuteAgo } } },
|
|
532
|
+
{ 'cron-jobs.status': { $elemMatch: { $eq: 'assigned' } } },
|
|
533
|
+
{ 'cron-jobs.active': { $elemMatch: { $ne: false } } }
|
|
299
534
|
]
|
|
300
535
|
}
|
|
301
536
|
]
|
|
@@ -306,27 +541,29 @@ async function pollForCronJobs() {
|
|
|
306
541
|
|
|
307
542
|
if (!data || !data.object) return;
|
|
308
543
|
|
|
309
|
-
for (
|
|
310
|
-
const cronJobs =
|
|
311
|
-
if (!cronJobs) continue;
|
|
544
|
+
for (const org of data.object) {
|
|
545
|
+
const cronJobs = org.cronJobs;
|
|
546
|
+
if (!cronJobs || !Array.isArray(cronJobs)) continue;
|
|
312
547
|
|
|
313
|
-
const organizationId =
|
|
548
|
+
const organizationId = org.organization_id || org._id;
|
|
549
|
+
const jobsToUpdate = [];
|
|
314
550
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
if (scheduledJobs[cronJobs[j]._id]) continue;
|
|
551
|
+
for (const job of cronJobs) {
|
|
552
|
+
if (!job._id || scheduledJobs[job._id]) continue;
|
|
318
553
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
cronJobs[j].organization_id = organizationId;
|
|
554
|
+
job.status = 'assigned';
|
|
555
|
+
job.clusterId = 'default-cluster';
|
|
556
|
+
job.serverId = server?.instanceId || server?.serverId || 'default-server';
|
|
557
|
+
job.workerId = server?.workerId !== undefined ? server.workerId : 'standalone';
|
|
558
|
+
job.organization_id = organizationId;
|
|
325
559
|
|
|
326
|
-
scheduleCronJob(
|
|
560
|
+
scheduleCronJob(job);
|
|
561
|
+
jobsToUpdate.push(job);
|
|
327
562
|
}
|
|
328
563
|
|
|
329
|
-
|
|
564
|
+
if (jobsToUpdate.length > 0) {
|
|
565
|
+
await updateCronJob(jobsToUpdate);
|
|
566
|
+
}
|
|
330
567
|
}
|
|
331
568
|
} catch (error) {
|
|
332
569
|
console.error('[@cocreate/cron-jobs] Error polling for cron jobs:', error);
|
|
@@ -335,8 +572,6 @@ async function pollForCronJobs() {
|
|
|
335
572
|
|
|
336
573
|
/**
|
|
337
574
|
* Adds a cron job reference to the platform database.
|
|
338
|
-
* @param {string} organization_id - The organization ID.
|
|
339
|
-
* @param {string} datetime - The datetime of the cron job to add to the platform DB.
|
|
340
575
|
*/
|
|
341
576
|
async function addToPlatformDB(organization_id, datetime) {
|
|
342
577
|
if (!crud) return;
|
|
@@ -357,7 +592,6 @@ async function addToPlatformDB(organization_id, datetime) {
|
|
|
357
592
|
|
|
358
593
|
/**
|
|
359
594
|
* Finds and schedules the next cron job for the organization after one is executed.
|
|
360
|
-
* @param {string} organization_id - The organization ID.
|
|
361
595
|
*/
|
|
362
596
|
async function findNextCronJob(organization_id) {
|
|
363
597
|
if (!crud) return;
|
|
@@ -386,34 +620,27 @@ async function findNextCronJob(organization_id) {
|
|
|
386
620
|
|
|
387
621
|
/**
|
|
388
622
|
* Self-Activating Auto-Initialization Loop.
|
|
389
|
-
* Runs on static module import, polling background state to detect when the
|
|
390
|
-
* parent server core has fully executed its async initialization blocks.
|
|
391
623
|
*/
|
|
392
624
|
async function init() {
|
|
393
625
|
if (server && server.isInitialized && server.crud && server.wsManager) {
|
|
394
626
|
crud = server.crud;
|
|
395
627
|
wsManager = server.wsManager;
|
|
396
628
|
|
|
397
|
-
// Handle incoming realtime CRUD operations to dynamically reschedule active jobs
|
|
398
629
|
process.on('crud-event', (event) => {
|
|
399
630
|
handleCrudEvent(event);
|
|
400
631
|
});
|
|
401
|
-
|
|
402
|
-
// Initial boot poll
|
|
632
|
+
|
|
403
633
|
pollForCronJobs();
|
|
404
634
|
|
|
405
|
-
// Setup the 5-minute interval cycle
|
|
406
635
|
setInterval(() => {
|
|
407
636
|
pollForCronJobs();
|
|
408
637
|
}, 5 * 60 * 1000);
|
|
409
|
-
|
|
638
|
+
|
|
410
639
|
} else {
|
|
411
|
-
// If the server core isn't fully booted/initialized yet, retry in 500ms
|
|
412
640
|
setTimeout(init, 500);
|
|
413
641
|
}
|
|
414
642
|
}
|
|
415
643
|
|
|
416
|
-
// Start auto-activation monitoring sequence instantly upon import evaluation
|
|
417
644
|
init();
|
|
418
645
|
|
|
419
646
|
export default {
|