millas 0.2.23 → 0.2.24

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.
@@ -0,0 +1,382 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const cron = require('node-cron');
6
+ const SchedulerLock = require('./SchedulerLock');
7
+
8
+ /**
9
+ * TaskScheduler
10
+ *
11
+ * Built-in task scheduler that runs alongside the HTTP server.
12
+ * Uses node-cron for reliable cron expression handling.
13
+ * Uses distributed locks to prevent duplicate execution across multiple instances.
14
+ *
15
+ * Features:
16
+ * - Zero configuration required
17
+ * - DI container integration
18
+ * - Queue system integration
19
+ * - Distributed locking (multi-instance safe)
20
+ * - Graceful shutdown handling
21
+ */
22
+ class TaskScheduler {
23
+ constructor(container = null, queue = null) {
24
+ this._container = container;
25
+ this._queue = queue;
26
+ this._tasks = new Map();
27
+ this._running = false;
28
+ this._lock = null;
29
+ this._config = {
30
+ enabled: true,
31
+ timezone: process.env.TZ || 'UTC',
32
+ useQueue: true,
33
+ useLocking: true, // Enable distributed locking by default
34
+ lockTTL: 300, // Lock expires after 5 minutes
35
+ };
36
+ }
37
+
38
+ configure(config = {}) {
39
+ this._config = { ...this._config, ...config };
40
+ return this;
41
+ }
42
+
43
+ /**
44
+ * Load scheduled tasks from a file (routes/schedule.js)
45
+ */
46
+ loadSchedules(schedulePath) {
47
+ if (!fs.existsSync(schedulePath)) return this;
48
+
49
+ try {
50
+ const scheduleDefinition = require(schedulePath);
51
+ const scheduleBuilder = new ScheduleBuilder(this);
52
+ scheduleDefinition(scheduleBuilder);
53
+ } catch (error) {
54
+ console.error(`[TaskScheduler] Failed to load schedules from ${schedulePath}:`, error.message);
55
+ }
56
+
57
+ return this;
58
+ }
59
+
60
+ /**
61
+ * Register a scheduled task
62
+ */
63
+ addTask(task) {
64
+ this._tasks.set(task.id, task);
65
+ return this;
66
+ }
67
+
68
+ /**
69
+ * Start the scheduler
70
+ */
71
+ start() {
72
+ if (!this._config.enabled || this._running) return;
73
+
74
+ this._running = true;
75
+
76
+ // Initialize distributed locking
77
+ if (this._config.useLocking) {
78
+ const db = this._container ? this._container.make('db') : null;
79
+ this._lock = new SchedulerLock(db);
80
+
81
+ // Clean up expired locks every minute
82
+ setInterval(() => {
83
+ this._lock.cleanup().catch(() => {});
84
+ }, 60000);
85
+ }
86
+
87
+ console.log(`[TaskScheduler] Starting with ${this._tasks.size} scheduled tasks`);
88
+ if (this._config.useLocking) {
89
+ console.log('[TaskScheduler] Distributed locking enabled (multi-instance safe)');
90
+ }
91
+
92
+ // Start all cron jobs
93
+ for (const task of this._tasks.values()) {
94
+ task.start();
95
+ }
96
+
97
+ return this;
98
+ }
99
+
100
+ /**
101
+ * Stop the scheduler
102
+ */
103
+ async stop() {
104
+ if (!this._running) return;
105
+
106
+ this._running = false;
107
+
108
+ // Stop all cron jobs
109
+ for (const task of this._tasks.values()) {
110
+ task.stop();
111
+ }
112
+
113
+ console.log('[TaskScheduler] Stopped');
114
+ }
115
+
116
+ /**
117
+ * Get all scheduled tasks
118
+ */
119
+ getTasks() {
120
+ return Array.from(this._tasks.values());
121
+ }
122
+
123
+ /**
124
+ * Execute a scheduled task (used by cron jobs and manual testing)
125
+ */
126
+ async _executeTask(task, now = new Date()) {
127
+ // Try to acquire distributed lock
128
+ if (this._config.useLocking && this._lock) {
129
+ const lockAcquired = await this._lock.acquire(task.id, this._config.lockTTL);
130
+
131
+ if (!lockAcquired) {
132
+ console.log(`[TaskScheduler] Skipping ${task.jobClass.name} - another instance is running it`);
133
+ return;
134
+ }
135
+ }
136
+
137
+ try {
138
+ // Prevent overlapping executions within same instance
139
+ if (task.isRunning()) {
140
+ console.warn(`[TaskScheduler] Skipping ${task.jobClass.name} - already running in this instance`);
141
+ return;
142
+ }
143
+
144
+ task.markAsRunning();
145
+ console.log(`[TaskScheduler] Executing ${task.jobClass.name}`);
146
+
147
+ // Create job instance with DI
148
+ const jobInstance = this._createJobInstance(task);
149
+
150
+ if (this._config.useQueue && this._queue) {
151
+ // Dispatch to queue system
152
+ await this._queue.push(jobInstance);
153
+ } else {
154
+ // Execute immediately
155
+ await jobInstance.handle();
156
+ }
157
+
158
+ task.updateLastRun(now);
159
+ task.markAsCompleted();
160
+
161
+ } catch (error) {
162
+ console.error(`[TaskScheduler] Failed to execute ${task.jobClass.name}:`, error.message);
163
+ task.markAsFailed(error);
164
+ } finally {
165
+ // Always release the lock
166
+ if (this._config.useLocking && this._lock) {
167
+ await this._lock.release(task.id);
168
+ }
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Create job instance using DI container
174
+ */
175
+ _createJobInstance(task) {
176
+ const JobClass = task.jobClass;
177
+
178
+ if (this._container) {
179
+ // Use DI container to resolve dependencies
180
+ return this._container.make(JobClass, task.parameters);
181
+ } else {
182
+ // Fallback to manual instantiation
183
+ return new JobClass(...Object.values(task.parameters || {}));
184
+ }
185
+ }
186
+ }
187
+
188
+ /**
189
+ * ScheduleBuilder
190
+ *
191
+ * Fluent API for defining scheduled tasks
192
+ */
193
+ class ScheduleBuilder {
194
+ constructor(scheduler) {
195
+ this._scheduler = scheduler;
196
+ }
197
+
198
+ /**
199
+ * Schedule a job class
200
+ */
201
+ job(JobClass) {
202
+ return new ScheduledTask(this._scheduler, JobClass);
203
+ }
204
+ }
205
+
206
+ /**
207
+ * ScheduledTask
208
+ *
209
+ * Represents a single scheduled task with its timing and parameters
210
+ */
211
+ class ScheduledTask {
212
+ constructor(scheduler, jobClass) {
213
+ this._scheduler = scheduler;
214
+ this.jobClass = jobClass;
215
+ this.id = `${jobClass.name}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
216
+ this.cronExpression = null;
217
+ this.cronJob = null;
218
+ this.parameters = {};
219
+ this.conditions = [];
220
+ this.timezone = scheduler._config.timezone;
221
+ this.lastRun = null;
222
+ this.running = false;
223
+ this.failures = [];
224
+ }
225
+
226
+ // ── Timing methods ────────────────────────────────────────────────────────
227
+
228
+ cron(expression) {
229
+ this.cronExpression = expression;
230
+ this._register();
231
+ return this;
232
+ }
233
+
234
+ daily() {
235
+ return this.cron('0 0 * * *');
236
+ }
237
+
238
+ hourly() {
239
+ return this.cron('0 * * * *');
240
+ }
241
+
242
+ weekly() {
243
+ return this.cron('0 0 * * 0');
244
+ }
245
+
246
+ monthly() {
247
+ return this.cron('0 0 1 * *');
248
+ }
249
+
250
+ weekdays() {
251
+ return this.cron('0 0 * * 1-5');
252
+ }
253
+
254
+ everyMinute() {
255
+ return this.cron('* * * * *');
256
+ }
257
+
258
+ everyFiveMinutes() {
259
+ return this.cron('*/5 * * * *');
260
+ }
261
+
262
+ everyTenMinutes() {
263
+ return this.cron('*/10 * * * *');
264
+ }
265
+
266
+ everyFifteenMinutes() {
267
+ return this.cron('*/15 * * * *');
268
+ }
269
+
270
+ everyThirtyMinutes() {
271
+ return this.cron('*/30 * * * *');
272
+ }
273
+
274
+ at(time) {
275
+ if (!this.cronExpression) {
276
+ throw new Error('Must call a frequency method (daily, hourly, etc.) before at()');
277
+ }
278
+
279
+ const [hour, minute = 0] = time.split(':').map(Number);
280
+ const parts = this.cronExpression.split(' ');
281
+ parts[0] = minute.toString();
282
+ parts[1] = hour.toString();
283
+
284
+ this.cronExpression = parts.join(' ');
285
+ return this;
286
+ }
287
+
288
+ // ── Configuration methods ─────────────────────────────────────────────────
289
+
290
+ with(params) {
291
+ this.parameters = { ...this.parameters, ...params };
292
+ return this;
293
+ }
294
+
295
+ when(condition) {
296
+ this.conditions.push(condition);
297
+ return this;
298
+ }
299
+
300
+ timezone(tz) {
301
+ this.timezone = tz;
302
+ return this;
303
+ }
304
+
305
+ // ── Execution control ─────────────────────────────────────────────────────
306
+
307
+ start() {
308
+ if (!this.cronExpression || this.cronJob) return;
309
+
310
+ this.cronJob = cron.schedule(
311
+ this.cronExpression,
312
+ async () => {
313
+ if (this._checkConditions()) {
314
+ await this._scheduler._executeTask(this, new Date());
315
+ }
316
+ },
317
+ {
318
+ scheduled: true,
319
+ timezone: this.timezone,
320
+ }
321
+ );
322
+ }
323
+
324
+ stop() {
325
+ if (this.cronJob) {
326
+ this.cronJob.stop();
327
+ this.cronJob = null;
328
+ }
329
+ }
330
+
331
+ // ── Execution state ───────────────────────────────────────────────────────
332
+
333
+ isRunning() {
334
+ return this.running;
335
+ }
336
+
337
+ markAsRunning() {
338
+ this.running = true;
339
+ }
340
+
341
+ markAsCompleted() {
342
+ this.running = false;
343
+ }
344
+
345
+ markAsFailed(error) {
346
+ this.running = false;
347
+ this.failures.push({
348
+ error: error.message,
349
+ timestamp: new Date(),
350
+ });
351
+ }
352
+
353
+ updateLastRun(timestamp) {
354
+ this.lastRun = timestamp;
355
+ }
356
+
357
+ getNextRun() {
358
+ // node-cron doesn't expose next run time easily
359
+ // Return a placeholder for now
360
+ return 'Scheduled';
361
+ }
362
+
363
+ // ── Internal methods ──────────────────────────────────────────────────────
364
+
365
+ _register() {
366
+ this._scheduler.addTask(this);
367
+ }
368
+
369
+ _checkConditions() {
370
+ return this.conditions.every(condition => {
371
+ try {
372
+ return condition();
373
+ } catch {
374
+ return false;
375
+ }
376
+ });
377
+ }
378
+ }
379
+
380
+ module.exports = TaskScheduler;
381
+ module.exports.ScheduleBuilder = ScheduleBuilder;
382
+ module.exports.ScheduledTask = ScheduledTask;
@@ -0,0 +1,9 @@
1
+ 'use strict';
2
+
3
+ const TaskScheduler = require('./TaskScheduler');
4
+ const SchedulerServiceProvider = require('./SchedulerServiceProvider');
5
+
6
+ module.exports = {
7
+ TaskScheduler,
8
+ SchedulerServiceProvider,
9
+ };