@cocreate/cron-jobs 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +1 -1
  3. package/src/index.js +346 -72
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ # [1.1.0](https://github.com/CoCreate-app/CoCreate-cron-jobs/compare/v1.0.0...v1.1.0) (2024-09-21)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * improved handling of platform db and organization db ([8edf03d](https://github.com/CoCreate-app/CoCreate-cron-jobs/commit/8edf03d83689dc7bbfe884e8db8c655934a3e36f))
7
+
8
+
9
+ ### Features
10
+
11
+ * getNextExecutionTime ([4ffc1aa](https://github.com/CoCreate-app/CoCreate-cron-jobs/commit/4ffc1aa899942b63b80fa281a6d687947cb8b50d))
12
+
1
13
  # 1.0.0 (2024-09-08)
2
14
 
3
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocreate/cron-jobs",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
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",
package/src/index.js CHANGED
@@ -20,131 +20,405 @@
20
20
  // you must obtain a commercial license from CoCreate LLC.
21
21
  // For details, visit <https://cocreate.app/licenses/> or contact us at sales@cocreate.app.
22
22
 
23
+ /**
24
+ * CoCreateCronJobs class is responsible for managing cron jobs for organizations.
25
+ * It listens to CRUD events and polls the platform DB to schedule and execute cron jobs.
26
+ *
27
+ * Example Object:
28
+ *
29
+ * @typedef {Object} CronJobEvent
30
+ * @property {string} organization_id - The ID of the organization for which the cron job is scheduled.
31
+ * @property {string} nextExecutionTime - The ISO 8601 timestamp for the next time the cron job is scheduled to execute.
32
+ * @property {Object} [schedule] - The scheduling information, including cronExpression, interval, and time-related settings.
33
+ * @property {string} [status] - The current status of the cron job, e.g., 'assigned', 'completed', 'failed'.
34
+ * @property {boolean} [active] - Whether the cron job is active or paused.
35
+ * @property {Object} [job] - The job that is to be executed, which could be $crud, $api, or other specific operations.
36
+ * @property {Object} [retryPolicy] - Retry settings for failed jobs, including retries and backoff strategy.
37
+ * @property {Object} [log] - An optional log of the cron job's last execution details, including status and message.
38
+ *
39
+ * Example Object:
40
+ *
41
+ * @example
42
+ * {
43
+ * "organization_id": "652c8d62679eca03e0b116a7",
44
+ * "nextExecutionTime": "2024-09-10T02:00:00Z",
45
+ * "active": true, // Whether the cron job is active or paused
46
+ * "status": "assigned",
47
+ * "schedule": {
48
+ * "cronExpression": "0 2 * * *", // Standard cron expression (for Unix-based scheduling)
49
+ * "startBoundary": "2024-09-10T02:00:00Z", // The first scheduled execution time
50
+ * "endBoundary": "2025-09-10T02:00:00Z", // The end time after which no further executions will occur
51
+ * "interval": "P1D", // ISO 8601 format for repeating interval (e.g., repeat every day)
52
+ * "daysOfWeek": ["Monday", "Wednesday"], // Days of the week when the job should run
53
+ * "daysOfMonth": [1, 15], // Specific days of the month for execution
54
+ * "months": ["January", "July"], // Specific months for execution
55
+ * "skipDates": ["2024-12-25"], // Dates to skip execution
56
+ * "timezone": "UTC", // Timezone for scheduling
57
+ * "time": "02:00:00" // Specific time of day when the job should run
58
+ * },
59
+ * "job": {
60
+ * "$crud": {
61
+ * "method": "object.update",
62
+ * "array": "users",
63
+ * "query": { "stripe.account_id": "acc_123456" },
64
+ * "object": { "stripe.balance": 1000 }
65
+ * }
66
+ * },
67
+ * "retryPolicy": {
68
+ * "retries": 3, // Number of retries for failed jobs
69
+ * "retryInterval": "PT10M" // Interval between retries in ISO 8601 format
70
+ * },
71
+ * "backoffStrategy": {
72
+ * "type": "exponential", // Type of backoff strategy (exponential, linear, etc.)
73
+ * "initialDelay": "PT5M" // Initial delay before the first retry
74
+ * },
75
+ * "executionTimeout": "PT2H", // Timeout for job execution (ISO 8601 format)
76
+ * "log": {
77
+ * "timestamp": "2024-09-09T02:00:00Z",
78
+ * "status": "completed",
79
+ * "message": "Job executed successfully"
80
+ * }
81
+ * }
82
+ *
83
+ * @class CoCreateCronJobs
84
+ */
23
85
  class CoCreateCronJobs {
86
+ /**
87
+ * Creates an instance of CoCreateCronJobs.
88
+ * @param {Object} crud - The CRUD module used to handle data operations.
89
+ */
24
90
  constructor(crud) {
25
- this.crud = crud; // CRUD module passed in
26
- this.scheduledJobs = {}; // To keep track of scheduled jobs
91
+ this.crud = crud;
92
+ this.scheduledJobs = {};
27
93
 
28
- // Listen to the 'crud-event'
29
- this.crud.on('crud-event', (event) => {
94
+ // Listen for CRUD events related to cron jobs
95
+ process.on('crud-event', (event) => {
30
96
  this.handleCrudEvent(event);
31
97
  });
32
98
 
33
99
  // Start polling every 5 minutes
34
100
  setInterval(() => {
35
101
  this.pollForCronJobs();
36
- }, 5 * 60 * 1000); // Poll every 5 minutes
102
+ }, 5 * 60 * 1000);
37
103
  }
38
104
 
39
- // Function to handle the 'crud-event'
40
- handleCrudEvent(event) {
41
- const { collection, data } = event;
105
+ /**
106
+ * Handles CRUD events to schedule cron jobs.
107
+ * @param {Object} data - The data object emitted from CRUD operations.
108
+ */
109
+ handleCrudEvent(data) {
110
+ // Check if the array is 'cron-job'
111
+ if (data.array !== 'cron-job') {
112
+ return; // Ignore non-cron-job events
113
+ }
114
+
115
+ // Iterate over the jobs in the data.object array
116
+ for (let i = 0; i < data.object.length; i++) {
117
+ if (data.method === 'object.delete' || data.object[i].active === false) {
118
+ const timeoutId = this.scheduledJobs[data.object[i]._id];
42
119
 
43
- // Check if the event relates to a cron job collection
44
- if (collection === 'cron-job') {
45
- const { organization_id, datetime } = data;
120
+ if (timeoutId) {
121
+ clearTimeout(timeoutId); // Cancel the timeout if it exists
122
+ delete this.scheduledJobs[data.object[i]._id]; // Remove the reference
123
+ }
46
124
 
47
- // Check if the cron job needs to be scheduled within the next 5 minutes
48
- const timeUntilExecution = new Date(datetime) - new Date();
125
+ data.object.splice(i, 1);
126
+ i--; // Adjust the index after removal
49
127
 
50
- if (timeUntilExecution <= 5 * 60 * 1000 && timeUntilExecution > 0) {
51
- // Schedule the cron job immediately if within the next 5 minutes
52
- this.scheduleCronJob(organization_id, datetime);
128
+ continue;
129
+ }
130
+
131
+ const nextExecutionTime = this.getNextExecutionTime(data.object[i].schedule);
132
+
133
+ if (nextExecutionTime) {
134
+ // TODO: Compare executionTime if equal continue
135
+ if (this.scheduledJobs[data.object[i]._id])
136
+ continue
137
+
138
+ const timeUntilExecution = new Date(nextExecutionTime) - new Date();
139
+
140
+ data.object[i].nextExecutionTime = nextExecutionTime
141
+
142
+ // If the next execution is within the next 5 minutes, schedule the job
143
+ if (timeUntilExecution <= 5 * 60 * 1000 && timeUntilExecution > 0) {
144
+ data.object[i].status = 'assigned'
145
+ this.scheduleCronJob(data.object[i]);
146
+ }
53
147
  } else {
54
- // Otherwise, add a reference to the platform DB to be picked up by polling
55
- this.addToPlatformDB(organization_id, datetime);
148
+ data.object.splice(i, 1);
149
+ i--; // Adjust the index after removal
150
+ }
151
+ }
152
+
153
+ this.updateCronJob(data.object);
154
+ }
155
+
156
+ /**
157
+ * Parses the schedule object and calculates the next execution time.
158
+ * Takes into account cronExpression, startTime, skipDates, time, daysOfWeek, daysOfMonth, months, endTime, etc.
159
+ *
160
+ * @param {Object} schedule - The schedule object containing cronExpression, startTime, etc.
161
+ * @returns {string|null} nextExecutionTime - The calculated next execution time in ISO 8601 format, or null if endTime has passed.
162
+ */
163
+ getNextExecutionTime(schedule) {
164
+ const now = new Date();
165
+ let currentDateTime = now;
166
+
167
+ // Use startTime if it's greater than now, otherwise use now
168
+ if (schedule.startTime && new Date(schedule.startTime) > now) {
169
+ currentDateTime = new Date(schedule.startTime);
170
+ }
171
+
172
+ // Apply skipDates: Skip the date if it matches any in skipDates
173
+ if (schedule.skipDates && schedule.skipDates.includes(currentDateTime.toISOString().split('T')[0])) {
174
+ currentDateTime = new Date(currentDateTime.getTime() + 24 * 60 * 60 * 1000); // Skip to the next day
175
+ }
176
+
177
+ // Apply daysOfWeek: If the current day is not in the allowed days, skip to the next valid day
178
+ if (schedule.daysOfWeek) {
179
+ const dayOfWeek = currentDateTime.toLocaleString('en-US', { weekday: 'long', timeZone: schedule.timezone || 'UTC' });
180
+
181
+ while (!schedule.daysOfWeek.includes(dayOfWeek)) {
182
+ currentDateTime.setDate(currentDateTime.getDate() + 1); // Move to the next day
183
+ }
184
+ }
185
+
186
+ // Apply daysOfMonth: Ensure the next execution is on a valid day of the month
187
+ if (schedule.daysOfMonth) {
188
+ while (!schedule.daysOfMonth.includes(currentDateTime.getDate())) {
189
+ currentDateTime.setDate(currentDateTime.getDate() + 1); // Move to the next day
56
190
  }
57
191
  }
192
+
193
+ // Apply months: Ensure the next execution falls within one of the allowed months
194
+ if (schedule.months) {
195
+ const monthName = currentDateTime.toLocaleString('en-US', { month: 'long', timeZone: schedule.timezone || 'UTC' });
196
+
197
+ while (!schedule.months.includes(monthName)) {
198
+ currentDateTime.setMonth(currentDateTime.getMonth() + 1); // Move to the next month
199
+ }
200
+ }
201
+
202
+ // Apply time: Set the execution time of day if specified
203
+ if (schedule.time) {
204
+ const [hours, minutes, seconds] = schedule.time.split(':');
205
+ currentDateTime.setHours(hours, minutes, seconds);
206
+ }
207
+
208
+ // Apply endTime: Ensure the next execution time does not exceed the endTime
209
+ if (schedule.endTime && new Date(currentDateTime) > new Date(schedule.endTime)) {
210
+ return null; // No further executions after endTime
211
+ }
212
+
213
+ // Now run the cronParser to handle cronExpression (if present) or fallback to currentDateTime
214
+ if (schedule.cronExpression) {
215
+ const interval = cronParser.parseExpression(schedule.cronExpression, {
216
+ currentDate: currentDateTime,
217
+ tz: schedule.timezone || 'UTC'
218
+ });
219
+ return interval.next().toISOString();
220
+ }
221
+
222
+ return currentDateTime.toISOString();
58
223
  }
59
224
 
60
- // Function to schedule the cron job
61
- scheduleCronJob(organization_id, datetime) {
62
- // Calculate delay until the job needs to be executed
63
- const delay = new Date(datetime) - new Date();
225
+ /**
226
+ * Schedules a cron job to be executed at a specific datetime.
227
+ *
228
+ * @param {string} organization_id - The organization ID.
229
+ * @param {string} datetime - The time at which the job should be executed.
230
+ */
231
+ scheduleCronJob(object) {
232
+ const delay = new Date(object.nextExecutionTime) - new Date();
64
233
 
65
234
  if (delay <= 0) {
66
- console.log(`Skipping past-due job for org ${organization_id}`);
235
+ this.executeCronJob(object);
67
236
  return;
68
237
  }
69
238
 
70
- console.log(`Scheduling cron job for org ${organization_id} in ${delay / 1000} seconds`);
239
+ console.log(`Scheduling cron job for org ${object.organization_id} in ${delay / 1000} seconds`);
71
240
 
72
- // Set a timeout to execute the cron job
73
- this.scheduledJobs[organization_id] = setTimeout(() => {
74
- this.executeCronJob(organization_id);
241
+ // TODO: get clusterId, serverId and workerId
242
+ object = {
243
+ _id: object._id,
244
+ nextExecutionTime: object.nextExecutionTime,
245
+ status: object.status,
246
+ clusterId: 'id',
247
+ severId: 'id',
248
+ workerId: 'id',
249
+ organization_id: object.organization_id
250
+ }
251
+
252
+ this.scheduledJobs[object._id] = setTimeout(() => {
253
+ this.executeCronJob(object);
75
254
  }, delay);
76
255
  }
77
256
 
78
- // Function to execute the cron job
79
- executeCronJob(organization_id) {
80
- console.log(`Executing cron job for org ${organization_id}`);
257
+ /**
258
+ * Executes the scheduled cron job.
259
+ *
260
+ * @param {Object} schedule - The cron-job object containing job, schedule, etc.
261
+ */
262
+ async executeCronJob(object) {
263
+ console.log(`Executing cron job for org ${object.organization_id}`);
81
264
 
82
- // Fetch the actual cron document from the organization's DB using the CRUD module
83
- this.crud.read({
84
- collection: 'cron-job',
85
- query: { organization_id },
86
- }).then(cronJobData => {
87
- // Process and execute the cron job based on the fetched data
88
- console.log('Fetched cron job data:', cronJobData);
89
-
90
- // After executing, find the next cron job for the organization
91
- this.findNextCronJob(organization_id);
92
- }).catch(error => {
93
- console.error(`Error fetching cron job for org ${organization_id}:`, error);
94
- });
95
- }
265
+ if (!object.job) {
266
+ let data = await this.crud.send({
267
+ method: 'object.read',
268
+ array: 'cron-job',
269
+ object: { _id: object._id },
270
+ organization_id: object.organization_id
271
+ })
272
+ object = data.object[0]
273
+ console.log('Fetched cron job data:', object);
274
+ }
96
275
 
97
- // Function to add cron job reference to platform DB for polling
98
- addToPlatformDB(organization_id, datetime) {
99
- console.log(`Adding cron job reference to platform DB for org ${organization_id}`);
276
+ // TODO: execute cron using lazyloader.webhook()
277
+ object.nextExecutionTime = this.getNextExecutionTime(object.schedule);
278
+ if (!object.nextExecutionTime) {
279
+ object.active = false
280
+ object.status = 'completed'
281
+ }
100
282
 
101
- this.crud.update({
102
- collection: 'organizations',
103
- query: { _id: organization_id },
104
- update: { nextCronExecutionTime: datetime },
105
- }).then(() => {
106
- console.log(`Platform DB updated for org ${organization_id}`);
107
- }).catch(error => {
108
- console.error(`Error updating platform DB for org ${organization_id}:`, error);
109
- });
283
+ this.updateCronJob(object);
110
284
  }
111
285
 
112
- // Poll for cron jobs that need to be executed soon (within 5 minutes)
286
+
287
+ /**
288
+ * Polls the platform DB for cron jobs that are scheduled or potentially failed.
289
+ */
113
290
  pollForCronJobs() {
114
291
  console.log('Polling for cron jobs...');
115
292
 
116
- // Query the platform DB for organizations with a nextCronExecutionTime within 5 minutes
117
293
  const now = new Date();
118
294
  const fiveMinutesFromNow = new Date(now.getTime() + 5 * 60 * 1000);
295
+ const oneMinuteAgo = new Date(now.getTime() - 1 * 60 * 1000); // 1 minute past due jobs
119
296
 
120
- this.crud.read({
121
- collection: 'organizations',
122
- query: {
123
- nextCronExecutionTime: { $lte: fiveMinutesFromNow },
297
+ this.crud.send({
298
+ method: 'object.read',
299
+ array: 'organizations',
300
+ filter: {
301
+ query: {
302
+ $or: [
303
+ {
304
+ $and: [
305
+ { 'cron-jobs.nextExecutionTime': { $elemMatch: { $lte: fiveMinutesFromNow, $gte: now } } }, // Match jobs in array within 5 minutes
306
+ { 'cron-jobs.status': { $elemMatch: { $ne: 'assigned' } } }, // Not already assigned
307
+ { 'cron-jobs.active': { $elemMatch: { $ne: false } } } // Is active
308
+ ]
309
+ },
310
+ {
311
+ $and: [
312
+ { 'cron-jobs.nextExecutionTime': { $elemMatch: { $lt: oneMinuteAgo } } }, // Past due by 1 minute
313
+ { 'cron-jobs.status': { $elemMatch: { $eq: 'assigned' } } }, // Assigned but not started
314
+ { 'cron-jobs.active': { $elemMatch: { $ne: false } } } // Is active
315
+ ]
316
+ }
317
+ ]
318
+ }
124
319
  },
125
- }).then(organizations => {
126
- organizations.forEach(org => {
127
- const { _id: organization_id, nextCronExecutionTime } = org;
320
+ organization_id: process.env.organization_id
321
+ }).then(data => {
322
+ for (let i = 0; i < data.object.length; i++) {
323
+ const cronJobs = data.object[i].cronJobs;
324
+ if (!cronJobs)
325
+ return
326
+
327
+ // Check each cron job in the organization's `cron-jobs` array
328
+ for (let j = 0; j < cronJobs; j++) {
329
+ if (this.scheduledJobs[cronJobs[j]._id])
330
+ return
128
331
 
129
- // Schedule the cron job if it hasn’t already been scheduled
130
- if (!this.scheduledJobs[organization_id]) {
131
- this.scheduleCronJob(organization_id, nextCronExecutionTime);
332
+ cronJobs[j].status = 'assigned'
333
+
334
+ // TODO: get clusterId, serverId and workerId
335
+ cronJobs[j].clusterId = 'id'
336
+ cronJobs[j].severId = 'id'
337
+ cronJobs[j].workerId = 'id'
338
+ cronJobs[j].organization_id = data.object[i].organization_id
339
+
340
+ this.scheduleCronJob(cronJobs[j]);
132
341
  }
133
- });
342
+
343
+ this.updateCronJob(cronJobs);
344
+ }
134
345
  }).catch(error => {
135
346
  console.error('Error polling for cron jobs:', error);
136
347
  });
137
348
  }
138
349
 
139
- // Find and schedule the next cron job for the organization after one is executed
350
+ /**
351
+ * Marks the cron job as failed after it is detected as past due and not started.
352
+ */
353
+ updateCronJob(object) {
354
+ if (!Array.isArray(object))
355
+ object = [object]
356
+
357
+ for (let i = 0; i < object.length; i++) {
358
+ this.crud.send({
359
+ method: 'object.update',
360
+ array: 'cron-jobs',
361
+ object,
362
+ upsert: true,
363
+ organization_id: object.organization_id
364
+ }).then(() => {
365
+ console.log(`Cron job for org ${object.organization_id} updated.`);
366
+ }).catch(error => {
367
+ console.error(`Error updating cron job for org ${object.organization_id}:`, error);
368
+ });
369
+
370
+ }
371
+
372
+ const cronJobs = object
373
+ this.crud.send({
374
+ method: 'object.update',
375
+ array: 'organizations',
376
+ object: {
377
+ _id: object.organization_id,
378
+ cronJobs
379
+ },
380
+ organization_id: process.env.organization_id
381
+ }).then(() => {
382
+ console.log(`Cron job for org ${object.organization_id} updated.`);
383
+ }).catch(error => {
384
+ console.error(`Error updating cron job for org ${object.organization_id}:`, error);
385
+ });
386
+ }
387
+
388
+ /**
389
+ * Adds a cron job reference to the platform database.
390
+ *
391
+ * @param {string} organization_id - The organization ID.
392
+ * @param {string} datetime - The datetime of the cron job to add to the platform DB.
393
+ */
394
+ addToPlatformDB(organization_id, datetime) {
395
+ console.log(`Adding cron job reference to platform DB for org ${organization_id}`);
396
+
397
+ this.crud.send({
398
+ method: 'object.read',
399
+ array: 'organizations',
400
+ object: { _id: organization_id, nextCron: datetime },
401
+ organization_id: process.env.organization_id
402
+ }).then(() => {
403
+ console.log(`Platform DB updated for org ${organization_id}`);
404
+ }).catch(error => {
405
+ console.error(`Error updating platform DB for org ${organization_id}:`, error);
406
+ });
407
+ }
408
+
409
+ /**
410
+ * Finds and schedules the next cron job for the organization after one is executed.
411
+ *
412
+ * @param {string} organization_id - The organization ID.
413
+ */
140
414
  findNextCronJob(organization_id) {
141
415
  console.log(`Finding next cron job for org ${organization_id}`);
142
416
 
143
417
  this.crud.read({
144
418
  collection: 'cron-job',
145
419
  query: { organization_id },
146
- sort: { datetime: 1 }, // Sort by the next closest datetime
147
- limit: 1,
420
+ sort: { datetime: 1 },
421
+ limit: 1
148
422
  }).then(nextJob => {
149
423
  if (nextJob.length > 0) {
150
424
  const { datetime } = nextJob[0];