@nicnocquee/dataqueue 1.30.0 → 1.31.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 +769 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +216 -1
- package/dist/index.d.ts +216 -1
- package/dist/index.js +768 -4
- 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 +69 -0
- package/src/backends/postgres.ts +331 -1
- package/src/backends/redis.test.ts +350 -0
- package/src/backends/redis.ts +389 -1
- package/src/cron.test.ts +126 -0
- package/src/cron.ts +40 -0
- package/src/index.test.ts +361 -0
- package/src/index.ts +157 -4
- package/src/processor.ts +22 -4
- package/src/types.ts +149 -0
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
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -14,12 +14,16 @@ import {
|
|
|
14
14
|
JobType,
|
|
15
15
|
PostgresJobQueueConfig,
|
|
16
16
|
RedisJobQueueConfig,
|
|
17
|
+
CronScheduleOptions,
|
|
18
|
+
CronScheduleStatus,
|
|
19
|
+
EditCronScheduleOptions,
|
|
17
20
|
} from './types.js';
|
|
18
|
-
import { QueueBackend } from './backend.js';
|
|
21
|
+
import { QueueBackend, CronScheduleInput } from './backend.js';
|
|
19
22
|
import { setLogContext } from './log-context.js';
|
|
20
23
|
import { createPool } from './db-util.js';
|
|
21
24
|
import { PostgresBackend } from './backends/postgres.js';
|
|
22
25
|
import { RedisBackend } from './backends/redis.js';
|
|
26
|
+
import { getNextCronOccurrence, validateCronExpression } from './cron.js';
|
|
23
27
|
|
|
24
28
|
/**
|
|
25
29
|
* Initialize the job queue system.
|
|
@@ -56,6 +60,66 @@ export const initJobQueue = <PayloadMap = any>(
|
|
|
56
60
|
return pool;
|
|
57
61
|
};
|
|
58
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Enqueue due cron jobs. Shared by the public API and the processor hook.
|
|
65
|
+
*/
|
|
66
|
+
const enqueueDueCronJobsImpl = async (): Promise<number> => {
|
|
67
|
+
const dueSchedules = await backend.getDueCronSchedules();
|
|
68
|
+
let count = 0;
|
|
69
|
+
|
|
70
|
+
for (const schedule of dueSchedules) {
|
|
71
|
+
// Overlap check: skip if allowOverlap is false and last job is still active
|
|
72
|
+
if (!schedule.allowOverlap && schedule.lastJobId !== null) {
|
|
73
|
+
const lastJob = await backend.getJob(schedule.lastJobId);
|
|
74
|
+
if (
|
|
75
|
+
lastJob &&
|
|
76
|
+
(lastJob.status === 'pending' ||
|
|
77
|
+
lastJob.status === 'processing' ||
|
|
78
|
+
lastJob.status === 'waiting')
|
|
79
|
+
) {
|
|
80
|
+
// Still active — advance nextRunAt but don't enqueue
|
|
81
|
+
const nextRunAt = getNextCronOccurrence(
|
|
82
|
+
schedule.cronExpression,
|
|
83
|
+
schedule.timezone,
|
|
84
|
+
);
|
|
85
|
+
await backend.updateCronScheduleAfterEnqueue(
|
|
86
|
+
schedule.id,
|
|
87
|
+
new Date(),
|
|
88
|
+
schedule.lastJobId,
|
|
89
|
+
nextRunAt,
|
|
90
|
+
);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Enqueue a new job instance
|
|
96
|
+
const jobId = await backend.addJob<any, any>({
|
|
97
|
+
jobType: schedule.jobType,
|
|
98
|
+
payload: schedule.payload,
|
|
99
|
+
maxAttempts: schedule.maxAttempts,
|
|
100
|
+
priority: schedule.priority,
|
|
101
|
+
timeoutMs: schedule.timeoutMs ?? undefined,
|
|
102
|
+
forceKillOnTimeout: schedule.forceKillOnTimeout,
|
|
103
|
+
tags: schedule.tags,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Advance to next occurrence
|
|
107
|
+
const nextRunAt = getNextCronOccurrence(
|
|
108
|
+
schedule.cronExpression,
|
|
109
|
+
schedule.timezone,
|
|
110
|
+
);
|
|
111
|
+
await backend.updateCronScheduleAfterEnqueue(
|
|
112
|
+
schedule.id,
|
|
113
|
+
new Date(),
|
|
114
|
+
jobId,
|
|
115
|
+
nextRunAt,
|
|
116
|
+
);
|
|
117
|
+
count++;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return count;
|
|
121
|
+
};
|
|
122
|
+
|
|
59
123
|
// Return the job queue API
|
|
60
124
|
return {
|
|
61
125
|
// Job queue operations
|
|
@@ -153,11 +217,14 @@ export const initJobQueue = <PayloadMap = any>(
|
|
|
153
217
|
config.verbose ?? false,
|
|
154
218
|
),
|
|
155
219
|
|
|
156
|
-
// Job processing
|
|
220
|
+
// Job processing — automatically enqueues due cron jobs before each batch
|
|
157
221
|
createProcessor: (
|
|
158
222
|
handlers: JobHandlers<PayloadMap>,
|
|
159
223
|
options?: ProcessorOptions,
|
|
160
|
-
) =>
|
|
224
|
+
) =>
|
|
225
|
+
createProcessor<PayloadMap>(backend, handlers, options, async () => {
|
|
226
|
+
await enqueueDueCronJobsImpl();
|
|
227
|
+
}),
|
|
161
228
|
|
|
162
229
|
// Job events
|
|
163
230
|
getJobEvents: withLogContext(
|
|
@@ -185,6 +252,91 @@ export const initJobQueue = <PayloadMap = any>(
|
|
|
185
252
|
config.verbose ?? false,
|
|
186
253
|
),
|
|
187
254
|
|
|
255
|
+
// Cron schedule operations
|
|
256
|
+
addCronJob: withLogContext(
|
|
257
|
+
<T extends JobType<PayloadMap>>(
|
|
258
|
+
options: CronScheduleOptions<PayloadMap, T>,
|
|
259
|
+
) => {
|
|
260
|
+
if (!validateCronExpression(options.cronExpression)) {
|
|
261
|
+
return Promise.reject(
|
|
262
|
+
new Error(`Invalid cron expression: "${options.cronExpression}"`),
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
const nextRunAt = getNextCronOccurrence(
|
|
266
|
+
options.cronExpression,
|
|
267
|
+
options.timezone ?? 'UTC',
|
|
268
|
+
);
|
|
269
|
+
const input: CronScheduleInput = {
|
|
270
|
+
scheduleName: options.scheduleName,
|
|
271
|
+
cronExpression: options.cronExpression,
|
|
272
|
+
jobType: options.jobType as string,
|
|
273
|
+
payload: options.payload,
|
|
274
|
+
maxAttempts: options.maxAttempts ?? 3,
|
|
275
|
+
priority: options.priority ?? 0,
|
|
276
|
+
timeoutMs: options.timeoutMs ?? null,
|
|
277
|
+
forceKillOnTimeout: options.forceKillOnTimeout ?? false,
|
|
278
|
+
tags: options.tags,
|
|
279
|
+
timezone: options.timezone ?? 'UTC',
|
|
280
|
+
allowOverlap: options.allowOverlap ?? false,
|
|
281
|
+
nextRunAt,
|
|
282
|
+
};
|
|
283
|
+
return backend.addCronSchedule(input);
|
|
284
|
+
},
|
|
285
|
+
config.verbose ?? false,
|
|
286
|
+
),
|
|
287
|
+
getCronJob: withLogContext(
|
|
288
|
+
(id: number) => backend.getCronSchedule(id),
|
|
289
|
+
config.verbose ?? false,
|
|
290
|
+
),
|
|
291
|
+
getCronJobByName: withLogContext(
|
|
292
|
+
(name: string) => backend.getCronScheduleByName(name),
|
|
293
|
+
config.verbose ?? false,
|
|
294
|
+
),
|
|
295
|
+
listCronJobs: withLogContext(
|
|
296
|
+
(status?: CronScheduleStatus) => backend.listCronSchedules(status),
|
|
297
|
+
config.verbose ?? false,
|
|
298
|
+
),
|
|
299
|
+
removeCronJob: withLogContext(
|
|
300
|
+
(id: number) => backend.removeCronSchedule(id),
|
|
301
|
+
config.verbose ?? false,
|
|
302
|
+
),
|
|
303
|
+
pauseCronJob: withLogContext(
|
|
304
|
+
(id: number) => backend.pauseCronSchedule(id),
|
|
305
|
+
config.verbose ?? false,
|
|
306
|
+
),
|
|
307
|
+
resumeCronJob: withLogContext(
|
|
308
|
+
(id: number) => backend.resumeCronSchedule(id),
|
|
309
|
+
config.verbose ?? false,
|
|
310
|
+
),
|
|
311
|
+
editCronJob: withLogContext(
|
|
312
|
+
async (id: number, updates: EditCronScheduleOptions) => {
|
|
313
|
+
if (
|
|
314
|
+
updates.cronExpression !== undefined &&
|
|
315
|
+
!validateCronExpression(updates.cronExpression)
|
|
316
|
+
) {
|
|
317
|
+
throw new Error(
|
|
318
|
+
`Invalid cron expression: "${updates.cronExpression}"`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
let nextRunAt: Date | null | undefined;
|
|
322
|
+
if (
|
|
323
|
+
updates.cronExpression !== undefined ||
|
|
324
|
+
updates.timezone !== undefined
|
|
325
|
+
) {
|
|
326
|
+
const existing = await backend.getCronSchedule(id);
|
|
327
|
+
const expr = updates.cronExpression ?? existing?.cronExpression ?? '';
|
|
328
|
+
const tz = updates.timezone ?? existing?.timezone ?? 'UTC';
|
|
329
|
+
nextRunAt = getNextCronOccurrence(expr, tz);
|
|
330
|
+
}
|
|
331
|
+
await backend.editCronSchedule(id, updates, nextRunAt);
|
|
332
|
+
},
|
|
333
|
+
config.verbose ?? false,
|
|
334
|
+
),
|
|
335
|
+
enqueueDueCronJobs: withLogContext(
|
|
336
|
+
() => enqueueDueCronJobsImpl(),
|
|
337
|
+
config.verbose ?? false,
|
|
338
|
+
),
|
|
339
|
+
|
|
188
340
|
// Advanced access
|
|
189
341
|
getPool: () => {
|
|
190
342
|
if (backendType !== 'postgres') {
|
|
@@ -213,9 +365,10 @@ const withLogContext =
|
|
|
213
365
|
};
|
|
214
366
|
|
|
215
367
|
export * from './types.js';
|
|
216
|
-
export { QueueBackend } from './backend.js';
|
|
368
|
+
export { QueueBackend, CronScheduleInput } from './backend.js';
|
|
217
369
|
export { PostgresBackend } from './backends/postgres.js';
|
|
218
370
|
export {
|
|
219
371
|
validateHandlerSerializable,
|
|
220
372
|
testHandlerSerialization,
|
|
221
373
|
} from './handler-validation.js';
|
|
374
|
+
export { getNextCronOccurrence, validateCronExpression } from './cron.js';
|
package/src/processor.ts
CHANGED
|
@@ -818,16 +818,18 @@ export async function processBatchWithHandlers<PayloadMap>(
|
|
|
818
818
|
}
|
|
819
819
|
|
|
820
820
|
/**
|
|
821
|
-
* Start a job processor that continuously processes jobs
|
|
822
|
-
* @param backend - The queue backend
|
|
823
|
-
* @param handlers - The job handlers for this processor instance
|
|
821
|
+
* Start a job processor that continuously processes jobs.
|
|
822
|
+
* @param backend - The queue backend.
|
|
823
|
+
* @param handlers - The job handlers for this processor instance.
|
|
824
824
|
* @param options - The processor options. Leave pollInterval empty to run only once. Use jobType to filter jobs by type.
|
|
825
|
-
* @
|
|
825
|
+
* @param onBeforeBatch - Optional callback invoked before each batch. Used internally to enqueue due cron jobs.
|
|
826
|
+
* @returns {Processor} The processor instance.
|
|
826
827
|
*/
|
|
827
828
|
export const createProcessor = <PayloadMap = any>(
|
|
828
829
|
backend: QueueBackend,
|
|
829
830
|
handlers: JobHandlers<PayloadMap>,
|
|
830
831
|
options: ProcessorOptions = {},
|
|
832
|
+
onBeforeBatch?: () => Promise<void>,
|
|
831
833
|
): Processor => {
|
|
832
834
|
const {
|
|
833
835
|
workerId = `worker-${Math.random().toString(36).substring(2, 9)}`,
|
|
@@ -847,6 +849,22 @@ export const createProcessor = <PayloadMap = any>(
|
|
|
847
849
|
const processJobs = async (): Promise<number> => {
|
|
848
850
|
if (!running) return 0;
|
|
849
851
|
|
|
852
|
+
// Run pre-batch hook (e.g. enqueue due cron jobs) before processing
|
|
853
|
+
if (onBeforeBatch) {
|
|
854
|
+
try {
|
|
855
|
+
await onBeforeBatch();
|
|
856
|
+
} catch (hookError) {
|
|
857
|
+
log(`onBeforeBatch hook error: ${hookError}`);
|
|
858
|
+
if (onError) {
|
|
859
|
+
onError(
|
|
860
|
+
hookError instanceof Error
|
|
861
|
+
? hookError
|
|
862
|
+
: new Error(String(hookError)),
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
850
868
|
log(
|
|
851
869
|
`Processing jobs with workerId: ${workerId}${jobType ? ` and jobType: ${Array.isArray(jobType) ? jobType.join(',') : jobType}` : ''}`,
|
|
852
870
|
);
|