s3db.js 9.3.0 → 10.0.1

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.
@@ -1,5 +1,6 @@
1
1
  import Plugin from "./plugin.class.js";
2
2
  import tryFn from "../concerns/try-fn.js";
3
+ import { idGenerator } from "../concerns/id.js";
3
4
 
4
5
  /**
5
6
  * SchedulerPlugin - Cron-based Task Scheduling System
@@ -14,7 +15,7 @@ import tryFn from "../concerns/try-fn.js";
14
15
  * - Error handling and retry logic
15
16
  * - Job persistence and recovery
16
17
  * - Timezone support
17
- * - Job dependencies and chaining
18
+ * - Distributed locking for multi-instance deployments
18
19
  * - Resource cleanup and maintenance tasks
19
20
  *
20
21
  * === Configuration Example ===
@@ -38,7 +39,7 @@ import tryFn from "../concerns/try-fn.js";
38
39
  * return { deleted: expired.length };
39
40
  * },
40
41
  * enabled: true,
41
- * retries: 3,
42
+ * retries: 3, // Number of retry attempts after initial failure (total: 4 attempts)
42
43
  * timeout: 300000 // 5 minutes
43
44
  * },
44
45
  *
@@ -80,7 +81,6 @@ import tryFn from "../concerns/try-fn.js";
80
81
  * }
81
82
  * throw new Error('BackupPlugin not available');
82
83
  * },
83
- * dependencies: ['backup_full'], // Run only after full backup exists
84
84
  * retries: 2
85
85
  * },
86
86
  *
@@ -153,7 +153,7 @@ export class SchedulerPlugin extends Plugin {
153
153
  jobs: options.jobs || {},
154
154
  defaultTimeout: options.defaultTimeout || 300000, // 5 minutes
155
155
  defaultRetries: options.defaultRetries || 1,
156
- jobHistoryResource: options.jobHistoryResource || 'job_executions',
156
+ jobHistoryResource: options.jobHistoryResource || 'plg_job_executions',
157
157
  persistJobs: options.persistJobs !== false,
158
158
  verbose: options.verbose || false,
159
159
  onJobStart: options.onJobStart || null,
@@ -163,14 +163,25 @@ export class SchedulerPlugin extends Plugin {
163
163
  };
164
164
 
165
165
  this.database = null;
166
+ this.lockResource = null;
166
167
  this.jobs = new Map();
167
168
  this.activeJobs = new Map();
168
169
  this.timers = new Map();
169
170
  this.statistics = new Map();
170
-
171
+
171
172
  this._validateConfiguration();
172
173
  }
173
174
 
175
+ /**
176
+ * Helper to detect test environment
177
+ * @private
178
+ */
179
+ _isTestEnvironment() {
180
+ return process.env.NODE_ENV === 'test' ||
181
+ process.env.JEST_WORKER_ID !== undefined ||
182
+ global.expect !== undefined;
183
+ }
184
+
174
185
  _validateConfiguration() {
175
186
  if (Object.keys(this.config.jobs).length === 0) {
176
187
  throw new Error('SchedulerPlugin: At least one job must be defined');
@@ -208,7 +219,10 @@ export class SchedulerPlugin extends Plugin {
208
219
 
209
220
  async setup(database) {
210
221
  this.database = database;
211
-
222
+
223
+ // Create lock resource for distributed locking
224
+ await this._createLockResource();
225
+
212
226
  // Create job execution history resource
213
227
  if (this.config.persistJobs) {
214
228
  await this._createJobHistoryResource();
@@ -245,6 +259,28 @@ export class SchedulerPlugin extends Plugin {
245
259
  this.emit('initialized', { jobs: this.jobs.size });
246
260
  }
247
261
 
262
+ async _createLockResource() {
263
+ const [ok, err, lockResource] = await tryFn(() =>
264
+ this.database.createResource({
265
+ name: 'plg_scheduler_job_locks',
266
+ attributes: {
267
+ id: 'string|required',
268
+ jobName: 'string|required',
269
+ lockedAt: 'number|required',
270
+ instanceId: 'string|optional'
271
+ },
272
+ behavior: 'body-only',
273
+ timestamps: false
274
+ })
275
+ );
276
+
277
+ if (!ok && !this.database.resources.plg_scheduler_job_locks) {
278
+ throw new Error(`Failed to create lock resource: ${err?.message}`);
279
+ }
280
+
281
+ this.lockResource = ok ? lockResource : this.database.resources.plg_scheduler_job_locks;
282
+ }
283
+
248
284
  async _createJobHistoryResource() {
249
285
  const [ok] = await tryFn(() => this.database.createResource({
250
286
  name: this.config.jobHistoryResource,
@@ -359,10 +395,7 @@ export class SchedulerPlugin extends Plugin {
359
395
  }
360
396
 
361
397
  // For tests, ensure we always schedule in the future
362
- const isTestEnvironment = process.env.NODE_ENV === 'test' ||
363
- process.env.JEST_WORKER_ID !== undefined ||
364
- global.expect !== undefined;
365
- if (isTestEnvironment) {
398
+ if (this._isTestEnvironment()) {
366
399
  // Add 1 second to ensure it's in the future for tests
367
400
  next.setTime(next.getTime() + 1000);
368
401
  }
@@ -372,147 +405,180 @@ export class SchedulerPlugin extends Plugin {
372
405
 
373
406
  async _executeJob(jobName) {
374
407
  const job = this.jobs.get(jobName);
375
- if (!job || this.activeJobs.has(jobName)) {
408
+ if (!job) {
376
409
  return;
377
410
  }
378
-
379
- const executionId = `${jobName}_${Date.now()}`;
411
+
412
+ // Check and mark as active atomically to prevent race conditions
413
+ if (this.activeJobs.has(jobName)) {
414
+ return;
415
+ }
416
+
417
+ // Mark as active immediately (will be updated with executionId later)
418
+ this.activeJobs.set(jobName, 'acquiring-lock');
419
+
420
+ // Acquire distributed lock to prevent concurrent execution across instances
421
+ const lockId = `lock-${jobName}`;
422
+ const [lockAcquired, lockErr] = await tryFn(() =>
423
+ this.lockResource.insert({
424
+ id: lockId,
425
+ jobName,
426
+ lockedAt: Date.now(),
427
+ instanceId: process.pid ? String(process.pid) : 'unknown'
428
+ })
429
+ );
430
+
431
+ // If lock couldn't be acquired, another instance is executing this job
432
+ if (!lockAcquired) {
433
+ if (this.config.verbose) {
434
+ console.log(`[SchedulerPlugin] Job '${jobName}' already running on another instance`);
435
+ }
436
+ // Remove from activeJobs since we didn't acquire the lock
437
+ this.activeJobs.delete(jobName);
438
+ return;
439
+ }
440
+
441
+ const executionId = `${jobName}_${idGenerator()}`;
380
442
  const startTime = Date.now();
381
-
443
+
382
444
  const context = {
383
445
  jobName,
384
446
  executionId,
385
447
  scheduledTime: new Date(startTime),
386
448
  database: this.database
387
449
  };
388
-
450
+
451
+ // Update with actual executionId
389
452
  this.activeJobs.set(jobName, executionId);
390
-
391
- // Execute onJobStart hook
392
- if (this.config.onJobStart) {
393
- await this._executeHook(this.config.onJobStart, jobName, context);
394
- }
395
-
396
- this.emit('job_start', { jobName, executionId, startTime });
397
-
398
- let attempt = 0;
399
- let lastError = null;
400
- let result = null;
401
- let status = 'success';
402
-
403
- // Detect test environment once
404
- const isTestEnvironment = process.env.NODE_ENV === 'test' ||
405
- process.env.JEST_WORKER_ID !== undefined ||
406
- global.expect !== undefined;
407
-
408
- while (attempt <= job.retries) { // attempt 0 = initial, attempt 1+ = retries
409
- try {
410
- // Set timeout for job execution (reduce timeout in test environment)
411
- const actualTimeout = isTestEnvironment ? Math.min(job.timeout, 1000) : job.timeout; // Max 1000ms in tests
412
-
413
- let timeoutId;
414
- const timeoutPromise = new Promise((_, reject) => {
415
- timeoutId = setTimeout(() => reject(new Error('Job execution timeout')), actualTimeout);
416
- });
417
-
418
- // Execute job with timeout
419
- const jobPromise = job.action(this.database, context, this);
420
-
453
+
454
+ try {
455
+ // Execute onJobStart hook
456
+ if (this.config.onJobStart) {
457
+ await this._executeHook(this.config.onJobStart, jobName, context);
458
+ }
459
+
460
+ this.emit('job_start', { jobName, executionId, startTime });
461
+
462
+ let attempt = 0;
463
+ let lastError = null;
464
+ let result = null;
465
+ let status = 'success';
466
+
467
+ // Detect test environment once
468
+ const isTestEnvironment = this._isTestEnvironment();
469
+
470
+ while (attempt <= job.retries) { // attempt 0 = initial, attempt 1+ = retries
421
471
  try {
422
- result = await Promise.race([jobPromise, timeoutPromise]);
423
- // Clear timeout if job completes successfully
424
- clearTimeout(timeoutId);
425
- } catch (raceError) {
426
- // Ensure timeout is cleared even on error
427
- clearTimeout(timeoutId);
428
- throw raceError;
429
- }
430
-
431
- status = 'success';
432
- break;
433
-
434
- } catch (error) {
435
- lastError = error;
436
- attempt++;
437
-
438
- if (attempt <= job.retries) {
439
- if (this.config.verbose) {
440
- console.warn(`[SchedulerPlugin] Job '${jobName}' failed (attempt ${attempt + 1}):`, error.message);
472
+ // Set timeout for job execution (reduce timeout in test environment)
473
+ const actualTimeout = isTestEnvironment ? Math.min(job.timeout, 1000) : job.timeout; // Max 1000ms in tests
474
+
475
+ let timeoutId;
476
+ const timeoutPromise = new Promise((_, reject) => {
477
+ timeoutId = setTimeout(() => reject(new Error('Job execution timeout')), actualTimeout);
478
+ });
479
+
480
+ // Execute job with timeout
481
+ const jobPromise = job.action(this.database, context, this);
482
+
483
+ try {
484
+ result = await Promise.race([jobPromise, timeoutPromise]);
485
+ // Clear timeout if job completes successfully
486
+ clearTimeout(timeoutId);
487
+ } catch (raceError) {
488
+ // Ensure timeout is cleared even on error
489
+ clearTimeout(timeoutId);
490
+ throw raceError;
491
+ }
492
+
493
+ status = 'success';
494
+ break;
495
+
496
+ } catch (error) {
497
+ lastError = error;
498
+ attempt++;
499
+
500
+ if (attempt <= job.retries) {
501
+ if (this.config.verbose) {
502
+ console.warn(`[SchedulerPlugin] Job '${jobName}' failed (attempt ${attempt + 1}):`, error.message);
503
+ }
504
+
505
+ // Wait before retry (exponential backoff with max delay, shorter in tests)
506
+ const baseDelay = Math.min(Math.pow(2, attempt) * 1000, 5000); // Max 5 seconds
507
+ const delay = isTestEnvironment ? 1 : baseDelay; // Just 1ms in tests
508
+ await new Promise(resolve => setTimeout(resolve, delay));
441
509
  }
442
-
443
- // Wait before retry (exponential backoff with max delay, shorter in tests)
444
- const baseDelay = Math.min(Math.pow(2, attempt) * 1000, 5000); // Max 5 seconds
445
- const delay = isTestEnvironment ? 1 : baseDelay; // Just 1ms in tests
446
- await new Promise(resolve => setTimeout(resolve, delay));
447
510
  }
448
511
  }
449
- }
450
-
451
- const endTime = Date.now();
452
- const duration = Math.max(1, endTime - startTime); // Ensure minimum 1ms duration
453
-
454
- if (lastError && attempt > job.retries) {
455
- status = lastError.message.includes('timeout') ? 'timeout' : 'error';
456
- }
457
-
458
- // Update job statistics
459
- job.lastRun = new Date(endTime);
460
- job.runCount++;
461
-
462
- if (status === 'success') {
463
- job.successCount++;
464
- } else {
465
- job.errorCount++;
466
- }
467
-
468
- // Update plugin statistics
469
- const stats = this.statistics.get(jobName);
470
- stats.totalRuns++;
471
- stats.lastRun = new Date(endTime);
472
-
473
- if (status === 'success') {
474
- stats.totalSuccesses++;
475
- stats.lastSuccess = new Date(endTime);
476
- } else {
477
- stats.totalErrors++;
478
- stats.lastError = { time: new Date(endTime), message: lastError?.message };
479
- }
480
-
481
- stats.avgDuration = ((stats.avgDuration * (stats.totalRuns - 1)) + duration) / stats.totalRuns;
482
-
483
- // Persist execution history
484
- if (this.config.persistJobs) {
485
- await this._persistJobExecution(jobName, executionId, startTime, endTime, duration, status, result, lastError, attempt);
486
- }
487
-
488
- // Execute completion hooks
489
- if (status === 'success' && this.config.onJobComplete) {
490
- await this._executeHook(this.config.onJobComplete, jobName, result, duration);
491
- } else if (status !== 'success' && this.config.onJobError) {
492
- await this._executeHook(this.config.onJobError, jobName, lastError, attempt);
493
- }
494
-
495
- this.emit('job_complete', {
496
- jobName,
497
- executionId,
498
- status,
499
- duration,
500
- result,
501
- error: lastError?.message,
502
- retryCount: attempt
503
- });
504
-
505
- // Remove from active jobs
506
- this.activeJobs.delete(jobName);
507
-
508
- // Schedule next execution if job is still enabled
509
- if (job.enabled) {
510
- this._scheduleNextExecution(jobName);
511
- }
512
+
513
+ const endTime = Date.now();
514
+ const duration = Math.max(1, endTime - startTime); // Ensure minimum 1ms duration
515
+
516
+ if (lastError && attempt > job.retries) {
517
+ status = lastError.message.includes('timeout') ? 'timeout' : 'error';
518
+ }
519
+
520
+ // Update job statistics
521
+ job.lastRun = new Date(endTime);
522
+ job.runCount++;
523
+
524
+ if (status === 'success') {
525
+ job.successCount++;
526
+ } else {
527
+ job.errorCount++;
528
+ }
529
+
530
+ // Update plugin statistics
531
+ const stats = this.statistics.get(jobName);
532
+ stats.totalRuns++;
533
+ stats.lastRun = new Date(endTime);
534
+
535
+ if (status === 'success') {
536
+ stats.totalSuccesses++;
537
+ stats.lastSuccess = new Date(endTime);
538
+ } else {
539
+ stats.totalErrors++;
540
+ stats.lastError = { time: new Date(endTime), message: lastError?.message };
541
+ }
542
+
543
+ stats.avgDuration = ((stats.avgDuration * (stats.totalRuns - 1)) + duration) / stats.totalRuns;
544
+
545
+ // Persist execution history
546
+ if (this.config.persistJobs) {
547
+ await this._persistJobExecution(jobName, executionId, startTime, endTime, duration, status, result, lastError, attempt);
548
+ }
549
+
550
+ // Execute completion hooks
551
+ if (status === 'success' && this.config.onJobComplete) {
552
+ await this._executeHook(this.config.onJobComplete, jobName, result, duration);
553
+ } else if (status !== 'success' && this.config.onJobError) {
554
+ await this._executeHook(this.config.onJobError, jobName, lastError, attempt);
555
+ }
556
+
557
+ this.emit('job_complete', {
558
+ jobName,
559
+ executionId,
560
+ status,
561
+ duration,
562
+ result,
563
+ error: lastError?.message,
564
+ retryCount: attempt
565
+ });
512
566
 
513
- // Throw error if all retries failed
514
- if (lastError && status !== 'success') {
515
- throw lastError;
567
+ // Remove from active jobs
568
+ this.activeJobs.delete(jobName);
569
+
570
+ // Schedule next execution if job is still enabled
571
+ if (job.enabled) {
572
+ this._scheduleNextExecution(jobName);
573
+ }
574
+
575
+ // Throw error if all retries failed
576
+ if (lastError && status !== 'success') {
577
+ throw lastError;
578
+ }
579
+ } finally {
580
+ // Always release the distributed lock
581
+ await tryFn(() => this.lockResource.delete(lockId));
516
582
  }
517
583
  }
518
584
 
@@ -548,17 +614,18 @@ export class SchedulerPlugin extends Plugin {
548
614
 
549
615
  /**
550
616
  * Manually trigger a job execution
617
+ * Note: Race conditions are prevented by distributed locking in _executeJob()
551
618
  */
552
619
  async runJob(jobName, context = {}) {
553
620
  const job = this.jobs.get(jobName);
554
621
  if (!job) {
555
622
  throw new Error(`Job '${jobName}' not found`);
556
623
  }
557
-
624
+
558
625
  if (this.activeJobs.has(jobName)) {
559
626
  throw new Error(`Job '${jobName}' is already running`);
560
627
  }
561
-
628
+
562
629
  await this._executeJob(jobName);
563
630
  }
564
631
 
@@ -647,33 +714,32 @@ export class SchedulerPlugin extends Plugin {
647
714
  if (!this.config.persistJobs) {
648
715
  return [];
649
716
  }
650
-
717
+
651
718
  const { limit = 50, status = null } = options;
652
-
653
- // Get all history first, then filter client-side
654
- const [ok, err, allHistory] = await tryFn(() =>
655
- this.database.resource(this.config.jobHistoryResource).list({
656
- orderBy: { startTime: 'desc' },
657
- limit: limit * 2 // Get more to allow for filtering
658
- })
719
+
720
+ // Build query to use partition (byJob)
721
+ const queryParams = {
722
+ jobName // Uses byJob partition for efficient lookup
723
+ };
724
+
725
+ if (status) {
726
+ queryParams.status = status;
727
+ }
728
+
729
+ // Use query() to leverage partitions instead of list() + filter
730
+ const [ok, err, history] = await tryFn(() =>
731
+ this.database.resource(this.config.jobHistoryResource).query(queryParams)
659
732
  );
660
-
733
+
661
734
  if (!ok) {
662
735
  if (this.config.verbose) {
663
736
  console.warn(`[SchedulerPlugin] Failed to get job history:`, err.message);
664
737
  }
665
738
  return [];
666
739
  }
667
-
668
- // Filter client-side
669
- let filtered = allHistory.filter(h => h.jobName === jobName);
670
-
671
- if (status) {
672
- filtered = filtered.filter(h => h.status === status);
673
- }
674
-
740
+
675
741
  // Sort by startTime descending and limit
676
- filtered = filtered.sort((a, b) => b.startTime - a.startTime).slice(0, limit);
742
+ let filtered = history.sort((a, b) => b.startTime - a.startTime).slice(0, limit);
677
743
 
678
744
  return filtered.map(h => {
679
745
  let result = null;
@@ -791,13 +857,9 @@ export class SchedulerPlugin extends Plugin {
791
857
  clearTimeout(timer);
792
858
  }
793
859
  this.timers.clear();
794
-
860
+
795
861
  // For tests, don't wait for active jobs - they may be mocked
796
- const isTestEnvironment = process.env.NODE_ENV === 'test' ||
797
- process.env.JEST_WORKER_ID !== undefined ||
798
- global.expect !== undefined;
799
-
800
- if (!isTestEnvironment && this.activeJobs.size > 0) {
862
+ if (!this._isTestEnvironment() && this.activeJobs.size > 0) {
801
863
  if (this.config.verbose) {
802
864
  console.log(`[SchedulerPlugin] Waiting for ${this.activeJobs.size} active jobs to complete...`);
803
865
  }
@@ -814,9 +876,9 @@ export class SchedulerPlugin extends Plugin {
814
876
  console.warn(`[SchedulerPlugin] ${this.activeJobs.size} jobs still running after timeout`);
815
877
  }
816
878
  }
817
-
879
+
818
880
  // Clear active jobs in test environment
819
- if (isTestEnvironment) {
881
+ if (this._isTestEnvironment()) {
820
882
  this.activeJobs.clear();
821
883
  }
822
884
  }