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.
@@ -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
+ });