queasy 0.1.0 → 0.2.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/.github/workflows/check.yml +50 -0
- package/.github/workflows/publish.yml +44 -0
- package/AGENTS.md +2 -1
- package/Readme.md +24 -19
- package/package.json +6 -2
- package/src/client.js +65 -25
- package/src/constants.js +3 -0
- package/src/pool.js +4 -7
- package/src/queasy.lua +31 -37
- package/src/queue.js +4 -11
- package/src/utils.js +26 -0
- package/test/client.test.js +39 -41
- package/test/errors.test.js +12 -12
- package/test/fixtures/always-fail-handler.js +3 -3
- package/test/fixtures/data-logger-handler.js +5 -0
- package/test/fixtures/failure-handler.js +2 -2
- package/test/fixtures/permanent-error-handler.js +3 -3
- package/test/fixtures/slow-handler.js +2 -2
- package/test/fixtures/success-handler.js +3 -3
- package/test/fixtures/with-failure-handler.js +3 -3
- package/test/guards.test.js +141 -0
- package/test/manager.test.js +215 -70
- package/test/pool.test.js +151 -57
- package/test/queue.test.js +1 -1
- package/test/redis-functions.test.js +18 -12
- package/test/utils.test.js +52 -0
- package/.claude/settings.local.json +0 -27
- package/.zed/settings.json +0 -39
- package/doc/Implementation.md +0 -70
- package/test/index.test.js +0 -55
package/test/client.test.js
CHANGED
|
@@ -5,45 +5,43 @@ import { Client } from '../src/index.js';
|
|
|
5
5
|
|
|
6
6
|
const QUEUE_NAME = 'client-test';
|
|
7
7
|
|
|
8
|
-
describe('Client heartbeat', () => {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
assert.ok(expiryScore > Date.now(), 'Expiry should be in the future');
|
|
48
|
-
});
|
|
8
|
+
describe.skip('Client heartbeat', () => {
|
|
9
|
+
/** @type {import('redis').RedisClientType} */
|
|
10
|
+
let redis;
|
|
11
|
+
/** @type {import('../src/client.js').Client} */
|
|
12
|
+
let client;
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
redis = createClient();
|
|
16
|
+
await redis.connect();
|
|
17
|
+
client = new Client(redis, 1);
|
|
18
|
+
client.scheduleBump = mock.fn();
|
|
19
|
+
if (client.manager) client.manager.addQueue = mock.fn();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await client.close();
|
|
24
|
+
const keys = await redis.keys(`{${QUEUE_NAME}}*`);
|
|
25
|
+
if (keys.length > 0) await redis.del(keys);
|
|
26
|
+
await redis.quit();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should update expiry key when bump is called', async () => {
|
|
30
|
+
const q = client.queue(QUEUE_NAME);
|
|
31
|
+
await q.dispatch({ task: 'test' });
|
|
32
|
+
|
|
33
|
+
const handlerPath = new URL('./fixtures/success-handler.js', import.meta.url).pathname;
|
|
34
|
+
await q.listen(handlerPath);
|
|
35
|
+
|
|
36
|
+
// Dequeue to start heartbeats
|
|
37
|
+
await (await q.dequeue(1)).promise;
|
|
38
|
+
|
|
39
|
+
// Manually call bump to exercise the bump method
|
|
40
|
+
await client.bump(`{${QUEUE_NAME}}`);
|
|
41
|
+
|
|
42
|
+
// Expiry key should have our client's entry
|
|
43
|
+
const expiryScore = await redis.zScore(`{${QUEUE_NAME}}:expiry`, client.clientId);
|
|
44
|
+
assert.ok(expiryScore !== null, 'Expiry entry should exist after dequeue');
|
|
45
|
+
assert.ok(expiryScore > Date.now(), 'Expiry should be in the future');
|
|
46
|
+
});
|
|
49
47
|
});
|
package/test/errors.test.js
CHANGED
|
@@ -3,17 +3,17 @@ import { describe, it } from 'node:test';
|
|
|
3
3
|
import { PermanentError, StallError } from '../src/errors.js';
|
|
4
4
|
|
|
5
5
|
describe('Error classes', () => {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
it('PermanentError has correct name and message', () => {
|
|
7
|
+
const err = new PermanentError('test message');
|
|
8
|
+
assert.equal(err.name, 'PermanentError');
|
|
9
|
+
assert.equal(err.message, 'test message');
|
|
10
|
+
assert.ok(err instanceof Error);
|
|
11
|
+
});
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
it('StallError has correct name and message', () => {
|
|
14
|
+
const err = new StallError('stall message');
|
|
15
|
+
assert.equal(err.name, 'StallError');
|
|
16
|
+
assert.equal(err.message, 'stall message');
|
|
17
|
+
assert.ok(err instanceof Error);
|
|
18
|
+
});
|
|
19
19
|
});
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Handler that always fails with a retriable error
|
|
3
|
-
* @param {any}
|
|
4
|
-
* @param {import('../../src/types.js').Job}
|
|
3
|
+
* @param {any} _data - Job data
|
|
4
|
+
* @param {import('../../src/types.js').Job} _job - Job metadata
|
|
5
5
|
*/
|
|
6
|
-
export async function handle(
|
|
6
|
+
export async function handle(_data, _job) {
|
|
7
7
|
throw new Error('Always fails');
|
|
8
8
|
}
|
|
@@ -3,8 +3,13 @@
|
|
|
3
3
|
* @param {any} data - Job data
|
|
4
4
|
* @param {import('../../src/types.js').Job} job - Job metadata
|
|
5
5
|
*/
|
|
6
|
+
/** @type {{ data: any, job: import('../../src/types.js').Job }[]} */
|
|
6
7
|
export const receivedJobs = [];
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* @param {any} data - Job data
|
|
11
|
+
* @param {import('../../src/types.js').Job} job - Job metadata
|
|
12
|
+
*/
|
|
8
13
|
export async function handle(data, job) {
|
|
9
14
|
receivedJobs.push({ data, job });
|
|
10
15
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Failure handler
|
|
3
3
|
* @param {any} data - Failure data [originalJobId, originalData, error]
|
|
4
|
-
* @param {import('../../src/types.js').Job}
|
|
4
|
+
* @param {import('../../src/types.js').Job} _job - Job metadata
|
|
5
5
|
*/
|
|
6
|
-
export async function handle(data,
|
|
6
|
+
export async function handle(data, _job) {
|
|
7
7
|
// Just succeed to process the failure
|
|
8
8
|
console.log('Failure handler called with:', data);
|
|
9
9
|
}
|
|
@@ -2,9 +2,9 @@ import { PermanentError } from '../../src/errors.js';
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Handler that throws a PermanentError
|
|
5
|
-
* @param {any}
|
|
6
|
-
* @param {import('../../src/types.js').Job}
|
|
5
|
+
* @param {any} _data - Job data
|
|
6
|
+
* @param {import('../../src/types.js').Job} _job - Job metadata
|
|
7
7
|
*/
|
|
8
|
-
export async function handle(
|
|
8
|
+
export async function handle(_data, _job) {
|
|
9
9
|
throw new PermanentError('Permanent failure');
|
|
10
10
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Handler that takes some time to complete (for testing heartbeat)
|
|
3
3
|
* @param {any} data - Job data
|
|
4
|
-
* @param {import('../../src/types.js').Job}
|
|
4
|
+
* @param {import('../../src/types.js').Job} _job - Job metadata
|
|
5
5
|
*/
|
|
6
|
-
export async function handle(data,
|
|
6
|
+
export async function handle(data, _job) {
|
|
7
7
|
const delay = data?.delay || 1000;
|
|
8
8
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
9
9
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Handler that always succeeds
|
|
3
|
-
* @param {any}
|
|
4
|
-
* @param {import('../../src/types.js').Job}
|
|
3
|
+
* @param {any} _data - Job data
|
|
4
|
+
* @param {import('../../src/types.js').Job} _job - Job metadata
|
|
5
5
|
*/
|
|
6
|
-
export async function handle(
|
|
6
|
+
export async function handle(_data, _job) {
|
|
7
7
|
// Job succeeds immediately
|
|
8
8
|
return;
|
|
9
9
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Handler that always fails
|
|
3
|
-
* @param {any}
|
|
4
|
-
* @param {import('../../src/types.js').Job}
|
|
3
|
+
* @param {any} _data - Job data
|
|
4
|
+
* @param {import('../../src/types.js').Job} _job - Job metadata
|
|
5
5
|
*/
|
|
6
|
-
export async function handle(
|
|
6
|
+
export async function handle(_data, _job) {
|
|
7
7
|
throw new Error('Always fails');
|
|
8
8
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { afterEach, beforeEach, describe, it, mock } from 'node:test';
|
|
3
|
+
import { createClient } from 'redis';
|
|
4
|
+
import { Client } from '../src/index.js';
|
|
5
|
+
|
|
6
|
+
describe('Client disconnect guard', () => {
|
|
7
|
+
/** @type {import('redis').RedisClientType} */
|
|
8
|
+
let redis;
|
|
9
|
+
/** @type {import('../src/client.js').Client} */
|
|
10
|
+
let client;
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
redis = createClient();
|
|
14
|
+
await redis.connect();
|
|
15
|
+
return new Promise((res) => {
|
|
16
|
+
client = new Client(redis, 0, res);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await client.close();
|
|
22
|
+
await redis.quit();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should throw from queue() when disconnected', () => {
|
|
26
|
+
client.disconnected = true;
|
|
27
|
+
assert.throws(() => client.queue('test'), /disconnected/);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('Queue disconnect guards', () => {
|
|
32
|
+
/** @type {import('redis').RedisClientType} */
|
|
33
|
+
let redis;
|
|
34
|
+
/** @type {import('../src/client.js').Client} */
|
|
35
|
+
let client;
|
|
36
|
+
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
redis = createClient();
|
|
39
|
+
await redis.connect();
|
|
40
|
+
client = new Client(redis, 1);
|
|
41
|
+
if (client.manager) client.manager.addQueue = mock.fn();
|
|
42
|
+
// Wait for installLuaFunctions to settle
|
|
43
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(async () => {
|
|
47
|
+
await client.close();
|
|
48
|
+
await redis.quit();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should throw from dispatch() when disconnected', async () => {
|
|
52
|
+
const q = client.queue('guard-test');
|
|
53
|
+
client.disconnected = true;
|
|
54
|
+
await assert.rejects(() => q.dispatch({ data: 1 }), /disconnected/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should throw from cancel() when disconnected', async () => {
|
|
58
|
+
const q = client.queue('guard-test');
|
|
59
|
+
client.disconnected = true;
|
|
60
|
+
await assert.rejects(() => q.cancel('some-id'), /disconnected/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should throw from listen() when disconnected', async () => {
|
|
64
|
+
const q = client.queue('guard-test');
|
|
65
|
+
client.disconnected = true;
|
|
66
|
+
await assert.rejects(() => q.listen('/some/handler.js'), /disconnected/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should throw from listen() on non-processing client', async () => {
|
|
70
|
+
const nonProcessingClient = new Client(redis, 0);
|
|
71
|
+
// Wait for installLuaFunctions to settle
|
|
72
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
73
|
+
const q = nonProcessingClient.queue('guard-test');
|
|
74
|
+
await assert.rejects(() => q.listen('/some/handler.js'), /non-processing/);
|
|
75
|
+
await client.close();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('Client bump lost lock', () => {
|
|
80
|
+
/** @type {import('redis').RedisClientType} */
|
|
81
|
+
let redis;
|
|
82
|
+
/** @type {import('../src/client.js').Client} */
|
|
83
|
+
let client;
|
|
84
|
+
|
|
85
|
+
const QUEUE_NAME = 'bump-lock-test';
|
|
86
|
+
|
|
87
|
+
beforeEach(async () => {
|
|
88
|
+
redis = createClient();
|
|
89
|
+
await redis.connect();
|
|
90
|
+
const keys = await redis.keys(`{${QUEUE_NAME}}*`);
|
|
91
|
+
if (keys.length > 0) await redis.del(keys);
|
|
92
|
+
|
|
93
|
+
client = new Client(redis, 1);
|
|
94
|
+
if (client.manager) client.manager.addQueue = mock.fn();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterEach(async () => {
|
|
98
|
+
await client.close();
|
|
99
|
+
const keys = await redis.keys(`{${QUEUE_NAME}}*`);
|
|
100
|
+
if (keys.length > 0) await redis.del(keys);
|
|
101
|
+
await redis.quit();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should close and emit disconnected when bump returns 0', async () => {
|
|
105
|
+
const q = client.queue(QUEUE_NAME);
|
|
106
|
+
await q.dispatch({ task: 'test' });
|
|
107
|
+
const handlerPath = new URL('./fixtures/success-handler.js', import.meta.url).pathname;
|
|
108
|
+
await q.listen(handlerPath);
|
|
109
|
+
|
|
110
|
+
// Dequeue to register the client in the expiry set and start heartbeats
|
|
111
|
+
await (await q.dequeue(1)).promise;
|
|
112
|
+
|
|
113
|
+
// Remove the client from the expiry set to simulate a lost lock
|
|
114
|
+
await redis.zRem(`{${QUEUE_NAME}}:expiry`, client.clientId);
|
|
115
|
+
|
|
116
|
+
const disconnected = new Promise((resolve) => client.on('disconnected', resolve));
|
|
117
|
+
|
|
118
|
+
// Bump should detect it's been evicted
|
|
119
|
+
await client.bump(`{${QUEUE_NAME}}`);
|
|
120
|
+
|
|
121
|
+
const reason = await disconnected;
|
|
122
|
+
assert.ok(String(reason).includes('Lost locks'));
|
|
123
|
+
assert.equal(client.disconnected, true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should skip bump when already disconnected', async () => {
|
|
127
|
+
const q = client.queue(QUEUE_NAME);
|
|
128
|
+
// Set up the queue entry so scheduleBump doesn't error
|
|
129
|
+
await q.dispatch({ task: 'test' });
|
|
130
|
+
const handlerPath = new URL('./fixtures/success-handler.js', import.meta.url).pathname;
|
|
131
|
+
await q.listen(handlerPath);
|
|
132
|
+
await (await q.dequeue(1)).promise;
|
|
133
|
+
|
|
134
|
+
client.disconnected = true;
|
|
135
|
+
|
|
136
|
+
// Should return early without calling fCall
|
|
137
|
+
await client.bump(`{${QUEUE_NAME}}`);
|
|
138
|
+
// If we get here without error, the early return worked
|
|
139
|
+
assert.equal(client.disconnected, true);
|
|
140
|
+
});
|
|
141
|
+
});
|
package/test/manager.test.js
CHANGED
|
@@ -1,74 +1,219 @@
|
|
|
1
1
|
import assert from 'node:assert';
|
|
2
|
-
import { afterEach, beforeEach, describe, it } from 'node:test';
|
|
2
|
+
import { afterEach, beforeEach, describe, it, mock } from 'node:test';
|
|
3
3
|
import { createClient } from 'redis';
|
|
4
4
|
import { Client } from '../src/index.js';
|
|
5
|
+
import { Manager } from '../src/manager.js';
|
|
6
|
+
|
|
7
|
+
describe('Manager unit tests', () => {
|
|
8
|
+
/**
|
|
9
|
+
* @param {object} [overrides]
|
|
10
|
+
* @returns {import('../src/manager.js').Manager}
|
|
11
|
+
*/
|
|
12
|
+
function createManager(overrides = {}) {
|
|
13
|
+
const pool = /** @type {import('../src/pool.js').Pool} */ ({
|
|
14
|
+
capacity: 100,
|
|
15
|
+
...overrides,
|
|
16
|
+
});
|
|
17
|
+
return new Manager(pool);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {{ handlerOptions?: Partial<import('../src/types.js').HandlerOptions>, dequeue?: import('node:test').Mock<any> }} [overrides]
|
|
22
|
+
*/
|
|
23
|
+
function mockQueue(overrides = {}) {
|
|
24
|
+
return /** @type {import('../src/queue.js').ProcessingQueue} */ ({
|
|
25
|
+
handlerOptions: { size: 10, priority: 100, ...overrides.handlerOptions },
|
|
26
|
+
dequeue: overrides.dequeue ?? mock.fn(async () => ({ count: 0 })),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
it('should return early from next() when queues are empty', async () => {
|
|
31
|
+
const mgr = createManager();
|
|
32
|
+
// next() with no queues should not throw
|
|
33
|
+
await mgr.next();
|
|
34
|
+
mgr.close();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should return early from next() when pool capacity is insufficient', async () => {
|
|
38
|
+
const mgr = createManager({ capacity: 5 });
|
|
39
|
+
const q = mockQueue({ handlerOptions: { size: 10, priority: 100 } });
|
|
40
|
+
mgr.addQueue(q);
|
|
41
|
+
// Wait for next() to run (addQueue calls next via setTimeout)
|
|
42
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
43
|
+
// dequeue should NOT have been called since capacity (5) < size (10)
|
|
44
|
+
assert.equal(/** @type {import('node:test').Mock<any>} */ (q.dequeue).mock.callCount(), 0);
|
|
45
|
+
mgr.close();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should clear existing timer when next() runs', async () => {
|
|
49
|
+
const mgr = createManager();
|
|
50
|
+
// Set a fake timer
|
|
51
|
+
mgr.timer = setTimeout(() => {}, 10000);
|
|
52
|
+
const q = mockQueue();
|
|
53
|
+
mgr.queues.push({ queue: q, lastDequeuedAt: 0, isBusy: false });
|
|
54
|
+
mgr.busyCount = 1;
|
|
55
|
+
await mgr.next();
|
|
56
|
+
// Timer should have been cleared and reset
|
|
57
|
+
assert.notEqual(mgr.timer, null);
|
|
58
|
+
mgr.close();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should set delay to 0 when queue is busy', async () => {
|
|
62
|
+
const mgr = createManager({ capacity: 100 });
|
|
63
|
+
// dequeue returns count equal to batchSize, making queue busy
|
|
64
|
+
const q = mockQueue({
|
|
65
|
+
dequeue: mock.fn(async () => ({ count: 10 })),
|
|
66
|
+
});
|
|
67
|
+
mgr.queues.push({ queue: q, lastDequeuedAt: 0, isBusy: false });
|
|
68
|
+
mgr.busyCount = 1;
|
|
69
|
+
await mgr.next();
|
|
70
|
+
assert.notEqual(mgr.timer, null);
|
|
71
|
+
mgr.close();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should sort queues: busy before non-busy', async () => {
|
|
75
|
+
const mgr = createManager({ capacity: 100 });
|
|
76
|
+
const q1 = mockQueue();
|
|
77
|
+
const q2 = mockQueue();
|
|
78
|
+
const entries = [
|
|
79
|
+
{ queue: q1, lastDequeuedAt: 0, isBusy: false },
|
|
80
|
+
{ queue: q2, lastDequeuedAt: 0, isBusy: true },
|
|
81
|
+
];
|
|
82
|
+
mgr.queues = [...entries];
|
|
83
|
+
mgr.busyCount = 1;
|
|
84
|
+
// Manually call next to trigger the sort
|
|
85
|
+
await mgr.next();
|
|
86
|
+
// The busy queue should be first after sort
|
|
87
|
+
assert.equal(mgr.queues[0].isBusy, true);
|
|
88
|
+
mgr.close();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should exercise priority sort branches', async () => {
|
|
92
|
+
const mgr = createManager({ capacity: 200 });
|
|
93
|
+
const qProcessed = mockQueue({ handlerOptions: { size: 10, priority: 100 } });
|
|
94
|
+
const qLow = mockQueue({ handlerOptions: { size: 10, priority: 50 } });
|
|
95
|
+
const qHigh = mockQueue({ handlerOptions: { size: 10, priority: 200 } });
|
|
96
|
+
mgr.queues = [
|
|
97
|
+
{ queue: qProcessed, lastDequeuedAt: 0, isBusy: false },
|
|
98
|
+
{ queue: qHigh, lastDequeuedAt: 0, isBusy: false },
|
|
99
|
+
{ queue: qLow, lastDequeuedAt: 0, isBusy: false },
|
|
100
|
+
];
|
|
101
|
+
mgr.busyCount = 3;
|
|
102
|
+
await mgr.next();
|
|
103
|
+
// Lower priority value sorts before higher
|
|
104
|
+
const priorities = mgr.queues.map((e) => e.queue.handlerOptions.priority);
|
|
105
|
+
assert.ok(priorities.indexOf(50) < priorities.indexOf(200));
|
|
106
|
+
mgr.close();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should exercise lastDequeuedAt sort branches', async () => {
|
|
110
|
+
const mgr = createManager({ capacity: 200 });
|
|
111
|
+
const qProcessed = mockQueue();
|
|
112
|
+
const qOld = mockQueue();
|
|
113
|
+
const qNew = mockQueue();
|
|
114
|
+
mgr.queues = [
|
|
115
|
+
{ queue: qProcessed, lastDequeuedAt: 0, isBusy: false },
|
|
116
|
+
{ queue: qOld, lastDequeuedAt: 100, isBusy: false },
|
|
117
|
+
{ queue: qNew, lastDequeuedAt: 200, isBusy: false },
|
|
118
|
+
];
|
|
119
|
+
mgr.busyCount = 3;
|
|
120
|
+
await mgr.next();
|
|
121
|
+
// Among the two unprocessed: newer lastDequeuedAt sorts before older
|
|
122
|
+
const [, ...rest] = mgr.queues; // skip the processed entry (now has Date.now())
|
|
123
|
+
const oldIdx = rest.findIndex((e) => e.queue === qOld);
|
|
124
|
+
const newIdx = rest.findIndex((e) => e.queue === qNew);
|
|
125
|
+
if (oldIdx !== -1 && newIdx !== -1) {
|
|
126
|
+
assert.ok(newIdx < oldIdx);
|
|
127
|
+
}
|
|
128
|
+
mgr.close();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should exercise size sort branches', async () => {
|
|
132
|
+
const mgr = createManager({ capacity: 200 });
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
const qProcessed = mockQueue({ handlerOptions: { size: 10, priority: 50 } });
|
|
135
|
+
const qSmall = mockQueue({ handlerOptions: { size: 5, priority: 100 } });
|
|
136
|
+
const qLarge = mockQueue({ handlerOptions: { size: 20, priority: 100 } });
|
|
137
|
+
mgr.queues = [
|
|
138
|
+
{ queue: qProcessed, lastDequeuedAt: now, isBusy: false },
|
|
139
|
+
{ queue: qSmall, lastDequeuedAt: now, isBusy: false },
|
|
140
|
+
{ queue: qLarge, lastDequeuedAt: now, isBusy: false },
|
|
141
|
+
];
|
|
142
|
+
mgr.busyCount = 3;
|
|
143
|
+
await mgr.next();
|
|
144
|
+
// Among entries with same priority and lastDequeuedAt: smaller size sorts first
|
|
145
|
+
const sizes = mgr.queues.map((e) => e.queue.handlerOptions.size);
|
|
146
|
+
assert.ok(sizes.indexOf(5) < sizes.indexOf(20));
|
|
147
|
+
mgr.close();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
5
150
|
|
|
6
151
|
const QUEUE_NAME = 'mgr-test';
|
|
7
152
|
|
|
8
153
|
describe('Manager scheduling', () => {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
154
|
+
/** @type {import('redis').RedisClientType} */
|
|
155
|
+
let redis;
|
|
156
|
+
/** @type {import('../src/client.js').Client} */
|
|
157
|
+
let client;
|
|
158
|
+
|
|
159
|
+
beforeEach(async () => {
|
|
160
|
+
redis = createClient();
|
|
161
|
+
await redis.connect();
|
|
162
|
+
const keys = await redis.keys(`{${QUEUE_NAME}}*`);
|
|
163
|
+
if (keys.length > 0) await redis.del(keys);
|
|
164
|
+
|
|
165
|
+
// Do NOT mock manager.addQueue — let the manager drive dequeuing
|
|
166
|
+
client = new Client(redis, 1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
afterEach(async () => {
|
|
170
|
+
await client.close();
|
|
171
|
+
const keys = await redis.keys(`{${QUEUE_NAME}}*`);
|
|
172
|
+
if (keys.length > 0) await redis.del(keys);
|
|
173
|
+
await redis.quit();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should dequeue and process jobs via manager without explicit dequeue', async () => {
|
|
177
|
+
const q = client.queue(QUEUE_NAME);
|
|
178
|
+
const jobId = await q.dispatch({ task: 'managed' });
|
|
179
|
+
|
|
180
|
+
const handlerPath = new URL('./fixtures/success-handler.js', import.meta.url).pathname;
|
|
181
|
+
await q.listen(handlerPath);
|
|
182
|
+
|
|
183
|
+
// Wait for manager to dequeue and process the job fully
|
|
184
|
+
await poll(async () => {
|
|
185
|
+
const score = await redis.zScore(`{${QUEUE_NAME}}`, jobId);
|
|
186
|
+
const activeExists = await redis.exists(`{${QUEUE_NAME}}:active_job:${jobId}`);
|
|
187
|
+
return score === null && activeExists === 0;
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should process jobs from multiple queues', async () => {
|
|
192
|
+
const staleKeys = await redis.keys('{mgr-q2}*');
|
|
193
|
+
if (staleKeys.length > 0) await redis.del(staleKeys);
|
|
194
|
+
|
|
195
|
+
const q1 = client.queue(QUEUE_NAME);
|
|
196
|
+
const q2 = client.queue('mgr-q2');
|
|
197
|
+
const jobId1 = await q1.dispatch({ q: 1 });
|
|
198
|
+
const jobId2 = await q2.dispatch({ q: 2 });
|
|
199
|
+
|
|
200
|
+
const handlerPath = new URL('./fixtures/success-handler.js', import.meta.url).pathname;
|
|
201
|
+
await q1.listen(handlerPath);
|
|
202
|
+
await q2.listen(handlerPath);
|
|
203
|
+
|
|
204
|
+
// Wait for both jobs to be fully processed
|
|
205
|
+
await poll(async () => {
|
|
206
|
+
const s1 = await redis.zScore(`{${QUEUE_NAME}}`, jobId1);
|
|
207
|
+
const a1 = await redis.exists(`{${QUEUE_NAME}}:active_job:${jobId1}`);
|
|
208
|
+
const s2 = await redis.zScore('{mgr-q2}', jobId2);
|
|
209
|
+
const a2 = await redis.exists(`{mgr-q2}:active_job:${jobId2}`);
|
|
210
|
+
return s1 === null && a1 === 0 && s2 === null && a2 === 0;
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Cleanup
|
|
214
|
+
const keys = await redis.keys('{mgr-q2}*');
|
|
215
|
+
if (keys.length > 0) await redis.del(keys);
|
|
216
|
+
});
|
|
72
217
|
});
|
|
73
218
|
|
|
74
219
|
/**
|
|
@@ -78,10 +223,10 @@ describe('Manager scheduling', () => {
|
|
|
78
223
|
* @param {number} [interval=20]
|
|
79
224
|
*/
|
|
80
225
|
async function poll(fn, timeout = 5000, interval = 20) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
226
|
+
const deadline = Date.now() + timeout;
|
|
227
|
+
while (Date.now() < deadline) {
|
|
228
|
+
if (await fn()) return;
|
|
229
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
230
|
+
}
|
|
231
|
+
assert.fail('Poll timed out');
|
|
87
232
|
}
|