@nicnocquee/dataqueue 1.34.0 → 1.35.0-beta.20260224075710
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/ai/docs-content.json +23 -11
- package/ai/rules/advanced.md +77 -1
- package/ai/rules/basic.md +72 -3
- package/ai/rules/react-dashboard.md +5 -1
- package/ai/skills/dataqueue-advanced/SKILL.md +159 -0
- package/ai/skills/dataqueue-core/SKILL.md +107 -3
- package/ai/skills/dataqueue-react/SKILL.md +19 -7
- package/dist/index.cjs +937 -108
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +358 -11
- package/dist/index.d.ts +358 -11
- package/dist/index.js +937 -108
- package/dist/index.js.map +1 -1
- package/migrations/1781200000005_add_retry_config_to_job_queue.sql +17 -0
- package/migrations/1781200000006_add_output_to_job_queue.sql +3 -0
- package/package.json +1 -1
- package/src/backend.ts +36 -3
- package/src/backends/postgres.ts +344 -42
- package/src/backends/redis-scripts.ts +173 -8
- package/src/backends/redis.test.ts +668 -0
- package/src/backends/redis.ts +244 -15
- package/src/db-util.ts +1 -1
- package/src/index.test.ts +811 -12
- package/src/index.ts +106 -14
- package/src/processor.ts +133 -49
- package/src/queue.test.ts +477 -0
- package/src/queue.ts +20 -3
- package/src/supervisor.test.ts +340 -0
- package/src/supervisor.ts +177 -0
- package/src/types.ts +318 -3
package/src/index.test.ts
CHANGED
|
@@ -2,7 +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
|
+
import type { CronScheduleRecord, AddJobOptions } from './types.js';
|
|
6
6
|
|
|
7
7
|
// Integration tests for index.ts
|
|
8
8
|
|
|
@@ -78,9 +78,7 @@ describe('index integration', () => {
|
|
|
78
78
|
},
|
|
79
79
|
{ pollInterval: 100 },
|
|
80
80
|
);
|
|
81
|
-
processor.start();
|
|
82
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
83
|
-
processor.stop();
|
|
81
|
+
await processor.start();
|
|
84
82
|
const job = await jobQueue.getJob(jobId);
|
|
85
83
|
expect(handler).toHaveBeenCalledWith(
|
|
86
84
|
{ foo: 'bar' },
|
|
@@ -432,9 +430,7 @@ describe('index integration', () => {
|
|
|
432
430
|
},
|
|
433
431
|
{ pollInterval: 100 },
|
|
434
432
|
);
|
|
435
|
-
processor.start();
|
|
436
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
437
|
-
processor.stop();
|
|
433
|
+
await processor.start();
|
|
438
434
|
|
|
439
435
|
expect(handler).toHaveBeenCalledWith(
|
|
440
436
|
{ foo: 'updated@example.com' },
|
|
@@ -459,9 +455,7 @@ describe('index integration', () => {
|
|
|
459
455
|
},
|
|
460
456
|
{ pollInterval: 100 },
|
|
461
457
|
);
|
|
462
|
-
processor.start();
|
|
463
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
464
|
-
processor.stop();
|
|
458
|
+
await processor.start();
|
|
465
459
|
|
|
466
460
|
const originalJob = await jobQueue.getJob(jobId1);
|
|
467
461
|
expect(originalJob?.status).toBe('completed');
|
|
@@ -494,7 +488,7 @@ describe('index integration', () => {
|
|
|
494
488
|
jobType: 'email',
|
|
495
489
|
payload: { to: 'processing@example.com' },
|
|
496
490
|
});
|
|
497
|
-
processor2.start();
|
|
491
|
+
const startPromise = processor2.start();
|
|
498
492
|
// Wait a bit for job to be picked up
|
|
499
493
|
await new Promise((r) => setTimeout(r, 150));
|
|
500
494
|
// Job should be processing now
|
|
@@ -510,7 +504,7 @@ describe('index integration', () => {
|
|
|
510
504
|
expect(job2?.payload).toEqual({ to: 'processing@example.com' });
|
|
511
505
|
}
|
|
512
506
|
}
|
|
513
|
-
|
|
507
|
+
await startPromise;
|
|
514
508
|
});
|
|
515
509
|
|
|
516
510
|
it('should record edited event when editing via JobQueue API', async () => {
|
|
@@ -532,6 +526,51 @@ describe('index integration', () => {
|
|
|
532
526
|
priority: 10,
|
|
533
527
|
});
|
|
534
528
|
});
|
|
529
|
+
|
|
530
|
+
// ── Configurable retry strategy integration tests ────────────────────
|
|
531
|
+
|
|
532
|
+
it('should store and return retry config through public API', async () => {
|
|
533
|
+
const jobId = await jobQueue.addJob({
|
|
534
|
+
jobType: 'email',
|
|
535
|
+
payload: { to: 'retry-api@example.com' },
|
|
536
|
+
retryDelay: 20,
|
|
537
|
+
retryBackoff: true,
|
|
538
|
+
retryDelayMax: 300,
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
const job = await jobQueue.getJob(jobId);
|
|
542
|
+
expect(job?.retryDelay).toBe(20);
|
|
543
|
+
expect(job?.retryBackoff).toBe(true);
|
|
544
|
+
expect(job?.retryDelayMax).toBe(300);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it('should use fixed delay on failure through public API', async () => {
|
|
548
|
+
const jobId = await jobQueue.addJob({
|
|
549
|
+
jobType: 'email',
|
|
550
|
+
payload: { to: 'fixed-api@example.com' },
|
|
551
|
+
maxAttempts: 3,
|
|
552
|
+
retryDelay: 10,
|
|
553
|
+
retryBackoff: false,
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
const handler = vi.fn(async () => {
|
|
557
|
+
throw new Error('fail');
|
|
558
|
+
});
|
|
559
|
+
const processor = jobQueue.createProcessor({
|
|
560
|
+
email: handler,
|
|
561
|
+
sms: vi.fn(async () => {}),
|
|
562
|
+
test: vi.fn(async () => {}),
|
|
563
|
+
});
|
|
564
|
+
await processor.start();
|
|
565
|
+
|
|
566
|
+
const job = await jobQueue.getJob(jobId);
|
|
567
|
+
expect(job?.status).toBe('failed');
|
|
568
|
+
expect(job?.nextAttemptAt).not.toBeNull();
|
|
569
|
+
const delaySec =
|
|
570
|
+
(job!.nextAttemptAt!.getTime() - job!.lastFailedAt!.getTime()) / 1000;
|
|
571
|
+
expect(delaySec).toBeGreaterThanOrEqual(9);
|
|
572
|
+
expect(delaySec).toBeLessThanOrEqual(11);
|
|
573
|
+
});
|
|
535
574
|
});
|
|
536
575
|
|
|
537
576
|
describe('cron schedules integration', () => {
|
|
@@ -892,4 +931,764 @@ describe('cron schedules integration', () => {
|
|
|
892
931
|
);
|
|
893
932
|
expect(cronJobs).toHaveLength(2);
|
|
894
933
|
});
|
|
934
|
+
|
|
935
|
+
it('should propagate retry config from cron schedule to enqueued jobs', async () => {
|
|
936
|
+
const cronId = await jobQueue.addCronJob({
|
|
937
|
+
scheduleName: 'retry-cron',
|
|
938
|
+
cronExpression: '* * * * *',
|
|
939
|
+
jobType: 'email',
|
|
940
|
+
payload: { to: 'cron-retry@example.com' },
|
|
941
|
+
retryDelay: 15,
|
|
942
|
+
retryBackoff: false,
|
|
943
|
+
retryDelayMax: 90,
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
// Force next_run_at to the past
|
|
947
|
+
await pool.query(
|
|
948
|
+
`UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
|
|
949
|
+
[cronId],
|
|
950
|
+
);
|
|
951
|
+
|
|
952
|
+
const count = await jobQueue.enqueueDueCronJobs();
|
|
953
|
+
expect(count).toBe(1);
|
|
954
|
+
|
|
955
|
+
const jobs = await jobQueue.getJobsByStatus('pending');
|
|
956
|
+
const cronJob = jobs.find(
|
|
957
|
+
(j) => (j.payload as any).to === 'cron-retry@example.com',
|
|
958
|
+
);
|
|
959
|
+
expect(cronJob).toBeDefined();
|
|
960
|
+
expect(cronJob?.retryDelay).toBe(15);
|
|
961
|
+
expect(cronJob?.retryBackoff).toBe(false);
|
|
962
|
+
expect(cronJob?.retryDelayMax).toBe(90);
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
it('should store retry config on cron schedule record', async () => {
|
|
966
|
+
const cronId = await jobQueue.addCronJob({
|
|
967
|
+
scheduleName: 'retry-cron-record',
|
|
968
|
+
cronExpression: '0 */2 * * *',
|
|
969
|
+
jobType: 'email',
|
|
970
|
+
payload: { to: 'cron-record@example.com' },
|
|
971
|
+
retryDelay: 30,
|
|
972
|
+
retryBackoff: true,
|
|
973
|
+
retryDelayMax: 600,
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
const schedule = await jobQueue.getCronJob(cronId);
|
|
977
|
+
expect(schedule?.retryDelay).toBe(30);
|
|
978
|
+
expect(schedule?.retryBackoff).toBe(true);
|
|
979
|
+
expect(schedule?.retryDelayMax).toBe(600);
|
|
980
|
+
});
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
// ── BYOC (Bring Your Own Connection) tests ──────────────────────────────
|
|
984
|
+
|
|
985
|
+
describe('BYOC: init with external pool', () => {
|
|
986
|
+
let pool: Pool;
|
|
987
|
+
let dbName: string;
|
|
988
|
+
let jobQueue: ReturnType<typeof initJobQueue<TestPayloadMap>>;
|
|
989
|
+
|
|
990
|
+
beforeEach(async () => {
|
|
991
|
+
const setup = await createTestDbAndPool();
|
|
992
|
+
pool = setup.pool;
|
|
993
|
+
dbName = setup.dbName;
|
|
994
|
+
jobQueue = initJobQueue<TestPayloadMap>({ pool });
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
afterEach(async () => {
|
|
998
|
+
await pool.end();
|
|
999
|
+
await destroyTestDb(dbName);
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it('uses the provided pool for addJob and getJob', async () => {
|
|
1003
|
+
// Act
|
|
1004
|
+
const jobId = await jobQueue.addJob({
|
|
1005
|
+
jobType: 'email',
|
|
1006
|
+
payload: { to: 'byoc@example.com' },
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
// Assert
|
|
1010
|
+
const job = await jobQueue.getJob(jobId);
|
|
1011
|
+
expect(job).not.toBeNull();
|
|
1012
|
+
expect(job?.jobType).toBe('email');
|
|
1013
|
+
expect(job?.payload).toEqual({ to: 'byoc@example.com' });
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
it('returns the same pool instance from getPool()', () => {
|
|
1017
|
+
// Act
|
|
1018
|
+
const returnedPool = jobQueue.getPool();
|
|
1019
|
+
|
|
1020
|
+
// Assert
|
|
1021
|
+
expect(returnedPool).toBe(pool);
|
|
1022
|
+
});
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
describe('BYOC: transactional addJob with db option', () => {
|
|
1026
|
+
let pool: Pool;
|
|
1027
|
+
let dbName: string;
|
|
1028
|
+
let testDbUrl: string;
|
|
1029
|
+
let jobQueue: ReturnType<typeof initJobQueue<TestPayloadMap>>;
|
|
1030
|
+
|
|
1031
|
+
beforeEach(async () => {
|
|
1032
|
+
const setup = await createTestDbAndPool();
|
|
1033
|
+
pool = setup.pool;
|
|
1034
|
+
dbName = setup.dbName;
|
|
1035
|
+
testDbUrl = setup.testDbUrl;
|
|
1036
|
+
jobQueue = initJobQueue<TestPayloadMap>({
|
|
1037
|
+
databaseConfig: { connectionString: testDbUrl },
|
|
1038
|
+
});
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
afterEach(async () => {
|
|
1042
|
+
jobQueue.getPool().end();
|
|
1043
|
+
await pool.end();
|
|
1044
|
+
await destroyTestDb(dbName);
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
it('rolls back the job when the transaction is rolled back', async () => {
|
|
1048
|
+
// Setup
|
|
1049
|
+
const client = await pool.connect();
|
|
1050
|
+
await client.query('BEGIN');
|
|
1051
|
+
|
|
1052
|
+
// Act
|
|
1053
|
+
const jobId = await jobQueue.addJob(
|
|
1054
|
+
{ jobType: 'email', payload: { to: 'rollback@example.com' } },
|
|
1055
|
+
{ db: client },
|
|
1056
|
+
);
|
|
1057
|
+
await client.query('ROLLBACK');
|
|
1058
|
+
client.release();
|
|
1059
|
+
|
|
1060
|
+
// Assert — job should not exist after rollback
|
|
1061
|
+
const job = await jobQueue.getJob(jobId);
|
|
1062
|
+
expect(job).toBeNull();
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
it('persists the job and event when the transaction is committed', async () => {
|
|
1066
|
+
// Setup
|
|
1067
|
+
const client = await pool.connect();
|
|
1068
|
+
await client.query('BEGIN');
|
|
1069
|
+
|
|
1070
|
+
// Act
|
|
1071
|
+
const jobId = await jobQueue.addJob(
|
|
1072
|
+
{ jobType: 'email', payload: { to: 'commit@example.com' } },
|
|
1073
|
+
{ db: client },
|
|
1074
|
+
);
|
|
1075
|
+
await client.query('COMMIT');
|
|
1076
|
+
client.release();
|
|
1077
|
+
|
|
1078
|
+
// Assert — job exists
|
|
1079
|
+
const job = await jobQueue.getJob(jobId);
|
|
1080
|
+
expect(job).not.toBeNull();
|
|
1081
|
+
expect(job?.payload).toEqual({ to: 'commit@example.com' });
|
|
1082
|
+
|
|
1083
|
+
// Assert — event was recorded in the same transaction
|
|
1084
|
+
const events = await jobQueue.getJobEvents(jobId);
|
|
1085
|
+
expect(events.length).toBeGreaterThanOrEqual(1);
|
|
1086
|
+
expect(events[0].eventType).toBe('added');
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
it('job is visible within the transaction before commit', async () => {
|
|
1090
|
+
// Setup
|
|
1091
|
+
const client = await pool.connect();
|
|
1092
|
+
await client.query('BEGIN');
|
|
1093
|
+
|
|
1094
|
+
// Act
|
|
1095
|
+
const jobId = await jobQueue.addJob(
|
|
1096
|
+
{ jobType: 'sms', payload: { to: 'in-tx@example.com' } },
|
|
1097
|
+
{ db: client },
|
|
1098
|
+
);
|
|
1099
|
+
|
|
1100
|
+
// Assert — visible within the transaction
|
|
1101
|
+
const res = await client.query('SELECT id FROM job_queue WHERE id = $1', [
|
|
1102
|
+
jobId,
|
|
1103
|
+
]);
|
|
1104
|
+
expect(res.rows).toHaveLength(1);
|
|
1105
|
+
|
|
1106
|
+
await client.query('ROLLBACK');
|
|
1107
|
+
client.release();
|
|
1108
|
+
});
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
describe('addJobs batch insert', () => {
|
|
1112
|
+
let pool: Pool;
|
|
1113
|
+
let dbName: string;
|
|
1114
|
+
let testDbUrl: string;
|
|
1115
|
+
let jobQueue: ReturnType<typeof initJobQueue<TestPayloadMap>>;
|
|
1116
|
+
|
|
1117
|
+
beforeEach(async () => {
|
|
1118
|
+
const setup = await createTestDbAndPool();
|
|
1119
|
+
pool = setup.pool;
|
|
1120
|
+
dbName = setup.dbName;
|
|
1121
|
+
testDbUrl = setup.testDbUrl;
|
|
1122
|
+
const config: JobQueueConfig = {
|
|
1123
|
+
databaseConfig: {
|
|
1124
|
+
connectionString: testDbUrl,
|
|
1125
|
+
},
|
|
1126
|
+
};
|
|
1127
|
+
jobQueue = initJobQueue<TestPayloadMap>(config);
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
afterEach(async () => {
|
|
1131
|
+
jobQueue.getPool().end();
|
|
1132
|
+
await pool.end();
|
|
1133
|
+
await destroyTestDb(dbName);
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
it('inserts multiple jobs and returns IDs in order', async () => {
|
|
1137
|
+
// Act
|
|
1138
|
+
const ids = await jobQueue.addJobs([
|
|
1139
|
+
{ jobType: 'email', payload: { to: 'a@test.com' } },
|
|
1140
|
+
{ jobType: 'sms', payload: { to: '+1234' } },
|
|
1141
|
+
{ jobType: 'email', payload: { to: 'b@test.com' } },
|
|
1142
|
+
]);
|
|
1143
|
+
|
|
1144
|
+
// Assert
|
|
1145
|
+
expect(ids).toHaveLength(3);
|
|
1146
|
+
|
|
1147
|
+
const job0 = await jobQueue.getJob(ids[0]);
|
|
1148
|
+
expect(job0?.jobType).toBe('email');
|
|
1149
|
+
expect(job0?.payload).toEqual({ to: 'a@test.com' });
|
|
1150
|
+
|
|
1151
|
+
const job1 = await jobQueue.getJob(ids[1]);
|
|
1152
|
+
expect(job1?.jobType).toBe('sms');
|
|
1153
|
+
|
|
1154
|
+
const job2 = await jobQueue.getJob(ids[2]);
|
|
1155
|
+
expect(job2?.jobType).toBe('email');
|
|
1156
|
+
expect(job2?.payload).toEqual({ to: 'b@test.com' });
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
it('returns empty array for empty input', async () => {
|
|
1160
|
+
// Act
|
|
1161
|
+
const ids = await jobQueue.addJobs([]);
|
|
1162
|
+
|
|
1163
|
+
// Assert
|
|
1164
|
+
expect(ids).toEqual([]);
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
it('handles idempotency keys correctly', async () => {
|
|
1168
|
+
// Setup
|
|
1169
|
+
const existingId = await jobQueue.addJob({
|
|
1170
|
+
jobType: 'email',
|
|
1171
|
+
payload: { to: 'existing@test.com' },
|
|
1172
|
+
idempotencyKey: 'batch-dup',
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
// Act
|
|
1176
|
+
const ids = await jobQueue.addJobs([
|
|
1177
|
+
{ jobType: 'email', payload: { to: 'new@test.com' } },
|
|
1178
|
+
{
|
|
1179
|
+
jobType: 'email',
|
|
1180
|
+
payload: { to: 'dup@test.com' },
|
|
1181
|
+
idempotencyKey: 'batch-dup',
|
|
1182
|
+
},
|
|
1183
|
+
]);
|
|
1184
|
+
|
|
1185
|
+
// Assert
|
|
1186
|
+
expect(ids).toHaveLength(2);
|
|
1187
|
+
expect(ids[1]).toBe(existingId);
|
|
1188
|
+
expect(ids[0]).not.toBe(existingId);
|
|
1189
|
+
});
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
describe('BYOC: validation errors', () => {
|
|
1193
|
+
it('throws when neither databaseConfig nor pool is provided for postgres', () => {
|
|
1194
|
+
// Act & Assert
|
|
1195
|
+
expect(() =>
|
|
1196
|
+
initJobQueue<TestPayloadMap>({ backend: 'postgres' } as any),
|
|
1197
|
+
).toThrow(
|
|
1198
|
+
'PostgreSQL backend requires either "databaseConfig" or "pool" to be provided.',
|
|
1199
|
+
);
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
it('throws when neither redisConfig nor client is provided for redis', () => {
|
|
1203
|
+
// Act & Assert
|
|
1204
|
+
expect(() =>
|
|
1205
|
+
initJobQueue<TestPayloadMap>({ backend: 'redis' } as any),
|
|
1206
|
+
).toThrow(
|
|
1207
|
+
'Redis backend requires either "redisConfig" or "client" to be provided.',
|
|
1208
|
+
);
|
|
1209
|
+
});
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
describe('event hooks', () => {
|
|
1213
|
+
let pool: Pool;
|
|
1214
|
+
let dbName: string;
|
|
1215
|
+
let testDbUrl: string;
|
|
1216
|
+
let jobQueue: ReturnType<typeof initJobQueue<TestPayloadMap>>;
|
|
1217
|
+
|
|
1218
|
+
beforeEach(async () => {
|
|
1219
|
+
const setup = await createTestDbAndPool();
|
|
1220
|
+
pool = setup.pool;
|
|
1221
|
+
dbName = setup.dbName;
|
|
1222
|
+
testDbUrl = setup.testDbUrl;
|
|
1223
|
+
const config: JobQueueConfig = {
|
|
1224
|
+
databaseConfig: {
|
|
1225
|
+
connectionString: testDbUrl,
|
|
1226
|
+
},
|
|
1227
|
+
};
|
|
1228
|
+
jobQueue = initJobQueue<TestPayloadMap>(config);
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
afterEach(async () => {
|
|
1232
|
+
jobQueue.removeAllListeners();
|
|
1233
|
+
jobQueue.getPool().end();
|
|
1234
|
+
await pool.end();
|
|
1235
|
+
await destroyTestDb(dbName);
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
it('emits job:added on addJob', async () => {
|
|
1239
|
+
const listener = vi.fn();
|
|
1240
|
+
jobQueue.on('job:added', listener);
|
|
1241
|
+
|
|
1242
|
+
const jobId = await jobQueue.addJob({
|
|
1243
|
+
jobType: 'email',
|
|
1244
|
+
payload: { to: 'test@example.com' },
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
1248
|
+
expect(listener).toHaveBeenCalledWith({ jobId, jobType: 'email' });
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
it('emits job:added for each job in addJobs', async () => {
|
|
1252
|
+
const listener = vi.fn();
|
|
1253
|
+
jobQueue.on('job:added', listener);
|
|
1254
|
+
|
|
1255
|
+
const ids = await jobQueue.addJobs([
|
|
1256
|
+
{ jobType: 'email', payload: { to: 'a@test.com' } },
|
|
1257
|
+
{ jobType: 'sms', payload: { to: '+1234' } },
|
|
1258
|
+
]);
|
|
1259
|
+
|
|
1260
|
+
expect(listener).toHaveBeenCalledTimes(2);
|
|
1261
|
+
expect(listener).toHaveBeenCalledWith({ jobId: ids[0], jobType: 'email' });
|
|
1262
|
+
expect(listener).toHaveBeenCalledWith({ jobId: ids[1], jobType: 'sms' });
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
it('emits job:cancelled on cancelJob', async () => {
|
|
1266
|
+
const listener = vi.fn();
|
|
1267
|
+
jobQueue.on('job:cancelled', listener);
|
|
1268
|
+
|
|
1269
|
+
const jobId = await jobQueue.addJob({
|
|
1270
|
+
jobType: 'email',
|
|
1271
|
+
payload: { to: 'test@example.com' },
|
|
1272
|
+
});
|
|
1273
|
+
await jobQueue.cancelJob(jobId);
|
|
1274
|
+
|
|
1275
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
1276
|
+
expect(listener).toHaveBeenCalledWith({ jobId });
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
it('emits job:retried on retryJob', async () => {
|
|
1280
|
+
const listener = vi.fn();
|
|
1281
|
+
jobQueue.on('job:retried', listener);
|
|
1282
|
+
|
|
1283
|
+
const jobId = await jobQueue.addJob({
|
|
1284
|
+
jobType: 'email',
|
|
1285
|
+
payload: { to: 'test@example.com' },
|
|
1286
|
+
});
|
|
1287
|
+
|
|
1288
|
+
const handler = vi.fn(async () => {
|
|
1289
|
+
throw new Error('fail');
|
|
1290
|
+
});
|
|
1291
|
+
const processor = jobQueue.createProcessor({
|
|
1292
|
+
email: handler,
|
|
1293
|
+
sms: vi.fn(async () => {}),
|
|
1294
|
+
test: vi.fn(async () => {}),
|
|
1295
|
+
});
|
|
1296
|
+
await processor.start();
|
|
1297
|
+
|
|
1298
|
+
await jobQueue.retryJob(jobId);
|
|
1299
|
+
|
|
1300
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
1301
|
+
expect(listener).toHaveBeenCalledWith({ jobId });
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
it('emits job:processing and job:completed on successful processing', async () => {
|
|
1305
|
+
const processingListener = vi.fn();
|
|
1306
|
+
const completedListener = vi.fn();
|
|
1307
|
+
jobQueue.on('job:processing', processingListener);
|
|
1308
|
+
jobQueue.on('job:completed', completedListener);
|
|
1309
|
+
|
|
1310
|
+
const jobId = await jobQueue.addJob({
|
|
1311
|
+
jobType: 'email',
|
|
1312
|
+
payload: { to: 'test@example.com' },
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
const processor = jobQueue.createProcessor({
|
|
1316
|
+
email: vi.fn(async () => {}),
|
|
1317
|
+
sms: vi.fn(async () => {}),
|
|
1318
|
+
test: vi.fn(async () => {}),
|
|
1319
|
+
});
|
|
1320
|
+
await processor.start();
|
|
1321
|
+
|
|
1322
|
+
expect(processingListener).toHaveBeenCalledTimes(1);
|
|
1323
|
+
expect(processingListener).toHaveBeenCalledWith({
|
|
1324
|
+
jobId,
|
|
1325
|
+
jobType: 'email',
|
|
1326
|
+
});
|
|
1327
|
+
expect(completedListener).toHaveBeenCalledTimes(1);
|
|
1328
|
+
expect(completedListener).toHaveBeenCalledWith({
|
|
1329
|
+
jobId,
|
|
1330
|
+
jobType: 'email',
|
|
1331
|
+
});
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
it('emits job:failed with willRetry true when attempts remain', async () => {
|
|
1335
|
+
const listener = vi.fn();
|
|
1336
|
+
jobQueue.on('job:failed', listener);
|
|
1337
|
+
|
|
1338
|
+
const jobId = await jobQueue.addJob({
|
|
1339
|
+
jobType: 'email',
|
|
1340
|
+
payload: { to: 'test@example.com' },
|
|
1341
|
+
maxAttempts: 3,
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
const processor = jobQueue.createProcessor({
|
|
1345
|
+
email: vi.fn(async () => {
|
|
1346
|
+
throw new Error('boom');
|
|
1347
|
+
}),
|
|
1348
|
+
sms: vi.fn(async () => {}),
|
|
1349
|
+
test: vi.fn(async () => {}),
|
|
1350
|
+
});
|
|
1351
|
+
await processor.start();
|
|
1352
|
+
|
|
1353
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
1354
|
+
expect(listener).toHaveBeenCalledWith(
|
|
1355
|
+
expect.objectContaining({
|
|
1356
|
+
jobId,
|
|
1357
|
+
jobType: 'email',
|
|
1358
|
+
willRetry: true,
|
|
1359
|
+
error: expect.any(Error),
|
|
1360
|
+
}),
|
|
1361
|
+
);
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
it('emits job:failed with willRetry false when no attempts remain', async () => {
|
|
1365
|
+
const listener = vi.fn();
|
|
1366
|
+
jobQueue.on('job:failed', listener);
|
|
1367
|
+
|
|
1368
|
+
const jobId = await jobQueue.addJob({
|
|
1369
|
+
jobType: 'email',
|
|
1370
|
+
payload: { to: 'test@example.com' },
|
|
1371
|
+
maxAttempts: 1,
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
const processor = jobQueue.createProcessor({
|
|
1375
|
+
email: vi.fn(async () => {
|
|
1376
|
+
throw new Error('boom');
|
|
1377
|
+
}),
|
|
1378
|
+
sms: vi.fn(async () => {}),
|
|
1379
|
+
test: vi.fn(async () => {}),
|
|
1380
|
+
});
|
|
1381
|
+
await processor.start();
|
|
1382
|
+
|
|
1383
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
1384
|
+
expect(listener).toHaveBeenCalledWith(
|
|
1385
|
+
expect.objectContaining({
|
|
1386
|
+
jobId,
|
|
1387
|
+
jobType: 'email',
|
|
1388
|
+
willRetry: false,
|
|
1389
|
+
}),
|
|
1390
|
+
);
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
it('emits job:waiting when handler calls ctx.waitFor', async () => {
|
|
1394
|
+
const listener = vi.fn();
|
|
1395
|
+
jobQueue.on('job:waiting', listener);
|
|
1396
|
+
|
|
1397
|
+
const jobId = await jobQueue.addJob({
|
|
1398
|
+
jobType: 'email',
|
|
1399
|
+
payload: { to: 'test@example.com' },
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
const processor = jobQueue.createProcessor({
|
|
1403
|
+
email: vi.fn(async (_payload, _signal, ctx) => {
|
|
1404
|
+
await ctx.waitFor({ hours: 1 });
|
|
1405
|
+
}),
|
|
1406
|
+
sms: vi.fn(async () => {}),
|
|
1407
|
+
test: vi.fn(async () => {}),
|
|
1408
|
+
});
|
|
1409
|
+
await processor.start();
|
|
1410
|
+
|
|
1411
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
1412
|
+
expect(listener).toHaveBeenCalledWith({ jobId, jobType: 'email' });
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
it('emits job:progress when handler calls ctx.setProgress', async () => {
|
|
1416
|
+
const listener = vi.fn();
|
|
1417
|
+
jobQueue.on('job:progress', listener);
|
|
1418
|
+
|
|
1419
|
+
const jobId = await jobQueue.addJob({
|
|
1420
|
+
jobType: 'email',
|
|
1421
|
+
payload: { to: 'test@example.com' },
|
|
1422
|
+
});
|
|
1423
|
+
|
|
1424
|
+
const processor = jobQueue.createProcessor({
|
|
1425
|
+
email: vi.fn(async (_payload, _signal, ctx) => {
|
|
1426
|
+
await ctx.setProgress(50);
|
|
1427
|
+
await ctx.setProgress(100);
|
|
1428
|
+
}),
|
|
1429
|
+
sms: vi.fn(async () => {}),
|
|
1430
|
+
test: vi.fn(async () => {}),
|
|
1431
|
+
});
|
|
1432
|
+
await processor.start();
|
|
1433
|
+
|
|
1434
|
+
expect(listener).toHaveBeenCalledTimes(2);
|
|
1435
|
+
expect(listener).toHaveBeenCalledWith({ jobId, progress: 50 });
|
|
1436
|
+
expect(listener).toHaveBeenCalledWith({ jobId, progress: 100 });
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
it('stores output from ctx.setOutput() and retrieves it via getJob', async () => {
|
|
1440
|
+
const jobId = await jobQueue.addJob({
|
|
1441
|
+
jobType: 'email',
|
|
1442
|
+
payload: { to: 'output@test.com' },
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
const processor = jobQueue.createProcessor({
|
|
1446
|
+
email: vi.fn(async (_payload, _signal, ctx) => {
|
|
1447
|
+
await ctx.setOutput({ reportUrl: 'https://example.com/report.pdf' });
|
|
1448
|
+
}),
|
|
1449
|
+
sms: vi.fn(async () => {}),
|
|
1450
|
+
test: vi.fn(async () => {}),
|
|
1451
|
+
});
|
|
1452
|
+
await processor.start();
|
|
1453
|
+
|
|
1454
|
+
const job = await jobQueue.getJob(jobId);
|
|
1455
|
+
expect(job?.status).toBe('completed');
|
|
1456
|
+
expect(job?.output).toEqual({
|
|
1457
|
+
reportUrl: 'https://example.com/report.pdf',
|
|
1458
|
+
});
|
|
1459
|
+
});
|
|
1460
|
+
|
|
1461
|
+
it('stores handler return value as output when setOutput is not called', async () => {
|
|
1462
|
+
const jobId = await jobQueue.addJob({
|
|
1463
|
+
jobType: 'email',
|
|
1464
|
+
payload: { to: 'return@test.com' },
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
const processor = jobQueue.createProcessor({
|
|
1468
|
+
email: vi.fn(async () => {
|
|
1469
|
+
return { processed: true, count: 42 };
|
|
1470
|
+
}),
|
|
1471
|
+
sms: vi.fn(async () => {}),
|
|
1472
|
+
test: vi.fn(async () => {}),
|
|
1473
|
+
});
|
|
1474
|
+
await processor.start();
|
|
1475
|
+
|
|
1476
|
+
const job = await jobQueue.getJob(jobId);
|
|
1477
|
+
expect(job?.status).toBe('completed');
|
|
1478
|
+
expect(job?.output).toEqual({ processed: true, count: 42 });
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
it('setOutput takes precedence over handler return value', async () => {
|
|
1482
|
+
const jobId = await jobQueue.addJob({
|
|
1483
|
+
jobType: 'email',
|
|
1484
|
+
payload: { to: 'precedence@test.com' },
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
const processor = jobQueue.createProcessor({
|
|
1488
|
+
email: vi.fn(async (_payload, _signal, ctx) => {
|
|
1489
|
+
await ctx.setOutput({ fromSetOutput: true });
|
|
1490
|
+
return { fromReturn: true };
|
|
1491
|
+
}),
|
|
1492
|
+
sms: vi.fn(async () => {}),
|
|
1493
|
+
test: vi.fn(async () => {}),
|
|
1494
|
+
});
|
|
1495
|
+
await processor.start();
|
|
1496
|
+
|
|
1497
|
+
const job = await jobQueue.getJob(jobId);
|
|
1498
|
+
expect(job?.status).toBe('completed');
|
|
1499
|
+
expect(job?.output).toEqual({ fromSetOutput: true });
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
it('output is null for jobs that do not set output (backward compat)', async () => {
|
|
1503
|
+
const jobId = await jobQueue.addJob({
|
|
1504
|
+
jobType: 'email',
|
|
1505
|
+
payload: { to: 'no-output@test.com' },
|
|
1506
|
+
});
|
|
1507
|
+
|
|
1508
|
+
const processor = jobQueue.createProcessor({
|
|
1509
|
+
email: vi.fn(async () => {}),
|
|
1510
|
+
sms: vi.fn(async () => {}),
|
|
1511
|
+
test: vi.fn(async () => {}),
|
|
1512
|
+
});
|
|
1513
|
+
await processor.start();
|
|
1514
|
+
|
|
1515
|
+
const job = await jobQueue.getJob(jobId);
|
|
1516
|
+
expect(job?.status).toBe('completed');
|
|
1517
|
+
expect(job?.output).toBeNull();
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
it('emits job:output when handler calls ctx.setOutput', async () => {
|
|
1521
|
+
const listener = vi.fn();
|
|
1522
|
+
jobQueue.on('job:output', listener);
|
|
1523
|
+
|
|
1524
|
+
const jobId = await jobQueue.addJob({
|
|
1525
|
+
jobType: 'email',
|
|
1526
|
+
payload: { to: 'event-output@test.com' },
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
const processor = jobQueue.createProcessor({
|
|
1530
|
+
email: vi.fn(async (_payload, _signal, ctx) => {
|
|
1531
|
+
await ctx.setOutput({ result: 'done' });
|
|
1532
|
+
}),
|
|
1533
|
+
sms: vi.fn(async () => {}),
|
|
1534
|
+
test: vi.fn(async () => {}),
|
|
1535
|
+
});
|
|
1536
|
+
await processor.start();
|
|
1537
|
+
|
|
1538
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
1539
|
+
expect(listener).toHaveBeenCalledWith({
|
|
1540
|
+
jobId,
|
|
1541
|
+
output: { result: 'done' },
|
|
1542
|
+
});
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
it('stores scalar output values (string, number, array)', async () => {
|
|
1546
|
+
const jobId1 = await jobQueue.addJob({
|
|
1547
|
+
jobType: 'email',
|
|
1548
|
+
payload: { to: 'string-output@test.com' },
|
|
1549
|
+
});
|
|
1550
|
+
const jobId2 = await jobQueue.addJob({
|
|
1551
|
+
jobType: 'sms',
|
|
1552
|
+
payload: { to: '+123' },
|
|
1553
|
+
});
|
|
1554
|
+
const jobId3 = await jobQueue.addJob({
|
|
1555
|
+
jobType: 'test',
|
|
1556
|
+
payload: { foo: 'arr' },
|
|
1557
|
+
});
|
|
1558
|
+
|
|
1559
|
+
const processor = jobQueue.createProcessor({
|
|
1560
|
+
email: vi.fn(async () => 'simple string'),
|
|
1561
|
+
sms: vi.fn(async () => 42),
|
|
1562
|
+
test: vi.fn(async () => [1, 2, 3]),
|
|
1563
|
+
});
|
|
1564
|
+
await processor.start();
|
|
1565
|
+
|
|
1566
|
+
const job1 = await jobQueue.getJob(jobId1);
|
|
1567
|
+
expect(job1?.output).toBe('simple string');
|
|
1568
|
+
|
|
1569
|
+
const job2 = await jobQueue.getJob(jobId2);
|
|
1570
|
+
expect(job2?.output).toBe(42);
|
|
1571
|
+
|
|
1572
|
+
const job3 = await jobQueue.getJob(jobId3);
|
|
1573
|
+
expect(job3?.output).toEqual([1, 2, 3]);
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
it('once fires only once then auto-unsubscribes', async () => {
|
|
1577
|
+
const listener = vi.fn();
|
|
1578
|
+
jobQueue.once('job:added', listener);
|
|
1579
|
+
|
|
1580
|
+
await jobQueue.addJob({
|
|
1581
|
+
jobType: 'email',
|
|
1582
|
+
payload: { to: 'a@test.com' },
|
|
1583
|
+
});
|
|
1584
|
+
await jobQueue.addJob({
|
|
1585
|
+
jobType: 'sms',
|
|
1586
|
+
payload: { to: '+1234' },
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1589
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
1590
|
+
});
|
|
1591
|
+
|
|
1592
|
+
it('off removes a listener', async () => {
|
|
1593
|
+
const listener = vi.fn();
|
|
1594
|
+
jobQueue.on('job:added', listener);
|
|
1595
|
+
|
|
1596
|
+
await jobQueue.addJob({
|
|
1597
|
+
jobType: 'email',
|
|
1598
|
+
payload: { to: 'a@test.com' },
|
|
1599
|
+
});
|
|
1600
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
1601
|
+
|
|
1602
|
+
jobQueue.off('job:added', listener);
|
|
1603
|
+
|
|
1604
|
+
await jobQueue.addJob({
|
|
1605
|
+
jobType: 'sms',
|
|
1606
|
+
payload: { to: '+1234' },
|
|
1607
|
+
});
|
|
1608
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
1609
|
+
});
|
|
1610
|
+
|
|
1611
|
+
it('removeAllListeners clears all listeners for a specific event', async () => {
|
|
1612
|
+
const listener1 = vi.fn();
|
|
1613
|
+
const listener2 = vi.fn();
|
|
1614
|
+
const otherListener = vi.fn();
|
|
1615
|
+
jobQueue.on('job:added', listener1);
|
|
1616
|
+
jobQueue.on('job:added', listener2);
|
|
1617
|
+
jobQueue.on('job:cancelled', otherListener);
|
|
1618
|
+
|
|
1619
|
+
jobQueue.removeAllListeners('job:added');
|
|
1620
|
+
|
|
1621
|
+
await jobQueue.addJob({
|
|
1622
|
+
jobType: 'email',
|
|
1623
|
+
payload: { to: 'a@test.com' },
|
|
1624
|
+
});
|
|
1625
|
+
const jobId = await jobQueue.addJob({
|
|
1626
|
+
jobType: 'sms',
|
|
1627
|
+
payload: { to: '+1234' },
|
|
1628
|
+
});
|
|
1629
|
+
await jobQueue.cancelJob(jobId);
|
|
1630
|
+
|
|
1631
|
+
expect(listener1).not.toHaveBeenCalled();
|
|
1632
|
+
expect(listener2).not.toHaveBeenCalled();
|
|
1633
|
+
expect(otherListener).toHaveBeenCalledTimes(1);
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
it('removeAllListeners with no args clears everything', async () => {
|
|
1637
|
+
const addedListener = vi.fn();
|
|
1638
|
+
const cancelledListener = vi.fn();
|
|
1639
|
+
jobQueue.on('job:added', addedListener);
|
|
1640
|
+
jobQueue.on('job:cancelled', cancelledListener);
|
|
1641
|
+
|
|
1642
|
+
jobQueue.removeAllListeners();
|
|
1643
|
+
|
|
1644
|
+
const jobId = await jobQueue.addJob({
|
|
1645
|
+
jobType: 'email',
|
|
1646
|
+
payload: { to: 'a@test.com' },
|
|
1647
|
+
});
|
|
1648
|
+
await jobQueue.cancelJob(jobId);
|
|
1649
|
+
|
|
1650
|
+
expect(addedListener).not.toHaveBeenCalled();
|
|
1651
|
+
expect(cancelledListener).not.toHaveBeenCalled();
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
it('onError callback still works alongside error event', async () => {
|
|
1655
|
+
const onErrorSpy = vi.fn();
|
|
1656
|
+
const errorListener = vi.fn();
|
|
1657
|
+
jobQueue.on('error', errorListener);
|
|
1658
|
+
|
|
1659
|
+
await jobQueue.addJob({
|
|
1660
|
+
jobType: 'email',
|
|
1661
|
+
payload: { to: 'test@example.com' },
|
|
1662
|
+
maxAttempts: 1,
|
|
1663
|
+
});
|
|
1664
|
+
|
|
1665
|
+
const processor = jobQueue.createProcessor(
|
|
1666
|
+
{
|
|
1667
|
+
email: vi.fn(async () => {
|
|
1668
|
+
throw new Error('boom');
|
|
1669
|
+
}),
|
|
1670
|
+
sms: vi.fn(async () => {}),
|
|
1671
|
+
test: vi.fn(async () => {}),
|
|
1672
|
+
},
|
|
1673
|
+
{ onError: onErrorSpy },
|
|
1674
|
+
);
|
|
1675
|
+
await processor.start();
|
|
1676
|
+
|
|
1677
|
+
// job:failed fires for individual job failures; error fires for
|
|
1678
|
+
// batch-level errors caught in processBatchWithHandlers. In this case
|
|
1679
|
+
// the job failure is handled inside processJobWithHandlers and doesn't
|
|
1680
|
+
// propagate to the batch-level error handler. So we verify that
|
|
1681
|
+
// onError still works as configured and job:failed events fire.
|
|
1682
|
+
const failedListener = vi.fn();
|
|
1683
|
+
jobQueue.on('job:failed', failedListener);
|
|
1684
|
+
|
|
1685
|
+
await jobQueue.addJob({
|
|
1686
|
+
jobType: 'email',
|
|
1687
|
+
payload: { to: 'test2@example.com' },
|
|
1688
|
+
maxAttempts: 1,
|
|
1689
|
+
});
|
|
1690
|
+
await processor.start();
|
|
1691
|
+
|
|
1692
|
+
expect(failedListener).toHaveBeenCalledTimes(1);
|
|
1693
|
+
});
|
|
895
1694
|
});
|