@nicnocquee/dataqueue 1.30.0 → 1.32.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.
- package/dist/index.cjs +2531 -1283
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +367 -17
- package/dist/index.d.ts +367 -17
- package/dist/index.js +2530 -1284
- package/dist/index.js.map +1 -1
- package/migrations/1781200000004_create_cron_schedules_table.sql +33 -0
- package/package.json +3 -2
- package/src/backend.ts +139 -4
- package/src/backends/postgres.ts +676 -30
- package/src/backends/redis-scripts.ts +197 -22
- package/src/backends/redis.test.ts +971 -0
- package/src/backends/redis.ts +789 -22
- package/src/cron.test.ts +126 -0
- package/src/cron.ts +40 -0
- package/src/index.test.ts +361 -0
- package/src/index.ts +165 -29
- package/src/processor.ts +36 -97
- package/src/queue.test.ts +29 -0
- package/src/queue.ts +19 -251
- package/src/types.ts +177 -10
package/src/cron.test.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
2
|
+
import { getNextCronOccurrence, validateCronExpression } from './cron.js';
|
|
3
|
+
|
|
4
|
+
describe('getNextCronOccurrence', () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.restoreAllMocks();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('returns the next occurrence for a every-5-minutes expression', () => {
|
|
10
|
+
// Setup
|
|
11
|
+
const after = new Date('2026-01-15T10:02:00Z');
|
|
12
|
+
|
|
13
|
+
// Act
|
|
14
|
+
const next = getNextCronOccurrence('*/5 * * * *', 'UTC', after);
|
|
15
|
+
|
|
16
|
+
// Assert
|
|
17
|
+
expect(next).toEqual(new Date('2026-01-15T10:05:00Z'));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns the next occurrence for a daily-at-midnight expression', () => {
|
|
21
|
+
// Setup
|
|
22
|
+
const after = new Date('2026-01-15T10:00:00Z');
|
|
23
|
+
|
|
24
|
+
// Act
|
|
25
|
+
const next = getNextCronOccurrence('0 0 * * *', 'UTC', after);
|
|
26
|
+
|
|
27
|
+
// Assert
|
|
28
|
+
expect(next).toEqual(new Date('2026-01-16T00:00:00Z'));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('uses the current time when after is not provided', () => {
|
|
32
|
+
// Act
|
|
33
|
+
const next = getNextCronOccurrence('*/5 * * * *');
|
|
34
|
+
|
|
35
|
+
// Assert
|
|
36
|
+
expect(next).toBeInstanceOf(Date);
|
|
37
|
+
expect(next!.getTime()).toBeGreaterThan(Date.now() - 1000);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('respects a non-UTC timezone', () => {
|
|
41
|
+
// Setup — 10:02 UTC is 19:02 in Asia/Tokyo (UTC+9)
|
|
42
|
+
const after = new Date('2026-01-15T10:02:00Z');
|
|
43
|
+
|
|
44
|
+
// Act — "0 20 * * *" = daily at 20:00 Tokyo time = 11:00 UTC
|
|
45
|
+
const next = getNextCronOccurrence('0 20 * * *', 'Asia/Tokyo', after);
|
|
46
|
+
|
|
47
|
+
// Assert
|
|
48
|
+
expect(next).toEqual(new Date('2026-01-15T11:00:00Z'));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns null when expression cannot produce a future match', () => {
|
|
52
|
+
// Setup — Feb 30 never exists: "0 0 30 2 *"
|
|
53
|
+
const after = new Date('2026-01-01T00:00:00Z');
|
|
54
|
+
|
|
55
|
+
// Act
|
|
56
|
+
const next = getNextCronOccurrence('0 0 30 2 *', 'UTC', after);
|
|
57
|
+
|
|
58
|
+
// Assert
|
|
59
|
+
expect(next).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('defaults to UTC timezone', () => {
|
|
63
|
+
// Setup
|
|
64
|
+
const after = new Date('2026-06-01T23:58:00Z');
|
|
65
|
+
|
|
66
|
+
// Act
|
|
67
|
+
const next = getNextCronOccurrence('0 0 * * *', undefined, after);
|
|
68
|
+
|
|
69
|
+
// Assert
|
|
70
|
+
expect(next).toEqual(new Date('2026-06-02T00:00:00Z'));
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('validateCronExpression', () => {
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
vi.restoreAllMocks();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns true for a valid every-minute expression', () => {
|
|
80
|
+
// Act
|
|
81
|
+
const result = validateCronExpression('* * * * *');
|
|
82
|
+
|
|
83
|
+
// Assert
|
|
84
|
+
expect(result).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns true for a valid complex expression', () => {
|
|
88
|
+
// Act
|
|
89
|
+
const result = validateCronExpression('0 9-17 * * 1-5');
|
|
90
|
+
|
|
91
|
+
// Assert
|
|
92
|
+
expect(result).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('returns false for an invalid expression with too few fields', () => {
|
|
96
|
+
// Act
|
|
97
|
+
const result = validateCronExpression('* *');
|
|
98
|
+
|
|
99
|
+
// Assert
|
|
100
|
+
expect(result).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('returns false for an empty string', () => {
|
|
104
|
+
// Act
|
|
105
|
+
const result = validateCronExpression('');
|
|
106
|
+
|
|
107
|
+
// Assert
|
|
108
|
+
expect(result).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('returns false for a completely invalid string', () => {
|
|
112
|
+
// Act
|
|
113
|
+
const result = validateCronExpression('not a cron expression');
|
|
114
|
+
|
|
115
|
+
// Assert
|
|
116
|
+
expect(result).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('returns true for an expression with step values', () => {
|
|
120
|
+
// Act
|
|
121
|
+
const result = validateCronExpression('*/15 * * * *');
|
|
122
|
+
|
|
123
|
+
// Assert
|
|
124
|
+
expect(result).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
});
|
package/src/cron.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Cron } from 'croner';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Calculate the next occurrence of a cron expression after a given date.
|
|
5
|
+
*
|
|
6
|
+
* @param cronExpression - A standard cron expression (5 fields, e.g. "0 * * * *").
|
|
7
|
+
* @param timezone - IANA timezone string (default: "UTC").
|
|
8
|
+
* @param after - The reference date to compute the next run from (default: now).
|
|
9
|
+
* @param CronImpl - Cron class for dependency injection (default: croner's Cron).
|
|
10
|
+
* @returns The next occurrence as a Date, or null if the expression will never fire again.
|
|
11
|
+
*/
|
|
12
|
+
export function getNextCronOccurrence(
|
|
13
|
+
cronExpression: string,
|
|
14
|
+
timezone: string = 'UTC',
|
|
15
|
+
after?: Date,
|
|
16
|
+
CronImpl: typeof Cron = Cron,
|
|
17
|
+
): Date | null {
|
|
18
|
+
const cron = new CronImpl(cronExpression, { timezone });
|
|
19
|
+
const next = cron.nextRun(after ?? new Date());
|
|
20
|
+
return next ?? null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Validate whether a string is a syntactically correct cron expression.
|
|
25
|
+
*
|
|
26
|
+
* @param cronExpression - The cron expression to validate.
|
|
27
|
+
* @param CronImpl - Cron class for dependency injection (default: croner's Cron).
|
|
28
|
+
* @returns True if the expression is valid, false otherwise.
|
|
29
|
+
*/
|
|
30
|
+
export function validateCronExpression(
|
|
31
|
+
cronExpression: string,
|
|
32
|
+
CronImpl: typeof Cron = Cron,
|
|
33
|
+
): boolean {
|
|
34
|
+
try {
|
|
35
|
+
new CronImpl(cronExpression);
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/index.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
|
2
2
|
import { initJobQueue, JobQueueConfig } from './index.js';
|
|
3
3
|
import { createTestDbAndPool, destroyTestDb } from './test-util.js';
|
|
4
4
|
import { Pool } from 'pg';
|
|
5
|
+
import type { CronScheduleRecord } from './types.js';
|
|
5
6
|
|
|
6
7
|
// Integration tests for index.ts
|
|
7
8
|
|
|
@@ -532,3 +533,363 @@ describe('index integration', () => {
|
|
|
532
533
|
});
|
|
533
534
|
});
|
|
534
535
|
});
|
|
536
|
+
|
|
537
|
+
describe('cron schedules integration', () => {
|
|
538
|
+
let pool: Pool;
|
|
539
|
+
let dbName: string;
|
|
540
|
+
let testDbUrl: string;
|
|
541
|
+
let jobQueue: ReturnType<typeof initJobQueue<TestPayloadMap>>;
|
|
542
|
+
|
|
543
|
+
beforeEach(async () => {
|
|
544
|
+
const setup = await createTestDbAndPool();
|
|
545
|
+
pool = setup.pool;
|
|
546
|
+
dbName = setup.dbName;
|
|
547
|
+
testDbUrl = setup.testDbUrl;
|
|
548
|
+
const config: JobQueueConfig = {
|
|
549
|
+
databaseConfig: {
|
|
550
|
+
connectionString: testDbUrl,
|
|
551
|
+
},
|
|
552
|
+
};
|
|
553
|
+
jobQueue = initJobQueue<TestPayloadMap>(config);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
afterEach(async () => {
|
|
557
|
+
vi.restoreAllMocks();
|
|
558
|
+
jobQueue.getPool().end();
|
|
559
|
+
await pool.end();
|
|
560
|
+
await destroyTestDb(dbName);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('creates a cron schedule and retrieves it by ID', async () => {
|
|
564
|
+
// Act
|
|
565
|
+
const id = await jobQueue.addCronJob({
|
|
566
|
+
scheduleName: 'every-5-min-email',
|
|
567
|
+
cronExpression: '*/5 * * * *',
|
|
568
|
+
jobType: 'email',
|
|
569
|
+
payload: { to: 'cron@example.com' },
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
// Assert
|
|
573
|
+
const schedule = await jobQueue.getCronJob(id);
|
|
574
|
+
expect(schedule).not.toBeNull();
|
|
575
|
+
expect(schedule!.scheduleName).toBe('every-5-min-email');
|
|
576
|
+
expect(schedule!.cronExpression).toBe('*/5 * * * *');
|
|
577
|
+
expect(schedule!.jobType).toBe('email');
|
|
578
|
+
expect(schedule!.payload).toEqual({ to: 'cron@example.com' });
|
|
579
|
+
expect(schedule!.status).toBe('active');
|
|
580
|
+
expect(schedule!.allowOverlap).toBe(false);
|
|
581
|
+
expect(schedule!.timezone).toBe('UTC');
|
|
582
|
+
expect(schedule!.nextRunAt).toBeInstanceOf(Date);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it('retrieves a cron schedule by name', async () => {
|
|
586
|
+
// Setup
|
|
587
|
+
await jobQueue.addCronJob({
|
|
588
|
+
scheduleName: 'my-schedule',
|
|
589
|
+
cronExpression: '0 * * * *',
|
|
590
|
+
jobType: 'email',
|
|
591
|
+
payload: { to: 'test@example.com' },
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// Act
|
|
595
|
+
const schedule = await jobQueue.getCronJobByName('my-schedule');
|
|
596
|
+
|
|
597
|
+
// Assert
|
|
598
|
+
expect(schedule).not.toBeNull();
|
|
599
|
+
expect(schedule!.scheduleName).toBe('my-schedule');
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('returns null for nonexistent schedule', async () => {
|
|
603
|
+
// Act
|
|
604
|
+
const byId = await jobQueue.getCronJob(99999);
|
|
605
|
+
const byName = await jobQueue.getCronJobByName('nonexistent');
|
|
606
|
+
|
|
607
|
+
// Assert
|
|
608
|
+
expect(byId).toBeNull();
|
|
609
|
+
expect(byName).toBeNull();
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it('rejects duplicate schedule names', async () => {
|
|
613
|
+
// Setup
|
|
614
|
+
await jobQueue.addCronJob({
|
|
615
|
+
scheduleName: 'unique-name',
|
|
616
|
+
cronExpression: '* * * * *',
|
|
617
|
+
jobType: 'email',
|
|
618
|
+
payload: { to: 'a@example.com' },
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
// Act & Assert
|
|
622
|
+
await expect(
|
|
623
|
+
jobQueue.addCronJob({
|
|
624
|
+
scheduleName: 'unique-name',
|
|
625
|
+
cronExpression: '*/5 * * * *',
|
|
626
|
+
jobType: 'sms',
|
|
627
|
+
payload: { to: 'b@example.com' },
|
|
628
|
+
}),
|
|
629
|
+
).rejects.toThrow();
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it('rejects invalid cron expressions', async () => {
|
|
633
|
+
// Act & Assert
|
|
634
|
+
await expect(
|
|
635
|
+
jobQueue.addCronJob({
|
|
636
|
+
scheduleName: 'bad-cron',
|
|
637
|
+
cronExpression: 'not a cron',
|
|
638
|
+
jobType: 'email',
|
|
639
|
+
payload: { to: 'a@example.com' },
|
|
640
|
+
}),
|
|
641
|
+
).rejects.toThrow('Invalid cron expression');
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('lists active and paused schedules', async () => {
|
|
645
|
+
// Setup
|
|
646
|
+
const id1 = await jobQueue.addCronJob({
|
|
647
|
+
scheduleName: 'schedule-1',
|
|
648
|
+
cronExpression: '* * * * *',
|
|
649
|
+
jobType: 'email',
|
|
650
|
+
payload: { to: 'a@example.com' },
|
|
651
|
+
});
|
|
652
|
+
await jobQueue.addCronJob({
|
|
653
|
+
scheduleName: 'schedule-2',
|
|
654
|
+
cronExpression: '*/5 * * * *',
|
|
655
|
+
jobType: 'sms',
|
|
656
|
+
payload: { to: 'b@example.com' },
|
|
657
|
+
});
|
|
658
|
+
await jobQueue.pauseCronJob(id1);
|
|
659
|
+
|
|
660
|
+
// Act
|
|
661
|
+
const all = await jobQueue.listCronJobs();
|
|
662
|
+
const active = await jobQueue.listCronJobs('active');
|
|
663
|
+
const paused = await jobQueue.listCronJobs('paused');
|
|
664
|
+
|
|
665
|
+
// Assert
|
|
666
|
+
expect(all).toHaveLength(2);
|
|
667
|
+
expect(active).toHaveLength(1);
|
|
668
|
+
expect(active[0].scheduleName).toBe('schedule-2');
|
|
669
|
+
expect(paused).toHaveLength(1);
|
|
670
|
+
expect(paused[0].scheduleName).toBe('schedule-1');
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('pauses and resumes a schedule', async () => {
|
|
674
|
+
// Setup
|
|
675
|
+
const id = await jobQueue.addCronJob({
|
|
676
|
+
scheduleName: 'pausable',
|
|
677
|
+
cronExpression: '* * * * *',
|
|
678
|
+
jobType: 'email',
|
|
679
|
+
payload: { to: 'a@example.com' },
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
// Act — pause
|
|
683
|
+
await jobQueue.pauseCronJob(id);
|
|
684
|
+
const paused = await jobQueue.getCronJob(id);
|
|
685
|
+
|
|
686
|
+
// Assert
|
|
687
|
+
expect(paused!.status).toBe('paused');
|
|
688
|
+
|
|
689
|
+
// Act — resume
|
|
690
|
+
await jobQueue.resumeCronJob(id);
|
|
691
|
+
const resumed = await jobQueue.getCronJob(id);
|
|
692
|
+
|
|
693
|
+
// Assert
|
|
694
|
+
expect(resumed!.status).toBe('active');
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it('edits a schedule and recalculates nextRunAt when expression changes', async () => {
|
|
698
|
+
// Setup
|
|
699
|
+
const id = await jobQueue.addCronJob({
|
|
700
|
+
scheduleName: 'editable',
|
|
701
|
+
cronExpression: '* * * * *',
|
|
702
|
+
jobType: 'email',
|
|
703
|
+
payload: { to: 'old@example.com' },
|
|
704
|
+
});
|
|
705
|
+
const before = await jobQueue.getCronJob(id);
|
|
706
|
+
|
|
707
|
+
// Act
|
|
708
|
+
await jobQueue.editCronJob(id, {
|
|
709
|
+
cronExpression: '0 0 * * *',
|
|
710
|
+
payload: { to: 'new@example.com' },
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
// Assert
|
|
714
|
+
const after = await jobQueue.getCronJob(id);
|
|
715
|
+
expect(after!.cronExpression).toBe('0 0 * * *');
|
|
716
|
+
expect(after!.payload).toEqual({ to: 'new@example.com' });
|
|
717
|
+
expect(after!.nextRunAt!.getTime()).not.toBe(before!.nextRunAt!.getTime());
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it('removes a schedule', async () => {
|
|
721
|
+
// Setup
|
|
722
|
+
const id = await jobQueue.addCronJob({
|
|
723
|
+
scheduleName: 'removable',
|
|
724
|
+
cronExpression: '* * * * *',
|
|
725
|
+
jobType: 'email',
|
|
726
|
+
payload: { to: 'a@example.com' },
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
// Act
|
|
730
|
+
await jobQueue.removeCronJob(id);
|
|
731
|
+
|
|
732
|
+
// Assert
|
|
733
|
+
const removed = await jobQueue.getCronJob(id);
|
|
734
|
+
expect(removed).toBeNull();
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it('enqueueDueCronJobs enqueues a job when nextRunAt is due', async () => {
|
|
738
|
+
// Setup — insert a schedule with nextRunAt in the past
|
|
739
|
+
const id = await jobQueue.addCronJob({
|
|
740
|
+
scheduleName: 'due-now',
|
|
741
|
+
cronExpression: '* * * * *',
|
|
742
|
+
jobType: 'email',
|
|
743
|
+
payload: { to: 'due@example.com' },
|
|
744
|
+
});
|
|
745
|
+
// Force nextRunAt to be in the past
|
|
746
|
+
await pool.query(
|
|
747
|
+
`UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
|
|
748
|
+
[id],
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
// Act
|
|
752
|
+
const count = await jobQueue.enqueueDueCronJobs();
|
|
753
|
+
|
|
754
|
+
// Assert
|
|
755
|
+
expect(count).toBe(1);
|
|
756
|
+
const jobs = await jobQueue.getJobsByStatus('pending');
|
|
757
|
+
const cronJob = jobs.find(
|
|
758
|
+
(j) =>
|
|
759
|
+
j.jobType === 'email' && (j.payload as any).to === 'due@example.com',
|
|
760
|
+
);
|
|
761
|
+
expect(cronJob).toBeDefined();
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it('enqueueDueCronJobs advances nextRunAt and sets lastJobId', async () => {
|
|
765
|
+
// Setup
|
|
766
|
+
const id = await jobQueue.addCronJob({
|
|
767
|
+
scheduleName: 'advance-test',
|
|
768
|
+
cronExpression: '* * * * *',
|
|
769
|
+
jobType: 'email',
|
|
770
|
+
payload: { to: 'advance@example.com' },
|
|
771
|
+
});
|
|
772
|
+
await pool.query(
|
|
773
|
+
`UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
|
|
774
|
+
[id],
|
|
775
|
+
);
|
|
776
|
+
|
|
777
|
+
// Act
|
|
778
|
+
await jobQueue.enqueueDueCronJobs();
|
|
779
|
+
|
|
780
|
+
// Assert
|
|
781
|
+
const schedule = await jobQueue.getCronJob(id);
|
|
782
|
+
expect(schedule!.lastJobId).not.toBeNull();
|
|
783
|
+
expect(schedule!.lastEnqueuedAt).toBeInstanceOf(Date);
|
|
784
|
+
expect(schedule!.nextRunAt).toBeInstanceOf(Date);
|
|
785
|
+
expect(schedule!.nextRunAt!.getTime()).toBeGreaterThan(Date.now() - 5000);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it('enqueueDueCronJobs skips paused schedules', async () => {
|
|
789
|
+
// Setup
|
|
790
|
+
const id = await jobQueue.addCronJob({
|
|
791
|
+
scheduleName: 'paused-skip',
|
|
792
|
+
cronExpression: '* * * * *',
|
|
793
|
+
jobType: 'email',
|
|
794
|
+
payload: { to: 'paused@example.com' },
|
|
795
|
+
});
|
|
796
|
+
await pool.query(
|
|
797
|
+
`UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
|
|
798
|
+
[id],
|
|
799
|
+
);
|
|
800
|
+
await jobQueue.pauseCronJob(id);
|
|
801
|
+
|
|
802
|
+
// Act
|
|
803
|
+
const count = await jobQueue.enqueueDueCronJobs();
|
|
804
|
+
|
|
805
|
+
// Assert
|
|
806
|
+
expect(count).toBe(0);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it('enqueueDueCronJobs skips schedules not yet due', async () => {
|
|
810
|
+
// Setup — nextRunAt is calculated to the future by addCronJob
|
|
811
|
+
await jobQueue.addCronJob({
|
|
812
|
+
scheduleName: 'future-schedule',
|
|
813
|
+
cronExpression: '0 0 1 1 *',
|
|
814
|
+
jobType: 'email',
|
|
815
|
+
payload: { to: 'future@example.com' },
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
// Act
|
|
819
|
+
const count = await jobQueue.enqueueDueCronJobs();
|
|
820
|
+
|
|
821
|
+
// Assert
|
|
822
|
+
expect(count).toBe(0);
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
it('enqueueDueCronJobs skips when allowOverlap=false and last job is still active', async () => {
|
|
826
|
+
// Setup
|
|
827
|
+
const id = await jobQueue.addCronJob({
|
|
828
|
+
scheduleName: 'no-overlap',
|
|
829
|
+
cronExpression: '* * * * *',
|
|
830
|
+
jobType: 'email',
|
|
831
|
+
payload: { to: 'overlap@example.com' },
|
|
832
|
+
allowOverlap: false,
|
|
833
|
+
});
|
|
834
|
+
await pool.query(
|
|
835
|
+
`UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
|
|
836
|
+
[id],
|
|
837
|
+
);
|
|
838
|
+
|
|
839
|
+
// First enqueue should succeed
|
|
840
|
+
const count1 = await jobQueue.enqueueDueCronJobs();
|
|
841
|
+
expect(count1).toBe(1);
|
|
842
|
+
|
|
843
|
+
// Set nextRunAt to past again (simulating next tick)
|
|
844
|
+
await pool.query(
|
|
845
|
+
`UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
|
|
846
|
+
[id],
|
|
847
|
+
);
|
|
848
|
+
|
|
849
|
+
// Act — second enqueue should be skipped because previous job is still pending
|
|
850
|
+
const count2 = await jobQueue.enqueueDueCronJobs();
|
|
851
|
+
|
|
852
|
+
// Assert
|
|
853
|
+
expect(count2).toBe(0);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it('enqueueDueCronJobs enqueues when allowOverlap=true even if last job is still active', async () => {
|
|
857
|
+
// Setup
|
|
858
|
+
const id = await jobQueue.addCronJob({
|
|
859
|
+
scheduleName: 'with-overlap',
|
|
860
|
+
cronExpression: '* * * * *',
|
|
861
|
+
jobType: 'email',
|
|
862
|
+
payload: { to: 'overlap@example.com' },
|
|
863
|
+
allowOverlap: true,
|
|
864
|
+
});
|
|
865
|
+
await pool.query(
|
|
866
|
+
`UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
|
|
867
|
+
[id],
|
|
868
|
+
);
|
|
869
|
+
|
|
870
|
+
// First enqueue
|
|
871
|
+
const count1 = await jobQueue.enqueueDueCronJobs();
|
|
872
|
+
expect(count1).toBe(1);
|
|
873
|
+
|
|
874
|
+
// Set nextRunAt to past again
|
|
875
|
+
await pool.query(
|
|
876
|
+
`UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
|
|
877
|
+
[id],
|
|
878
|
+
);
|
|
879
|
+
|
|
880
|
+
// Act — second enqueue should succeed because allowOverlap=true
|
|
881
|
+
const count2 = await jobQueue.enqueueDueCronJobs();
|
|
882
|
+
|
|
883
|
+
// Assert
|
|
884
|
+
expect(count2).toBe(1);
|
|
885
|
+
|
|
886
|
+
// Verify there are two pending jobs
|
|
887
|
+
const jobs = await jobQueue.getJobsByStatus('pending');
|
|
888
|
+
const cronJobs = jobs.filter(
|
|
889
|
+
(j) =>
|
|
890
|
+
j.jobType === 'email' &&
|
|
891
|
+
(j.payload as any).to === 'overlap@example.com',
|
|
892
|
+
);
|
|
893
|
+
expect(cronJobs).toHaveLength(2);
|
|
894
|
+
});
|
|
895
|
+
});
|