queasy 0.1.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/.claude/settings.local.json +27 -0
- package/.luarc.json +13 -0
- package/.zed/settings.json +39 -0
- package/AGENTS.md +102 -0
- package/CLAUDE.md +83 -0
- package/License.md +7 -0
- package/Readme.md +130 -0
- package/biome.json +28 -0
- package/doc/Implementation.md +70 -0
- package/docker-compose.yml +19 -0
- package/jsconfig.json +17 -0
- package/package.json +37 -0
- package/src/client.js +218 -0
- package/src/constants.js +34 -0
- package/src/errors.js +25 -0
- package/src/index.js +2 -0
- package/src/manager.js +94 -0
- package/src/pool.js +164 -0
- package/src/queasy.lua +397 -0
- package/src/queue.js +161 -0
- package/src/types.ts +92 -0
- package/src/utils.js +13 -0
- package/src/worker.js +44 -0
- package/test/client.test.js +49 -0
- package/test/errors.test.js +19 -0
- package/test/fixtures/always-fail-handler.js +8 -0
- package/test/fixtures/data-logger-handler.js +14 -0
- package/test/fixtures/failure-handler.js +9 -0
- package/test/fixtures/no-handle-handler.js +1 -0
- package/test/fixtures/permanent-error-handler.js +10 -0
- package/test/fixtures/slow-handler.js +9 -0
- package/test/fixtures/success-handler.js +9 -0
- package/test/fixtures/with-failure-handler.js +8 -0
- package/test/index.test.js +55 -0
- package/test/manager.test.js +87 -0
- package/test/pool.test.js +66 -0
- package/test/queue.test.js +438 -0
- package/test/redis-functions.test.js +683 -0
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { after, afterEach, before, describe, it } from 'node:test';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { createClient } from 'redis';
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = dirname(__filename);
|
|
10
|
+
|
|
11
|
+
describe('Redis Lua functions', () => {
|
|
12
|
+
/** @type {import('redis').RedisClientType} */
|
|
13
|
+
let redis;
|
|
14
|
+
const QUEUE_NAME = '{test-queue}';
|
|
15
|
+
|
|
16
|
+
before(async () => {
|
|
17
|
+
redis = createClient({
|
|
18
|
+
socket: {
|
|
19
|
+
host: 'localhost',
|
|
20
|
+
port: 6379,
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
await redis.connect();
|
|
25
|
+
await redis.ping();
|
|
26
|
+
|
|
27
|
+
// Load Lua script
|
|
28
|
+
const luaScript = readFileSync(join(__dirname, '../src/queasy.lua'), 'utf8');
|
|
29
|
+
|
|
30
|
+
// Load the library into Redis
|
|
31
|
+
try {
|
|
32
|
+
await redis.sendCommand(['FUNCTION', 'LOAD', 'REPLACE', luaScript]);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.error('Failed to load Lua functions:', error);
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
after(async () => {
|
|
40
|
+
// Clean up all test keys
|
|
41
|
+
const keys = await redis.keys(`${QUEUE_NAME}*`);
|
|
42
|
+
if (keys.length > 0) {
|
|
43
|
+
await redis.del(keys);
|
|
44
|
+
}
|
|
45
|
+
await redis.disconnect();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(async () => {
|
|
49
|
+
// Clean up between tests
|
|
50
|
+
const keys = await redis.keys(`${QUEUE_NAME}*`);
|
|
51
|
+
if (keys.length > 0) {
|
|
52
|
+
await redis.del(keys);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('dispatch', () => {
|
|
57
|
+
it('should add a new job to waiting queue', async () => {
|
|
58
|
+
const jobId = 'job1';
|
|
59
|
+
const runAt = Date.now();
|
|
60
|
+
|
|
61
|
+
const result = await redis.fCall('queasy_dispatch', {
|
|
62
|
+
keys: [QUEUE_NAME],
|
|
63
|
+
arguments: [
|
|
64
|
+
jobId,
|
|
65
|
+
runAt.toString(),
|
|
66
|
+
JSON.stringify({ task: 'test' }),
|
|
67
|
+
'true', // update_data
|
|
68
|
+
'true', // update_run_at
|
|
69
|
+
'false', // reset_counts
|
|
70
|
+
],
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
assert.equal(result, 'OK');
|
|
74
|
+
|
|
75
|
+
// Verify job is in waiting queue
|
|
76
|
+
const score = await redis.zScore(QUEUE_NAME, jobId);
|
|
77
|
+
assert.equal(Number(score), runAt);
|
|
78
|
+
|
|
79
|
+
// Verify job data is stored
|
|
80
|
+
const storedData = await redis.hGetAll(`${QUEUE_NAME}:waiting_job:${jobId}`);
|
|
81
|
+
assert.equal(storedData.id, jobId);
|
|
82
|
+
assert.equal(storedData.data, JSON.stringify({ task: 'test' }));
|
|
83
|
+
assert.equal(storedData.retry_count, '0');
|
|
84
|
+
assert.equal(storedData.stall_count, '0');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should block job if active job with same ID exists', async () => {
|
|
88
|
+
const jobId = 'job2';
|
|
89
|
+
const runAt = Date.now() + 1000;
|
|
90
|
+
|
|
91
|
+
// Create an active job first
|
|
92
|
+
await redis.hSet(`${QUEUE_NAME}:active_job:${jobId}`, 'id', jobId);
|
|
93
|
+
|
|
94
|
+
await redis.fCall('queasy_dispatch', {
|
|
95
|
+
keys: [QUEUE_NAME],
|
|
96
|
+
arguments: [
|
|
97
|
+
jobId,
|
|
98
|
+
runAt.toString(),
|
|
99
|
+
JSON.stringify({ task: 'test' }),
|
|
100
|
+
'true',
|
|
101
|
+
'true',
|
|
102
|
+
'false',
|
|
103
|
+
],
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Verify job has negative score (blocked)
|
|
107
|
+
const score = await redis.zScore(QUEUE_NAME, jobId);
|
|
108
|
+
assert.equal(Number(score), -runAt);
|
|
109
|
+
|
|
110
|
+
// Cleanup
|
|
111
|
+
await redis.del(`${QUEUE_NAME}:active_job:${jobId}`);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should respect update_run_at=false flag', async () => {
|
|
115
|
+
const jobId = 'job3';
|
|
116
|
+
const runAt1 = Date.now();
|
|
117
|
+
const runAt2 = Date.now() + 5000;
|
|
118
|
+
|
|
119
|
+
// Add job first time
|
|
120
|
+
await redis.fCall('queasy_dispatch', {
|
|
121
|
+
keys: [QUEUE_NAME],
|
|
122
|
+
arguments: [
|
|
123
|
+
jobId,
|
|
124
|
+
runAt1.toString(),
|
|
125
|
+
JSON.stringify({ task: 'test1' }),
|
|
126
|
+
'true',
|
|
127
|
+
'true',
|
|
128
|
+
'false',
|
|
129
|
+
],
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Try to update with update_run_at=false
|
|
133
|
+
await redis.fCall('queasy_dispatch', {
|
|
134
|
+
keys: [QUEUE_NAME],
|
|
135
|
+
arguments: [
|
|
136
|
+
jobId,
|
|
137
|
+
runAt2.toString(),
|
|
138
|
+
JSON.stringify({ task: 'test2' }),
|
|
139
|
+
'true',
|
|
140
|
+
'false',
|
|
141
|
+
'false',
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Score should still be runAt1
|
|
146
|
+
const score = await redis.zScore(QUEUE_NAME, jobId);
|
|
147
|
+
assert.equal(Number(score), runAt1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should respect update_data=false flag', async () => {
|
|
151
|
+
const jobId = 'job4';
|
|
152
|
+
const runAt = Date.now();
|
|
153
|
+
|
|
154
|
+
// Add job first time
|
|
155
|
+
await redis.fCall('queasy_dispatch', {
|
|
156
|
+
keys: [QUEUE_NAME],
|
|
157
|
+
arguments: [
|
|
158
|
+
jobId,
|
|
159
|
+
runAt.toString(),
|
|
160
|
+
JSON.stringify({ task: 'original' }),
|
|
161
|
+
'true',
|
|
162
|
+
'true',
|
|
163
|
+
'false',
|
|
164
|
+
],
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Try to update with update_data=false
|
|
168
|
+
await redis.fCall('queasy_dispatch', {
|
|
169
|
+
keys: [QUEUE_NAME],
|
|
170
|
+
arguments: [
|
|
171
|
+
jobId,
|
|
172
|
+
runAt.toString(),
|
|
173
|
+
JSON.stringify({ task: 'updated' }),
|
|
174
|
+
'false',
|
|
175
|
+
'true',
|
|
176
|
+
'false',
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Data should still be original
|
|
181
|
+
const data = await redis.hGet(`${QUEUE_NAME}:waiting_job:${jobId}`, 'data');
|
|
182
|
+
assert.equal(data, JSON.stringify({ task: 'original' }));
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should reset counts when reset_counts=true', async () => {
|
|
186
|
+
const jobId = 'job5';
|
|
187
|
+
const runAt = Date.now();
|
|
188
|
+
|
|
189
|
+
// Add job
|
|
190
|
+
await redis.fCall('queasy_dispatch', {
|
|
191
|
+
keys: [QUEUE_NAME],
|
|
192
|
+
arguments: [
|
|
193
|
+
jobId,
|
|
194
|
+
runAt.toString(),
|
|
195
|
+
JSON.stringify({ task: 'test' }),
|
|
196
|
+
'true',
|
|
197
|
+
'true',
|
|
198
|
+
'false',
|
|
199
|
+
],
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Manually increment counts
|
|
203
|
+
await redis.hSet(`${QUEUE_NAME}:waiting_job:${jobId}`, {
|
|
204
|
+
retry_count: '5',
|
|
205
|
+
stall_count: '2',
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Verify counts are set
|
|
209
|
+
let jobData = await redis.hGetAll(`${QUEUE_NAME}:waiting_job:${jobId}`);
|
|
210
|
+
assert.equal(jobData.retry_count, '5');
|
|
211
|
+
assert.equal(jobData.stall_count, '2');
|
|
212
|
+
|
|
213
|
+
// Update with reset_counts=true
|
|
214
|
+
await redis.fCall('queasy_dispatch', {
|
|
215
|
+
keys: [QUEUE_NAME],
|
|
216
|
+
arguments: [
|
|
217
|
+
jobId,
|
|
218
|
+
runAt.toString(),
|
|
219
|
+
JSON.stringify({ task: 'test' }),
|
|
220
|
+
'true',
|
|
221
|
+
'true',
|
|
222
|
+
'true',
|
|
223
|
+
],
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Verify counts are reset
|
|
227
|
+
jobData = await redis.hGetAll(`${QUEUE_NAME}:waiting_job:${jobId}`);
|
|
228
|
+
assert.equal(jobData.retry_count, '0');
|
|
229
|
+
assert.equal(jobData.stall_count, '0');
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('cancel', () => {
|
|
234
|
+
it('should remove job from waiting queue', async () => {
|
|
235
|
+
const jobId = 'job-cancel';
|
|
236
|
+
const runAt = Date.now();
|
|
237
|
+
|
|
238
|
+
// Add a job
|
|
239
|
+
await redis.zAdd(QUEUE_NAME, { score: runAt, value: jobId });
|
|
240
|
+
await redis.hSet(`${QUEUE_NAME}:waiting_job:${jobId}`, 'id', jobId);
|
|
241
|
+
|
|
242
|
+
// Cancel it
|
|
243
|
+
const result = await redis.fCall('queasy_cancel', {
|
|
244
|
+
keys: [QUEUE_NAME],
|
|
245
|
+
arguments: [jobId],
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
assert.equal(result, 1);
|
|
249
|
+
|
|
250
|
+
// Verify it's gone
|
|
251
|
+
const score = await redis.zScore(QUEUE_NAME, jobId);
|
|
252
|
+
assert.equal(score, null);
|
|
253
|
+
|
|
254
|
+
const exists = await redis.exists(`${QUEUE_NAME}:waiting_job:${jobId}`);
|
|
255
|
+
assert.equal(exists, 0);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('should return 0 if job does not exist', async () => {
|
|
259
|
+
const result = await redis.fCall('queasy_cancel', {
|
|
260
|
+
keys: [QUEUE_NAME],
|
|
261
|
+
arguments: ['nonexistent'],
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
assert.equal(result, 0);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe('dequeue', () => {
|
|
269
|
+
it('should dequeue jobs ready to run', async () => {
|
|
270
|
+
const now = Date.now();
|
|
271
|
+
const clientId = 'client1';
|
|
272
|
+
const jobId1 = 'job-deq-1';
|
|
273
|
+
const jobId2 = 'job-deq-2';
|
|
274
|
+
|
|
275
|
+
// Add jobs ready to run
|
|
276
|
+
await redis.zAdd(QUEUE_NAME, { score: now - 1000, value: jobId1 });
|
|
277
|
+
await redis.hSet(`${QUEUE_NAME}:waiting_job:${jobId1}`, 'id', jobId1);
|
|
278
|
+
|
|
279
|
+
await redis.zAdd(QUEUE_NAME, { score: now - 500, value: jobId2 });
|
|
280
|
+
await redis.hSet(`${QUEUE_NAME}:waiting_job:${jobId2}`, 'id', jobId2);
|
|
281
|
+
|
|
282
|
+
// Dequeue
|
|
283
|
+
const expiryTime = now + 10000;
|
|
284
|
+
const result = await redis.fCall('queasy_dequeue', {
|
|
285
|
+
keys: [QUEUE_NAME],
|
|
286
|
+
arguments: [clientId, now.toString(), expiryTime.toString(), '10'],
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
assert.equal(result.length, 2);
|
|
290
|
+
assert.deepEqual(result[0], ['id', jobId1]);
|
|
291
|
+
assert.deepEqual(result[1], ['id', jobId2]);
|
|
292
|
+
|
|
293
|
+
// Verify jobs are in checkouts set
|
|
294
|
+
const checkouts = await redis.sMembers(`${QUEUE_NAME}:checkouts:${clientId}`);
|
|
295
|
+
assert.equal(checkouts.length, 2);
|
|
296
|
+
assert.ok(checkouts.includes(jobId1));
|
|
297
|
+
assert.ok(checkouts.includes(jobId2));
|
|
298
|
+
|
|
299
|
+
// Verify client is in expiry set
|
|
300
|
+
const expiry = await redis.zScore(`${QUEUE_NAME}:expiry`, clientId);
|
|
301
|
+
assert.ok(expiry !== null);
|
|
302
|
+
assert.equal(Number(expiry), expiryTime);
|
|
303
|
+
|
|
304
|
+
// Verify job data moved to active
|
|
305
|
+
const exists1 = await redis.exists(`${QUEUE_NAME}:active_job:${jobId1}`);
|
|
306
|
+
assert.equal(exists1, 1);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('should not dequeue jobs scheduled for future', async () => {
|
|
310
|
+
const now = Date.now();
|
|
311
|
+
const clientId = 'client1';
|
|
312
|
+
const jobId = 'job-future';
|
|
313
|
+
|
|
314
|
+
// Add job scheduled for future
|
|
315
|
+
await redis.zAdd(QUEUE_NAME, { score: now + 10000, value: jobId });
|
|
316
|
+
await redis.hSet(`${QUEUE_NAME}:waiting_job:${jobId}`, 'id', jobId);
|
|
317
|
+
|
|
318
|
+
// Try to dequeue
|
|
319
|
+
const result = await redis.fCall('queasy_dequeue', {
|
|
320
|
+
keys: [QUEUE_NAME],
|
|
321
|
+
arguments: [clientId, now.toString(), String(now + 10000), '10'],
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
assert.equal(result.length, 0);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('should not dequeue blocked jobs', async () => {
|
|
328
|
+
const now = Date.now();
|
|
329
|
+
const clientId = 'client1';
|
|
330
|
+
const jobId = 'job-blocked';
|
|
331
|
+
|
|
332
|
+
// Add blocked job (negative score)
|
|
333
|
+
await redis.zAdd(QUEUE_NAME, { score: -now, value: jobId });
|
|
334
|
+
await redis.hSet(`${QUEUE_NAME}:waiting_job:${jobId}`, 'id', jobId);
|
|
335
|
+
|
|
336
|
+
// Try to dequeue
|
|
337
|
+
const result = await redis.fCall('queasy_dequeue', {
|
|
338
|
+
keys: [QUEUE_NAME],
|
|
339
|
+
arguments: [clientId, now.toString(), String(now + 10000), '10'],
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
assert.equal(result.length, 0);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
describe('bump', () => {
|
|
347
|
+
it('should update heartbeat expiry for client', async () => {
|
|
348
|
+
const now = Date.now();
|
|
349
|
+
const clientId = 'client1';
|
|
350
|
+
|
|
351
|
+
// Setup: client in expiry set
|
|
352
|
+
await redis.zAdd(`${QUEUE_NAME}:expiry`, {
|
|
353
|
+
score: now + 5000,
|
|
354
|
+
value: clientId,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// Bump
|
|
358
|
+
const newExpiry = now + 10000;
|
|
359
|
+
const result = await redis.fCall('queasy_bump', {
|
|
360
|
+
keys: [QUEUE_NAME],
|
|
361
|
+
arguments: [clientId, now.toString(), newExpiry.toString()],
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
assert.equal(result, 1);
|
|
365
|
+
|
|
366
|
+
// Verify expiry was updated
|
|
367
|
+
const expiry = await redis.zScore(`${QUEUE_NAME}:expiry`, clientId);
|
|
368
|
+
assert.equal(Number(expiry), newExpiry);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('should return 0 if client does not exist', async () => {
|
|
372
|
+
const now = Date.now();
|
|
373
|
+
const clientId = 'nonexistent-client';
|
|
374
|
+
|
|
375
|
+
const result = await redis.fCall('queasy_bump', {
|
|
376
|
+
keys: [QUEUE_NAME],
|
|
377
|
+
arguments: [clientId, now.toString(), String(now + 10000)],
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
assert.equal(result, 0);
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
describe('finish', () => {
|
|
385
|
+
it('should remove active job', async () => {
|
|
386
|
+
const now = Date.now();
|
|
387
|
+
const clientId = 'client1';
|
|
388
|
+
const jobId = 'job-clear';
|
|
389
|
+
|
|
390
|
+
// Setup: active job with checkouts + expiry
|
|
391
|
+
await redis.zAdd(`${QUEUE_NAME}:expiry`, {
|
|
392
|
+
score: now + 10000,
|
|
393
|
+
value: clientId,
|
|
394
|
+
});
|
|
395
|
+
await redis.sAdd(`${QUEUE_NAME}:checkouts:${clientId}`, jobId);
|
|
396
|
+
await redis.hSet(`${QUEUE_NAME}:active_job:${jobId}`, 'id', jobId);
|
|
397
|
+
|
|
398
|
+
// Finish
|
|
399
|
+
const result = await redis.fCall('queasy_finish', {
|
|
400
|
+
keys: [QUEUE_NAME],
|
|
401
|
+
arguments: [jobId, clientId, now.toString()],
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
assert.equal(result, 'OK');
|
|
405
|
+
|
|
406
|
+
// Verify job is removed
|
|
407
|
+
const activeExists = await redis.exists(`${QUEUE_NAME}:active_job:${jobId}`);
|
|
408
|
+
assert.equal(activeExists, 0);
|
|
409
|
+
|
|
410
|
+
// Verify job removed from checkouts
|
|
411
|
+
const inCheckouts = await redis.sIsMember(`${QUEUE_NAME}:checkouts:${clientId}`, jobId);
|
|
412
|
+
assert.equal(inCheckouts, 0);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('should unblock waiting job when active completes', async () => {
|
|
416
|
+
const now = Date.now();
|
|
417
|
+
const clientId = 'client1';
|
|
418
|
+
const jobId = 'job-unblock';
|
|
419
|
+
const runAt = Date.now() + 5000;
|
|
420
|
+
|
|
421
|
+
// Setup: active job with checkouts + expiry
|
|
422
|
+
await redis.zAdd(`${QUEUE_NAME}:expiry`, {
|
|
423
|
+
score: now + 10000,
|
|
424
|
+
value: clientId,
|
|
425
|
+
});
|
|
426
|
+
await redis.sAdd(`${QUEUE_NAME}:checkouts:${clientId}`, jobId);
|
|
427
|
+
await redis.hSet(`${QUEUE_NAME}:active_job:${jobId}`, 'id', jobId);
|
|
428
|
+
|
|
429
|
+
// Setup blocked waiting job
|
|
430
|
+
await redis.zAdd(QUEUE_NAME, { score: -runAt, value: jobId });
|
|
431
|
+
await redis.hSet(`${QUEUE_NAME}:waiting_job:${jobId}`, {
|
|
432
|
+
id: jobId,
|
|
433
|
+
update_run_at: 'true',
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// Finish
|
|
437
|
+
await redis.fCall('queasy_finish', {
|
|
438
|
+
keys: [QUEUE_NAME],
|
|
439
|
+
arguments: [jobId, clientId, now.toString()],
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// Verify waiting job is unblocked (positive score)
|
|
443
|
+
const score = await redis.zScore(QUEUE_NAME, jobId);
|
|
444
|
+
assert.ok(Number(score) > 0);
|
|
445
|
+
assert.equal(Number(score), runAt);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
describe('retry', () => {
|
|
450
|
+
it('should increment retry count and move job back to waiting', async () => {
|
|
451
|
+
const now = Date.now();
|
|
452
|
+
const jobId = 'job-retry';
|
|
453
|
+
const clientId = 'client1';
|
|
454
|
+
const nextRunAt = Date.now() + 5000;
|
|
455
|
+
|
|
456
|
+
// Setup: active job with checkouts + expiry
|
|
457
|
+
await redis.zAdd(`${QUEUE_NAME}:expiry`, {
|
|
458
|
+
score: now + 10000,
|
|
459
|
+
value: clientId,
|
|
460
|
+
});
|
|
461
|
+
await redis.sAdd(`${QUEUE_NAME}:checkouts:${clientId}`, jobId);
|
|
462
|
+
await redis.hSet(`${QUEUE_NAME}:active_job:${jobId}`, {
|
|
463
|
+
id: jobId,
|
|
464
|
+
retry_count: '0',
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// Call retry
|
|
468
|
+
const result = await redis.fCall('queasy_retry', {
|
|
469
|
+
keys: [QUEUE_NAME],
|
|
470
|
+
arguments: [
|
|
471
|
+
jobId,
|
|
472
|
+
clientId,
|
|
473
|
+
nextRunAt.toString(),
|
|
474
|
+
'{"message":"error"}',
|
|
475
|
+
now.toString(),
|
|
476
|
+
],
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
assert.equal(result, 'OK');
|
|
480
|
+
|
|
481
|
+
// Verify retry count incremented
|
|
482
|
+
const retryCount = await redis.hGet(
|
|
483
|
+
`${QUEUE_NAME}:waiting_job:${jobId}`,
|
|
484
|
+
'retry_count'
|
|
485
|
+
);
|
|
486
|
+
assert.equal(retryCount, '1');
|
|
487
|
+
|
|
488
|
+
// Verify job is in waiting queue
|
|
489
|
+
const score = await redis.zScore(QUEUE_NAME, jobId);
|
|
490
|
+
assert.equal(Number(score), nextRunAt);
|
|
491
|
+
|
|
492
|
+
// Verify job is not in active
|
|
493
|
+
const activeExists = await redis.exists(`${QUEUE_NAME}:active_job:${jobId}`);
|
|
494
|
+
assert.equal(activeExists, 0);
|
|
495
|
+
|
|
496
|
+
// Verify job removed from checkouts
|
|
497
|
+
const inCheckouts = await redis.sIsMember(`${QUEUE_NAME}:checkouts:${clientId}`, jobId);
|
|
498
|
+
assert.equal(inCheckouts, 0);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('should always retry regardless of retry count', async () => {
|
|
502
|
+
const now = Date.now();
|
|
503
|
+
const jobId = 'job-retry-many';
|
|
504
|
+
const clientId = 'client1';
|
|
505
|
+
const nextRunAt = Date.now() + 5000;
|
|
506
|
+
|
|
507
|
+
// Setup: active job with checkouts + expiry and high retry count
|
|
508
|
+
await redis.zAdd(`${QUEUE_NAME}:expiry`, {
|
|
509
|
+
score: now + 10000,
|
|
510
|
+
value: clientId,
|
|
511
|
+
});
|
|
512
|
+
await redis.sAdd(`${QUEUE_NAME}:checkouts:${clientId}`, jobId);
|
|
513
|
+
await redis.hSet(`${QUEUE_NAME}:active_job:${jobId}`, {
|
|
514
|
+
id: jobId,
|
|
515
|
+
retry_count: '100', // Very high retry count
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// Call retry - should still work
|
|
519
|
+
const result = await redis.fCall('queasy_retry', {
|
|
520
|
+
keys: [QUEUE_NAME],
|
|
521
|
+
arguments: [
|
|
522
|
+
jobId,
|
|
523
|
+
clientId,
|
|
524
|
+
nextRunAt.toString(),
|
|
525
|
+
'{"message":"error"}',
|
|
526
|
+
now.toString(),
|
|
527
|
+
],
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
assert.equal(result, 'OK');
|
|
531
|
+
|
|
532
|
+
// Verify retry count incremented to 101
|
|
533
|
+
const retryCount = await redis.hGet(
|
|
534
|
+
`${QUEUE_NAME}:waiting_job:${jobId}`,
|
|
535
|
+
'retry_count'
|
|
536
|
+
);
|
|
537
|
+
assert.equal(retryCount, '101');
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
describe('fail', () => {
|
|
542
|
+
it('should dispatch fail job and finish original job', async () => {
|
|
543
|
+
const now = Date.now();
|
|
544
|
+
const jobId = 'job-fail';
|
|
545
|
+
const clientId = 'client1';
|
|
546
|
+
const failJobId = 'fail-job-1';
|
|
547
|
+
const failJobData = JSON.stringify([
|
|
548
|
+
jobId,
|
|
549
|
+
'{"original":"data"}',
|
|
550
|
+
'{"message":"error"}',
|
|
551
|
+
]);
|
|
552
|
+
|
|
553
|
+
// Setup: active job with checkouts + expiry
|
|
554
|
+
await redis.zAdd(`${QUEUE_NAME}:expiry`, {
|
|
555
|
+
score: now + 10000,
|
|
556
|
+
value: clientId,
|
|
557
|
+
});
|
|
558
|
+
await redis.sAdd(`${QUEUE_NAME}:checkouts:${clientId}`, jobId);
|
|
559
|
+
await redis.hSet(`${QUEUE_NAME}:active_job:${jobId}`, {
|
|
560
|
+
id: jobId,
|
|
561
|
+
data: '{"original":"data"}',
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
// Call fail
|
|
565
|
+
const result = await redis.fCall('queasy_fail', {
|
|
566
|
+
keys: [QUEUE_NAME, `${QUEUE_NAME}-fail`],
|
|
567
|
+
arguments: [jobId, clientId, failJobId, failJobData, now.toString()],
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
assert.equal(result, 'OK');
|
|
571
|
+
|
|
572
|
+
// Verify original job is finished (removed)
|
|
573
|
+
const activeExists = await redis.exists(`${QUEUE_NAME}:active_job:${jobId}`);
|
|
574
|
+
assert.equal(activeExists, 0);
|
|
575
|
+
|
|
576
|
+
const inCheckouts = await redis.sIsMember(`${QUEUE_NAME}:checkouts:${clientId}`, jobId);
|
|
577
|
+
assert.equal(inCheckouts, 0);
|
|
578
|
+
|
|
579
|
+
// Verify fail job was dispatched
|
|
580
|
+
const failScore = await redis.zScore(`${QUEUE_NAME}-fail`, failJobId);
|
|
581
|
+
assert.ok(failScore !== null);
|
|
582
|
+
|
|
583
|
+
const failData = await redis.hGet(
|
|
584
|
+
`${QUEUE_NAME}-fail:waiting_job:${failJobId}`,
|
|
585
|
+
'data'
|
|
586
|
+
);
|
|
587
|
+
assert.equal(failData, failJobData);
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
describe('sweep (via bump)', () => {
|
|
592
|
+
it('should process stalled jobs when bump is called', async () => {
|
|
593
|
+
const now = Date.now();
|
|
594
|
+
const stalledClientId = 'stalled-client';
|
|
595
|
+
const healthyClientId = 'healthy-client';
|
|
596
|
+
const jobId = 'job-stall';
|
|
597
|
+
|
|
598
|
+
// Setup stalled client (heartbeat expired)
|
|
599
|
+
await redis.zAdd(`${QUEUE_NAME}:expiry`, {
|
|
600
|
+
score: now - 10000,
|
|
601
|
+
value: stalledClientId,
|
|
602
|
+
});
|
|
603
|
+
await redis.sAdd(`${QUEUE_NAME}:checkouts:${stalledClientId}`, jobId);
|
|
604
|
+
await redis.hSet(`${QUEUE_NAME}:active_job:${jobId}`, {
|
|
605
|
+
id: jobId,
|
|
606
|
+
stall_count: '0',
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
// Setup healthy client
|
|
610
|
+
await redis.zAdd(`${QUEUE_NAME}:expiry`, {
|
|
611
|
+
score: now + 10000,
|
|
612
|
+
value: healthyClientId,
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
// Bump from healthy client triggers sweep
|
|
616
|
+
const result = await redis.fCall('queasy_bump', {
|
|
617
|
+
keys: [QUEUE_NAME],
|
|
618
|
+
arguments: [healthyClientId, now.toString(), String(now + 10000)],
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
assert.equal(result, 1);
|
|
622
|
+
|
|
623
|
+
// Verify stall count incremented and job moved to waiting
|
|
624
|
+
const stallCount = await redis.hGet(
|
|
625
|
+
`${QUEUE_NAME}:waiting_job:${jobId}`,
|
|
626
|
+
'stall_count'
|
|
627
|
+
);
|
|
628
|
+
assert.equal(stallCount, '1');
|
|
629
|
+
|
|
630
|
+
// Verify job is in waiting queue
|
|
631
|
+
const score = await redis.zScore(QUEUE_NAME, jobId);
|
|
632
|
+
assert.ok(score !== null);
|
|
633
|
+
|
|
634
|
+
// Verify stalled client cleaned up
|
|
635
|
+
const stalledExpiry = await redis.zScore(`${QUEUE_NAME}:expiry`, stalledClientId);
|
|
636
|
+
assert.equal(stalledExpiry, null);
|
|
637
|
+
|
|
638
|
+
const stalledCheckouts = await redis.exists(
|
|
639
|
+
`${QUEUE_NAME}:checkouts:${stalledClientId}`
|
|
640
|
+
);
|
|
641
|
+
assert.equal(stalledCheckouts, 0);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('should always retry stalled jobs regardless of stall count', async () => {
|
|
645
|
+
const now = Date.now();
|
|
646
|
+
const stalledClientId = 'stalled-client';
|
|
647
|
+
const healthyClientId = 'healthy-client';
|
|
648
|
+
const jobId = 'job-stall-many';
|
|
649
|
+
|
|
650
|
+
// Setup stalled client with high stall count
|
|
651
|
+
await redis.zAdd(`${QUEUE_NAME}:expiry`, {
|
|
652
|
+
score: now - 10000,
|
|
653
|
+
value: stalledClientId,
|
|
654
|
+
});
|
|
655
|
+
await redis.sAdd(`${QUEUE_NAME}:checkouts:${stalledClientId}`, jobId);
|
|
656
|
+
await redis.hSet(`${QUEUE_NAME}:active_job:${jobId}`, {
|
|
657
|
+
id: jobId,
|
|
658
|
+
stall_count: '100', // Very high stall count
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
// Setup healthy client
|
|
662
|
+
await redis.zAdd(`${QUEUE_NAME}:expiry`, {
|
|
663
|
+
score: now + 10000,
|
|
664
|
+
value: healthyClientId,
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// Bump from healthy client triggers sweep
|
|
668
|
+
const result = await redis.fCall('queasy_bump', {
|
|
669
|
+
keys: [QUEUE_NAME],
|
|
670
|
+
arguments: [healthyClientId, now.toString(), String(now + 10000)],
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
assert.equal(result, 1);
|
|
674
|
+
|
|
675
|
+
// Verify stall count incremented to 101
|
|
676
|
+
const stallCount = await redis.hGet(
|
|
677
|
+
`${QUEUE_NAME}:waiting_job:${jobId}`,
|
|
678
|
+
'stall_count'
|
|
679
|
+
);
|
|
680
|
+
assert.equal(stallCount, '101');
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
});
|