@blokjs/trigger-worker 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/CHANGELOG.md +22 -0
- package/dist/WorkerTrigger.d.ts +197 -0
- package/dist/WorkerTrigger.js +311 -0
- package/dist/adapters/BullMQAdapter.d.ts +71 -0
- package/dist/adapters/BullMQAdapter.js +259 -0
- package/dist/adapters/InMemoryAdapter.d.ts +48 -0
- package/dist/adapters/InMemoryAdapter.js +224 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +64 -0
- package/package.json +45 -0
- package/src/WorkerTrigger.test.ts +510 -0
- package/src/WorkerTrigger.ts +501 -0
- package/src/adapters/BullMQAdapter.ts +296 -0
- package/src/adapters/InMemoryAdapter.ts +276 -0
- package/src/index.ts +67 -0
- package/tsconfig.json +32 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @blok/trigger-worker
|
|
4
|
+
*
|
|
5
|
+
* Worker-based trigger for Blok workflows.
|
|
6
|
+
* Supports background job processing with:
|
|
7
|
+
* - Configurable concurrency per queue
|
|
8
|
+
* - Automatic retries with exponential backoff
|
|
9
|
+
* - Job timeouts
|
|
10
|
+
* - Priority-based job ordering
|
|
11
|
+
* - Delayed job scheduling
|
|
12
|
+
* - Queue statistics and monitoring
|
|
13
|
+
*
|
|
14
|
+
* Adapters:
|
|
15
|
+
* - BullMQ (Redis-backed, production)
|
|
16
|
+
* - InMemory (development/testing)
|
|
17
|
+
*
|
|
18
|
+
* @example BullMQ
|
|
19
|
+
* ```typescript
|
|
20
|
+
* import { WorkerTrigger, BullMQAdapter } from "@blok/trigger-worker";
|
|
21
|
+
*
|
|
22
|
+
* class MyWorkerTrigger extends WorkerTrigger {
|
|
23
|
+
* protected adapter = new BullMQAdapter({
|
|
24
|
+
* host: "localhost",
|
|
25
|
+
* port: 6379,
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* protected nodes = myNodes;
|
|
29
|
+
* protected workflows = myWorkflows;
|
|
30
|
+
* }
|
|
31
|
+
*
|
|
32
|
+
* const trigger = new MyWorkerTrigger();
|
|
33
|
+
* await trigger.listen();
|
|
34
|
+
*
|
|
35
|
+
* // Dispatch a job
|
|
36
|
+
* await trigger.dispatch("background-jobs", { userId: "123" }, {
|
|
37
|
+
* priority: 10,
|
|
38
|
+
* retries: 3,
|
|
39
|
+
* delay: 5000, // delay 5 seconds
|
|
40
|
+
* });
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* @example InMemory (development)
|
|
44
|
+
* ```typescript
|
|
45
|
+
* import { WorkerTrigger, InMemoryAdapter } from "@blok/trigger-worker";
|
|
46
|
+
*
|
|
47
|
+
* class DevWorkerTrigger extends WorkerTrigger {
|
|
48
|
+
* protected adapter = new InMemoryAdapter();
|
|
49
|
+
* protected nodes = myNodes;
|
|
50
|
+
* protected workflows = myWorkflows;
|
|
51
|
+
* }
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
55
|
+
exports.InMemoryAdapter = exports.BullMQAdapter = exports.WorkerTrigger = void 0;
|
|
56
|
+
// Core exports
|
|
57
|
+
var WorkerTrigger_1 = require("./WorkerTrigger");
|
|
58
|
+
Object.defineProperty(exports, "WorkerTrigger", { enumerable: true, get: function () { return WorkerTrigger_1.WorkerTrigger; } });
|
|
59
|
+
// Adapters
|
|
60
|
+
var BullMQAdapter_1 = require("./adapters/BullMQAdapter");
|
|
61
|
+
Object.defineProperty(exports, "BullMQAdapter", { enumerable: true, get: function () { return BullMQAdapter_1.BullMQAdapter; } });
|
|
62
|
+
var InMemoryAdapter_1 = require("./adapters/InMemoryAdapter");
|
|
63
|
+
Object.defineProperty(exports, "InMemoryAdapter", { enumerable: true, get: function () { return InMemoryAdapter_1.InMemoryAdapter; } });
|
|
64
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi9zcmMvaW5kZXgudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IjtBQUFBOzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7R0FtREc7OztBQUVILGVBQWU7QUFDZixpREFLeUI7QUFKeEIsOEdBQUEsYUFBYSxPQUFBO0FBTWQsV0FBVztBQUNYLDBEQUE0RTtBQUFuRSw4R0FBQSxhQUFhLE9BQUE7QUFDdEIsOERBQTZEO0FBQXBELGtIQUFBLGVBQWUsT0FBQSIsInNvdXJjZXNDb250ZW50IjpbIi8qKlxuICogQGJsb2svdHJpZ2dlci13b3JrZXJcbiAqXG4gKiBXb3JrZXItYmFzZWQgdHJpZ2dlciBmb3IgQmxvayB3b3JrZmxvd3MuXG4gKiBTdXBwb3J0cyBiYWNrZ3JvdW5kIGpvYiBwcm9jZXNzaW5nIHdpdGg6XG4gKiAtIENvbmZpZ3VyYWJsZSBjb25jdXJyZW5jeSBwZXIgcXVldWVcbiAqIC0gQXV0b21hdGljIHJldHJpZXMgd2l0aCBleHBvbmVudGlhbCBiYWNrb2ZmXG4gKiAtIEpvYiB0aW1lb3V0c1xuICogLSBQcmlvcml0eS1iYXNlZCBqb2Igb3JkZXJpbmdcbiAqIC0gRGVsYXllZCBqb2Igc2NoZWR1bGluZ1xuICogLSBRdWV1ZSBzdGF0aXN0aWNzIGFuZCBtb25pdG9yaW5nXG4gKlxuICogQWRhcHRlcnM6XG4gKiAtIEJ1bGxNUSAoUmVkaXMtYmFja2VkLCBwcm9kdWN0aW9uKVxuICogLSBJbk1lbW9yeSAoZGV2ZWxvcG1lbnQvdGVzdGluZylcbiAqXG4gKiBAZXhhbXBsZSBCdWxsTVFcbiAqIGBgYHR5cGVzY3JpcHRcbiAqIGltcG9ydCB7IFdvcmtlclRyaWdnZXIsIEJ1bGxNUUFkYXB0ZXIgfSBmcm9tIFwiQGJsb2svdHJpZ2dlci13b3JrZXJcIjtcbiAqXG4gKiBjbGFzcyBNeVdvcmtlclRyaWdnZXIgZXh0ZW5kcyBXb3JrZXJUcmlnZ2VyIHtcbiAqICAgcHJvdGVjdGVkIGFkYXB0ZXIgPSBuZXcgQnVsbE1RQWRhcHRlcih7XG4gKiAgICAgaG9zdDogXCJsb2NhbGhvc3RcIixcbiAqICAgICBwb3J0OiA2Mzc5LFxuICogICB9KTtcbiAqXG4gKiAgIHByb3RlY3RlZCBub2RlcyA9IG15Tm9kZXM7XG4gKiAgIHByb3RlY3RlZCB3b3JrZmxvd3MgPSBteVdvcmtmbG93cztcbiAqIH1cbiAqXG4gKiBjb25zdCB0cmlnZ2VyID0gbmV3IE15V29ya2VyVHJpZ2dlcigpO1xuICogYXdhaXQgdHJpZ2dlci5saXN0ZW4oKTtcbiAqXG4gKiAvLyBEaXNwYXRjaCBhIGpvYlxuICogYXdhaXQgdHJpZ2dlci5kaXNwYXRjaChcImJhY2tncm91bmQtam9ic1wiLCB7IHVzZXJJZDogXCIxMjNcIiB9LCB7XG4gKiAgIHByaW9yaXR5OiAxMCxcbiAqICAgcmV0cmllczogMyxcbiAqICAgZGVsYXk6IDUwMDAsIC8vIGRlbGF5IDUgc2Vjb25kc1xuICogfSk7XG4gKiBgYGBcbiAqXG4gKiBAZXhhbXBsZSBJbk1lbW9yeSAoZGV2ZWxvcG1lbnQpXG4gKiBgYGB0eXBlc2NyaXB0XG4gKiBpbXBvcnQgeyBXb3JrZXJUcmlnZ2VyLCBJbk1lbW9yeUFkYXB0ZXIgfSBmcm9tIFwiQGJsb2svdHJpZ2dlci13b3JrZXJcIjtcbiAqXG4gKiBjbGFzcyBEZXZXb3JrZXJUcmlnZ2VyIGV4dGVuZHMgV29ya2VyVHJpZ2dlciB7XG4gKiAgIHByb3RlY3RlZCBhZGFwdGVyID0gbmV3IEluTWVtb3J5QWRhcHRlcigpO1xuICogICBwcm90ZWN0ZWQgbm9kZXMgPSBteU5vZGVzO1xuICogICBwcm90ZWN0ZWQgd29ya2Zsb3dzID0gbXlXb3JrZmxvd3M7XG4gKiB9XG4gKiBgYGBcbiAqL1xuXG4vLyBDb3JlIGV4cG9ydHNcbmV4cG9ydCB7XG5cdFdvcmtlclRyaWdnZXIsXG5cdHR5cGUgV29ya2VyQWRhcHRlcixcblx0dHlwZSBXb3JrZXJKb2IsXG5cdHR5cGUgV29ya2VyUXVldWVTdGF0cyxcbn0gZnJvbSBcIi4vV29ya2VyVHJpZ2dlclwiO1xuXG4vLyBBZGFwdGVyc1xuZXhwb3J0IHsgQnVsbE1RQWRhcHRlciwgdHlwZSBCdWxsTVFDb25maWcgfSBmcm9tIFwiLi9hZGFwdGVycy9CdWxsTVFBZGFwdGVyXCI7XG5leHBvcnQgeyBJbk1lbW9yeUFkYXB0ZXIgfSBmcm9tIFwiLi9hZGFwdGVycy9Jbk1lbW9yeUFkYXB0ZXJcIjtcblxuLy8gUmUtZXhwb3J0IHR5cGVzIGZyb20gaGVscGVyIGZvciBjb252ZW5pZW5jZVxuZXhwb3J0IHR5cGUgeyBXb3JrZXJUcmlnZ2VyT3B0cyB9IGZyb20gXCJAYmxvay9oZWxwZXJcIjtcbiJdfQ==
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@blokjs/trigger-worker",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Worker-based trigger for Blok workflows - supports background job processing with concurrency, retries, and scheduling",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "rm -rf dist && bun run tsc",
|
|
10
|
+
"build:dev": "tsc --watch",
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"test:dev": "vitest"
|
|
13
|
+
},
|
|
14
|
+
"author": "Deskree Technologies Inc.",
|
|
15
|
+
"license": "Apache-2.0",
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@blokjs/helper": "workspace:*",
|
|
18
|
+
"@blokjs/runner": "workspace:*",
|
|
19
|
+
"@blokjs/shared": "workspace:*",
|
|
20
|
+
"@opentelemetry/api": "^1.9.0",
|
|
21
|
+
"uuid": "^11.1.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^22.15.21",
|
|
25
|
+
"@types/uuid": "^11.0.0",
|
|
26
|
+
"typescript": "^5.8.3",
|
|
27
|
+
"vitest": "^4.0.18"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"bullmq": "^5.67.2",
|
|
31
|
+
"ioredis": "^5.9.2"
|
|
32
|
+
},
|
|
33
|
+
"peerDependenciesMeta": {
|
|
34
|
+
"bullmq": {
|
|
35
|
+
"optional": true
|
|
36
|
+
},
|
|
37
|
+
"ioredis": {
|
|
38
|
+
"optional": true
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"private": false,
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkerTrigger Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests the WorkerTrigger base class, WorkerAdapter interface,
|
|
5
|
+
* InMemoryAdapter, and BullMQAdapter configuration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
import type { WorkerAdapter, WorkerJob, WorkerQueueStats } from "./WorkerTrigger";
|
|
10
|
+
import { InMemoryAdapter } from "./adapters/InMemoryAdapter";
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// WorkerJob Interface Tests
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
describe("WorkerTrigger", () => {
|
|
17
|
+
describe("WorkerJob Interface", () => {
|
|
18
|
+
it("should accept valid worker job structure", () => {
|
|
19
|
+
const job: WorkerJob = {
|
|
20
|
+
id: "job-123",
|
|
21
|
+
data: { userId: "user-1", action: "send-email" },
|
|
22
|
+
headers: { "content-type": "application/json" },
|
|
23
|
+
queue: "background-jobs",
|
|
24
|
+
priority: 5,
|
|
25
|
+
attempts: 0,
|
|
26
|
+
maxRetries: 3,
|
|
27
|
+
createdAt: new Date(),
|
|
28
|
+
delay: 0,
|
|
29
|
+
timeout: 30000,
|
|
30
|
+
raw: {},
|
|
31
|
+
complete: async () => {},
|
|
32
|
+
fail: async () => {},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
expect(job.id).toBe("job-123");
|
|
36
|
+
expect(job.data).toEqual({ userId: "user-1", action: "send-email" });
|
|
37
|
+
expect(job.queue).toBe("background-jobs");
|
|
38
|
+
expect(job.priority).toBe(5);
|
|
39
|
+
expect(job.maxRetries).toBe(3);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should handle minimal required fields", () => {
|
|
43
|
+
const job: WorkerJob = {
|
|
44
|
+
id: "job-min",
|
|
45
|
+
data: null,
|
|
46
|
+
headers: {},
|
|
47
|
+
queue: "default",
|
|
48
|
+
priority: 0,
|
|
49
|
+
attempts: 0,
|
|
50
|
+
maxRetries: 0,
|
|
51
|
+
createdAt: new Date(),
|
|
52
|
+
raw: null,
|
|
53
|
+
complete: async () => {},
|
|
54
|
+
fail: async () => {},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
expect(job.id).toBeDefined();
|
|
58
|
+
expect(job.queue).toBeDefined();
|
|
59
|
+
expect(job.complete).toBeDefined();
|
|
60
|
+
expect(job.fail).toBeDefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should support optional delay and timeout", () => {
|
|
64
|
+
const job: WorkerJob = {
|
|
65
|
+
id: "job-delayed",
|
|
66
|
+
data: { type: "scheduled-report" },
|
|
67
|
+
headers: {},
|
|
68
|
+
queue: "reports",
|
|
69
|
+
priority: 1,
|
|
70
|
+
attempts: 0,
|
|
71
|
+
maxRetries: 2,
|
|
72
|
+
createdAt: new Date(),
|
|
73
|
+
delay: 60000,
|
|
74
|
+
timeout: 120000,
|
|
75
|
+
raw: {},
|
|
76
|
+
complete: async () => {},
|
|
77
|
+
fail: async () => {},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
expect(job.delay).toBe(60000);
|
|
81
|
+
expect(job.timeout).toBe(120000);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("WorkerAdapter Interface", () => {
|
|
86
|
+
it("should validate adapter interface methods", () => {
|
|
87
|
+
const mockAdapter: WorkerAdapter = {
|
|
88
|
+
provider: "mock",
|
|
89
|
+
connect: vi.fn().mockResolvedValue(undefined),
|
|
90
|
+
disconnect: vi.fn().mockResolvedValue(undefined),
|
|
91
|
+
process: vi.fn().mockResolvedValue(undefined),
|
|
92
|
+
addJob: vi.fn().mockResolvedValue("job-1"),
|
|
93
|
+
stopProcessing: vi.fn().mockResolvedValue(undefined),
|
|
94
|
+
isConnected: vi.fn().mockReturnValue(true),
|
|
95
|
+
healthCheck: vi.fn().mockResolvedValue(true),
|
|
96
|
+
getQueueStats: vi.fn().mockResolvedValue({
|
|
97
|
+
waiting: 5,
|
|
98
|
+
active: 2,
|
|
99
|
+
completed: 100,
|
|
100
|
+
failed: 3,
|
|
101
|
+
delayed: 1,
|
|
102
|
+
}),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
expect(mockAdapter.provider).toBe("mock");
|
|
106
|
+
expect(typeof mockAdapter.connect).toBe("function");
|
|
107
|
+
expect(typeof mockAdapter.disconnect).toBe("function");
|
|
108
|
+
expect(typeof mockAdapter.process).toBe("function");
|
|
109
|
+
expect(typeof mockAdapter.addJob).toBe("function");
|
|
110
|
+
expect(typeof mockAdapter.stopProcessing).toBe("function");
|
|
111
|
+
expect(typeof mockAdapter.isConnected).toBe("function");
|
|
112
|
+
expect(typeof mockAdapter.healthCheck).toBe("function");
|
|
113
|
+
expect(typeof mockAdapter.getQueueStats).toBe("function");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("should return correct queue stats structure", async () => {
|
|
117
|
+
const stats: WorkerQueueStats = {
|
|
118
|
+
waiting: 10,
|
|
119
|
+
active: 3,
|
|
120
|
+
completed: 500,
|
|
121
|
+
failed: 12,
|
|
122
|
+
delayed: 5,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
expect(stats.waiting).toBe(10);
|
|
126
|
+
expect(stats.active).toBe(3);
|
|
127
|
+
expect(stats.completed).toBe(500);
|
|
128
|
+
expect(stats.failed).toBe(12);
|
|
129
|
+
expect(stats.delayed).toBe(5);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ============================================================================
|
|
135
|
+
// InMemoryAdapter Tests
|
|
136
|
+
// ============================================================================
|
|
137
|
+
|
|
138
|
+
describe("InMemoryAdapter", () => {
|
|
139
|
+
let adapter: InMemoryAdapter;
|
|
140
|
+
|
|
141
|
+
beforeEach(() => {
|
|
142
|
+
adapter = new InMemoryAdapter();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
afterEach(async () => {
|
|
146
|
+
await adapter.disconnect();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("Connection Lifecycle", () => {
|
|
150
|
+
it("should connect successfully", async () => {
|
|
151
|
+
expect(adapter.isConnected()).toBe(false);
|
|
152
|
+
await adapter.connect();
|
|
153
|
+
expect(adapter.isConnected()).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("should disconnect successfully", async () => {
|
|
157
|
+
await adapter.connect();
|
|
158
|
+
expect(adapter.isConnected()).toBe(true);
|
|
159
|
+
await adapter.disconnect();
|
|
160
|
+
expect(adapter.isConnected()).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("should report healthy when connected", async () => {
|
|
164
|
+
await adapter.connect();
|
|
165
|
+
expect(await adapter.healthCheck()).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("should report unhealthy when disconnected", async () => {
|
|
169
|
+
expect(await adapter.healthCheck()).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("should have provider name 'in-memory'", () => {
|
|
173
|
+
expect(adapter.provider).toBe("in-memory");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("Job Dispatching", () => {
|
|
178
|
+
it("should add a job and return its ID", async () => {
|
|
179
|
+
await adapter.connect();
|
|
180
|
+
const jobId = await adapter.addJob("test-queue", { action: "test" });
|
|
181
|
+
expect(jobId).toBeDefined();
|
|
182
|
+
expect(typeof jobId).toBe("string");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("should accept custom job ID", async () => {
|
|
186
|
+
await adapter.connect();
|
|
187
|
+
const jobId = await adapter.addJob(
|
|
188
|
+
"test-queue",
|
|
189
|
+
{ data: 1 },
|
|
190
|
+
{
|
|
191
|
+
jobId: "custom-id-123",
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
expect(jobId).toBe("custom-id-123");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("should add jobs with priority ordering", async () => {
|
|
198
|
+
await adapter.connect();
|
|
199
|
+
await adapter.addJob("priority-queue", { order: "low" }, { priority: 1 });
|
|
200
|
+
await adapter.addJob("priority-queue", { order: "high" }, { priority: 10 });
|
|
201
|
+
await adapter.addJob("priority-queue", { order: "medium" }, { priority: 5 });
|
|
202
|
+
|
|
203
|
+
const stats = await adapter.getQueueStats("priority-queue");
|
|
204
|
+
expect(stats.waiting).toBe(3);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("should add delayed jobs", async () => {
|
|
208
|
+
await adapter.connect();
|
|
209
|
+
await adapter.addJob(
|
|
210
|
+
"delayed-queue",
|
|
211
|
+
{ data: 1 },
|
|
212
|
+
{
|
|
213
|
+
delay: 5000,
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const stats = await adapter.getQueueStats("delayed-queue");
|
|
218
|
+
expect(stats.delayed).toBe(1);
|
|
219
|
+
expect(stats.waiting).toBe(0);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("should throw when not connected", async () => {
|
|
223
|
+
await expect(adapter.addJob("test-queue", { data: 1 })).rejects.toThrow("Not connected");
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("Job Processing", () => {
|
|
228
|
+
it("should process jobs from a queue", async () => {
|
|
229
|
+
await adapter.connect();
|
|
230
|
+
|
|
231
|
+
const processedJobs: WorkerJob[] = [];
|
|
232
|
+
|
|
233
|
+
await adapter.process({ queue: "process-queue", concurrency: 1, retries: 3, priority: 0 }, async (job) => {
|
|
234
|
+
processedJobs.push(job);
|
|
235
|
+
await job.complete();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
await adapter.addJob("process-queue", { item: "test-1" });
|
|
239
|
+
|
|
240
|
+
// Wait for processing
|
|
241
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
242
|
+
|
|
243
|
+
expect(processedJobs).toHaveLength(1);
|
|
244
|
+
expect(processedJobs[0].data).toEqual({ item: "test-1" });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("should process multiple jobs sequentially", async () => {
|
|
248
|
+
await adapter.connect();
|
|
249
|
+
|
|
250
|
+
const processedOrder: number[] = [];
|
|
251
|
+
|
|
252
|
+
await adapter.process({ queue: "seq-queue", concurrency: 1, retries: 3, priority: 0 }, async (job) => {
|
|
253
|
+
processedOrder.push(job.data as number);
|
|
254
|
+
await job.complete();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await adapter.addJob("seq-queue", 1);
|
|
258
|
+
await adapter.addJob("seq-queue", 2);
|
|
259
|
+
await adapter.addJob("seq-queue", 3);
|
|
260
|
+
|
|
261
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
262
|
+
|
|
263
|
+
expect(processedOrder).toEqual([1, 2, 3]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("should track queue stats correctly", async () => {
|
|
267
|
+
await adapter.connect();
|
|
268
|
+
|
|
269
|
+
await adapter.process({ queue: "stats-queue", concurrency: 1, retries: 3, priority: 0 }, async (job) => {
|
|
270
|
+
await job.complete();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
await adapter.addJob("stats-queue", { a: 1 });
|
|
274
|
+
await adapter.addJob("stats-queue", { a: 2 });
|
|
275
|
+
|
|
276
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
277
|
+
|
|
278
|
+
const stats = await adapter.getQueueStats("stats-queue");
|
|
279
|
+
expect(stats.completed).toBe(2);
|
|
280
|
+
expect(stats.waiting).toBe(0);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("should handle job failures", async () => {
|
|
284
|
+
await adapter.connect();
|
|
285
|
+
|
|
286
|
+
await adapter.process({ queue: "fail-queue", concurrency: 1, retries: 0, priority: 0 }, async (job) => {
|
|
287
|
+
await job.fail(new Error("test failure"), false);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
await adapter.addJob("fail-queue", { data: "will-fail" }, { retries: 0 });
|
|
291
|
+
|
|
292
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
293
|
+
|
|
294
|
+
const stats = await adapter.getQueueStats("fail-queue");
|
|
295
|
+
expect(stats.failed).toBe(1);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("should requeue failed jobs for retry", async () => {
|
|
299
|
+
await adapter.connect();
|
|
300
|
+
|
|
301
|
+
let attemptCount = 0;
|
|
302
|
+
|
|
303
|
+
await adapter.process({ queue: "retry-queue", concurrency: 1, retries: 3, priority: 0 }, async (job) => {
|
|
304
|
+
attemptCount++;
|
|
305
|
+
if (attemptCount < 2) {
|
|
306
|
+
await job.fail(new Error("temporary failure"), true);
|
|
307
|
+
} else {
|
|
308
|
+
await job.complete();
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
await adapter.addJob("retry-queue", { data: "retry-me" }, { retries: 3 });
|
|
313
|
+
|
|
314
|
+
// Wait long enough for retry backoff + processing
|
|
315
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
316
|
+
|
|
317
|
+
expect(attemptCount).toBeGreaterThanOrEqual(2);
|
|
318
|
+
}, 5000);
|
|
319
|
+
|
|
320
|
+
it("should stop processing a queue", async () => {
|
|
321
|
+
await adapter.connect();
|
|
322
|
+
|
|
323
|
+
const processed: string[] = [];
|
|
324
|
+
|
|
325
|
+
await adapter.process({ queue: "stop-queue", concurrency: 1, retries: 3, priority: 0 }, async (job) => {
|
|
326
|
+
processed.push(job.id);
|
|
327
|
+
await job.complete();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
await adapter.addJob("stop-queue", { first: true });
|
|
331
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
332
|
+
|
|
333
|
+
await adapter.stopProcessing("stop-queue");
|
|
334
|
+
|
|
335
|
+
await adapter.addJob("stop-queue", { second: true });
|
|
336
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
337
|
+
|
|
338
|
+
// Only first job should have been processed
|
|
339
|
+
expect(processed).toHaveLength(1);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("should throw when processing without connection", async () => {
|
|
343
|
+
await expect(
|
|
344
|
+
adapter.process({ queue: "q", concurrency: 1, retries: 0, priority: 0 }, async () => {}),
|
|
345
|
+
).rejects.toThrow("Not connected");
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
describe("Queue Stats", () => {
|
|
350
|
+
it("should return zeros for unknown queue", async () => {
|
|
351
|
+
await adapter.connect();
|
|
352
|
+
const stats = await adapter.getQueueStats("nonexistent");
|
|
353
|
+
expect(stats).toEqual({
|
|
354
|
+
waiting: 0,
|
|
355
|
+
active: 0,
|
|
356
|
+
completed: 0,
|
|
357
|
+
failed: 0,
|
|
358
|
+
delayed: 0,
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("should track waiting count", async () => {
|
|
363
|
+
await adapter.connect();
|
|
364
|
+
await adapter.addJob("count-queue", { a: 1 });
|
|
365
|
+
await adapter.addJob("count-queue", { a: 2 });
|
|
366
|
+
await adapter.addJob("count-queue", { a: 3 });
|
|
367
|
+
|
|
368
|
+
const stats = await adapter.getQueueStats("count-queue");
|
|
369
|
+
expect(stats.waiting).toBe(3);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// ============================================================================
|
|
375
|
+
// BullMQAdapter Config Tests
|
|
376
|
+
// ============================================================================
|
|
377
|
+
|
|
378
|
+
describe("BullMQAdapter", () => {
|
|
379
|
+
it("should read config from environment variables", () => {
|
|
380
|
+
const originalHost = process.env.REDIS_HOST;
|
|
381
|
+
const originalPort = process.env.REDIS_PORT;
|
|
382
|
+
const originalPassword = process.env.REDIS_PASSWORD;
|
|
383
|
+
const originalDb = process.env.REDIS_DB;
|
|
384
|
+
|
|
385
|
+
process.env.REDIS_HOST = "redis.example.com";
|
|
386
|
+
process.env.REDIS_PORT = "6380";
|
|
387
|
+
process.env.REDIS_PASSWORD = "secret123";
|
|
388
|
+
process.env.REDIS_DB = "2";
|
|
389
|
+
|
|
390
|
+
const config = {
|
|
391
|
+
host: process.env.REDIS_HOST || "localhost",
|
|
392
|
+
port: Number.parseInt(process.env.REDIS_PORT || "6379", 10),
|
|
393
|
+
password: process.env.REDIS_PASSWORD,
|
|
394
|
+
db: Number.parseInt(process.env.REDIS_DB || "0", 10),
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
expect(config.host).toBe("redis.example.com");
|
|
398
|
+
expect(config.port).toBe(6380);
|
|
399
|
+
expect(config.password).toBe("secret123");
|
|
400
|
+
expect(config.db).toBe(2);
|
|
401
|
+
|
|
402
|
+
// Restore
|
|
403
|
+
process.env.REDIS_HOST = originalHost;
|
|
404
|
+
process.env.REDIS_PORT = originalPort;
|
|
405
|
+
process.env.REDIS_PASSWORD = originalPassword;
|
|
406
|
+
process.env.REDIS_DB = originalDb;
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("should use default values when env vars not set", () => {
|
|
410
|
+
const originalHost = process.env.REDIS_HOST;
|
|
411
|
+
const originalPort = process.env.REDIS_PORT;
|
|
412
|
+
|
|
413
|
+
delete process.env.REDIS_HOST;
|
|
414
|
+
delete process.env.REDIS_PORT;
|
|
415
|
+
|
|
416
|
+
const config = {
|
|
417
|
+
host: process.env.REDIS_HOST || "localhost",
|
|
418
|
+
port: Number.parseInt(process.env.REDIS_PORT || "6379", 10),
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
expect(config.host).toBe("localhost");
|
|
422
|
+
expect(config.port).toBe(6379);
|
|
423
|
+
|
|
424
|
+
// Restore
|
|
425
|
+
process.env.REDIS_HOST = originalHost;
|
|
426
|
+
process.env.REDIS_PORT = originalPort;
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// ============================================================================
|
|
431
|
+
// WorkerTriggerOpts Schema Tests
|
|
432
|
+
// ============================================================================
|
|
433
|
+
|
|
434
|
+
describe("WorkerTriggerOpts Schema", () => {
|
|
435
|
+
it("should validate worker trigger configuration", () => {
|
|
436
|
+
const validConfig = {
|
|
437
|
+
queue: "background-jobs",
|
|
438
|
+
concurrency: 5,
|
|
439
|
+
timeout: 30000,
|
|
440
|
+
retries: 3,
|
|
441
|
+
priority: 10,
|
|
442
|
+
delay: 1000,
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
expect(validConfig.queue).toBe("background-jobs");
|
|
446
|
+
expect(validConfig.concurrency).toBe(5);
|
|
447
|
+
expect(validConfig.timeout).toBe(30000);
|
|
448
|
+
expect(validConfig.retries).toBe(3);
|
|
449
|
+
expect(validConfig.priority).toBe(10);
|
|
450
|
+
expect(validConfig.delay).toBe(1000);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("should support minimal configuration", () => {
|
|
454
|
+
const minConfig = {
|
|
455
|
+
queue: "default",
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
expect(minConfig.queue).toBe("default");
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("should support high-concurrency configuration", () => {
|
|
462
|
+
const config = {
|
|
463
|
+
queue: "high-throughput",
|
|
464
|
+
concurrency: 50,
|
|
465
|
+
retries: 5,
|
|
466
|
+
timeout: 60000,
|
|
467
|
+
priority: 0,
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
expect(config.concurrency).toBe(50);
|
|
471
|
+
expect(config.retries).toBe(5);
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// ============================================================================
|
|
476
|
+
// Exponential Backoff Tests
|
|
477
|
+
// ============================================================================
|
|
478
|
+
|
|
479
|
+
describe("Exponential Backoff", () => {
|
|
480
|
+
it("should calculate increasing delays", () => {
|
|
481
|
+
const base = 1000;
|
|
482
|
+
const maxDelay = 30000;
|
|
483
|
+
|
|
484
|
+
const delays = [0, 1, 2, 3, 4, 5].map((attempt) => {
|
|
485
|
+
const exponential = Math.min(base * Math.pow(2, attempt), maxDelay);
|
|
486
|
+
return exponential;
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
expect(delays[0]).toBe(1000); // 1s
|
|
490
|
+
expect(delays[1]).toBe(2000); // 2s
|
|
491
|
+
expect(delays[2]).toBe(4000); // 4s
|
|
492
|
+
expect(delays[3]).toBe(8000); // 8s
|
|
493
|
+
expect(delays[4]).toBe(16000); // 16s
|
|
494
|
+
expect(delays[5]).toBe(30000); // capped at 30s
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("should cap at maximum delay", () => {
|
|
498
|
+
const base = 1000;
|
|
499
|
+
const maxDelay = 30000;
|
|
500
|
+
|
|
501
|
+
const delay = Math.min(base * Math.pow(2, 10), maxDelay);
|
|
502
|
+
expect(delay).toBe(30000);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("should support custom base delay", () => {
|
|
506
|
+
const base = 500;
|
|
507
|
+
const exponential = base * Math.pow(2, 2);
|
|
508
|
+
expect(exponential).toBe(2000);
|
|
509
|
+
});
|
|
510
|
+
});
|