@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.
@@ -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
- }