@nicnocquee/dataqueue 1.31.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 +2418 -1936
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +151 -16
- package/dist/index.d.ts +151 -16
- package/dist/index.js +2418 -1936
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/backend.ts +70 -4
- package/src/backends/postgres.ts +345 -29
- package/src/backends/redis-scripts.ts +197 -22
- package/src/backends/redis.test.ts +621 -0
- package/src/backends/redis.ts +400 -21
- package/src/index.ts +12 -29
- package/src/processor.ts +14 -93
- package/src/queue.test.ts +29 -0
- package/src/queue.ts +19 -251
- package/src/types.ts +28 -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',
|
|
@@ -891,3 +931,584 @@ describe('Redis cron schedules integration', () => {
|
|
|
891
931
|
expect(cronJobs).toHaveLength(2);
|
|
892
932
|
});
|
|
893
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
|
+
});
|