@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
|
@@ -383,6 +383,46 @@ describe('Redis backend integration', () => {
|
|
|
383
383
|
expect(job).toBeNull();
|
|
384
384
|
});
|
|
385
385
|
|
|
386
|
+
it('should cleanup old completed jobs in batches', async () => {
|
|
387
|
+
const ids: number[] = [];
|
|
388
|
+
for (let i = 0; i < 5; i++) {
|
|
389
|
+
const jobId = await jobQueue.addJob({
|
|
390
|
+
jobType: 'test',
|
|
391
|
+
payload: { foo: `batch-${i}` },
|
|
392
|
+
});
|
|
393
|
+
ids.push(jobId);
|
|
394
|
+
}
|
|
395
|
+
// Complete all jobs
|
|
396
|
+
const processor = jobQueue.createProcessor({
|
|
397
|
+
email: vi.fn(async () => {}),
|
|
398
|
+
sms: vi.fn(async () => {}),
|
|
399
|
+
test: vi.fn(async () => {}),
|
|
400
|
+
});
|
|
401
|
+
await processor.start();
|
|
402
|
+
for (const id of ids) {
|
|
403
|
+
const job = await jobQueue.getJob(id);
|
|
404
|
+
expect(job?.status).toBe('completed');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Backdate all to 31 days ago
|
|
408
|
+
const oldMs = Date.now() - 31 * 24 * 60 * 60 * 1000;
|
|
409
|
+
for (const id of ids) {
|
|
410
|
+
await redisClient.hset(
|
|
411
|
+
`${prefix}job:${id}`,
|
|
412
|
+
'updatedAt',
|
|
413
|
+
oldMs.toString(),
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Cleanup with small batchSize to force multiple SSCAN iterations
|
|
418
|
+
const deleted = await jobQueue.cleanupOldJobs(30, 2);
|
|
419
|
+
expect(deleted).toBe(5);
|
|
420
|
+
for (const id of ids) {
|
|
421
|
+
const job = await jobQueue.getJob(id);
|
|
422
|
+
expect(job).toBeNull();
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
|
|
386
426
|
it('should reclaim stuck jobs', async () => {
|
|
387
427
|
const jobId = await jobQueue.addJob({
|
|
388
428
|
jobType: 'email',
|
|
@@ -541,3 +581,934 @@ describe('Redis backend integration', () => {
|
|
|
541
581
|
expect(job?.status).toBe('pending');
|
|
542
582
|
});
|
|
543
583
|
});
|
|
584
|
+
|
|
585
|
+
describe('Redis cron schedules integration', () => {
|
|
586
|
+
let prefix: string;
|
|
587
|
+
let jobQueue: ReturnType<typeof initJobQueue<TestPayloadMap>>;
|
|
588
|
+
let redisClient: any;
|
|
589
|
+
|
|
590
|
+
beforeEach(async () => {
|
|
591
|
+
prefix = createRedisTestPrefix();
|
|
592
|
+
const config: RedisJobQueueConfig = {
|
|
593
|
+
backend: 'redis',
|
|
594
|
+
redisConfig: {
|
|
595
|
+
url: REDIS_URL,
|
|
596
|
+
keyPrefix: prefix,
|
|
597
|
+
},
|
|
598
|
+
};
|
|
599
|
+
jobQueue = initJobQueue<TestPayloadMap>(config);
|
|
600
|
+
redisClient = jobQueue.getRedisClient();
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
afterEach(async () => {
|
|
604
|
+
vi.restoreAllMocks();
|
|
605
|
+
await cleanupRedisPrefix(redisClient, prefix);
|
|
606
|
+
await redisClient.quit();
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it('creates a cron schedule and retrieves it by ID', async () => {
|
|
610
|
+
// Act
|
|
611
|
+
const id = await jobQueue.addCronJob({
|
|
612
|
+
scheduleName: 'every-5-min-email',
|
|
613
|
+
cronExpression: '*/5 * * * *',
|
|
614
|
+
jobType: 'email',
|
|
615
|
+
payload: { to: 'cron@example.com' },
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
// Assert
|
|
619
|
+
const schedule = await jobQueue.getCronJob(id);
|
|
620
|
+
expect(schedule).not.toBeNull();
|
|
621
|
+
expect(schedule!.scheduleName).toBe('every-5-min-email');
|
|
622
|
+
expect(schedule!.cronExpression).toBe('*/5 * * * *');
|
|
623
|
+
expect(schedule!.jobType).toBe('email');
|
|
624
|
+
expect(schedule!.payload).toEqual({ to: 'cron@example.com' });
|
|
625
|
+
expect(schedule!.status).toBe('active');
|
|
626
|
+
expect(schedule!.allowOverlap).toBe(false);
|
|
627
|
+
expect(schedule!.timezone).toBe('UTC');
|
|
628
|
+
expect(schedule!.nextRunAt).toBeInstanceOf(Date);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('retrieves a cron schedule by name', async () => {
|
|
632
|
+
// Setup
|
|
633
|
+
await jobQueue.addCronJob({
|
|
634
|
+
scheduleName: 'my-schedule',
|
|
635
|
+
cronExpression: '0 * * * *',
|
|
636
|
+
jobType: 'email',
|
|
637
|
+
payload: { to: 'test@example.com' },
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
// Act
|
|
641
|
+
const schedule = await jobQueue.getCronJobByName('my-schedule');
|
|
642
|
+
|
|
643
|
+
// Assert
|
|
644
|
+
expect(schedule).not.toBeNull();
|
|
645
|
+
expect(schedule!.scheduleName).toBe('my-schedule');
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it('returns null for nonexistent schedule', async () => {
|
|
649
|
+
// Act
|
|
650
|
+
const byId = await jobQueue.getCronJob(99999);
|
|
651
|
+
const byName = await jobQueue.getCronJobByName('nonexistent');
|
|
652
|
+
|
|
653
|
+
// Assert
|
|
654
|
+
expect(byId).toBeNull();
|
|
655
|
+
expect(byName).toBeNull();
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it('rejects duplicate schedule names', async () => {
|
|
659
|
+
// Setup
|
|
660
|
+
await jobQueue.addCronJob({
|
|
661
|
+
scheduleName: 'unique-name',
|
|
662
|
+
cronExpression: '* * * * *',
|
|
663
|
+
jobType: 'email',
|
|
664
|
+
payload: { to: 'a@example.com' },
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// Act & Assert
|
|
668
|
+
await expect(
|
|
669
|
+
jobQueue.addCronJob({
|
|
670
|
+
scheduleName: 'unique-name',
|
|
671
|
+
cronExpression: '*/5 * * * *',
|
|
672
|
+
jobType: 'sms',
|
|
673
|
+
payload: { to: 'b@example.com' },
|
|
674
|
+
}),
|
|
675
|
+
).rejects.toThrow();
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it('rejects invalid cron expressions', async () => {
|
|
679
|
+
// Act & Assert
|
|
680
|
+
await expect(
|
|
681
|
+
jobQueue.addCronJob({
|
|
682
|
+
scheduleName: 'bad-cron',
|
|
683
|
+
cronExpression: 'not a cron',
|
|
684
|
+
jobType: 'email',
|
|
685
|
+
payload: { to: 'a@example.com' },
|
|
686
|
+
}),
|
|
687
|
+
).rejects.toThrow('Invalid cron expression');
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it('lists active and paused schedules', async () => {
|
|
691
|
+
// Setup
|
|
692
|
+
const id1 = await jobQueue.addCronJob({
|
|
693
|
+
scheduleName: 'schedule-1',
|
|
694
|
+
cronExpression: '* * * * *',
|
|
695
|
+
jobType: 'email',
|
|
696
|
+
payload: { to: 'a@example.com' },
|
|
697
|
+
});
|
|
698
|
+
await jobQueue.addCronJob({
|
|
699
|
+
scheduleName: 'schedule-2',
|
|
700
|
+
cronExpression: '*/5 * * * *',
|
|
701
|
+
jobType: 'sms',
|
|
702
|
+
payload: { to: 'b@example.com' },
|
|
703
|
+
});
|
|
704
|
+
await jobQueue.pauseCronJob(id1);
|
|
705
|
+
|
|
706
|
+
// Act
|
|
707
|
+
const all = await jobQueue.listCronJobs();
|
|
708
|
+
const active = await jobQueue.listCronJobs('active');
|
|
709
|
+
const paused = await jobQueue.listCronJobs('paused');
|
|
710
|
+
|
|
711
|
+
// Assert
|
|
712
|
+
expect(all).toHaveLength(2);
|
|
713
|
+
expect(active).toHaveLength(1);
|
|
714
|
+
expect(active[0].scheduleName).toBe('schedule-2');
|
|
715
|
+
expect(paused).toHaveLength(1);
|
|
716
|
+
expect(paused[0].scheduleName).toBe('schedule-1');
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it('pauses and resumes a schedule', async () => {
|
|
720
|
+
// Setup
|
|
721
|
+
const id = await jobQueue.addCronJob({
|
|
722
|
+
scheduleName: 'pausable',
|
|
723
|
+
cronExpression: '* * * * *',
|
|
724
|
+
jobType: 'email',
|
|
725
|
+
payload: { to: 'a@example.com' },
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
// Act — pause
|
|
729
|
+
await jobQueue.pauseCronJob(id);
|
|
730
|
+
const paused = await jobQueue.getCronJob(id);
|
|
731
|
+
|
|
732
|
+
// Assert
|
|
733
|
+
expect(paused!.status).toBe('paused');
|
|
734
|
+
|
|
735
|
+
// Act — resume
|
|
736
|
+
await jobQueue.resumeCronJob(id);
|
|
737
|
+
const resumed = await jobQueue.getCronJob(id);
|
|
738
|
+
|
|
739
|
+
// Assert
|
|
740
|
+
expect(resumed!.status).toBe('active');
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it('edits a schedule and recalculates nextRunAt when expression changes', async () => {
|
|
744
|
+
// Setup
|
|
745
|
+
const id = await jobQueue.addCronJob({
|
|
746
|
+
scheduleName: 'editable',
|
|
747
|
+
cronExpression: '* * * * *',
|
|
748
|
+
jobType: 'email',
|
|
749
|
+
payload: { to: 'old@example.com' },
|
|
750
|
+
});
|
|
751
|
+
const before = await jobQueue.getCronJob(id);
|
|
752
|
+
|
|
753
|
+
// Act
|
|
754
|
+
await jobQueue.editCronJob(id, {
|
|
755
|
+
cronExpression: '0 0 * * *',
|
|
756
|
+
payload: { to: 'new@example.com' },
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
// Assert
|
|
760
|
+
const after = await jobQueue.getCronJob(id);
|
|
761
|
+
expect(after!.cronExpression).toBe('0 0 * * *');
|
|
762
|
+
expect(after!.payload).toEqual({ to: 'new@example.com' });
|
|
763
|
+
expect(after!.nextRunAt!.getTime()).not.toBe(before!.nextRunAt!.getTime());
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it('removes a schedule', async () => {
|
|
767
|
+
// Setup
|
|
768
|
+
const id = await jobQueue.addCronJob({
|
|
769
|
+
scheduleName: 'removable',
|
|
770
|
+
cronExpression: '* * * * *',
|
|
771
|
+
jobType: 'email',
|
|
772
|
+
payload: { to: 'a@example.com' },
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
// Act
|
|
776
|
+
await jobQueue.removeCronJob(id);
|
|
777
|
+
|
|
778
|
+
// Assert
|
|
779
|
+
const removed = await jobQueue.getCronJob(id);
|
|
780
|
+
expect(removed).toBeNull();
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it('enqueueDueCronJobs enqueues a job when nextRunAt is due', async () => {
|
|
784
|
+
// Setup — create schedule then force nextRunAt into the past
|
|
785
|
+
const id = await jobQueue.addCronJob({
|
|
786
|
+
scheduleName: 'due-now',
|
|
787
|
+
cronExpression: '* * * * *',
|
|
788
|
+
jobType: 'email',
|
|
789
|
+
payload: { to: 'due@example.com' },
|
|
790
|
+
});
|
|
791
|
+
const pastMs = (Date.now() - 60_000).toString();
|
|
792
|
+
await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs);
|
|
793
|
+
await redisClient.zadd(`${prefix}cron_due`, Number(pastMs), id.toString());
|
|
794
|
+
|
|
795
|
+
// Act
|
|
796
|
+
const count = await jobQueue.enqueueDueCronJobs();
|
|
797
|
+
|
|
798
|
+
// Assert
|
|
799
|
+
expect(count).toBe(1);
|
|
800
|
+
const jobs = await jobQueue.getJobsByStatus('pending');
|
|
801
|
+
const cronJob = jobs.find(
|
|
802
|
+
(j) =>
|
|
803
|
+
j.jobType === 'email' && (j.payload as any).to === 'due@example.com',
|
|
804
|
+
);
|
|
805
|
+
expect(cronJob).toBeDefined();
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
it('enqueueDueCronJobs advances nextRunAt and sets lastJobId', async () => {
|
|
809
|
+
// Setup
|
|
810
|
+
const id = await jobQueue.addCronJob({
|
|
811
|
+
scheduleName: 'advance-test',
|
|
812
|
+
cronExpression: '* * * * *',
|
|
813
|
+
jobType: 'email',
|
|
814
|
+
payload: { to: 'advance@example.com' },
|
|
815
|
+
});
|
|
816
|
+
const pastMs = (Date.now() - 60_000).toString();
|
|
817
|
+
await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs);
|
|
818
|
+
await redisClient.zadd(`${prefix}cron_due`, Number(pastMs), id.toString());
|
|
819
|
+
|
|
820
|
+
// Act
|
|
821
|
+
await jobQueue.enqueueDueCronJobs();
|
|
822
|
+
|
|
823
|
+
// Assert
|
|
824
|
+
const schedule = await jobQueue.getCronJob(id);
|
|
825
|
+
expect(schedule!.lastJobId).not.toBeNull();
|
|
826
|
+
expect(schedule!.lastEnqueuedAt).toBeInstanceOf(Date);
|
|
827
|
+
expect(schedule!.nextRunAt).toBeInstanceOf(Date);
|
|
828
|
+
expect(schedule!.nextRunAt!.getTime()).toBeGreaterThan(Date.now() - 5000);
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it('enqueueDueCronJobs skips paused schedules', async () => {
|
|
832
|
+
// Setup
|
|
833
|
+
const id = await jobQueue.addCronJob({
|
|
834
|
+
scheduleName: 'paused-skip',
|
|
835
|
+
cronExpression: '* * * * *',
|
|
836
|
+
jobType: 'email',
|
|
837
|
+
payload: { to: 'paused@example.com' },
|
|
838
|
+
});
|
|
839
|
+
const pastMs = (Date.now() - 60_000).toString();
|
|
840
|
+
await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs);
|
|
841
|
+
await redisClient.zadd(`${prefix}cron_due`, Number(pastMs), id.toString());
|
|
842
|
+
await jobQueue.pauseCronJob(id);
|
|
843
|
+
|
|
844
|
+
// Act
|
|
845
|
+
const count = await jobQueue.enqueueDueCronJobs();
|
|
846
|
+
|
|
847
|
+
// Assert
|
|
848
|
+
expect(count).toBe(0);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
it('enqueueDueCronJobs skips schedules not yet due', async () => {
|
|
852
|
+
// Setup — nextRunAt is in the future by default
|
|
853
|
+
await jobQueue.addCronJob({
|
|
854
|
+
scheduleName: 'future-schedule',
|
|
855
|
+
cronExpression: '0 0 1 1 *',
|
|
856
|
+
jobType: 'email',
|
|
857
|
+
payload: { to: 'future@example.com' },
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
// Act
|
|
861
|
+
const count = await jobQueue.enqueueDueCronJobs();
|
|
862
|
+
|
|
863
|
+
// Assert
|
|
864
|
+
expect(count).toBe(0);
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
it('enqueueDueCronJobs skips when allowOverlap=false and last job is still active', async () => {
|
|
868
|
+
// Setup
|
|
869
|
+
const id = await jobQueue.addCronJob({
|
|
870
|
+
scheduleName: 'no-overlap',
|
|
871
|
+
cronExpression: '* * * * *',
|
|
872
|
+
jobType: 'email',
|
|
873
|
+
payload: { to: 'overlap@example.com' },
|
|
874
|
+
allowOverlap: false,
|
|
875
|
+
});
|
|
876
|
+
const pastMs = (Date.now() - 60_000).toString();
|
|
877
|
+
await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs);
|
|
878
|
+
await redisClient.zadd(`${prefix}cron_due`, Number(pastMs), id.toString());
|
|
879
|
+
|
|
880
|
+
// First enqueue should succeed
|
|
881
|
+
const count1 = await jobQueue.enqueueDueCronJobs();
|
|
882
|
+
expect(count1).toBe(1);
|
|
883
|
+
|
|
884
|
+
// Force nextRunAt into the past again
|
|
885
|
+
const pastMs2 = (Date.now() - 60_000).toString();
|
|
886
|
+
await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs2);
|
|
887
|
+
await redisClient.zadd(`${prefix}cron_due`, Number(pastMs2), id.toString());
|
|
888
|
+
|
|
889
|
+
// Act — second enqueue should be skipped because previous job is pending
|
|
890
|
+
const count2 = await jobQueue.enqueueDueCronJobs();
|
|
891
|
+
|
|
892
|
+
// Assert
|
|
893
|
+
expect(count2).toBe(0);
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
it('enqueueDueCronJobs enqueues when allowOverlap=true even if last job is still active', async () => {
|
|
897
|
+
// Setup
|
|
898
|
+
const id = await jobQueue.addCronJob({
|
|
899
|
+
scheduleName: 'with-overlap',
|
|
900
|
+
cronExpression: '* * * * *',
|
|
901
|
+
jobType: 'email',
|
|
902
|
+
payload: { to: 'overlap@example.com' },
|
|
903
|
+
allowOverlap: true,
|
|
904
|
+
});
|
|
905
|
+
const pastMs = (Date.now() - 60_000).toString();
|
|
906
|
+
await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs);
|
|
907
|
+
await redisClient.zadd(`${prefix}cron_due`, Number(pastMs), id.toString());
|
|
908
|
+
|
|
909
|
+
// First enqueue
|
|
910
|
+
const count1 = await jobQueue.enqueueDueCronJobs();
|
|
911
|
+
expect(count1).toBe(1);
|
|
912
|
+
|
|
913
|
+
// Force nextRunAt into the past again
|
|
914
|
+
const pastMs2 = (Date.now() - 60_000).toString();
|
|
915
|
+
await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs2);
|
|
916
|
+
await redisClient.zadd(`${prefix}cron_due`, Number(pastMs2), id.toString());
|
|
917
|
+
|
|
918
|
+
// Act — second enqueue should succeed because allowOverlap=true
|
|
919
|
+
const count2 = await jobQueue.enqueueDueCronJobs();
|
|
920
|
+
|
|
921
|
+
// Assert
|
|
922
|
+
expect(count2).toBe(1);
|
|
923
|
+
|
|
924
|
+
// Verify two pending jobs
|
|
925
|
+
const jobs = await jobQueue.getJobsByStatus('pending');
|
|
926
|
+
const cronJobs = jobs.filter(
|
|
927
|
+
(j) =>
|
|
928
|
+
j.jobType === 'email' &&
|
|
929
|
+
(j.payload as any).to === 'overlap@example.com',
|
|
930
|
+
);
|
|
931
|
+
expect(cronJobs).toHaveLength(2);
|
|
932
|
+
});
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
describe('Redis parity features', () => {
|
|
936
|
+
let prefix: string;
|
|
937
|
+
let jobQueue: ReturnType<typeof initJobQueue<TestPayloadMap>>;
|
|
938
|
+
let redisClient: any;
|
|
939
|
+
|
|
940
|
+
beforeEach(async () => {
|
|
941
|
+
prefix = createRedisTestPrefix();
|
|
942
|
+
const config: RedisJobQueueConfig = {
|
|
943
|
+
backend: 'redis',
|
|
944
|
+
redisConfig: {
|
|
945
|
+
url: REDIS_URL,
|
|
946
|
+
keyPrefix: prefix,
|
|
947
|
+
},
|
|
948
|
+
};
|
|
949
|
+
jobQueue = initJobQueue<TestPayloadMap>(config);
|
|
950
|
+
redisClient = jobQueue.getRedisClient();
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
afterEach(async () => {
|
|
954
|
+
vi.restoreAllMocks();
|
|
955
|
+
await cleanupRedisPrefix(redisClient, prefix);
|
|
956
|
+
await redisClient.quit();
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
// ── Cursor-based pagination ─────────────────────────────────────────
|
|
960
|
+
|
|
961
|
+
it('getJobs supports cursor-based pagination', async () => {
|
|
962
|
+
// Setup
|
|
963
|
+
const id1 = await jobQueue.addJob({
|
|
964
|
+
jobType: 'email',
|
|
965
|
+
payload: { to: 'a@example.com' },
|
|
966
|
+
});
|
|
967
|
+
const id2 = await jobQueue.addJob({
|
|
968
|
+
jobType: 'email',
|
|
969
|
+
payload: { to: 'b@example.com' },
|
|
970
|
+
});
|
|
971
|
+
const id3 = await jobQueue.addJob({
|
|
972
|
+
jobType: 'email',
|
|
973
|
+
payload: { to: 'c@example.com' },
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
// Act — first page (no cursor, limit 2)
|
|
977
|
+
const page1 = await jobQueue.getJobs({}, 2);
|
|
978
|
+
|
|
979
|
+
// Assert
|
|
980
|
+
expect(page1).toHaveLength(2);
|
|
981
|
+
// Descending by id: id3, id2
|
|
982
|
+
expect(page1[0].id).toBe(id3);
|
|
983
|
+
expect(page1[1].id).toBe(id2);
|
|
984
|
+
|
|
985
|
+
// Act — second page using cursor
|
|
986
|
+
const page2 = await jobQueue.getJobs({ cursor: page1[1].id }, 2);
|
|
987
|
+
|
|
988
|
+
// Assert
|
|
989
|
+
expect(page2).toHaveLength(1);
|
|
990
|
+
expect(page2[0].id).toBe(id1);
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
// ── retryJob status validation ──────────────────────────────────────
|
|
994
|
+
|
|
995
|
+
it('retryJob only retries failed or processing jobs', async () => {
|
|
996
|
+
// Setup — completed job
|
|
997
|
+
const jobId = await jobQueue.addJob({
|
|
998
|
+
jobType: 'test',
|
|
999
|
+
payload: { foo: 'retry-test' },
|
|
1000
|
+
});
|
|
1001
|
+
const processor = jobQueue.createProcessor({
|
|
1002
|
+
email: vi.fn(async () => {}),
|
|
1003
|
+
sms: vi.fn(async () => {}),
|
|
1004
|
+
test: vi.fn(async () => {}),
|
|
1005
|
+
});
|
|
1006
|
+
await processor.start();
|
|
1007
|
+
const completedJob = await jobQueue.getJob(jobId);
|
|
1008
|
+
expect(completedJob?.status).toBe('completed');
|
|
1009
|
+
|
|
1010
|
+
// Act — retry a completed job (should be a no-op)
|
|
1011
|
+
await jobQueue.retryJob(jobId);
|
|
1012
|
+
|
|
1013
|
+
// Assert — still completed
|
|
1014
|
+
const job = await jobQueue.getJob(jobId);
|
|
1015
|
+
expect(job?.status).toBe('completed');
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
it('retryJob retries a failed job', async () => {
|
|
1019
|
+
// Setup
|
|
1020
|
+
const jobId = await jobQueue.addJob({
|
|
1021
|
+
jobType: 'email',
|
|
1022
|
+
payload: { to: 'fail-retry@example.com' },
|
|
1023
|
+
});
|
|
1024
|
+
const processor = jobQueue.createProcessor({
|
|
1025
|
+
email: async () => {
|
|
1026
|
+
throw new Error('boom');
|
|
1027
|
+
},
|
|
1028
|
+
sms: vi.fn(async () => {}),
|
|
1029
|
+
test: vi.fn(async () => {}),
|
|
1030
|
+
});
|
|
1031
|
+
await processor.start();
|
|
1032
|
+
const failedJob = await jobQueue.getJob(jobId);
|
|
1033
|
+
expect(failedJob?.status).toBe('failed');
|
|
1034
|
+
|
|
1035
|
+
// Act
|
|
1036
|
+
await jobQueue.retryJob(jobId);
|
|
1037
|
+
|
|
1038
|
+
// Assert
|
|
1039
|
+
const job = await jobQueue.getJob(jobId);
|
|
1040
|
+
expect(job?.status).toBe('pending');
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
// ── cancelJob with waiting status ───────────────────────────────────
|
|
1044
|
+
|
|
1045
|
+
it('cancelJob cancels a waiting job', async () => {
|
|
1046
|
+
// Setup — add a job and manually set it to waiting
|
|
1047
|
+
const jobId = await jobQueue.addJob({
|
|
1048
|
+
jobType: 'email',
|
|
1049
|
+
payload: { to: 'waiting-cancel@example.com' },
|
|
1050
|
+
});
|
|
1051
|
+
const futureMs = Date.now() + 60_000;
|
|
1052
|
+
await redisClient.hmset(
|
|
1053
|
+
`${prefix}job:${jobId}`,
|
|
1054
|
+
'status',
|
|
1055
|
+
'waiting',
|
|
1056
|
+
'waitUntil',
|
|
1057
|
+
futureMs.toString(),
|
|
1058
|
+
);
|
|
1059
|
+
await redisClient.srem(`${prefix}status:pending`, jobId.toString());
|
|
1060
|
+
await redisClient.sadd(`${prefix}status:waiting`, jobId.toString());
|
|
1061
|
+
await redisClient.zrem(`${prefix}queue`, jobId.toString());
|
|
1062
|
+
|
|
1063
|
+
// Act
|
|
1064
|
+
await jobQueue.cancelJob(jobId);
|
|
1065
|
+
|
|
1066
|
+
// Assert
|
|
1067
|
+
const job = await jobQueue.getJob(jobId);
|
|
1068
|
+
expect(job?.status).toBe('cancelled');
|
|
1069
|
+
expect(job?.waitUntil).toBeNull();
|
|
1070
|
+
expect(job?.waitTokenId).toBeNull();
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
// ── completeJob clears wait fields ──────────────────────────────────
|
|
1074
|
+
|
|
1075
|
+
it('completeJob clears wait-related fields', async () => {
|
|
1076
|
+
// Setup
|
|
1077
|
+
const jobId = await jobQueue.addJob({
|
|
1078
|
+
jobType: 'test',
|
|
1079
|
+
payload: { foo: 'wait-clear' },
|
|
1080
|
+
});
|
|
1081
|
+
// Manually set wait fields
|
|
1082
|
+
await redisClient.hmset(
|
|
1083
|
+
`${prefix}job:${jobId}`,
|
|
1084
|
+
'stepData',
|
|
1085
|
+
JSON.stringify({ step1: { __completed: true, result: 42 } }),
|
|
1086
|
+
'waitUntil',
|
|
1087
|
+
(Date.now() + 60000).toString(),
|
|
1088
|
+
'waitTokenId',
|
|
1089
|
+
'wp_test',
|
|
1090
|
+
);
|
|
1091
|
+
|
|
1092
|
+
// Process the job to completion
|
|
1093
|
+
const processor = jobQueue.createProcessor({
|
|
1094
|
+
email: vi.fn(async () => {}),
|
|
1095
|
+
sms: vi.fn(async () => {}),
|
|
1096
|
+
test: vi.fn(async () => {}),
|
|
1097
|
+
});
|
|
1098
|
+
await processor.start();
|
|
1099
|
+
|
|
1100
|
+
// Assert
|
|
1101
|
+
const job = await jobQueue.getJob(jobId);
|
|
1102
|
+
expect(job?.status).toBe('completed');
|
|
1103
|
+
expect(job?.stepData).toBeUndefined();
|
|
1104
|
+
expect(job?.waitUntil).toBeNull();
|
|
1105
|
+
expect(job?.waitTokenId).toBeNull();
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
// ── cleanupOldJobEvents ─────────────────────────────────────────────
|
|
1109
|
+
|
|
1110
|
+
it('cleanupOldJobEvents removes old events', async () => {
|
|
1111
|
+
// Setup
|
|
1112
|
+
const jobId = await jobQueue.addJob({
|
|
1113
|
+
jobType: 'email',
|
|
1114
|
+
payload: { to: 'events-cleanup@example.com' },
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
// Create an old event (31 days ago)
|
|
1118
|
+
const oldMs = Date.now() - 31 * 24 * 60 * 60 * 1000;
|
|
1119
|
+
const oldEvent = JSON.stringify({
|
|
1120
|
+
id: 999,
|
|
1121
|
+
jobId,
|
|
1122
|
+
eventType: 'added',
|
|
1123
|
+
createdAt: oldMs,
|
|
1124
|
+
metadata: null,
|
|
1125
|
+
});
|
|
1126
|
+
await redisClient.rpush(`${prefix}events:${jobId}`, oldEvent);
|
|
1127
|
+
|
|
1128
|
+
// Get events before cleanup
|
|
1129
|
+
const eventsBefore = await jobQueue.getJobEvents(jobId);
|
|
1130
|
+
const countBefore = eventsBefore.length;
|
|
1131
|
+
expect(countBefore).toBeGreaterThanOrEqual(2); // at least the original 'added' + our old event
|
|
1132
|
+
|
|
1133
|
+
// Act
|
|
1134
|
+
const deleted = await jobQueue.cleanupOldJobEvents(30);
|
|
1135
|
+
|
|
1136
|
+
// Assert
|
|
1137
|
+
expect(deleted).toBeGreaterThanOrEqual(1);
|
|
1138
|
+
const eventsAfter = await jobQueue.getJobEvents(jobId);
|
|
1139
|
+
expect(eventsAfter.length).toBeLessThan(countBefore);
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
it('cleanupOldJobEvents removes orphaned event lists', async () => {
|
|
1143
|
+
// Setup — create events for a non-existent job
|
|
1144
|
+
const orphanEvent = JSON.stringify({
|
|
1145
|
+
id: 888,
|
|
1146
|
+
jobId: 99999,
|
|
1147
|
+
eventType: 'added',
|
|
1148
|
+
createdAt: Date.now(),
|
|
1149
|
+
metadata: null,
|
|
1150
|
+
});
|
|
1151
|
+
await redisClient.rpush(`${prefix}events:99999`, orphanEvent);
|
|
1152
|
+
|
|
1153
|
+
// Act
|
|
1154
|
+
const deleted = await jobQueue.cleanupOldJobEvents(30);
|
|
1155
|
+
|
|
1156
|
+
// Assert
|
|
1157
|
+
expect(deleted).toBe(1);
|
|
1158
|
+
const remaining = await redisClient.llen(`${prefix}events:99999`);
|
|
1159
|
+
expect(remaining).toBe(0);
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
// ── Waiting system ──────────────────────────────────────────────────
|
|
1163
|
+
|
|
1164
|
+
it('createToken and getToken work via the public API', async () => {
|
|
1165
|
+
// Act
|
|
1166
|
+
const token = await jobQueue.createToken({ timeout: '10m' });
|
|
1167
|
+
|
|
1168
|
+
// Assert
|
|
1169
|
+
expect(token.id).toMatch(/^wp_/);
|
|
1170
|
+
const record = await jobQueue.getToken(token.id);
|
|
1171
|
+
expect(record).not.toBeNull();
|
|
1172
|
+
expect(record!.status).toBe('waiting');
|
|
1173
|
+
expect(record!.timeoutAt).toBeInstanceOf(Date);
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
it('completeToken completes the token and provides data', async () => {
|
|
1177
|
+
// Setup
|
|
1178
|
+
const token = await jobQueue.createToken();
|
|
1179
|
+
|
|
1180
|
+
// Act
|
|
1181
|
+
await jobQueue.completeToken(token.id, { result: 'success' });
|
|
1182
|
+
|
|
1183
|
+
// Assert
|
|
1184
|
+
const record = await jobQueue.getToken(token.id);
|
|
1185
|
+
expect(record!.status).toBe('completed');
|
|
1186
|
+
expect(record!.output).toEqual({ result: 'success' });
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
it('completeToken resumes a waiting job', async () => {
|
|
1190
|
+
// Setup — add a job, process it to create a token, then manually put it in waiting
|
|
1191
|
+
const jobId = await jobQueue.addJob({
|
|
1192
|
+
jobType: 'email',
|
|
1193
|
+
payload: { to: 'token-resume@example.com' },
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
// Create a token associated with this job
|
|
1197
|
+
// We need to use the backend directly since createToken from public API uses null jobId
|
|
1198
|
+
const backend = jobQueue as any; // accessing the backend is tricky from the public API
|
|
1199
|
+
// Instead, create a token, then manually associate it
|
|
1200
|
+
const token = await jobQueue.createToken();
|
|
1201
|
+
|
|
1202
|
+
// Manually update the token's jobId and put the job in waiting state
|
|
1203
|
+
await redisClient.hset(
|
|
1204
|
+
`${prefix}waitpoint:${token.id}`,
|
|
1205
|
+
'jobId',
|
|
1206
|
+
jobId.toString(),
|
|
1207
|
+
);
|
|
1208
|
+
await redisClient.hmset(
|
|
1209
|
+
`${prefix}job:${jobId}`,
|
|
1210
|
+
'status',
|
|
1211
|
+
'waiting',
|
|
1212
|
+
'waitTokenId',
|
|
1213
|
+
token.id,
|
|
1214
|
+
);
|
|
1215
|
+
await redisClient.srem(`${prefix}status:pending`, jobId.toString());
|
|
1216
|
+
await redisClient.sadd(`${prefix}status:waiting`, jobId.toString());
|
|
1217
|
+
await redisClient.zrem(`${prefix}queue`, jobId.toString());
|
|
1218
|
+
|
|
1219
|
+
// Act
|
|
1220
|
+
await jobQueue.completeToken(token.id, { data: 42 });
|
|
1221
|
+
|
|
1222
|
+
// Assert
|
|
1223
|
+
const job = await jobQueue.getJob(jobId);
|
|
1224
|
+
expect(job?.status).toBe('pending');
|
|
1225
|
+
expect(job?.waitTokenId).toBeNull();
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
it('expireTimedOutTokens expires tokens past their timeout', async () => {
|
|
1229
|
+
// Setup — create a token with a very short timeout, then backdate it
|
|
1230
|
+
const token = await jobQueue.createToken({ timeout: '1s' });
|
|
1231
|
+
// Force the timeout to be in the past
|
|
1232
|
+
const pastMs = Date.now() - 10_000;
|
|
1233
|
+
await redisClient.hset(
|
|
1234
|
+
`${prefix}waitpoint:${token.id}`,
|
|
1235
|
+
'timeoutAt',
|
|
1236
|
+
pastMs.toString(),
|
|
1237
|
+
);
|
|
1238
|
+
await redisClient.zadd(`${prefix}waitpoint_timeout`, pastMs, token.id);
|
|
1239
|
+
|
|
1240
|
+
// Act
|
|
1241
|
+
const expired = await jobQueue.expireTimedOutTokens();
|
|
1242
|
+
|
|
1243
|
+
// Assert
|
|
1244
|
+
expect(expired).toBe(1);
|
|
1245
|
+
const record = await jobQueue.getToken(token.id);
|
|
1246
|
+
expect(record!.status).toBe('timed_out');
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
it('expireTimedOutTokens resumes a waiting job when its token times out', async () => {
|
|
1250
|
+
// Setup
|
|
1251
|
+
const jobId = await jobQueue.addJob({
|
|
1252
|
+
jobType: 'email',
|
|
1253
|
+
payload: { to: 'timeout-resume@example.com' },
|
|
1254
|
+
});
|
|
1255
|
+
const token = await jobQueue.createToken({ timeout: '1s' });
|
|
1256
|
+
|
|
1257
|
+
// Associate token with job and put job in waiting
|
|
1258
|
+
await redisClient.hset(
|
|
1259
|
+
`${prefix}waitpoint:${token.id}`,
|
|
1260
|
+
'jobId',
|
|
1261
|
+
jobId.toString(),
|
|
1262
|
+
);
|
|
1263
|
+
await redisClient.hmset(
|
|
1264
|
+
`${prefix}job:${jobId}`,
|
|
1265
|
+
'status',
|
|
1266
|
+
'waiting',
|
|
1267
|
+
'waitTokenId',
|
|
1268
|
+
token.id,
|
|
1269
|
+
);
|
|
1270
|
+
await redisClient.srem(`${prefix}status:pending`, jobId.toString());
|
|
1271
|
+
await redisClient.sadd(`${prefix}status:waiting`, jobId.toString());
|
|
1272
|
+
await redisClient.zrem(`${prefix}queue`, jobId.toString());
|
|
1273
|
+
|
|
1274
|
+
// Force the timeout to be in the past
|
|
1275
|
+
const pastMs = Date.now() - 10_000;
|
|
1276
|
+
await redisClient.hset(
|
|
1277
|
+
`${prefix}waitpoint:${token.id}`,
|
|
1278
|
+
'timeoutAt',
|
|
1279
|
+
pastMs.toString(),
|
|
1280
|
+
);
|
|
1281
|
+
await redisClient.zadd(`${prefix}waitpoint_timeout`, pastMs, token.id);
|
|
1282
|
+
|
|
1283
|
+
// Act
|
|
1284
|
+
await jobQueue.expireTimedOutTokens();
|
|
1285
|
+
|
|
1286
|
+
// Assert
|
|
1287
|
+
const job = await jobQueue.getJob(jobId);
|
|
1288
|
+
expect(job?.status).toBe('pending');
|
|
1289
|
+
expect(job?.waitTokenId).toBeNull();
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
it('getNextBatch promotes time-based waiting jobs', async () => {
|
|
1293
|
+
// Setup — add a job and manually set it to waiting with a past waitUntil
|
|
1294
|
+
const jobId = await jobQueue.addJob({
|
|
1295
|
+
jobType: 'test',
|
|
1296
|
+
payload: { foo: 'wait-promote' },
|
|
1297
|
+
});
|
|
1298
|
+
const pastMs = Date.now() - 5000;
|
|
1299
|
+
await redisClient.hmset(
|
|
1300
|
+
`${prefix}job:${jobId}`,
|
|
1301
|
+
'status',
|
|
1302
|
+
'waiting',
|
|
1303
|
+
'waitUntil',
|
|
1304
|
+
pastMs.toString(),
|
|
1305
|
+
'waitTokenId',
|
|
1306
|
+
'null',
|
|
1307
|
+
);
|
|
1308
|
+
await redisClient.srem(`${prefix}status:pending`, jobId.toString());
|
|
1309
|
+
await redisClient.sadd(`${prefix}status:waiting`, jobId.toString());
|
|
1310
|
+
await redisClient.zrem(`${prefix}queue`, jobId.toString());
|
|
1311
|
+
await redisClient.zadd(`${prefix}waiting`, pastMs, jobId.toString());
|
|
1312
|
+
|
|
1313
|
+
// Act — process jobs, the waiting job should get promoted and processed
|
|
1314
|
+
const handler = vi.fn(async () => {});
|
|
1315
|
+
const processor = jobQueue.createProcessor({
|
|
1316
|
+
email: vi.fn(async () => {}),
|
|
1317
|
+
sms: vi.fn(async () => {}),
|
|
1318
|
+
test: handler,
|
|
1319
|
+
});
|
|
1320
|
+
const processed = await processor.start();
|
|
1321
|
+
|
|
1322
|
+
// Assert
|
|
1323
|
+
expect(processed).toBe(1);
|
|
1324
|
+
expect(handler).toHaveBeenCalled();
|
|
1325
|
+
const job = await jobQueue.getJob(jobId);
|
|
1326
|
+
expect(job?.status).toBe('completed');
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
it('getNextBatch does NOT promote token-based waiting jobs', async () => {
|
|
1330
|
+
// Setup — add a job waiting for a token
|
|
1331
|
+
const jobId = await jobQueue.addJob({
|
|
1332
|
+
jobType: 'test',
|
|
1333
|
+
payload: { foo: 'token-wait-nopromote' },
|
|
1334
|
+
});
|
|
1335
|
+
const pastMs = Date.now() - 5000;
|
|
1336
|
+
await redisClient.hmset(
|
|
1337
|
+
`${prefix}job:${jobId}`,
|
|
1338
|
+
'status',
|
|
1339
|
+
'waiting',
|
|
1340
|
+
'waitUntil',
|
|
1341
|
+
pastMs.toString(),
|
|
1342
|
+
'waitTokenId',
|
|
1343
|
+
'wp_some_token',
|
|
1344
|
+
);
|
|
1345
|
+
await redisClient.srem(`${prefix}status:pending`, jobId.toString());
|
|
1346
|
+
await redisClient.sadd(`${prefix}status:waiting`, jobId.toString());
|
|
1347
|
+
await redisClient.zrem(`${prefix}queue`, jobId.toString());
|
|
1348
|
+
await redisClient.zadd(`${prefix}waiting`, pastMs, jobId.toString());
|
|
1349
|
+
|
|
1350
|
+
// Act
|
|
1351
|
+
const processor = jobQueue.createProcessor({
|
|
1352
|
+
email: vi.fn(async () => {}),
|
|
1353
|
+
sms: vi.fn(async () => {}),
|
|
1354
|
+
test: vi.fn(async () => {}),
|
|
1355
|
+
});
|
|
1356
|
+
const processed = await processor.start();
|
|
1357
|
+
|
|
1358
|
+
// Assert — should not pick up the token-based waiting job
|
|
1359
|
+
expect(processed).toBe(0);
|
|
1360
|
+
const job = await jobQueue.getJob(jobId);
|
|
1361
|
+
expect(job?.status).toBe('waiting');
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
it('waitFor pauses a job and resumes after time elapses', async () => {
|
|
1365
|
+
// Setup
|
|
1366
|
+
let invocationCount = 0;
|
|
1367
|
+
const jobId = await jobQueue.addJob({
|
|
1368
|
+
jobType: 'test',
|
|
1369
|
+
payload: { foo: 'waitfor-test' },
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
// First invocation: handler calls ctx.waitFor
|
|
1373
|
+
const handler = vi.fn(async (_payload: any, _signal: any, ctx: any) => {
|
|
1374
|
+
invocationCount++;
|
|
1375
|
+
if (invocationCount === 1) {
|
|
1376
|
+
await ctx.waitFor({ seconds: 1 });
|
|
1377
|
+
}
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
const processor = jobQueue.createProcessor({
|
|
1381
|
+
email: vi.fn(async () => {}),
|
|
1382
|
+
sms: vi.fn(async () => {}),
|
|
1383
|
+
test: handler,
|
|
1384
|
+
});
|
|
1385
|
+
await processor.start();
|
|
1386
|
+
|
|
1387
|
+
// Assert — job should be in waiting state
|
|
1388
|
+
let job = await jobQueue.getJob(jobId);
|
|
1389
|
+
expect(job?.status).toBe('waiting');
|
|
1390
|
+
expect(job?.waitUntil).toBeInstanceOf(Date);
|
|
1391
|
+
expect(job?.stepData).toBeDefined();
|
|
1392
|
+
|
|
1393
|
+
// Manually advance: set waitUntil to past and add to waiting sorted set
|
|
1394
|
+
const pastMs = Date.now() - 5000;
|
|
1395
|
+
await redisClient.hset(
|
|
1396
|
+
`${prefix}job:${jobId}`,
|
|
1397
|
+
'waitUntil',
|
|
1398
|
+
pastMs.toString(),
|
|
1399
|
+
);
|
|
1400
|
+
await redisClient.zadd(`${prefix}waiting`, pastMs, jobId.toString());
|
|
1401
|
+
|
|
1402
|
+
// Second invocation: job resumes and completes
|
|
1403
|
+
await processor.start();
|
|
1404
|
+
|
|
1405
|
+
// Assert
|
|
1406
|
+
job = await jobQueue.getJob(jobId);
|
|
1407
|
+
expect(job?.status).toBe('completed');
|
|
1408
|
+
expect(invocationCount).toBe(2);
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
it('ctx.run memoizes step results across re-invocations', async () => {
|
|
1412
|
+
// Setup
|
|
1413
|
+
let invocationCount = 0;
|
|
1414
|
+
let stepCallCount = 0;
|
|
1415
|
+
const jobId = await jobQueue.addJob({
|
|
1416
|
+
jobType: 'test',
|
|
1417
|
+
payload: { foo: 'memoize-test' },
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
const handler = vi.fn(async (_payload: any, _signal: any, ctx: any) => {
|
|
1421
|
+
invocationCount++;
|
|
1422
|
+
const result = await ctx.run('step1', async () => {
|
|
1423
|
+
stepCallCount++;
|
|
1424
|
+
return 42;
|
|
1425
|
+
});
|
|
1426
|
+
expect(result).toBe(42);
|
|
1427
|
+
|
|
1428
|
+
if (invocationCount === 1) {
|
|
1429
|
+
await ctx.waitFor({ seconds: 1 });
|
|
1430
|
+
}
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
const processor = jobQueue.createProcessor({
|
|
1434
|
+
email: vi.fn(async () => {}),
|
|
1435
|
+
sms: vi.fn(async () => {}),
|
|
1436
|
+
test: handler,
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
// First invocation
|
|
1440
|
+
await processor.start();
|
|
1441
|
+
let job = await jobQueue.getJob(jobId);
|
|
1442
|
+
expect(job?.status).toBe('waiting');
|
|
1443
|
+
expect(stepCallCount).toBe(1);
|
|
1444
|
+
|
|
1445
|
+
// Advance time
|
|
1446
|
+
const pastMs = Date.now() - 5000;
|
|
1447
|
+
await redisClient.hset(
|
|
1448
|
+
`${prefix}job:${jobId}`,
|
|
1449
|
+
'waitUntil',
|
|
1450
|
+
pastMs.toString(),
|
|
1451
|
+
);
|
|
1452
|
+
await redisClient.zadd(`${prefix}waiting`, pastMs, jobId.toString());
|
|
1453
|
+
|
|
1454
|
+
// Second invocation
|
|
1455
|
+
await processor.start();
|
|
1456
|
+
|
|
1457
|
+
// Assert — step1 should NOT have been called again (memoized)
|
|
1458
|
+
job = await jobQueue.getJob(jobId);
|
|
1459
|
+
expect(job?.status).toBe('completed');
|
|
1460
|
+
expect(stepCallCount).toBe(1);
|
|
1461
|
+
expect(invocationCount).toBe(2);
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
it('waitForToken pauses and resumes on token completion', async () => {
|
|
1465
|
+
// Setup
|
|
1466
|
+
let invocationCount = 0;
|
|
1467
|
+
let tokenId: string;
|
|
1468
|
+
const jobId = await jobQueue.addJob({
|
|
1469
|
+
jobType: 'test',
|
|
1470
|
+
payload: { foo: 'token-wait-test' },
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
const handler = vi.fn(async (_payload: any, _signal: any, ctx: any) => {
|
|
1474
|
+
invocationCount++;
|
|
1475
|
+
if (invocationCount === 1) {
|
|
1476
|
+
const token = await ctx.createToken({ timeout: '1h' });
|
|
1477
|
+
tokenId = token.id;
|
|
1478
|
+
const result = await ctx.waitForToken(token.id);
|
|
1479
|
+
// Should not reach here on first invocation (throws WaitSignal)
|
|
1480
|
+
expect(result.ok).toBe(true);
|
|
1481
|
+
} else {
|
|
1482
|
+
// Second invocation: token should be completed
|
|
1483
|
+
// The step data should have the result cached
|
|
1484
|
+
}
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
const processor = jobQueue.createProcessor({
|
|
1488
|
+
email: vi.fn(async () => {}),
|
|
1489
|
+
sms: vi.fn(async () => {}),
|
|
1490
|
+
test: handler,
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
// First invocation — should pause on waitForToken
|
|
1494
|
+
await processor.start();
|
|
1495
|
+
|
|
1496
|
+
let job = await jobQueue.getJob(jobId);
|
|
1497
|
+
expect(job?.status).toBe('waiting');
|
|
1498
|
+
expect(job?.waitTokenId).toBe(tokenId!);
|
|
1499
|
+
|
|
1500
|
+
// Complete the token externally
|
|
1501
|
+
await jobQueue.completeToken(tokenId!, { answer: 'yes' });
|
|
1502
|
+
|
|
1503
|
+
// Verify job is back to pending
|
|
1504
|
+
job = await jobQueue.getJob(jobId);
|
|
1505
|
+
expect(job?.status).toBe('pending');
|
|
1506
|
+
|
|
1507
|
+
// Second invocation — should complete
|
|
1508
|
+
await processor.start();
|
|
1509
|
+
|
|
1510
|
+
job = await jobQueue.getJob(jobId);
|
|
1511
|
+
expect(job?.status).toBe('completed');
|
|
1512
|
+
expect(invocationCount).toBe(2);
|
|
1513
|
+
});
|
|
1514
|
+
});
|