@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
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BullMQAdapter - Worker adapter using BullMQ (Redis-backed)
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Redis-backed persistent job queues
|
|
6
|
+
* - Configurable concurrency per queue
|
|
7
|
+
* - Job priority support
|
|
8
|
+
* - Delayed job scheduling
|
|
9
|
+
* - Automatic retries with configurable backoff
|
|
10
|
+
* - Queue statistics
|
|
11
|
+
*
|
|
12
|
+
* Requires: bullmq and ioredis as peer dependencies
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* const adapter = new BullMQAdapter({
|
|
17
|
+
* host: "localhost",
|
|
18
|
+
* port: 6379,
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { WorkerAdapter, WorkerJob, WorkerQueueStats } from "../WorkerTrigger";
|
|
24
|
+
|
|
25
|
+
import type { WorkerTriggerOpts } from "@blokjs/helper";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* BullMQ adapter configuration
|
|
29
|
+
*/
|
|
30
|
+
export interface BullMQConfig {
|
|
31
|
+
/** Redis host (default: from REDIS_HOST env or "localhost") */
|
|
32
|
+
host: string;
|
|
33
|
+
/** Redis port (default: from REDIS_PORT env or 6379) */
|
|
34
|
+
port: number;
|
|
35
|
+
/** Redis password (default: from REDIS_PASSWORD env) */
|
|
36
|
+
password?: string;
|
|
37
|
+
/** Redis database (default: from REDIS_DB env or 0) */
|
|
38
|
+
db?: number;
|
|
39
|
+
/** Key prefix for all BullMQ keys */
|
|
40
|
+
prefix?: string;
|
|
41
|
+
/** Max stalled count before job fails */
|
|
42
|
+
maxStalledCount?: number;
|
|
43
|
+
/** Stalled interval in ms */
|
|
44
|
+
stalledInterval?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* BullMQ Worker Adapter
|
|
49
|
+
*
|
|
50
|
+
* Uses BullMQ for robust, Redis-backed job processing with support for
|
|
51
|
+
* priority queues, delayed jobs, retries, and dead letter handling.
|
|
52
|
+
*/
|
|
53
|
+
export class BullMQAdapter implements WorkerAdapter {
|
|
54
|
+
readonly provider = "bullmq" as const;
|
|
55
|
+
private connection: unknown = null;
|
|
56
|
+
private workers: Map<string, unknown> = new Map();
|
|
57
|
+
private queues: Map<string, unknown> = new Map();
|
|
58
|
+
private connected = false;
|
|
59
|
+
private readonly config: BullMQConfig;
|
|
60
|
+
|
|
61
|
+
constructor(config?: Partial<BullMQConfig>) {
|
|
62
|
+
this.config = {
|
|
63
|
+
host: config?.host || process.env.REDIS_HOST || "localhost",
|
|
64
|
+
port: config?.port ?? Number.parseInt(process.env.REDIS_PORT || "6379", 10),
|
|
65
|
+
password: config?.password || process.env.REDIS_PASSWORD,
|
|
66
|
+
db: config?.db ?? Number.parseInt(process.env.REDIS_DB || "0", 10),
|
|
67
|
+
prefix: config?.prefix || "blok-worker",
|
|
68
|
+
maxStalledCount: config?.maxStalledCount ?? 2,
|
|
69
|
+
stalledInterval: config?.stalledInterval ?? 5000,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async connect(): Promise<void> {
|
|
74
|
+
if (this.connected) return;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const { default: IORedis } = await import("ioredis");
|
|
78
|
+
this.connection = new IORedis({
|
|
79
|
+
host: this.config.host,
|
|
80
|
+
port: this.config.port,
|
|
81
|
+
password: this.config.password,
|
|
82
|
+
db: this.config.db,
|
|
83
|
+
maxRetriesPerRequest: null, // Required for BullMQ
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Verify connection
|
|
87
|
+
await (this.connection as { ping: () => Promise<string> }).ping();
|
|
88
|
+
this.connected = true;
|
|
89
|
+
console.log(`[BullMQAdapter] Connected to Redis at ${this.config.host}:${this.config.port}`);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Failed to connect to Redis: ${(error as Error).message}. Ensure ioredis and bullmq are installed: npm install ioredis bullmq`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async disconnect(): Promise<void> {
|
|
98
|
+
if (!this.connected) return;
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
// Close all workers
|
|
102
|
+
for (const [, worker] of this.workers) {
|
|
103
|
+
await (worker as { close: () => Promise<void> }).close();
|
|
104
|
+
}
|
|
105
|
+
this.workers.clear();
|
|
106
|
+
|
|
107
|
+
// Close all queues
|
|
108
|
+
for (const [, queue] of this.queues) {
|
|
109
|
+
await (queue as { close: () => Promise<void> }).close();
|
|
110
|
+
}
|
|
111
|
+
this.queues.clear();
|
|
112
|
+
|
|
113
|
+
// Close Redis connection
|
|
114
|
+
if (this.connection) {
|
|
115
|
+
await (this.connection as { quit: () => Promise<string> }).quit();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
this.connected = false;
|
|
119
|
+
console.log("[BullMQAdapter] Disconnected from Redis");
|
|
120
|
+
} catch (error) {
|
|
121
|
+
console.error(`[BullMQAdapter] Disconnect error: ${(error as Error).message}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async process(config: WorkerTriggerOpts, handler: (job: WorkerJob) => Promise<void>): Promise<void> {
|
|
126
|
+
if (!this.connected) {
|
|
127
|
+
throw new Error("Not connected. Call connect() first.");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
// Dynamic import to avoid hard dependency on bullmq
|
|
132
|
+
const bullmq = await import("bullmq");
|
|
133
|
+
const BullWorker = bullmq.Worker;
|
|
134
|
+
const BullQueue = bullmq.Queue;
|
|
135
|
+
|
|
136
|
+
// Build worker options
|
|
137
|
+
const workerOpts = {
|
|
138
|
+
connection: this.connection,
|
|
139
|
+
concurrency: config.concurrency ?? 1,
|
|
140
|
+
prefix: this.config.prefix,
|
|
141
|
+
stalledInterval: this.config.stalledInterval ?? 5000,
|
|
142
|
+
maxStalledCount: this.config.maxStalledCount ?? 2,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const worker = new BullWorker(
|
|
146
|
+
config.queue,
|
|
147
|
+
// BullMQ processor callback
|
|
148
|
+
((bullJob: unknown) => {
|
|
149
|
+
const job = bullJob as {
|
|
150
|
+
id?: string;
|
|
151
|
+
data: unknown;
|
|
152
|
+
opts?: { priority?: number; delay?: number };
|
|
153
|
+
attemptsMade: number;
|
|
154
|
+
timestamp: number;
|
|
155
|
+
token?: string;
|
|
156
|
+
moveToFailed: (err: Error, token: string, fetchNext: boolean) => Promise<void>;
|
|
157
|
+
};
|
|
158
|
+
const workerJob: WorkerJob = {
|
|
159
|
+
id: job.id || `job-${Date.now()}`,
|
|
160
|
+
data: job.data,
|
|
161
|
+
headers: ((job.data as Record<string, unknown>)?._headers as Record<string, string>) || {},
|
|
162
|
+
queue: config.queue,
|
|
163
|
+
priority: job.opts?.priority ?? config.priority ?? 0,
|
|
164
|
+
attempts: job.attemptsMade,
|
|
165
|
+
maxRetries: config.retries ?? 3,
|
|
166
|
+
createdAt: new Date(job.timestamp),
|
|
167
|
+
delay: job.opts?.delay,
|
|
168
|
+
timeout: config.timeout,
|
|
169
|
+
raw: job,
|
|
170
|
+
complete: async () => {
|
|
171
|
+
// BullMQ auto-completes when processor resolves
|
|
172
|
+
},
|
|
173
|
+
fail: async (error: Error, requeue?: boolean) => {
|
|
174
|
+
if (!requeue) {
|
|
175
|
+
await job.moveToFailed(error, job.token || "", true);
|
|
176
|
+
} else {
|
|
177
|
+
throw error; // BullMQ will auto-retry
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
return handler(workerJob);
|
|
182
|
+
}) as never,
|
|
183
|
+
workerOpts as never,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
this.workers.set(config.queue, worker);
|
|
187
|
+
|
|
188
|
+
// Ensure queue object exists for job dispatching
|
|
189
|
+
if (!this.queues.has(config.queue)) {
|
|
190
|
+
const queue = new BullQueue(config.queue, {
|
|
191
|
+
connection: this.connection as { host: string; port: number },
|
|
192
|
+
prefix: this.config.prefix,
|
|
193
|
+
} as never);
|
|
194
|
+
this.queues.set(config.queue, queue);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
console.log(`[BullMQAdapter] Processing queue: ${config.queue} (concurrency=${config.concurrency ?? 1})`);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
throw new Error(`Failed to start processing: ${(error as Error).message}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async addJob(
|
|
204
|
+
queue: string,
|
|
205
|
+
data: unknown,
|
|
206
|
+
opts?: {
|
|
207
|
+
priority?: number;
|
|
208
|
+
delay?: number;
|
|
209
|
+
retries?: number;
|
|
210
|
+
timeout?: number;
|
|
211
|
+
jobId?: string;
|
|
212
|
+
},
|
|
213
|
+
): Promise<string> {
|
|
214
|
+
if (!this.connected) {
|
|
215
|
+
throw new Error("Not connected. Call connect() first.");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
// Ensure queue exists
|
|
220
|
+
if (!this.queues.has(queue)) {
|
|
221
|
+
const { Queue } = await import("bullmq");
|
|
222
|
+
const q = new Queue(queue, {
|
|
223
|
+
connection: this.connection as { host: string; port: number },
|
|
224
|
+
prefix: this.config.prefix,
|
|
225
|
+
});
|
|
226
|
+
this.queues.set(queue, q);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const q = this.queues.get(queue) as {
|
|
230
|
+
add: (name: string, data: unknown, opts: Record<string, unknown>) => Promise<{ id: string }>;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const job = await q.add("process", data, {
|
|
234
|
+
priority: opts?.priority,
|
|
235
|
+
delay: opts?.delay,
|
|
236
|
+
attempts: (opts?.retries ?? 3) + 1,
|
|
237
|
+
jobId: opts?.jobId,
|
|
238
|
+
backoff: {
|
|
239
|
+
type: "exponential",
|
|
240
|
+
delay: 1000,
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
return job.id;
|
|
245
|
+
} catch (error) {
|
|
246
|
+
throw new Error(`Failed to add job: ${(error as Error).message}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async stopProcessing(queue: string): Promise<void> {
|
|
251
|
+
const worker = this.workers.get(queue);
|
|
252
|
+
if (worker) {
|
|
253
|
+
await (worker as { close: () => Promise<void> }).close();
|
|
254
|
+
this.workers.delete(queue);
|
|
255
|
+
console.log(`[BullMQAdapter] Stopped processing queue: ${queue}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
isConnected(): boolean {
|
|
260
|
+
return this.connected;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async healthCheck(): Promise<boolean> {
|
|
264
|
+
if (!this.connected || !this.connection) return false;
|
|
265
|
+
try {
|
|
266
|
+
await (this.connection as { ping: () => Promise<string> }).ping();
|
|
267
|
+
return true;
|
|
268
|
+
} catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async getQueueStats(queue: string): Promise<WorkerQueueStats> {
|
|
274
|
+
if (!this.queues.has(queue)) {
|
|
275
|
+
return { waiting: 0, active: 0, completed: 0, failed: 0, delayed: 0 };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const q = this.queues.get(queue) as {
|
|
279
|
+
getWaitingCount: () => Promise<number>;
|
|
280
|
+
getActiveCount: () => Promise<number>;
|
|
281
|
+
getCompletedCount: () => Promise<number>;
|
|
282
|
+
getFailedCount: () => Promise<number>;
|
|
283
|
+
getDelayedCount: () => Promise<number>;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const [waiting, active, completed, failed, delayed] = await Promise.all([
|
|
287
|
+
q.getWaitingCount(),
|
|
288
|
+
q.getActiveCount(),
|
|
289
|
+
q.getCompletedCount(),
|
|
290
|
+
q.getFailedCount(),
|
|
291
|
+
q.getDelayedCount(),
|
|
292
|
+
]);
|
|
293
|
+
|
|
294
|
+
return { waiting, active, completed, failed, delayed };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InMemoryAdapter - Worker adapter using in-process queues
|
|
3
|
+
*
|
|
4
|
+
* Ideal for:
|
|
5
|
+
* - Development and testing
|
|
6
|
+
* - Simple background job processing
|
|
7
|
+
* - Single-instance deployments
|
|
8
|
+
*
|
|
9
|
+
* Limitations:
|
|
10
|
+
* - Jobs are lost on process restart
|
|
11
|
+
* - No distributed processing
|
|
12
|
+
* - No persistence
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* const adapter = new InMemoryAdapter();
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { WorkerTriggerOpts } from "@blokjs/helper";
|
|
21
|
+
import { v4 as uuid } from "uuid";
|
|
22
|
+
import type { WorkerAdapter, WorkerJob, WorkerQueueStats } from "../WorkerTrigger";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Internal job representation
|
|
26
|
+
*/
|
|
27
|
+
interface InternalJob {
|
|
28
|
+
id: string;
|
|
29
|
+
data: unknown;
|
|
30
|
+
queue: string;
|
|
31
|
+
priority: number;
|
|
32
|
+
attempts: number;
|
|
33
|
+
maxRetries: number;
|
|
34
|
+
createdAt: Date;
|
|
35
|
+
delay: number;
|
|
36
|
+
timeout: number;
|
|
37
|
+
status: "waiting" | "active" | "completed" | "failed" | "delayed";
|
|
38
|
+
scheduledAt?: Date;
|
|
39
|
+
error?: Error;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Queue processor entry
|
|
44
|
+
*/
|
|
45
|
+
interface QueueProcessor {
|
|
46
|
+
config: WorkerTriggerOpts;
|
|
47
|
+
handler: (job: WorkerJob) => Promise<void>;
|
|
48
|
+
active: number;
|
|
49
|
+
running: boolean;
|
|
50
|
+
timer?: ReturnType<typeof setInterval>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* InMemoryAdapter - Simple in-process worker queue
|
|
55
|
+
*/
|
|
56
|
+
export class InMemoryAdapter implements WorkerAdapter {
|
|
57
|
+
readonly provider = "in-memory" as const;
|
|
58
|
+
private connected = false;
|
|
59
|
+
private jobs: Map<string, InternalJob[]> = new Map();
|
|
60
|
+
private processors: Map<string, QueueProcessor> = new Map();
|
|
61
|
+
private stats: Map<string, { completed: number; failed: number }> = new Map();
|
|
62
|
+
|
|
63
|
+
async connect(): Promise<void> {
|
|
64
|
+
this.connected = true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async disconnect(): Promise<void> {
|
|
68
|
+
// Stop all processors
|
|
69
|
+
for (const [queue, processor] of this.processors) {
|
|
70
|
+
processor.running = false;
|
|
71
|
+
if (processor.timer) {
|
|
72
|
+
clearInterval(processor.timer);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
this.processors.clear();
|
|
76
|
+
this.jobs.clear();
|
|
77
|
+
this.stats.clear();
|
|
78
|
+
this.connected = false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async process(config: WorkerTriggerOpts, handler: (job: WorkerJob) => Promise<void>): Promise<void> {
|
|
82
|
+
if (!this.connected) {
|
|
83
|
+
throw new Error("Not connected. Call connect() first.");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!this.jobs.has(config.queue)) {
|
|
87
|
+
this.jobs.set(config.queue, []);
|
|
88
|
+
}
|
|
89
|
+
if (!this.stats.has(config.queue)) {
|
|
90
|
+
this.stats.set(config.queue, { completed: 0, failed: 0 });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const processor: QueueProcessor = {
|
|
94
|
+
config,
|
|
95
|
+
handler,
|
|
96
|
+
active: 0,
|
|
97
|
+
running: true,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
this.processors.set(config.queue, processor);
|
|
101
|
+
|
|
102
|
+
// Start polling for jobs
|
|
103
|
+
processor.timer = setInterval(() => {
|
|
104
|
+
this.processNext(config.queue).catch((err) => {
|
|
105
|
+
console.error(`[InMemoryAdapter] Error processing ${config.queue}: ${(err as Error).message}`);
|
|
106
|
+
});
|
|
107
|
+
}, 50); // Poll every 50ms
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async addJob(
|
|
111
|
+
queue: string,
|
|
112
|
+
data: unknown,
|
|
113
|
+
opts?: {
|
|
114
|
+
priority?: number;
|
|
115
|
+
delay?: number;
|
|
116
|
+
retries?: number;
|
|
117
|
+
timeout?: number;
|
|
118
|
+
jobId?: string;
|
|
119
|
+
},
|
|
120
|
+
): Promise<string> {
|
|
121
|
+
if (!this.connected) {
|
|
122
|
+
throw new Error("Not connected. Call connect() first.");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!this.jobs.has(queue)) {
|
|
126
|
+
this.jobs.set(queue, []);
|
|
127
|
+
}
|
|
128
|
+
if (!this.stats.has(queue)) {
|
|
129
|
+
this.stats.set(queue, { completed: 0, failed: 0 });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const job: InternalJob = {
|
|
133
|
+
id: opts?.jobId || uuid(),
|
|
134
|
+
data,
|
|
135
|
+
queue,
|
|
136
|
+
priority: opts?.priority ?? 0,
|
|
137
|
+
attempts: 0,
|
|
138
|
+
maxRetries: opts?.retries ?? 3,
|
|
139
|
+
createdAt: new Date(),
|
|
140
|
+
delay: opts?.delay ?? 0,
|
|
141
|
+
timeout: opts?.timeout ?? 0,
|
|
142
|
+
status: opts?.delay && opts.delay > 0 ? "delayed" : "waiting",
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
if (job.status === "delayed") {
|
|
146
|
+
job.scheduledAt = new Date(Date.now() + job.delay);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const jobs = this.jobs.get(queue)!;
|
|
150
|
+
|
|
151
|
+
// Insert sorted by priority (higher first)
|
|
152
|
+
const insertIdx = jobs.findIndex((j) => j.status === "waiting" && j.priority < job.priority);
|
|
153
|
+
if (insertIdx >= 0) {
|
|
154
|
+
jobs.splice(insertIdx, 0, job);
|
|
155
|
+
} else {
|
|
156
|
+
jobs.push(job);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return job.id;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async stopProcessing(queue: string): Promise<void> {
|
|
163
|
+
const processor = this.processors.get(queue);
|
|
164
|
+
if (processor) {
|
|
165
|
+
processor.running = false;
|
|
166
|
+
if (processor.timer) {
|
|
167
|
+
clearInterval(processor.timer);
|
|
168
|
+
}
|
|
169
|
+
this.processors.delete(queue);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
isConnected(): boolean {
|
|
174
|
+
return this.connected;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async healthCheck(): Promise<boolean> {
|
|
178
|
+
return this.connected;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async getQueueStats(queue: string): Promise<WorkerQueueStats> {
|
|
182
|
+
const jobs = this.jobs.get(queue) || [];
|
|
183
|
+
const queueStats = this.stats.get(queue) || { completed: 0, failed: 0 };
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
waiting: jobs.filter((j) => j.status === "waiting").length,
|
|
187
|
+
active: jobs.filter((j) => j.status === "active").length,
|
|
188
|
+
completed: queueStats.completed,
|
|
189
|
+
failed: queueStats.failed,
|
|
190
|
+
delayed: jobs.filter((j) => j.status === "delayed").length,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Process the next available job from a queue
|
|
196
|
+
*/
|
|
197
|
+
private async processNext(queue: string): Promise<void> {
|
|
198
|
+
const processor = this.processors.get(queue);
|
|
199
|
+
if (!processor || !processor.running) return;
|
|
200
|
+
|
|
201
|
+
const concurrency = processor.config.concurrency ?? 1;
|
|
202
|
+
if (processor.active >= concurrency) return;
|
|
203
|
+
|
|
204
|
+
const jobs = this.jobs.get(queue);
|
|
205
|
+
if (!jobs || jobs.length === 0) return;
|
|
206
|
+
|
|
207
|
+
// Check for delayed jobs that are ready
|
|
208
|
+
const now = Date.now();
|
|
209
|
+
for (const job of jobs) {
|
|
210
|
+
if (job.status === "delayed" && job.scheduledAt && job.scheduledAt.getTime() <= now) {
|
|
211
|
+
job.status = "waiting";
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Find next waiting job
|
|
216
|
+
const jobIdx = jobs.findIndex((j) => j.status === "waiting");
|
|
217
|
+
if (jobIdx < 0) return;
|
|
218
|
+
|
|
219
|
+
const internalJob = jobs[jobIdx];
|
|
220
|
+
internalJob.status = "active";
|
|
221
|
+
processor.active++;
|
|
222
|
+
|
|
223
|
+
const workerJob: WorkerJob = {
|
|
224
|
+
id: internalJob.id,
|
|
225
|
+
data: internalJob.data,
|
|
226
|
+
headers: {},
|
|
227
|
+
queue: internalJob.queue,
|
|
228
|
+
priority: internalJob.priority,
|
|
229
|
+
attempts: internalJob.attempts,
|
|
230
|
+
maxRetries: internalJob.maxRetries,
|
|
231
|
+
createdAt: internalJob.createdAt,
|
|
232
|
+
delay: internalJob.delay,
|
|
233
|
+
timeout: internalJob.timeout,
|
|
234
|
+
raw: internalJob,
|
|
235
|
+
complete: async () => {
|
|
236
|
+
internalJob.status = "completed";
|
|
237
|
+
const idx = jobs.indexOf(internalJob);
|
|
238
|
+
if (idx >= 0) jobs.splice(idx, 1);
|
|
239
|
+
const s = this.stats.get(queue);
|
|
240
|
+
if (s) s.completed++;
|
|
241
|
+
},
|
|
242
|
+
fail: async (error: Error, requeue?: boolean) => {
|
|
243
|
+
internalJob.attempts++;
|
|
244
|
+
internalJob.error = error;
|
|
245
|
+
|
|
246
|
+
if (requeue && internalJob.attempts < internalJob.maxRetries) {
|
|
247
|
+
// Requeue with backoff
|
|
248
|
+
const backoff = Math.min(1000 * Math.pow(2, internalJob.attempts), 30000);
|
|
249
|
+
internalJob.status = "delayed";
|
|
250
|
+
internalJob.scheduledAt = new Date(Date.now() + backoff);
|
|
251
|
+
} else {
|
|
252
|
+
internalJob.status = "failed";
|
|
253
|
+
const idx = jobs.indexOf(internalJob);
|
|
254
|
+
if (idx >= 0) jobs.splice(idx, 1);
|
|
255
|
+
const s = this.stats.get(queue);
|
|
256
|
+
if (s) s.failed++;
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
await processor.handler(workerJob);
|
|
263
|
+
} catch {
|
|
264
|
+
// Handler threw - treat as failure
|
|
265
|
+
if (internalJob.status === "active") {
|
|
266
|
+
internalJob.status = "failed";
|
|
267
|
+
const idx = jobs.indexOf(internalJob);
|
|
268
|
+
if (idx >= 0) jobs.splice(idx, 1);
|
|
269
|
+
const s = this.stats.get(queue);
|
|
270
|
+
if (s) s.failed++;
|
|
271
|
+
}
|
|
272
|
+
} finally {
|
|
273
|
+
processor.active--;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @blokjs/trigger-worker
|
|
3
|
+
*
|
|
4
|
+
* Worker-based trigger for Blok workflows.
|
|
5
|
+
* Supports background job processing with:
|
|
6
|
+
* - Configurable concurrency per queue
|
|
7
|
+
* - Automatic retries with exponential backoff
|
|
8
|
+
* - Job timeouts
|
|
9
|
+
* - Priority-based job ordering
|
|
10
|
+
* - Delayed job scheduling
|
|
11
|
+
* - Queue statistics and monitoring
|
|
12
|
+
*
|
|
13
|
+
* Adapters:
|
|
14
|
+
* - BullMQ (Redis-backed, production)
|
|
15
|
+
* - InMemory (development/testing)
|
|
16
|
+
*
|
|
17
|
+
* @example BullMQ
|
|
18
|
+
* ```typescript
|
|
19
|
+
* import { WorkerTrigger, BullMQAdapter } from "@blokjs/trigger-worker";
|
|
20
|
+
*
|
|
21
|
+
* class MyWorkerTrigger extends WorkerTrigger {
|
|
22
|
+
* protected adapter = new BullMQAdapter({
|
|
23
|
+
* host: "localhost",
|
|
24
|
+
* port: 6379,
|
|
25
|
+
* });
|
|
26
|
+
*
|
|
27
|
+
* protected nodes = myNodes;
|
|
28
|
+
* protected workflows = myWorkflows;
|
|
29
|
+
* }
|
|
30
|
+
*
|
|
31
|
+
* const trigger = new MyWorkerTrigger();
|
|
32
|
+
* await trigger.listen();
|
|
33
|
+
*
|
|
34
|
+
* // Dispatch a job
|
|
35
|
+
* await trigger.dispatch("background-jobs", { userId: "123" }, {
|
|
36
|
+
* priority: 10,
|
|
37
|
+
* retries: 3,
|
|
38
|
+
* delay: 5000, // delay 5 seconds
|
|
39
|
+
* });
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* @example InMemory (development)
|
|
43
|
+
* ```typescript
|
|
44
|
+
* import { WorkerTrigger, InMemoryAdapter } from "@blokjs/trigger-worker";
|
|
45
|
+
*
|
|
46
|
+
* class DevWorkerTrigger extends WorkerTrigger {
|
|
47
|
+
* protected adapter = new InMemoryAdapter();
|
|
48
|
+
* protected nodes = myNodes;
|
|
49
|
+
* protected workflows = myWorkflows;
|
|
50
|
+
* }
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
// Core exports
|
|
55
|
+
export {
|
|
56
|
+
WorkerTrigger,
|
|
57
|
+
type WorkerAdapter,
|
|
58
|
+
type WorkerJob,
|
|
59
|
+
type WorkerQueueStats,
|
|
60
|
+
} from "./WorkerTrigger";
|
|
61
|
+
|
|
62
|
+
// Adapters
|
|
63
|
+
export { BullMQAdapter, type BullMQConfig } from "./adapters/BullMQAdapter";
|
|
64
|
+
export { InMemoryAdapter } from "./adapters/InMemoryAdapter";
|
|
65
|
+
|
|
66
|
+
// Re-export types from helper for convenience
|
|
67
|
+
export type { WorkerTriggerOpts } from "@blokjs/helper";
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"ts-node": {
|
|
3
|
+
"transpileOnly": true
|
|
4
|
+
},
|
|
5
|
+
"compilerOptions": {
|
|
6
|
+
"target": "ES2022",
|
|
7
|
+
"module": "es2022",
|
|
8
|
+
"lib": ["ES2022"],
|
|
9
|
+
"declaration": true,
|
|
10
|
+
"strict": true,
|
|
11
|
+
"noImplicitAny": true,
|
|
12
|
+
"strictNullChecks": true,
|
|
13
|
+
"noImplicitThis": true,
|
|
14
|
+
"alwaysStrict": true,
|
|
15
|
+
"noUnusedLocals": false,
|
|
16
|
+
"noUnusedParameters": false,
|
|
17
|
+
"noImplicitReturns": true,
|
|
18
|
+
"noFallthroughCasesInSwitch": false,
|
|
19
|
+
"inlineSourceMap": true,
|
|
20
|
+
"inlineSources": true,
|
|
21
|
+
"experimentalDecorators": true,
|
|
22
|
+
"emitDecoratorMetadata": true,
|
|
23
|
+
"skipLibCheck": true,
|
|
24
|
+
"esModuleInterop": true,
|
|
25
|
+
"resolveJsonModule": true,
|
|
26
|
+
"outDir": "./dist",
|
|
27
|
+
"rootDir": "./src",
|
|
28
|
+
"moduleResolution": "bundler"
|
|
29
|
+
},
|
|
30
|
+
"include": ["src/**/*"],
|
|
31
|
+
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
|
32
|
+
}
|