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,49 @@
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
+ const QUEUE_NAME = 'client-test';
7
+
8
+ describe('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
+ const keys = await redis.keys(`{${QUEUE_NAME}}*`);
18
+ if (keys.length > 0) await redis.del(keys);
19
+
20
+ client = new Client(redis, 1);
21
+ if (client.manager) client.manager.addQueue = mock.fn();
22
+ });
23
+
24
+ afterEach(async () => {
25
+ client.close();
26
+ const keys = await redis.keys(`{${QUEUE_NAME}}*`);
27
+ if (keys.length > 0) await redis.del(keys);
28
+ await redis.quit();
29
+ });
30
+
31
+ it('should update expiry key when bump is called', async () => {
32
+ const q = client.queue(QUEUE_NAME);
33
+ await q.dispatch({ task: 'test' });
34
+
35
+ const handlerPath = new URL('./fixtures/success-handler.js', import.meta.url).pathname;
36
+ await q.listen(handlerPath);
37
+
38
+ // Dequeue to start heartbeats
39
+ await (await q.dequeue(1)).promise;
40
+
41
+ // Manually call bump to exercise the bump method
42
+ await client.bump(`{${QUEUE_NAME}}`);
43
+
44
+ // Expiry key should have our client's entry
45
+ const expiryScore = await redis.zScore(`{${QUEUE_NAME}}:expiry`, client.clientId);
46
+ assert.ok(expiryScore !== null, 'Expiry entry should exist after bump');
47
+ assert.ok(expiryScore > Date.now(), 'Expiry should be in the future');
48
+ });
49
+ });
@@ -0,0 +1,19 @@
1
+ import assert from 'node:assert';
2
+ import { describe, it } from 'node:test';
3
+ import { PermanentError, StallError } from '../src/errors.js';
4
+
5
+ describe('Error classes', () => {
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
+
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
+ });
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Handler that always fails with a retriable error
3
+ * @param {any} data - Job data
4
+ * @param {import('../../src/types.js').Job} job - Job metadata
5
+ */
6
+ export async function handle(data, job) {
7
+ throw new Error('Always fails');
8
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Handler that logs received data to a global array (for testing)
3
+ * @param {any} data - Job data
4
+ * @param {import('../../src/types.js').Job} job - Job metadata
5
+ */
6
+ export const receivedJobs = [];
7
+
8
+ export async function handle(data, job) {
9
+ receivedJobs.push({ data, job });
10
+ }
11
+
12
+ export function clearLogs() {
13
+ receivedJobs.length = 0;
14
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Failure handler
3
+ * @param {any} data - Failure data [originalJobId, originalData, error]
4
+ * @param {import('../../src/types.js').Job} job - Job metadata
5
+ */
6
+ export async function handle(data, job) {
7
+ // Just succeed to process the failure
8
+ console.log('Failure handler called with:', data);
9
+ }
@@ -0,0 +1 @@
1
+ export const notAFunction = 'hello';
@@ -0,0 +1,10 @@
1
+ import { PermanentError } from '../../src/errors.js';
2
+
3
+ /**
4
+ * Handler that throws a PermanentError
5
+ * @param {any} data - Job data
6
+ * @param {import('../../src/types.js').Job} job - Job metadata
7
+ */
8
+ export async function handle(data, job) {
9
+ throw new PermanentError('Permanent failure');
10
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Handler that takes some time to complete (for testing heartbeat)
3
+ * @param {any} data - Job data
4
+ * @param {import('../../src/types.js').Job} job - Job metadata
5
+ */
6
+ export async function handle(data, job) {
7
+ const delay = data?.delay || 1000;
8
+ await new Promise((resolve) => setTimeout(resolve, delay));
9
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Handler that always succeeds
3
+ * @param {any} data - Job data
4
+ * @param {import('../../src/types.js').Job} job - Job metadata
5
+ */
6
+ export async function handle(data, job) {
7
+ // Job succeeds immediately
8
+ return;
9
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Handler that always fails
3
+ * @param {any} data - Job data
4
+ * @param {import('../../src/types.js').Job} job - Job metadata
5
+ */
6
+ export async function handle(data, job) {
7
+ throw new Error('Always fails');
8
+ }
@@ -0,0 +1,55 @@
1
+ import assert from 'node:assert/strict';
2
+ import { after, before, describe, it } from 'node:test';
3
+ import { createClient } from 'redis';
4
+
5
+ describe('Redis connection', () => {
6
+ /** @type {import('redis').RedisClientType} */
7
+ let redis;
8
+
9
+ before(async () => {
10
+ // Connect to Redis running in Docker
11
+ redis = createClient({
12
+ socket: {
13
+ host: 'localhost',
14
+ port: 6379,
15
+ reconnectStrategy: (retries) => {
16
+ if (retries > 10) {
17
+ return new Error('Max retries reached');
18
+ }
19
+ return Math.min(retries * 50, 2000); // Exponential backoff
20
+ },
21
+ },
22
+ });
23
+
24
+ await redis.connect();
25
+ // Wait for connection to be ready
26
+ await redis.ping();
27
+ });
28
+
29
+ after(async () => {
30
+ redis.destroy();
31
+ });
32
+
33
+ it('should connect to Redis and execute basic commands', async () => {
34
+ // Test SET command
35
+ const setResult = await redis.set('test:key', 'test-value');
36
+ assert.equal(setResult, 'OK');
37
+
38
+ // Test GET command
39
+ const getValue = await redis.get('test:key');
40
+ assert.equal(getValue, 'test-value');
41
+
42
+ // Test DEL command
43
+ const delResult = await redis.del('test:key');
44
+ assert.equal(delResult, 1);
45
+
46
+ // Verify key is deleted
47
+ const deletedValue = await redis.get('test:key');
48
+ assert.equal(deletedValue, null);
49
+ });
50
+
51
+ it('should execute PING command', async () => {
52
+ const result = await redis.ping();
53
+ assert.equal(result, 'PONG');
54
+ });
55
+ });
@@ -0,0 +1,87 @@
1
+ import assert from 'node:assert';
2
+ import { afterEach, beforeEach, describe, it } from 'node:test';
3
+ import { createClient } from 'redis';
4
+ import { Client } from '../src/index.js';
5
+
6
+ const QUEUE_NAME = 'mgr-test';
7
+
8
+ describe('Manager scheduling', () => {
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
+ const keys = await redis.keys(`{${QUEUE_NAME}}*`);
18
+ if (keys.length > 0) await redis.del(keys);
19
+
20
+ // Do NOT mock manager.addQueue — let the manager drive dequeuing
21
+ client = new Client(redis, 1);
22
+ });
23
+
24
+ afterEach(async () => {
25
+ client.close();
26
+ const keys = await redis.keys(`{${QUEUE_NAME}}*`);
27
+ if (keys.length > 0) await redis.del(keys);
28
+ await redis.quit();
29
+ });
30
+
31
+ it('should dequeue and process jobs via manager without explicit dequeue', async () => {
32
+ const q = client.queue(QUEUE_NAME);
33
+ const jobId = await q.dispatch({ task: 'managed' });
34
+
35
+ const handlerPath = new URL('./fixtures/success-handler.js', import.meta.url).pathname;
36
+ await q.listen(handlerPath);
37
+
38
+ // Wait for manager to dequeue and process the job fully
39
+ await poll(async () => {
40
+ const score = await redis.zScore(`{${QUEUE_NAME}}`, jobId);
41
+ const activeExists = await redis.exists(`{${QUEUE_NAME}}:active_job:${jobId}`);
42
+ return score === null && activeExists === 0;
43
+ });
44
+ });
45
+
46
+ it('should process jobs from multiple queues', async () => {
47
+ const staleKeys = await redis.keys('{mgr-q2}*');
48
+ if (staleKeys.length > 0) await redis.del(staleKeys);
49
+
50
+ const q1 = client.queue(QUEUE_NAME);
51
+ const q2 = client.queue('mgr-q2');
52
+ const jobId1 = await q1.dispatch({ q: 1 });
53
+ const jobId2 = await q2.dispatch({ q: 2 });
54
+
55
+ const handlerPath = new URL('./fixtures/success-handler.js', import.meta.url).pathname;
56
+ await q1.listen(handlerPath);
57
+ await q2.listen(handlerPath);
58
+
59
+ // Wait for both jobs to be fully processed
60
+ await poll(async () => {
61
+ const s1 = await redis.zScore(`{${QUEUE_NAME}}`, jobId1);
62
+ const a1 = await redis.exists(`{${QUEUE_NAME}}:active_job:${jobId1}`);
63
+ const s2 = await redis.zScore('{mgr-q2}', jobId2);
64
+ const a2 = await redis.exists(`{mgr-q2}:active_job:${jobId2}`);
65
+ return s1 === null && a1 === 0 && s2 === null && a2 === 0;
66
+ });
67
+
68
+ // Cleanup
69
+ const keys = await redis.keys('{mgr-q2}*');
70
+ if (keys.length > 0) await redis.del(keys);
71
+ });
72
+ });
73
+
74
+ /**
75
+ * Poll a condition until it returns true, with a timeout.
76
+ * @param {() => Promise<boolean>} fn
77
+ * @param {number} [timeout=5000]
78
+ * @param {number} [interval=20]
79
+ */
80
+ async function poll(fn, timeout = 5000, interval = 20) {
81
+ const deadline = Date.now() + timeout;
82
+ while (Date.now() < deadline) {
83
+ if (await fn()) return;
84
+ await new Promise((r) => setTimeout(r, interval));
85
+ }
86
+ assert.fail('Poll timed out');
87
+ }
@@ -0,0 +1,66 @@
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
+ const QUEUE_NAME = 'pool-test';
7
+
8
+ describe('Pool stall and timeout', () => {
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
+ const keys = await redis.keys(`{${QUEUE_NAME}}*`);
18
+ if (keys.length > 0) await redis.del(keys);
19
+
20
+ client = new Client(redis, 1);
21
+ if (client.manager) client.manager.addQueue = mock.fn();
22
+ });
23
+
24
+ afterEach(async () => {
25
+ client.close();
26
+ const keys = await redis.keys(`{${QUEUE_NAME}}*`);
27
+ if (keys.length > 0) await redis.del(keys);
28
+ await redis.quit();
29
+ });
30
+
31
+ it('should handle worker timeout as a stall', async () => {
32
+ const q = client.queue(QUEUE_NAME);
33
+ const jobId = await q.dispatch({ delay: 500 });
34
+
35
+ const handlerPath = new URL('./fixtures/slow-handler.js', import.meta.url).pathname;
36
+ // Very short timeout (50ms) so pool.handleTimeout fires before the 500ms handler completes
37
+ await q.listen(handlerPath, { timeout: 50, maxRetries: 5 });
38
+
39
+ await (await q.dequeue(1)).promise;
40
+
41
+ // Job should be back in waiting set (retried after timeout stall)
42
+ const score = await redis.zScore(`{${QUEUE_NAME}}`, jobId);
43
+ assert.ok(score !== null, 'Job should be retried back to waiting set');
44
+ });
45
+
46
+ it('should reject active jobs when pool is closed', async () => {
47
+ const q = client.queue(QUEUE_NAME);
48
+ const jobId = await q.dispatch({ delay: 2000 });
49
+
50
+ const handlerPath = new URL('./fixtures/slow-handler.js', import.meta.url).pathname;
51
+ await q.listen(handlerPath, { maxRetries: 5 });
52
+
53
+ // Dequeue but don't await the promise — job is still processing
54
+ const { promise } = await q.dequeue(1);
55
+
56
+ // Close the client immediately while job is active
57
+ client.close();
58
+
59
+ // The promise should resolve (the rejection gets handled by the catch block in dequeue)
60
+ await promise;
61
+
62
+ // Job should be back in waiting set (retry after stall rejection)
63
+ const score = await redis.zScore(`{${QUEUE_NAME}}`, jobId);
64
+ assert.ok(score !== null, 'Job should be retried after pool close');
65
+ });
66
+ });