@elysiumjs/heracles 0.4.4 → 0.5.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/README.md +83 -75
- package/dist/heracles.d.ts +13 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +2 -0
- package/dist/job.d.ts +249 -0
- package/dist/queue.d.ts +247 -0
- package/dist/transport.d.ts +427 -0
- package/dist/transports/redis.transport.d.ts +251 -0
- package/dist/utils.d.ts +34 -0
- package/dist/worker.d.ts +250 -0
- package/dist/workers/base.worker.d.ts +216 -0
- package/dist/workers/redis.worker.d.ts +57 -0
- package/package.json +12 -10
- package/tsconfig.json +34 -33
- package/USAGE.md +0 -527
- package/index.ts +0 -16
- package/src/heracles.ts +0 -68
- package/src/index.ts +0 -28
- package/src/job.ts +0 -413
- package/src/queue.ts +0 -567
- package/src/transport.ts +0 -518
- package/src/transports/redis.transport.ts +0 -1558
- package/src/transports/thread.transport.ts +0 -289
- package/src/utils.ts +0 -61
- package/src/worker.ts +0 -312
- package/src/worker_pool.ts +0 -393
- package/src/workers/base.worker.ts +0 -804
- package/src/workers/redis.worker.ts +0 -523
- package/src/workers/thread.worker.entry.ts +0 -171
- package/src/workers/thread.worker.ts +0 -525
- package/types.d.ts +0 -21
|
@@ -1,1558 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025-present Workbud Technologies Inc. All rights reserved.
|
|
2
|
-
//
|
|
3
|
-
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
-
// you may not use this file except in compliance with the License.
|
|
5
|
-
// You may obtain a copy of the License at
|
|
6
|
-
//
|
|
7
|
-
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
-
//
|
|
9
|
-
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
-
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
-
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
-
// See the License for the specific language governing permissions and
|
|
13
|
-
// limitations under the License.
|
|
14
|
-
|
|
15
|
-
import type {
|
|
16
|
-
JobStatusInfo,
|
|
17
|
-
Transport,
|
|
18
|
-
TransportEvent,
|
|
19
|
-
TransportJobStatusMessage,
|
|
20
|
-
TransportMode
|
|
21
|
-
} from '../transport';
|
|
22
|
-
|
|
23
|
-
import { InteractsWithConsole, Redis } from '@elysiumjs/core';
|
|
24
|
-
import { uid } from 'radash';
|
|
25
|
-
|
|
26
|
-
import { JobStatus } from '../job';
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Redis transport configuration options
|
|
30
|
-
* @author Axel Nana <axel.nana@workbud.com>
|
|
31
|
-
*/
|
|
32
|
-
export type RedisTransportOptions = {
|
|
33
|
-
/**
|
|
34
|
-
* Redis connection name.
|
|
35
|
-
* If not provided, uses the default connection.
|
|
36
|
-
*/
|
|
37
|
-
connection?: string;
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Prefix for all Redis keys used by this transport.
|
|
41
|
-
* @default 'elysium:heracles'
|
|
42
|
-
*/
|
|
43
|
-
keyPrefix?: string;
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Group name for Redis consumer groups.
|
|
47
|
-
* @default 'workers'
|
|
48
|
-
*/
|
|
49
|
-
consumerGroup?: string;
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Consumer name for this instance in the consumer group.
|
|
53
|
-
* If not provided, a random name will be generated.
|
|
54
|
-
*/
|
|
55
|
-
consumerName?: string;
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* How often to poll Redis for new messages (in milliseconds).
|
|
59
|
-
* @default 1000
|
|
60
|
-
*/
|
|
61
|
-
pollInterval?: number;
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Maximum number of messages to read in a single poll.
|
|
65
|
-
* @default 10
|
|
66
|
-
*/
|
|
67
|
-
batchSize?: number;
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Time to live for job status information (in seconds).
|
|
71
|
-
* @default 86400 (24 hours)
|
|
72
|
-
*/
|
|
73
|
-
statusTTL?: number;
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Whether to automatically clean up completed and failed jobs from streams.
|
|
77
|
-
* @default true
|
|
78
|
-
*/
|
|
79
|
-
cleanupCompletedJobs?: boolean;
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* How long to retain completed jobs in the stream before cleanup (in seconds).
|
|
83
|
-
* @default 3600 (1 hour)
|
|
84
|
-
*/
|
|
85
|
-
completedJobRetention?: number;
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Maximum number of entries to keep in each stream.
|
|
89
|
-
* @default 1000
|
|
90
|
-
*/
|
|
91
|
-
maxStreamSize?: number;
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Transport implementation using Redis streams for distributed job processing.
|
|
96
|
-
* @author Axel Nana <axel.nana@workbud.com>
|
|
97
|
-
*/
|
|
98
|
-
export class RedisTransport extends InteractsWithConsole implements Transport {
|
|
99
|
-
/**
|
|
100
|
-
* Redis client instance.
|
|
101
|
-
*/
|
|
102
|
-
private client: Bun.RedisClient;
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Options for this transport.
|
|
106
|
-
*/
|
|
107
|
-
private options: Required<RedisTransportOptions>;
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Mode this transport is operating in.
|
|
111
|
-
*/
|
|
112
|
-
private mode: TransportMode;
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Registered message handlers.
|
|
116
|
-
*/
|
|
117
|
-
private messageHandlers: Array<(message: TransportEvent) => void | Promise<void>> = [];
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Timer for polling Redis in consumer mode.
|
|
121
|
-
*/
|
|
122
|
-
private pollTimer?: NodeJS.Timeout;
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Timer for stream cleanup.
|
|
126
|
-
*/
|
|
127
|
-
private cleanupTimer?: NodeJS.Timeout;
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Last processed message IDs by stream.
|
|
131
|
-
*/
|
|
132
|
-
private lastIds: Map<string, string> = new Map();
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Whether to check the logs instead of new messages.
|
|
136
|
-
*/
|
|
137
|
-
private checkLogs: Map<string, boolean> = new Map();
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Consumer ID for this instance.
|
|
141
|
-
*/
|
|
142
|
-
private consumerId: string;
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Whether the consumer group has been created for each stream.
|
|
146
|
-
*/
|
|
147
|
-
private consumerGroupCreated: Set<string> = new Set();
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Map of message IDs to completed status for tracking completed jobs.
|
|
151
|
-
*/
|
|
152
|
-
private completedMessageIds: Map<string, { streamKey: string; timestamp: number }> = new Map();
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Map to track locks on job IDs for NO_OVERLAP jobs.
|
|
156
|
-
*/
|
|
157
|
-
private jobLocks: Map<string, { lockedUntil: number; queue: string }> = new Map();
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Create a new Redis transport.
|
|
161
|
-
*/
|
|
162
|
-
constructor(mode: TransportMode, options: RedisTransportOptions = {}) {
|
|
163
|
-
super();
|
|
164
|
-
|
|
165
|
-
this.mode = mode;
|
|
166
|
-
this.options = {
|
|
167
|
-
connection: options.connection ?? 'default',
|
|
168
|
-
keyPrefix: options.keyPrefix ?? 'elysium:heracles',
|
|
169
|
-
consumerGroup: options.consumerGroup ?? 'workers',
|
|
170
|
-
consumerName: options.consumerName ?? `worker-${uid(8)}`,
|
|
171
|
-
pollInterval: options.pollInterval ?? 1000,
|
|
172
|
-
batchSize: options.batchSize ?? 10,
|
|
173
|
-
statusTTL: options.statusTTL ?? 86400,
|
|
174
|
-
cleanupCompletedJobs: options.cleanupCompletedJobs !== false, // Default to true
|
|
175
|
-
completedJobRetention: options.completedJobRetention ?? 3600, // Default to 1 hour
|
|
176
|
-
maxStreamSize: options.maxStreamSize ?? 1000
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
// Get Redis client from Redis service
|
|
180
|
-
this.client = Redis.getConnection(this.options.connection);
|
|
181
|
-
this.consumerId = this.mode === 'consumer' ? this.options.consumerName : `producer-${uid(8)}`;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Helper method to execute Redis commands with proper type conversion
|
|
186
|
-
* @param command Redis command
|
|
187
|
-
* @param args Command arguments
|
|
188
|
-
* @returns Redis response
|
|
189
|
-
*/
|
|
190
|
-
private redisCommand(command: string, args: any[]): Promise<any> {
|
|
191
|
-
// Convert any non-string arguments to strings
|
|
192
|
-
const stringArgs = args.map((arg) => {
|
|
193
|
-
if (typeof arg === 'number') {
|
|
194
|
-
return arg.toString();
|
|
195
|
-
} else if (arg === null || arg === undefined) {
|
|
196
|
-
return '';
|
|
197
|
-
} else if (Array.isArray(arg)) {
|
|
198
|
-
return JSON.stringify(arg);
|
|
199
|
-
} else if (typeof arg === 'object') {
|
|
200
|
-
return JSON.stringify(arg);
|
|
201
|
-
}
|
|
202
|
-
return arg;
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
try {
|
|
206
|
-
return this.client.send(command, stringArgs);
|
|
207
|
-
} catch (error) {
|
|
208
|
-
throw new Error(`Redis command failed: ${command} - ${error}`);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Finds job message ID in the stream by job ID
|
|
214
|
-
* @param jobId The job ID to find
|
|
215
|
-
* @param queueName The queue to search in
|
|
216
|
-
* @returns The message ID if found, null otherwise
|
|
217
|
-
*/
|
|
218
|
-
private async findJobMessageId(
|
|
219
|
-
jobId: string,
|
|
220
|
-
dispatchId: string,
|
|
221
|
-
queueName: string
|
|
222
|
-
): Promise<string | null> {
|
|
223
|
-
const streamKey = this.getStreamKey(queueName);
|
|
224
|
-
|
|
225
|
-
try {
|
|
226
|
-
// Use XRANGE to find messages for this job
|
|
227
|
-
// First try with the job's exact ID included in message ID (producer pattern)
|
|
228
|
-
const messages = await this.redisCommand('XRANGE', [streamKey, '-', '+', 'COUNT', '1000']);
|
|
229
|
-
|
|
230
|
-
for (const [messageId, fields] of messages) {
|
|
231
|
-
// Convert fields to object and check jobId field
|
|
232
|
-
const messageObj: Record<string, string> = {};
|
|
233
|
-
for (let i = 0; i < fields.length; i += 2) {
|
|
234
|
-
messageObj[fields[i]] = fields[i + 1];
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
if (messageObj.jobId === jobId && messageObj.dispatchId === dispatchId) {
|
|
238
|
-
return messageId;
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
} catch (error) {
|
|
242
|
-
this.error(`Error finding job message: ${error}`);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
return null;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* Start the transport
|
|
250
|
-
*/
|
|
251
|
-
async start(): Promise<void> {
|
|
252
|
-
try {
|
|
253
|
-
// Test connection using raw command
|
|
254
|
-
await this.redisCommand('PING', []);
|
|
255
|
-
this.info(`RedisTransport started in ${this.mode} mode with ID: ${this.consumerId}`);
|
|
256
|
-
|
|
257
|
-
if (this.mode === 'consumer') {
|
|
258
|
-
// Start polling for messages
|
|
259
|
-
this.startPolling();
|
|
260
|
-
|
|
261
|
-
// Schedule periodic cleanup of completed jobs if enabled
|
|
262
|
-
if (this.options.cleanupCompletedJobs) {
|
|
263
|
-
this.scheduleStreamCleanup();
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
} catch (error) {
|
|
267
|
-
this.error(`Failed to start RedisTransport: ${error}`);
|
|
268
|
-
throw new Error(`Redis connection failed: ${error}`);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* Stop the transport
|
|
274
|
-
*/
|
|
275
|
-
async stop(): Promise<void> {
|
|
276
|
-
// Stop polling
|
|
277
|
-
if (this.pollTimer) {
|
|
278
|
-
clearInterval(this.pollTimer);
|
|
279
|
-
this.pollTimer = undefined;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// Stop cleanup timer
|
|
283
|
-
if (this.cleanupTimer) {
|
|
284
|
-
clearInterval(this.cleanupTimer);
|
|
285
|
-
this.cleanupTimer = undefined;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
this.info('RedisTransport stopped');
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* Send a message through the transport
|
|
293
|
-
*/
|
|
294
|
-
async send(message: TransportEvent): Promise<void> {
|
|
295
|
-
try {
|
|
296
|
-
let queueNames: string[] = ['default'];
|
|
297
|
-
|
|
298
|
-
// Extract queue name if the message type has it
|
|
299
|
-
if ('queue' in message && typeof message.queue === 'string') {
|
|
300
|
-
queueNames = [message.queue];
|
|
301
|
-
} else if ('queues' in message && Array.isArray(message.queues)) {
|
|
302
|
-
queueNames = message.queues;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
for (const queueName of queueNames) {
|
|
306
|
-
// Determine the appropriate stream based on message type
|
|
307
|
-
const streamName: string = this.getStreamKey(queueName);
|
|
308
|
-
|
|
309
|
-
// Convert message to Redis hash format
|
|
310
|
-
const messageData = this.serializeMessage(message);
|
|
311
|
-
|
|
312
|
-
// ID of the created message in the stream
|
|
313
|
-
let messageId: string = '0';
|
|
314
|
-
|
|
315
|
-
// Handle job status updates differently
|
|
316
|
-
if (message.type === 'job:status' || message.type === 'job:result') {
|
|
317
|
-
// Find existing message for this job
|
|
318
|
-
const existingMessageId = await this.findJobMessageId(
|
|
319
|
-
message.jobId,
|
|
320
|
-
message.dispatchId,
|
|
321
|
-
queueName
|
|
322
|
-
);
|
|
323
|
-
|
|
324
|
-
if (existingMessageId) {
|
|
325
|
-
// Update existing job entry with new status
|
|
326
|
-
const statusInfo: Partial<Record<keyof JobStatusInfo, string>> = {
|
|
327
|
-
status: message.status,
|
|
328
|
-
updatedAt: Date.now().toString()
|
|
329
|
-
};
|
|
330
|
-
|
|
331
|
-
// Add additional fields if present
|
|
332
|
-
if (message.error) statusInfo['error'] = message.error;
|
|
333
|
-
if (message.retries !== undefined) statusInfo['retries'] = message.retries.toString();
|
|
334
|
-
if ((message as any).startedAt) statusInfo['startedAt'] = (message as any).startedAt;
|
|
335
|
-
if (message.completedAt) statusInfo['completedAt'] = message.completedAt;
|
|
336
|
-
|
|
337
|
-
// Update the job entry
|
|
338
|
-
await this.redisCommand('HSET', [
|
|
339
|
-
this.getJobStatusKey(message.jobId, message.dispatchId, queueName),
|
|
340
|
-
...Object.entries(statusInfo).flat()
|
|
341
|
-
]);
|
|
342
|
-
|
|
343
|
-
this.debug(`Updated status for job ${message.jobId} to ${message.status}`);
|
|
344
|
-
|
|
345
|
-
// Store in-memory for quick lookup
|
|
346
|
-
await this.storeJobStatus({
|
|
347
|
-
jobId: message.jobId,
|
|
348
|
-
dispatchId: message.dispatchId,
|
|
349
|
-
queue: queueName,
|
|
350
|
-
status: message.status,
|
|
351
|
-
error: message.error,
|
|
352
|
-
retries: message.retries ?? 0,
|
|
353
|
-
createdAt: (message as any).createdAt ?? new Date().toISOString(),
|
|
354
|
-
startedAt: (message as any).startedAt,
|
|
355
|
-
completedAt: message.completedAt
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
return;
|
|
359
|
-
} else {
|
|
360
|
-
// First check if we should update an existing job status instead
|
|
361
|
-
const jobId = message.jobId;
|
|
362
|
-
const dispatchId = message.dispatchId;
|
|
363
|
-
const statusKey = this.getJobStatusKey(jobId, dispatchId, queueName);
|
|
364
|
-
const exists = await this.redisCommand('EXISTS', [statusKey]);
|
|
365
|
-
|
|
366
|
-
// Add to Redis stream using raw command
|
|
367
|
-
messageId = await this.redisCommand('XADD', [
|
|
368
|
-
streamName,
|
|
369
|
-
'*', // Auto-generate ID
|
|
370
|
-
...messageData
|
|
371
|
-
]);
|
|
372
|
-
|
|
373
|
-
if (exists) {
|
|
374
|
-
// Update existing job status
|
|
375
|
-
await this.updateJobStatus(message.jobId, message.dispatchId, queueName, {
|
|
376
|
-
status: message.status,
|
|
377
|
-
error: message.error,
|
|
378
|
-
retries: message.retries ?? 0,
|
|
379
|
-
startedAt: (message as any).startedAt,
|
|
380
|
-
completedAt: message.completedAt,
|
|
381
|
-
updatedAt: Date.now().toString()
|
|
382
|
-
});
|
|
383
|
-
} else {
|
|
384
|
-
// Create new job status
|
|
385
|
-
await this.storeJobStatus({
|
|
386
|
-
jobId: message.jobId,
|
|
387
|
-
dispatchId: message.dispatchId,
|
|
388
|
-
queue: queueName,
|
|
389
|
-
status: message.status,
|
|
390
|
-
error: message.error,
|
|
391
|
-
retries: message.retries ?? 0,
|
|
392
|
-
createdAt: (message as any).createdAt ?? new Date().toISOString(),
|
|
393
|
-
startedAt: (message as any).startedAt,
|
|
394
|
-
completedAt: message.completedAt,
|
|
395
|
-
messageId: messageId,
|
|
396
|
-
updatedAt: Date.now().toString()
|
|
397
|
-
});
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
} else if (message.type === 'job:process') {
|
|
401
|
-
// Add to Redis stream using raw command
|
|
402
|
-
messageId = await this.redisCommand('XADD', [
|
|
403
|
-
streamName,
|
|
404
|
-
'*', // Auto-generate ID
|
|
405
|
-
...messageData
|
|
406
|
-
]);
|
|
407
|
-
|
|
408
|
-
// Store job information in separate hash for easy access and updates
|
|
409
|
-
await this.storeJobStatus({
|
|
410
|
-
jobId: message.jobId,
|
|
411
|
-
dispatchId: message.dispatchId,
|
|
412
|
-
queue: queueName,
|
|
413
|
-
status: JobStatus.PENDING,
|
|
414
|
-
retries: 0,
|
|
415
|
-
createdAt: new Date().toISOString(),
|
|
416
|
-
messageId: messageId,
|
|
417
|
-
updatedAt: Date.now().toString()
|
|
418
|
-
});
|
|
419
|
-
} else if (message.type === 'job:update') {
|
|
420
|
-
// Direct update of job status without creating a new message
|
|
421
|
-
await this.updateJobStatus(message.jobId, message.dispatchId, queueName, message.updates);
|
|
422
|
-
} else {
|
|
423
|
-
// Add to Redis stream using raw command
|
|
424
|
-
messageId = await this.redisCommand('XADD', [
|
|
425
|
-
streamName,
|
|
426
|
-
'*', // Auto-generate ID
|
|
427
|
-
...messageData
|
|
428
|
-
]);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
this.debug(`Sent ${message.type} message to stream ${streamName} with ID ${messageId}`);
|
|
432
|
-
}
|
|
433
|
-
} catch (error) {
|
|
434
|
-
this.error(`Failed to send message: ${error}`);
|
|
435
|
-
throw new Error(`Failed to send message: ${error}`);
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/**
|
|
440
|
-
* Register a callback to handle messages
|
|
441
|
-
*/
|
|
442
|
-
onMessage(handler: (message: TransportEvent) => void | Promise<void>): void {
|
|
443
|
-
this.messageHandlers.push(handler);
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
/**
|
|
447
|
-
* Gets the current status of a job.
|
|
448
|
-
*/
|
|
449
|
-
async getJobStatus(jobId: string, dispatchId: string, queueName: string): Promise<JobStatusInfo> {
|
|
450
|
-
// Try to get from Redis
|
|
451
|
-
try {
|
|
452
|
-
const statusKey = this.getJobStatusKey(jobId, dispatchId, queueName);
|
|
453
|
-
const jobData = await this.redisCommand('HGETALL', [statusKey]);
|
|
454
|
-
|
|
455
|
-
if (jobData) {
|
|
456
|
-
const status: JobStatusInfo = {
|
|
457
|
-
jobId,
|
|
458
|
-
dispatchId: jobData.dispatchId,
|
|
459
|
-
queue: queueName,
|
|
460
|
-
status: jobData.status || 'unknown',
|
|
461
|
-
error: jobData.error,
|
|
462
|
-
retries: parseInt(jobData.retries || '0', 10),
|
|
463
|
-
createdAt: jobData.createdAt || new Date().toISOString(),
|
|
464
|
-
startedAt: jobData.startedAt,
|
|
465
|
-
completedAt: jobData.completedAt,
|
|
466
|
-
updatedAt: jobData.updatedAt,
|
|
467
|
-
messageId: jobData.messageId
|
|
468
|
-
};
|
|
469
|
-
|
|
470
|
-
this.debug(`Retrieved job status for ${jobId} from Redis: ${status.status}`);
|
|
471
|
-
return status;
|
|
472
|
-
}
|
|
473
|
-
} catch (error) {
|
|
474
|
-
this.warning(`Failed to get job status from Redis: ${error}`);
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
// If we couldn't find the status, check if the job exists in the queue
|
|
478
|
-
try {
|
|
479
|
-
const messageId = await this.findJobMessageId(jobId, dispatchId, queueName);
|
|
480
|
-
|
|
481
|
-
if (messageId) {
|
|
482
|
-
// Job exists in the stream but doesn't have a status entry yet
|
|
483
|
-
const status: JobStatusInfo = {
|
|
484
|
-
jobId,
|
|
485
|
-
dispatchId,
|
|
486
|
-
queue: queueName,
|
|
487
|
-
status: JobStatus.PENDING,
|
|
488
|
-
retries: 0,
|
|
489
|
-
createdAt: new Date().toISOString(),
|
|
490
|
-
messageId,
|
|
491
|
-
updatedAt: Date.now().toString()
|
|
492
|
-
};
|
|
493
|
-
|
|
494
|
-
// Store this status for future reference
|
|
495
|
-
await this.storeJobStatus(status);
|
|
496
|
-
return status;
|
|
497
|
-
}
|
|
498
|
-
} catch (error) {
|
|
499
|
-
this.debug(`Error checking job existence in stream: ${error}`);
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
// Return default status if not found
|
|
503
|
-
return {
|
|
504
|
-
jobId,
|
|
505
|
-
dispatchId,
|
|
506
|
-
queue: queueName,
|
|
507
|
-
status: 'unknown',
|
|
508
|
-
retries: 0,
|
|
509
|
-
createdAt: new Date().toISOString()
|
|
510
|
-
};
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
/**
|
|
514
|
-
* Updates a job's status directly in Redis without creating a new message.
|
|
515
|
-
*
|
|
516
|
-
* @param jobId The ID of the job to update
|
|
517
|
-
* @param queueName The queue the job belongs to
|
|
518
|
-
* @param updates The status updates to apply
|
|
519
|
-
*/
|
|
520
|
-
async updateJobStatus(
|
|
521
|
-
jobId: string,
|
|
522
|
-
dispatchId: string,
|
|
523
|
-
queueName: string,
|
|
524
|
-
updates: Partial<JobStatusInfo>
|
|
525
|
-
): Promise<void> {
|
|
526
|
-
try {
|
|
527
|
-
const statusKey = this.getJobStatusKey(jobId, dispatchId, queueName);
|
|
528
|
-
const timestamp = Date.now().toString();
|
|
529
|
-
|
|
530
|
-
// Check if job status exists
|
|
531
|
-
const exists = await this.redisCommand('EXISTS', [statusKey]);
|
|
532
|
-
|
|
533
|
-
if (!exists) {
|
|
534
|
-
// Job status doesn't exist in Redis yet
|
|
535
|
-
if (updates.status) {
|
|
536
|
-
// Create new status entry
|
|
537
|
-
const newStatus: JobStatusInfo = {
|
|
538
|
-
jobId,
|
|
539
|
-
dispatchId,
|
|
540
|
-
queue: queueName,
|
|
541
|
-
status: updates.status,
|
|
542
|
-
error: updates.error,
|
|
543
|
-
retries: updates.retries || 0,
|
|
544
|
-
createdAt: updates.createdAt || new Date().toISOString(),
|
|
545
|
-
startedAt: updates.startedAt,
|
|
546
|
-
completedAt: updates.completedAt,
|
|
547
|
-
updatedAt: timestamp,
|
|
548
|
-
messageId: updates.messageId
|
|
549
|
-
};
|
|
550
|
-
await this.storeJobStatus(newStatus);
|
|
551
|
-
|
|
552
|
-
// Also send notifications if this is a final status
|
|
553
|
-
if (
|
|
554
|
-
updates.status === JobStatus.COMPLETED ||
|
|
555
|
-
updates.status === JobStatus.FAILED ||
|
|
556
|
-
updates.status === JobStatus.CANCELLED
|
|
557
|
-
) {
|
|
558
|
-
// Forward the status update to any message handlers as a job:result event
|
|
559
|
-
for (const handler of this.messageHandlers) {
|
|
560
|
-
try {
|
|
561
|
-
handler({
|
|
562
|
-
type: 'job:result',
|
|
563
|
-
jobId,
|
|
564
|
-
dispatchId,
|
|
565
|
-
queue: queueName,
|
|
566
|
-
status: updates.status,
|
|
567
|
-
error: updates.error,
|
|
568
|
-
completedAt: updates.completedAt ?? new Date().toISOString()
|
|
569
|
-
});
|
|
570
|
-
} catch (handlerError) {
|
|
571
|
-
this.error(`Error in message handler: ${handlerError}`);
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
} else {
|
|
575
|
-
// For non-terminal statuses, just send a regular status update
|
|
576
|
-
for (const handler of this.messageHandlers) {
|
|
577
|
-
try {
|
|
578
|
-
handler({
|
|
579
|
-
type: 'job:status',
|
|
580
|
-
jobId,
|
|
581
|
-
dispatchId,
|
|
582
|
-
queue: queueName,
|
|
583
|
-
status: updates.status,
|
|
584
|
-
error: updates.error,
|
|
585
|
-
updatedAt: updates.updatedAt ?? new Date().toISOString()
|
|
586
|
-
});
|
|
587
|
-
} catch (handlerError) {
|
|
588
|
-
this.error(`Error in message handler: ${handlerError}`);
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
return;
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// Build update fields for existing entry
|
|
597
|
-
const updateFields: string[] = [];
|
|
598
|
-
const notifyFields: TransportJobStatusMessage = {} as TransportJobStatusMessage;
|
|
599
|
-
|
|
600
|
-
// Add all update fields
|
|
601
|
-
if (updates.status) {
|
|
602
|
-
updateFields.push('status', updates.status);
|
|
603
|
-
notifyFields.status = updates.status;
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
if (updates.error !== undefined) {
|
|
607
|
-
updateFields.push('error', updates.error || '');
|
|
608
|
-
notifyFields.error = updates.error || '';
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
if (updates.retries !== undefined) {
|
|
612
|
-
updateFields.push('retries', updates.retries.toString());
|
|
613
|
-
notifyFields.retries = updates.retries;
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
if (updates.startedAt) {
|
|
617
|
-
updateFields.push('startedAt', updates.startedAt);
|
|
618
|
-
notifyFields.startedAt = updates.startedAt;
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
if (updates.completedAt) {
|
|
622
|
-
updateFields.push('completedAt', updates.completedAt);
|
|
623
|
-
notifyFields.completedAt = updates.completedAt;
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
// Always include updatedAt timestamp
|
|
627
|
-
updateFields.push('updatedAt', timestamp);
|
|
628
|
-
notifyFields.updatedAt = timestamp;
|
|
629
|
-
|
|
630
|
-
// Update Redis hash
|
|
631
|
-
if (updateFields.length > 0) {
|
|
632
|
-
await this.redisCommand('HSET', [statusKey, ...updateFields]);
|
|
633
|
-
|
|
634
|
-
// Reset expiration time
|
|
635
|
-
await this.redisCommand('EXPIRE', [statusKey, this.options.statusTTL]);
|
|
636
|
-
|
|
637
|
-
this.debug(`Updated job ${jobId} status: ${updates.status ?? 'fields updated'}`);
|
|
638
|
-
|
|
639
|
-
// If this is a terminal status, track it for cleanup
|
|
640
|
-
if (
|
|
641
|
-
updates.status === JobStatus.COMPLETED ||
|
|
642
|
-
updates.status === JobStatus.FAILED ||
|
|
643
|
-
updates.status === JobStatus.CANCELLED
|
|
644
|
-
) {
|
|
645
|
-
// Get the message ID associated with this job
|
|
646
|
-
const currentStatus = await this.getJobStatus(jobId, dispatchId, queueName);
|
|
647
|
-
if (currentStatus.messageId) {
|
|
648
|
-
this.trackCompletedMessage(currentStatus.messageId, this.getStreamKey(queueName));
|
|
649
|
-
|
|
650
|
-
// Also notify handlers of job completion
|
|
651
|
-
for (const handler of this.messageHandlers) {
|
|
652
|
-
try {
|
|
653
|
-
handler({
|
|
654
|
-
type: 'job:result',
|
|
655
|
-
jobId,
|
|
656
|
-
dispatchId,
|
|
657
|
-
queue: queueName,
|
|
658
|
-
status: updates.status,
|
|
659
|
-
error: updates.error,
|
|
660
|
-
completedAt: updates.completedAt || timestamp,
|
|
661
|
-
retries: parseInt(currentStatus.retries.toString(), 10)
|
|
662
|
-
});
|
|
663
|
-
} catch (handlerError) {
|
|
664
|
-
this.error(`Error in message handler: ${handlerError}`);
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
} else {
|
|
669
|
-
// For non-terminal statuses, just send a regular status update
|
|
670
|
-
for (const handler of this.messageHandlers) {
|
|
671
|
-
try {
|
|
672
|
-
handler({
|
|
673
|
-
...notifyFields,
|
|
674
|
-
type: 'job:status',
|
|
675
|
-
jobId,
|
|
676
|
-
queue: queueName
|
|
677
|
-
});
|
|
678
|
-
} catch (handlerError) {
|
|
679
|
-
this.error(`Error in message handler: ${handlerError}`);
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
} catch (error) {
|
|
685
|
-
this.error(`Failed to update job status: ${error}`);
|
|
686
|
-
throw new Error(`Failed to update job status: ${error}`);
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
/**
|
|
691
|
-
* Register a worker with this transport
|
|
692
|
-
*/
|
|
693
|
-
async registerWorker(workerId: string, queues: string[]): Promise<void> {
|
|
694
|
-
try {
|
|
695
|
-
for (const queue of queues) {
|
|
696
|
-
const streamName = this.getStreamKey(queue);
|
|
697
|
-
|
|
698
|
-
// Ensure stream exists
|
|
699
|
-
await this.ensureStreamExists(streamName);
|
|
700
|
-
|
|
701
|
-
// Ensure consumer group exists
|
|
702
|
-
await this.ensureConsumerGroupExists(streamName);
|
|
703
|
-
|
|
704
|
-
// Register worker in Redis using raw command
|
|
705
|
-
const workerKey = this.getWorkerKey(workerId);
|
|
706
|
-
await this.redisCommand('HSET', [
|
|
707
|
-
workerKey,
|
|
708
|
-
'id',
|
|
709
|
-
workerId,
|
|
710
|
-
'status',
|
|
711
|
-
'active',
|
|
712
|
-
'lastSeen',
|
|
713
|
-
new Date().toISOString(),
|
|
714
|
-
'queues',
|
|
715
|
-
JSON.stringify(queues)
|
|
716
|
-
]);
|
|
717
|
-
|
|
718
|
-
// Set TTL to auto-expire if heartbeat stops
|
|
719
|
-
await this.redisCommand('EXPIRE', [workerKey, '60']);
|
|
720
|
-
|
|
721
|
-
this.debug(`Registered worker ${workerId} for queue ${queue}`);
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
// Send worker registration message
|
|
725
|
-
await this.send({
|
|
726
|
-
type: 'worker:register',
|
|
727
|
-
workerId,
|
|
728
|
-
queues
|
|
729
|
-
});
|
|
730
|
-
} catch (error) {
|
|
731
|
-
this.error(`Failed to register worker: ${error}`);
|
|
732
|
-
throw new Error(`Failed to register worker: ${error}`);
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
/**
|
|
737
|
-
* Unregister a worker from this transport
|
|
738
|
-
*/
|
|
739
|
-
async unregisterWorker(workerId: string): Promise<void> {
|
|
740
|
-
try {
|
|
741
|
-
// Remove worker from Redis using raw command
|
|
742
|
-
const workerKey = this.getWorkerKey(workerId);
|
|
743
|
-
await this.redisCommand('DEL', [workerKey]);
|
|
744
|
-
|
|
745
|
-
// Send worker unregistration message
|
|
746
|
-
await this.send({
|
|
747
|
-
type: 'worker:unregister',
|
|
748
|
-
workerId
|
|
749
|
-
});
|
|
750
|
-
|
|
751
|
-
this.debug(`Unregistered worker ${workerId}`);
|
|
752
|
-
} catch (error) {
|
|
753
|
-
this.error(`Failed to unregister worker: ${error}`);
|
|
754
|
-
throw new Error(`Failed to unregister worker: ${error}`);
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
/**
|
|
759
|
-
* Start polling Redis for messages
|
|
760
|
-
*/
|
|
761
|
-
private startPolling(): void {
|
|
762
|
-
this.pollTimer = setInterval(() => {
|
|
763
|
-
this.pollMessages().catch((error) => {
|
|
764
|
-
this.error(`Error polling messages: ${error}`);
|
|
765
|
-
});
|
|
766
|
-
}, this.options.pollInterval);
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
/**
|
|
770
|
-
* Poll for new messages
|
|
771
|
-
*/
|
|
772
|
-
private async pollMessages(): Promise<void> {
|
|
773
|
-
if (this.mode !== 'consumer') {
|
|
774
|
-
return;
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
// Poll each queue stream
|
|
778
|
-
const queues = await this.getRegisteredQueues();
|
|
779
|
-
|
|
780
|
-
for (const queue of queues) {
|
|
781
|
-
const streamName = this.getStreamKey(queue);
|
|
782
|
-
|
|
783
|
-
try {
|
|
784
|
-
// Ensure consumer group exists
|
|
785
|
-
await this.ensureConsumerGroupExists(streamName);
|
|
786
|
-
|
|
787
|
-
let messages: { [stream: string]: Array<[string, string[]]> };
|
|
788
|
-
|
|
789
|
-
if (this.checkLogs.get(queue) ?? true) {
|
|
790
|
-
// Read pending messages first using raw command
|
|
791
|
-
messages =
|
|
792
|
-
(await this.redisCommand('XREADGROUP', [
|
|
793
|
-
'GROUP',
|
|
794
|
-
this.options.consumerGroup,
|
|
795
|
-
this.consumerId,
|
|
796
|
-
'COUNT',
|
|
797
|
-
this.options.batchSize,
|
|
798
|
-
'STREAMS',
|
|
799
|
-
streamName,
|
|
800
|
-
0
|
|
801
|
-
])) ?? {};
|
|
802
|
-
} else {
|
|
803
|
-
// Then read new messages using raw command
|
|
804
|
-
messages =
|
|
805
|
-
(await this.redisCommand('XREADGROUP', [
|
|
806
|
-
'GROUP',
|
|
807
|
-
this.options.consumerGroup,
|
|
808
|
-
this.consumerId,
|
|
809
|
-
'COUNT',
|
|
810
|
-
this.options.batchSize,
|
|
811
|
-
'STREAMS',
|
|
812
|
-
streamName,
|
|
813
|
-
'>'
|
|
814
|
-
])) ?? {};
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
await this.processMessages(messages[streamName], queue);
|
|
818
|
-
} catch (error) {
|
|
819
|
-
console.error(error);
|
|
820
|
-
if (!String(error).includes('timeout')) {
|
|
821
|
-
this.error(`Error reading from stream ${streamName}: ${error}`);
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
/**
|
|
828
|
-
* Process messages from Redis stream
|
|
829
|
-
*/
|
|
830
|
-
private async processMessages(messages: Array<[string, string[]]>, queue: string): Promise<void> {
|
|
831
|
-
if (!messages || messages.length === 0) {
|
|
832
|
-
this.checkLogs.set(queue, !(this.checkLogs.get(queue) ?? true));
|
|
833
|
-
return;
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
const streamName = this.getStreamKey(queue);
|
|
837
|
-
|
|
838
|
-
for (const [messageId, fields] of messages) {
|
|
839
|
-
try {
|
|
840
|
-
// Convert Redis hash format back to message object
|
|
841
|
-
const message = this.deserializeMessage(fields, queue);
|
|
842
|
-
|
|
843
|
-
// Update last processed ID
|
|
844
|
-
this.lastIds.set(streamName, messageId);
|
|
845
|
-
|
|
846
|
-
// For job:process messages, first check if this is a duplicate
|
|
847
|
-
if (message.type === 'job:process') {
|
|
848
|
-
// Check if we already have status for this job
|
|
849
|
-
const statusKey = this.getJobStatusKey(message.jobId, message.dispatchId, queue);
|
|
850
|
-
const exists = await this.redisCommand('EXISTS', [statusKey]);
|
|
851
|
-
|
|
852
|
-
if (exists) {
|
|
853
|
-
const statusInfo = await this.getJobStatus(message.jobId, message.dispatchId, queue);
|
|
854
|
-
if (statusInfo.status !== JobStatus.PENDING) {
|
|
855
|
-
// Job already exists, skip processing
|
|
856
|
-
this.debug(`Skipping duplicate job message for ${message.jobId}`);
|
|
857
|
-
await this.redisCommand('XACK', [streamName, this.options.consumerGroup, messageId]);
|
|
858
|
-
continue;
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
// Trigger message handlers
|
|
864
|
-
for (const handler of this.messageHandlers) {
|
|
865
|
-
try {
|
|
866
|
-
await handler(message);
|
|
867
|
-
} catch (error) {
|
|
868
|
-
this.error(`Error in message handler: ${error}`);
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
// Track completed or failed job messages for cleanup
|
|
873
|
-
if (
|
|
874
|
-
(message.type === 'job:result' || message.type === 'job:status') &&
|
|
875
|
-
(message.status === JobStatus.COMPLETED ||
|
|
876
|
-
message.status === JobStatus.FAILED ||
|
|
877
|
-
message.status === JobStatus.CANCELLED)
|
|
878
|
-
) {
|
|
879
|
-
this.trackCompletedMessage(messageId, streamName);
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
// Acknowledge message
|
|
883
|
-
await this.redisCommand('XACK', [streamName, this.options.consumerGroup, messageId]);
|
|
884
|
-
} catch (error) {
|
|
885
|
-
this.error(`Error processing message ${messageId}: ${error}`);
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
/**
|
|
891
|
-
* Get all registered queues
|
|
892
|
-
*/
|
|
893
|
-
private async getRegisteredQueues(): Promise<string[]> {
|
|
894
|
-
try {
|
|
895
|
-
const pattern = `${this.options.keyPrefix}:stream:*`;
|
|
896
|
-
const keys = await this.redisCommand('KEYS', [pattern]);
|
|
897
|
-
|
|
898
|
-
// Ensure keys is an array
|
|
899
|
-
if (!Array.isArray(keys)) {
|
|
900
|
-
return ['default'];
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
return keys
|
|
904
|
-
.map((key) => key.replace(`${this.options.keyPrefix}:stream:`, ''))
|
|
905
|
-
.filter(Boolean);
|
|
906
|
-
} catch (error) {
|
|
907
|
-
this.error(`Failed to get registered queues: ${error}`);
|
|
908
|
-
return ['default']; // Return default queue on error
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
/**
|
|
913
|
-
* Ensure a stream exists
|
|
914
|
-
*/
|
|
915
|
-
private async ensureStreamExists(streamName: string): Promise<void> {
|
|
916
|
-
try {
|
|
917
|
-
// Check if stream exists by trying to get info using raw command
|
|
918
|
-
const streamInfo = await this.redisCommand('XINFO', ['STREAM', streamName]).catch(() => null);
|
|
919
|
-
|
|
920
|
-
if (!streamInfo) {
|
|
921
|
-
// Create stream with a dummy message that we'll remove
|
|
922
|
-
const messageId = await this.redisCommand('XADD', [streamName, '*', 'init', 'true']);
|
|
923
|
-
await this.redisCommand('XDEL', [streamName, messageId]);
|
|
924
|
-
this.debug(`Created stream ${streamName}`);
|
|
925
|
-
}
|
|
926
|
-
} catch (error) {
|
|
927
|
-
this.error(`Failed to ensure stream ${streamName} exists: ${error}`);
|
|
928
|
-
// Don't throw - just log the error and continue
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
/**
|
|
933
|
-
* Ensure a consumer group exists for a stream
|
|
934
|
-
*/
|
|
935
|
-
private async ensureConsumerGroupExists(streamName: string): Promise<void> {
|
|
936
|
-
// Skip if already created
|
|
937
|
-
if (this.consumerGroupCreated.has(streamName)) {
|
|
938
|
-
return;
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
try {
|
|
942
|
-
// Check if consumer group exists using raw command
|
|
943
|
-
const groups = await this.redisCommand('XINFO', ['GROUPS', streamName]).catch(() => []);
|
|
944
|
-
|
|
945
|
-
const groupExists =
|
|
946
|
-
Array.isArray(groups) && groups.some((group) => group[1] === this.options.consumerGroup);
|
|
947
|
-
|
|
948
|
-
if (!groupExists) {
|
|
949
|
-
// Create consumer group from the beginning of the stream using raw command
|
|
950
|
-
await this.redisCommand('XGROUP', [
|
|
951
|
-
'CREATE',
|
|
952
|
-
streamName,
|
|
953
|
-
this.options.consumerGroup,
|
|
954
|
-
'0',
|
|
955
|
-
'MKSTREAM'
|
|
956
|
-
]);
|
|
957
|
-
this.debug(`Created consumer group ${this.options.consumerGroup} for stream ${streamName}`);
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
// Mark as created
|
|
961
|
-
this.consumerGroupCreated.add(streamName);
|
|
962
|
-
} catch (error: any) {
|
|
963
|
-
// Check if this is a BUSYGROUP error
|
|
964
|
-
if (error.toString().includes('BUSYGROUP')) {
|
|
965
|
-
// Group already exists, which is fine
|
|
966
|
-
this.debug(
|
|
967
|
-
`Consumer group ${this.options.consumerGroup} already exists for stream ${streamName}`
|
|
968
|
-
);
|
|
969
|
-
|
|
970
|
-
// Mark as created since it exists
|
|
971
|
-
this.consumerGroupCreated.add(streamName);
|
|
972
|
-
} else {
|
|
973
|
-
// This is a different error, log and rethrow
|
|
974
|
-
this.error(`Failed to ensure consumer group exists: ${error}`);
|
|
975
|
-
throw error;
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
/**
|
|
981
|
-
* Store job status in Redis
|
|
982
|
-
*/
|
|
983
|
-
private async storeJobStatus(status: JobStatusInfo): Promise<void> {
|
|
984
|
-
try {
|
|
985
|
-
const statusKey = this.getJobStatusKey(status.jobId, status.dispatchId, status.queue);
|
|
986
|
-
|
|
987
|
-
// Store in Redis hash using raw command
|
|
988
|
-
// Build array of hash field-value pairs
|
|
989
|
-
const hashFields: string[] = [
|
|
990
|
-
'jobId',
|
|
991
|
-
status.jobId,
|
|
992
|
-
'queue',
|
|
993
|
-
status.queue,
|
|
994
|
-
'status',
|
|
995
|
-
status.status,
|
|
996
|
-
'retries',
|
|
997
|
-
status.retries.toString(),
|
|
998
|
-
'createdAt',
|
|
999
|
-
status.createdAt
|
|
1000
|
-
];
|
|
1001
|
-
|
|
1002
|
-
// Add optional fields if present
|
|
1003
|
-
if (status.startedAt) {
|
|
1004
|
-
hashFields.push('startedAt', status.startedAt);
|
|
1005
|
-
}
|
|
1006
|
-
if (status.completedAt) {
|
|
1007
|
-
hashFields.push('completedAt', status.completedAt);
|
|
1008
|
-
}
|
|
1009
|
-
if (status.error) {
|
|
1010
|
-
hashFields.push('error', status.error);
|
|
1011
|
-
}
|
|
1012
|
-
if (status.messageId) {
|
|
1013
|
-
hashFields.push('messageId', status.messageId);
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
// Ensure updatedAt is set
|
|
1017
|
-
const updatedAt = status.updatedAt || Date.now().toString();
|
|
1018
|
-
hashFields.push('updatedAt', updatedAt);
|
|
1019
|
-
|
|
1020
|
-
await this.redisCommand('HSET', [statusKey, ...hashFields]);
|
|
1021
|
-
|
|
1022
|
-
// Set TTL using raw command
|
|
1023
|
-
await this.redisCommand('EXPIRE', [statusKey, this.options.statusTTL]);
|
|
1024
|
-
|
|
1025
|
-
// If this is a new job with an initial status, notify any handlers
|
|
1026
|
-
if (status.status === JobStatus.PENDING || status.status === JobStatus.SCHEDULED_FOR_RETRY) {
|
|
1027
|
-
for (const handler of this.messageHandlers) {
|
|
1028
|
-
try {
|
|
1029
|
-
handler({
|
|
1030
|
-
type: 'job:status',
|
|
1031
|
-
jobId: status.jobId,
|
|
1032
|
-
dispatchId: status.dispatchId,
|
|
1033
|
-
queue: status.queue,
|
|
1034
|
-
status: status.status,
|
|
1035
|
-
createdAt: status.createdAt,
|
|
1036
|
-
updatedAt
|
|
1037
|
-
});
|
|
1038
|
-
} catch (error) {
|
|
1039
|
-
this.error(`Error in message handler: ${error}`);
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
// If this job is completed, failed, or cancelled, mark for stream cleanup
|
|
1045
|
-
if (
|
|
1046
|
-
status.status === JobStatus.COMPLETED ||
|
|
1047
|
-
status.status === JobStatus.FAILED ||
|
|
1048
|
-
status.status === JobStatus.CANCELLED
|
|
1049
|
-
) {
|
|
1050
|
-
// Try to find a message ID if we don't already have one
|
|
1051
|
-
let messageId: string | null = status.messageId ?? null;
|
|
1052
|
-
if (!messageId) {
|
|
1053
|
-
messageId = await this.findJobMessageId(status.jobId, status.dispatchId, status.queue);
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
if (messageId) {
|
|
1057
|
-
const streamKey = this.getStreamKey(status.queue);
|
|
1058
|
-
this.trackCompletedMessage(messageId, streamKey);
|
|
1059
|
-
|
|
1060
|
-
// Notify handlers of job completion
|
|
1061
|
-
for (const handler of this.messageHandlers) {
|
|
1062
|
-
try {
|
|
1063
|
-
handler({
|
|
1064
|
-
type: 'job:result',
|
|
1065
|
-
jobId: status.jobId,
|
|
1066
|
-
dispatchId: status.dispatchId,
|
|
1067
|
-
queue: status.queue,
|
|
1068
|
-
status: status.status,
|
|
1069
|
-
error: status.error,
|
|
1070
|
-
completedAt: status.completedAt || new Date().toISOString(),
|
|
1071
|
-
retries: status.retries
|
|
1072
|
-
});
|
|
1073
|
-
} catch (error) {
|
|
1074
|
-
this.error(`Error in message handler: ${error}`);
|
|
1075
|
-
}
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
} catch (error) {
|
|
1080
|
-
this.error(`Failed to store job status: ${error}`);
|
|
1081
|
-
throw new Error(`Failed to store job status: ${error}`);
|
|
1082
|
-
}
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
/**
|
|
1086
|
-
* Serialize a message for Redis
|
|
1087
|
-
*/
|
|
1088
|
-
private serializeMessage(message: TransportEvent): string[] {
|
|
1089
|
-
const fields: string[] = ['type', message.type, 'timestamp', Date.now().toString()];
|
|
1090
|
-
|
|
1091
|
-
// Add queue if present in message type
|
|
1092
|
-
if ('queue' in message && typeof message.queue === 'string') {
|
|
1093
|
-
fields.push('queue', message.queue);
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
// Add message-specific fields
|
|
1097
|
-
switch (message.type) {
|
|
1098
|
-
case 'job:process':
|
|
1099
|
-
fields.push(
|
|
1100
|
-
'job',
|
|
1101
|
-
message.job,
|
|
1102
|
-
'args',
|
|
1103
|
-
JSON.stringify(message.args || []),
|
|
1104
|
-
'jobId',
|
|
1105
|
-
message.jobId,
|
|
1106
|
-
'dispatchId',
|
|
1107
|
-
message.dispatchId
|
|
1108
|
-
);
|
|
1109
|
-
|
|
1110
|
-
// Add options if present
|
|
1111
|
-
if (message.options) {
|
|
1112
|
-
fields.push('options', JSON.stringify(message.options));
|
|
1113
|
-
}
|
|
1114
|
-
break;
|
|
1115
|
-
|
|
1116
|
-
case 'job:cancel':
|
|
1117
|
-
case 'job:status':
|
|
1118
|
-
case 'job:result':
|
|
1119
|
-
fields.push('jobId', message.jobId);
|
|
1120
|
-
fields.push('dispatchId', message.dispatchId);
|
|
1121
|
-
if (message.type !== 'job:cancel' && message.status) {
|
|
1122
|
-
fields.push('status', message.status);
|
|
1123
|
-
}
|
|
1124
|
-
if (message.type !== 'job:cancel' && message.error) {
|
|
1125
|
-
fields.push('error', message.error);
|
|
1126
|
-
}
|
|
1127
|
-
if ((message as any).retries !== undefined) {
|
|
1128
|
-
fields.push('retries', (message as any).retries);
|
|
1129
|
-
}
|
|
1130
|
-
if ((message as any).startedAt) {
|
|
1131
|
-
fields.push('startedAt', (message as any).startedAt);
|
|
1132
|
-
}
|
|
1133
|
-
if ((message as any).completedAt) {
|
|
1134
|
-
fields.push('completedAt', (message as any).completedAt);
|
|
1135
|
-
}
|
|
1136
|
-
break;
|
|
1137
|
-
|
|
1138
|
-
case 'job:cancelAll':
|
|
1139
|
-
// No additional fields needed
|
|
1140
|
-
break;
|
|
1141
|
-
|
|
1142
|
-
case 'worker:register':
|
|
1143
|
-
fields.push('workerId', message.workerId, 'queues', JSON.stringify(message.queues ?? []));
|
|
1144
|
-
break;
|
|
1145
|
-
|
|
1146
|
-
case 'worker:unregister':
|
|
1147
|
-
fields.push('workerId', message.workerId);
|
|
1148
|
-
break;
|
|
1149
|
-
|
|
1150
|
-
case 'worker:status':
|
|
1151
|
-
fields.push(
|
|
1152
|
-
'workerId',
|
|
1153
|
-
message.workerId,
|
|
1154
|
-
'status',
|
|
1155
|
-
message.status,
|
|
1156
|
-
'queues',
|
|
1157
|
-
JSON.stringify(message.queues),
|
|
1158
|
-
'processing',
|
|
1159
|
-
message.processing.toString(),
|
|
1160
|
-
'waiting',
|
|
1161
|
-
message.waiting.toString()
|
|
1162
|
-
);
|
|
1163
|
-
break;
|
|
1164
|
-
|
|
1165
|
-
case 'worker:ready':
|
|
1166
|
-
fields.push('id', message.id, 'queues', JSON.stringify(message.queues));
|
|
1167
|
-
if (message.status) {
|
|
1168
|
-
fields.push('status', message.status);
|
|
1169
|
-
}
|
|
1170
|
-
if (message.timestamp) {
|
|
1171
|
-
fields.push('timestamp', message.timestamp.toString());
|
|
1172
|
-
}
|
|
1173
|
-
break;
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
return fields;
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
/**
|
|
1180
|
-
* Deserialize a message from Redis
|
|
1181
|
-
*/
|
|
1182
|
-
private deserializeMessage(fields: any[], queue: string): TransportEvent {
|
|
1183
|
-
// Convert array of field/value pairs to object
|
|
1184
|
-
const data: Record<string, string> = {};
|
|
1185
|
-
for (let i = 0; i < fields.length; i += 2) {
|
|
1186
|
-
data[fields[i]] = fields[i + 1];
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
const type = data.type as any;
|
|
1190
|
-
const baseMessage = { type, queue: data.queue || queue };
|
|
1191
|
-
|
|
1192
|
-
switch (type) {
|
|
1193
|
-
case 'job:process':
|
|
1194
|
-
return {
|
|
1195
|
-
...baseMessage,
|
|
1196
|
-
type: 'job:process',
|
|
1197
|
-
job: data.job,
|
|
1198
|
-
args: JSON.parse(data.args || '[]'),
|
|
1199
|
-
options: data.options ? JSON.parse(data.options) : undefined,
|
|
1200
|
-
jobId: data.jobId,
|
|
1201
|
-
dispatchId: data.dispatchId
|
|
1202
|
-
};
|
|
1203
|
-
|
|
1204
|
-
case 'job:cancel':
|
|
1205
|
-
return {
|
|
1206
|
-
...baseMessage,
|
|
1207
|
-
type: 'job:cancel',
|
|
1208
|
-
jobId: data.jobId,
|
|
1209
|
-
dispatchId: data.dispatchId
|
|
1210
|
-
};
|
|
1211
|
-
|
|
1212
|
-
case 'job:cancelAll':
|
|
1213
|
-
return {
|
|
1214
|
-
...baseMessage,
|
|
1215
|
-
type: 'job:cancelAll'
|
|
1216
|
-
};
|
|
1217
|
-
|
|
1218
|
-
case 'job:status':
|
|
1219
|
-
return {
|
|
1220
|
-
...baseMessage,
|
|
1221
|
-
type: 'job:status',
|
|
1222
|
-
jobId: data.jobId,
|
|
1223
|
-
dispatchId: data.dispatchId,
|
|
1224
|
-
status: data.status,
|
|
1225
|
-
error: data.error,
|
|
1226
|
-
retries: data.retries ? parseInt(data.retries, 10) : 0,
|
|
1227
|
-
startedAt: data.startedAt,
|
|
1228
|
-
completedAt: data.completedAt
|
|
1229
|
-
};
|
|
1230
|
-
|
|
1231
|
-
case 'job:result':
|
|
1232
|
-
return {
|
|
1233
|
-
...baseMessage,
|
|
1234
|
-
type: 'job:result',
|
|
1235
|
-
jobId: data.jobId,
|
|
1236
|
-
dispatchId: data.dispatchId,
|
|
1237
|
-
status: data.status,
|
|
1238
|
-
error: data.error,
|
|
1239
|
-
completedAt: data.completedAt || new Date().toISOString()
|
|
1240
|
-
};
|
|
1241
|
-
|
|
1242
|
-
case 'worker:register':
|
|
1243
|
-
return {
|
|
1244
|
-
...baseMessage,
|
|
1245
|
-
type: 'worker:register',
|
|
1246
|
-
workerId: data.workerId,
|
|
1247
|
-
queues: data.queues ? JSON.parse(data.queues) : []
|
|
1248
|
-
};
|
|
1249
|
-
|
|
1250
|
-
case 'worker:unregister':
|
|
1251
|
-
return {
|
|
1252
|
-
...baseMessage,
|
|
1253
|
-
type: 'worker:unregister',
|
|
1254
|
-
workerId: data.workerId
|
|
1255
|
-
};
|
|
1256
|
-
|
|
1257
|
-
case 'worker:status':
|
|
1258
|
-
return {
|
|
1259
|
-
...baseMessage,
|
|
1260
|
-
type: 'worker:status',
|
|
1261
|
-
workerId: data.workerId,
|
|
1262
|
-
queues: data.queues ? JSON.parse(data.queues) : [],
|
|
1263
|
-
status: data.status as any,
|
|
1264
|
-
processing: parseInt(data.processing || '0', 10),
|
|
1265
|
-
waiting: parseInt(data.waiting || '0', 10)
|
|
1266
|
-
};
|
|
1267
|
-
|
|
1268
|
-
case 'worker:ready':
|
|
1269
|
-
return {
|
|
1270
|
-
...baseMessage,
|
|
1271
|
-
type: 'worker:ready',
|
|
1272
|
-
id: data.id,
|
|
1273
|
-
queues: data.queues ? JSON.parse(data.queues) : [],
|
|
1274
|
-
status: data.status,
|
|
1275
|
-
timestamp: data.timestamp ? parseInt(data.timestamp, 10) : undefined
|
|
1276
|
-
};
|
|
1277
|
-
|
|
1278
|
-
default:
|
|
1279
|
-
// Unknown message type
|
|
1280
|
-
return baseMessage as any;
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
/**
|
|
1285
|
-
* Get Redis key for a stream
|
|
1286
|
-
*/
|
|
1287
|
-
private getStreamKey(queue: string): string {
|
|
1288
|
-
return `${this.options.keyPrefix}:stream:${queue || 'default'}`;
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
/**
|
|
1292
|
-
* Get Redis key for job status
|
|
1293
|
-
*/
|
|
1294
|
-
private getJobStatusKey(jobId: string, dispatchId: string, queue: string): string {
|
|
1295
|
-
return `${this.options.keyPrefix}:status:${queue}:${jobId}:${dispatchId}`;
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
/**
|
|
1299
|
-
* Get Redis key for worker status
|
|
1300
|
-
*/
|
|
1301
|
-
private getWorkerKey(workerId: string): string {
|
|
1302
|
-
return `${this.options.keyPrefix}:worker:${workerId}`;
|
|
1303
|
-
}
|
|
1304
|
-
|
|
1305
|
-
/**
|
|
1306
|
-
* Track completed message for later cleanup
|
|
1307
|
-
*/
|
|
1308
|
-
private trackCompletedMessage(messageId: string, streamKey: string): void {
|
|
1309
|
-
// Only track if cleanup is enabled
|
|
1310
|
-
if (this.options.cleanupCompletedJobs) {
|
|
1311
|
-
this.completedMessageIds.set(messageId, {
|
|
1312
|
-
streamKey,
|
|
1313
|
-
timestamp: Date.now()
|
|
1314
|
-
});
|
|
1315
|
-
this.debug(`Marked message ${messageId} for future cleanup`);
|
|
1316
|
-
}
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
/**
|
|
1320
|
-
* Schedule periodic stream cleanup
|
|
1321
|
-
*/
|
|
1322
|
-
private scheduleStreamCleanup(): void {
|
|
1323
|
-
if (this.cleanupTimer) {
|
|
1324
|
-
clearInterval(this.cleanupTimer);
|
|
1325
|
-
}
|
|
1326
|
-
|
|
1327
|
-
// Run cleanup every 5 minutes
|
|
1328
|
-
const cleanupInterval = 5 * 60 * 1000;
|
|
1329
|
-
|
|
1330
|
-
this.cleanupTimer = setInterval(() => {
|
|
1331
|
-
this.cleanupStreams().catch((error) => {
|
|
1332
|
-
this.error(`Error during stream cleanup: ${error}`);
|
|
1333
|
-
});
|
|
1334
|
-
}, cleanupInterval);
|
|
1335
|
-
|
|
1336
|
-
this.debug(`Scheduled stream cleanup every ${cleanupInterval / 1000} seconds`);
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
/**
|
|
1340
|
-
* Find expired message IDs for cleanup
|
|
1341
|
-
*/
|
|
1342
|
-
private async cleanupStreams(): Promise<void> {
|
|
1343
|
-
if (!this.options.cleanupCompletedJobs) {
|
|
1344
|
-
return;
|
|
1345
|
-
}
|
|
1346
|
-
|
|
1347
|
-
this.debug('Starting stream cleanup for completed jobs');
|
|
1348
|
-
|
|
1349
|
-
const now = Date.now();
|
|
1350
|
-
const retentionMs = this.options.completedJobRetention * 1000;
|
|
1351
|
-
const expiredIds = new Map<string, string[]>(); // streamKey -> message IDs
|
|
1352
|
-
const expiredStatusKeys: string[] = []; // status keys to delete
|
|
1353
|
-
|
|
1354
|
-
// Find all queues
|
|
1355
|
-
const queues = await this.getRegisteredQueues();
|
|
1356
|
-
|
|
1357
|
-
// For each queue, find completed jobs older than retention period
|
|
1358
|
-
for (const queueName of queues) {
|
|
1359
|
-
const streamKey = this.getStreamKey(queueName);
|
|
1360
|
-
|
|
1361
|
-
try {
|
|
1362
|
-
// Get all status keys for this queue
|
|
1363
|
-
const statusPattern = `${this.options.keyPrefix}:status:${queueName}:*`;
|
|
1364
|
-
const statusKeys: string[] = await this.redisCommand('KEYS', [statusPattern]);
|
|
1365
|
-
|
|
1366
|
-
for (const statusKey of statusKeys) {
|
|
1367
|
-
// Get job status
|
|
1368
|
-
const statusData = await this.redisCommand('HGETALL', [statusKey]);
|
|
1369
|
-
if (!statusData || statusData.length === 0) continue;
|
|
1370
|
-
|
|
1371
|
-
// Convert to object
|
|
1372
|
-
const status: Record<string, string> = {};
|
|
1373
|
-
for (let i = 0; i < statusData.length; i += 2) {
|
|
1374
|
-
status[statusData[i]] = statusData[i + 1];
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
// Check if job is completed and older than retention period
|
|
1378
|
-
if (
|
|
1379
|
-
(status.status === JobStatus.COMPLETED ||
|
|
1380
|
-
status.status === JobStatus.FAILED ||
|
|
1381
|
-
status.status === JobStatus.CANCELLED) &&
|
|
1382
|
-
status.updatedAt &&
|
|
1383
|
-
now - parseInt(status.updatedAt) > retentionMs
|
|
1384
|
-
) {
|
|
1385
|
-
// Extract job ID from status key
|
|
1386
|
-
const parts = statusKey.split(':');
|
|
1387
|
-
const dispatchId = parts.pop()!;
|
|
1388
|
-
const jobId = parts.pop()!;
|
|
1389
|
-
|
|
1390
|
-
// Add status key to list for deletion
|
|
1391
|
-
expiredStatusKeys.push(statusKey);
|
|
1392
|
-
|
|
1393
|
-
// If we have the message ID stored, use it directly
|
|
1394
|
-
if (status.messageId) {
|
|
1395
|
-
if (!expiredIds.has(streamKey)) {
|
|
1396
|
-
expiredIds.set(streamKey, []);
|
|
1397
|
-
}
|
|
1398
|
-
expiredIds.get(streamKey)!.push(status.messageId);
|
|
1399
|
-
} else {
|
|
1400
|
-
// Otherwise try to find the message ID
|
|
1401
|
-
const messageId = await this.findJobMessageId(jobId, dispatchId, queueName);
|
|
1402
|
-
|
|
1403
|
-
if (messageId) {
|
|
1404
|
-
if (!expiredIds.has(streamKey)) {
|
|
1405
|
-
expiredIds.set(streamKey, []);
|
|
1406
|
-
}
|
|
1407
|
-
expiredIds.get(streamKey)!.push(messageId);
|
|
1408
|
-
}
|
|
1409
|
-
}
|
|
1410
|
-
}
|
|
1411
|
-
}
|
|
1412
|
-
} catch (error) {
|
|
1413
|
-
this.error(`Error finding completed jobs for queue ${queueName}: ${error}`);
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
// Delete expired messages from each stream
|
|
1418
|
-
for (const [streamKey, messageIds] of expiredIds.entries()) {
|
|
1419
|
-
if (messageIds.length > 0) {
|
|
1420
|
-
try {
|
|
1421
|
-
// Delete messages in batches of 100
|
|
1422
|
-
for (let i = 0; i < messageIds.length; i += 100) {
|
|
1423
|
-
const batch = messageIds.slice(i, i + 100);
|
|
1424
|
-
await this.redisCommand('XDEL', [streamKey, ...batch]);
|
|
1425
|
-
}
|
|
1426
|
-
|
|
1427
|
-
this.debug(`Cleaned up ${messageIds.length} completed messages from stream ${streamKey}`);
|
|
1428
|
-
|
|
1429
|
-
// Remove from tracking map
|
|
1430
|
-
for (const messageId of messageIds) {
|
|
1431
|
-
this.completedMessageIds.delete(messageId);
|
|
1432
|
-
}
|
|
1433
|
-
} catch (error) {
|
|
1434
|
-
this.error(`Error deleting completed messages from stream ${streamKey}: ${error}`);
|
|
1435
|
-
}
|
|
1436
|
-
}
|
|
1437
|
-
}
|
|
1438
|
-
|
|
1439
|
-
// Delete expired status keys
|
|
1440
|
-
if (expiredStatusKeys.length > 0) {
|
|
1441
|
-
try {
|
|
1442
|
-
// Delete in batches of 100
|
|
1443
|
-
for (let i = 0; i < expiredStatusKeys.length; i += 100) {
|
|
1444
|
-
const batch = expiredStatusKeys.slice(i, i + 100);
|
|
1445
|
-
await this.redisCommand('DEL', batch);
|
|
1446
|
-
}
|
|
1447
|
-
|
|
1448
|
-
this.debug(`Cleaned up ${expiredStatusKeys.length} job status records`);
|
|
1449
|
-
} catch (error) {
|
|
1450
|
-
this.error(`Error deleting expired status keys: ${error}`);
|
|
1451
|
-
}
|
|
1452
|
-
}
|
|
1453
|
-
|
|
1454
|
-
// Trim all streams to max size if configured
|
|
1455
|
-
if (this.options.maxStreamSize > 0) {
|
|
1456
|
-
await this.trimStreamsToMaxSize();
|
|
1457
|
-
}
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
/**
|
|
1461
|
-
* Trim all streams to the maximum configured size
|
|
1462
|
-
*/
|
|
1463
|
-
private async trimStreamsToMaxSize(): Promise<void> {
|
|
1464
|
-
try {
|
|
1465
|
-
const queues = await this.getRegisteredQueues();
|
|
1466
|
-
|
|
1467
|
-
for (const queue of queues) {
|
|
1468
|
-
const streamKey = this.getStreamKey(queue);
|
|
1469
|
-
|
|
1470
|
-
// Get stream length
|
|
1471
|
-
const streamLength = await this.redisCommand('XLEN', [streamKey]);
|
|
1472
|
-
|
|
1473
|
-
if (streamLength > this.options.maxStreamSize) {
|
|
1474
|
-
// Trim the stream to maxStreamSize entries
|
|
1475
|
-
await this.redisCommand('XTRIM', [
|
|
1476
|
-
streamKey,
|
|
1477
|
-
'MAXLEN',
|
|
1478
|
-
'~', // Approximate trimming for better performance
|
|
1479
|
-
this.options.maxStreamSize.toString()
|
|
1480
|
-
]);
|
|
1481
|
-
|
|
1482
|
-
this.debug(
|
|
1483
|
-
`Trimmed stream ${streamKey} from ${streamLength} to ~${this.options.maxStreamSize} entries`
|
|
1484
|
-
);
|
|
1485
|
-
}
|
|
1486
|
-
}
|
|
1487
|
-
} catch (error) {
|
|
1488
|
-
this.error(`Error trimming streams to max size: ${error}`);
|
|
1489
|
-
}
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
/**
|
|
1493
|
-
* Check if a job with the same ID is currently locked
|
|
1494
|
-
* @param jobId The job ID to check
|
|
1495
|
-
* @param queue The queue name
|
|
1496
|
-
* @returns True if the job is locked, false otherwise
|
|
1497
|
-
*/
|
|
1498
|
-
public async isJobLocked(jobId: string, queue: string): Promise<boolean> {
|
|
1499
|
-
// First check local cache
|
|
1500
|
-
const localLock = this.jobLocks.get(jobId);
|
|
1501
|
-
if (localLock && localLock.lockedUntil > Date.now()) {
|
|
1502
|
-
return true;
|
|
1503
|
-
}
|
|
1504
|
-
|
|
1505
|
-
// Check Redis for distributed lock
|
|
1506
|
-
const lockKey = `${this.options.keyPrefix}:lock:${queue}:${jobId}`;
|
|
1507
|
-
const lockExists = await this.redisCommand('EXISTS', [lockKey]);
|
|
1508
|
-
|
|
1509
|
-
return !!lockExists;
|
|
1510
|
-
}
|
|
1511
|
-
|
|
1512
|
-
/**
|
|
1513
|
-
* Acquire a lock for a job ID
|
|
1514
|
-
* @param jobId The job ID to lock
|
|
1515
|
-
* @param queue The queue name
|
|
1516
|
-
* @param duration Duration in milliseconds to lock the job
|
|
1517
|
-
* @returns True if lock was acquired, false otherwise
|
|
1518
|
-
*/
|
|
1519
|
-
public async acquireJobLock(
|
|
1520
|
-
jobId: string,
|
|
1521
|
-
queue: string,
|
|
1522
|
-
duration: number = 60000
|
|
1523
|
-
): Promise<boolean> {
|
|
1524
|
-
const lockKey = `${this.options.keyPrefix}:lock:${queue}:${jobId}`;
|
|
1525
|
-
|
|
1526
|
-
// Try to set lock in Redis with NX (only if not exists)
|
|
1527
|
-
const result = await this.redisCommand('SET', [
|
|
1528
|
-
lockKey,
|
|
1529
|
-
Date.now().toString(),
|
|
1530
|
-
'PX',
|
|
1531
|
-
duration.toString(),
|
|
1532
|
-
'NX'
|
|
1533
|
-
]);
|
|
1534
|
-
|
|
1535
|
-
if (result === 'OK') {
|
|
1536
|
-
// Also store in local cache
|
|
1537
|
-
this.jobLocks.set(jobId, { lockedUntil: Date.now() + duration, queue });
|
|
1538
|
-
return true;
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
|
-
return false;
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
/**
|
|
1545
|
-
* Release a lock for a job ID
|
|
1546
|
-
* @param jobId The job ID to unlock
|
|
1547
|
-
* @param queue The queue name
|
|
1548
|
-
*/
|
|
1549
|
-
public async releaseJobLock(jobId: string, queue: string): Promise<void> {
|
|
1550
|
-
const lockKey = `${this.options.keyPrefix}:lock:${queue}:${jobId}`;
|
|
1551
|
-
|
|
1552
|
-
// Remove from Redis
|
|
1553
|
-
await this.redisCommand('DEL', [lockKey]);
|
|
1554
|
-
|
|
1555
|
-
// Remove from local cache
|
|
1556
|
-
this.jobLocks.delete(jobId);
|
|
1557
|
-
}
|
|
1558
|
-
}
|