@elysiumjs/heracles 0.4.3 → 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,523 +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 { JobClass } from '../job';
16
- import type { JobDispatchOptions, QueueOptions } from '../queue';
17
- import type { TransportEvent } from '../transport';
18
-
19
- import { Service } from '@elysiumjs/core';
20
-
21
- import { Job, JobOverlapBehavior, JobStatus, WithId } from '../job';
22
- import { TransportMode } from '../transport';
23
- import { RedisTransport } from '../transports/redis.transport';
24
- import { getJobMetadata } from '../utils';
25
- import { BaseWorker } from './base.worker';
26
-
27
- /**
28
- * Worker implementation that uses Redis for communication.
29
- * This worker can run in a separate process from the dispatcher.
30
- * @author Axel Nana <axel.nana@workbud.com>
31
- */
32
- export class RedisWorker extends BaseWorker {
33
- /**
34
- * Transport instance used for Redis communication
35
- */
36
- private transport: RedisTransport;
37
-
38
- /**
39
- * Whether the worker is currently running
40
- */
41
- private isRunning: boolean = false;
42
-
43
- /**
44
- * Queues registered with this worker
45
- */
46
- private registeredQueues: Set<string> = new Set();
47
-
48
- /**
49
- * Creates a new RedisWorker instance
50
- * @param connectionName Name of the Redis connection to use
51
- * @param options Additional options for the Redis transport
52
- */
53
- constructor(connectionName: string = 'default', options: Record<string, any> = {}) {
54
- super(options.id);
55
-
56
- // Create transport in consumer mode
57
- this.transport = new RedisTransport(TransportMode.CONSUMER, {
58
- connection: connectionName,
59
- consumerName: `worker-${this.id}`,
60
- ...options
61
- });
62
-
63
- // Set up message handling
64
- this.transport.onMessage(this.handleTransportMessage.bind(this));
65
-
66
- this.debug(`RedisWorker ${this.id} created with connection: ${connectionName}`);
67
- }
68
-
69
- /**
70
- * Start the worker and begin processing jobs
71
- */
72
- public async start(): Promise<void> {
73
- // Call parent implementation first
74
- await super.start();
75
-
76
- if (this.isRunning) {
77
- return;
78
- }
79
-
80
- this.isRunning = true;
81
-
82
- // Store registered queues
83
- for (const queueName of this.queues.keys()) {
84
- this.registeredQueues.add(queueName);
85
- }
86
-
87
- // Start the transport
88
- await this.transport.start();
89
-
90
- // Register worker with queues
91
- await this.transport.registerWorker(this.id, Array.from(this.registeredQueues));
92
-
93
- this.info(
94
- `Redis worker ${this.id} started for queues: ${Array.from(this.registeredQueues).join(', ')}`
95
- );
96
- }
97
-
98
- /**
99
- * Stop the worker
100
- * @param force If true, cancel all jobs in progress
101
- */
102
- public async stop(force: boolean = false): Promise<void> {
103
- this.isRunning = false;
104
-
105
- // Call parent implementation
106
- await super.stop(force);
107
-
108
- // Unregister worker
109
- await this.transport.unregisterWorker(this.id);
110
-
111
- // Stop the transport
112
- await this.transport.stop();
113
-
114
- this.info(`Redis worker ${this.id} stopped`);
115
- }
116
-
117
- /**
118
- * Process the next job in a queue, respecting overlap settings
119
- * @param queueName Name of the queue
120
- */
121
- protected async processNextJob(queueName: string): Promise<void> {
122
- const queue = this.queues.get(queueName);
123
- if (!queue || queue.paused || queue.processing >= queue.options.concurrency!) {
124
- return;
125
- }
126
-
127
- // Find the next eligible job based on overlap behavior
128
- let eligibleJobIndex = -1;
129
-
130
- const now = new Date();
131
-
132
- for (let i = 0; i < queue.jobs.length; i++) {
133
- const queuedJob = queue.jobs[i];
134
-
135
- // Skip jobs scheduled for the future
136
- if (queuedJob.scheduledFor && queuedJob.scheduledFor > now) {
137
- continue;
138
- }
139
-
140
- // Remove cancelled jobs
141
- if (queuedJob.job.status === JobStatus.CANCELLED) {
142
- eligibleJobIndex = i;
143
- break;
144
- }
145
-
146
- // Check job overlap constraints
147
- const jobOptions = queuedJob.job.constructor as JobClass<Job>;
148
- const metadata = getJobMetadata(jobOptions);
149
-
150
- // If this job has NO_OVERLAP behavior, check if another job with the same ID is running
151
- if (metadata?.overlapBehavior === JobOverlapBehavior.NO_OVERLAP) {
152
- const isLocked = await this.transport.isJobLocked(queuedJob.job.id, queueName);
153
-
154
- if (isLocked) {
155
- // Skip this job for now, as another job with the same ID is running
156
- continue;
157
- }
158
- }
159
-
160
- // This job is eligible for processing
161
- eligibleJobIndex = i;
162
- break;
163
- }
164
-
165
- // If no eligible job was found, return
166
- if (eligibleJobIndex === -1) {
167
- return;
168
- }
169
-
170
- // Get the eligible job and remove it from the queue
171
- const queuedJob = queue.jobs.splice(eligibleJobIndex, 1)[0];
172
-
173
- if (!queuedJob) {
174
- return;
175
- }
176
-
177
- // Skip cancelled jobs
178
- if (queuedJob.job.status === JobStatus.CANCELLED) {
179
- this.info(`Skipping cancelled job ${queuedJob.job.id}`);
180
-
181
- await this.sendJobStatusUpdate(
182
- queuedJob.job.id,
183
- queuedJob.job.dispatchId,
184
- queueName,
185
- JobStatus.CANCELLED
186
- );
187
-
188
- return this.processQueueJobs(queueName);
189
- }
190
-
191
- // If job has NO_OVERLAP behavior, acquire a lock
192
- const jobOptions = queuedJob.job.constructor as JobClass<Job>;
193
- const metadata = getJobMetadata(jobOptions);
194
-
195
- if (metadata?.overlapBehavior === JobOverlapBehavior.NO_OVERLAP) {
196
- const transport = this.transport as RedisTransport;
197
- const lockAcquired = await transport.acquireJobLock(queuedJob.job.id, queueName);
198
-
199
- if (!lockAcquired) {
200
- // Another worker acquired the lock first, put the job back in the queue
201
- queue.jobs.push(queuedJob);
202
- return;
203
- }
204
- }
205
-
206
- // Track job as active
207
- queue.activeJobs.set(queuedJob.job.id, queuedJob);
208
- queue.processing++;
209
-
210
- // Update job status to running
211
- await this.sendJobStatusUpdate(
212
- queuedJob.job.id,
213
- queuedJob.job.dispatchId,
214
- queueName,
215
- JobStatus.RUNNING,
216
- undefined
217
- );
218
-
219
- try {
220
- this.debug(
221
- `Processing job ${queuedJob.job.id} (dispatch: ${queuedJob.job.dispatchId}) from queue '${queueName}'`
222
- );
223
-
224
- await queuedJob.job.run();
225
-
226
- // Handle job completion based on status
227
- if (queuedJob.job.status === JobStatus.FAILED) {
228
- // Get max retries
229
- const maxRetries =
230
- queuedJob.maxRetries !== undefined ? queuedJob.maxRetries : queue.options.maxRetries!;
231
-
232
- if (queuedJob.retries < maxRetries) {
233
- // Increment retry count
234
- queuedJob.retries++;
235
- queuedJob.job.incrementRetries();
236
- this.warning(
237
- `Job ${queuedJob.job.id} failed, retrying (${queuedJob.retries}/${maxRetries})`
238
- );
239
-
240
- // Get retry delay
241
- const retryDelay =
242
- queuedJob.retryDelay !== undefined ? queuedJob.retryDelay : queue.options.retryDelay!;
243
-
244
- // Update job status to scheduled for retry
245
- await this.sendJobStatusUpdate(
246
- queuedJob.job.id,
247
- queuedJob.job.dispatchId,
248
- queueName,
249
- JobStatus.SCHEDULED_FOR_RETRY,
250
- queuedJob.job.lastError?.message
251
- );
252
-
253
- // Schedule retry
254
- setTimeout(() => {
255
- if (queuedJob.job.status !== JobStatus.CANCELLED) {
256
- queue.jobs.push(queuedJob);
257
- this.processQueueJobs(queueName);
258
- } else {
259
- this.info(`Skipping cancelled job ${queuedJob.job.id} during retry delay`);
260
- this.sendJobStatusUpdate(
261
- queuedJob.job.id,
262
- queuedJob.job.dispatchId,
263
- queueName,
264
- JobStatus.CANCELLED
265
- );
266
- }
267
- }, retryDelay);
268
- } else {
269
- // Job failed after all retries
270
- this.error(
271
- `Job ${queuedJob.job.id} failed after ${queuedJob.retries} retries (max: ${maxRetries}): ${queuedJob.job.lastError?.message}`
272
- );
273
-
274
- // Update job status to failed
275
- await this.sendJobStatusUpdate(
276
- queuedJob.job.id,
277
- queuedJob.job.dispatchId,
278
- queueName,
279
- JobStatus.FAILED,
280
- queuedJob.job.lastError?.message
281
- );
282
-
283
- // Pause queue if configured to do so
284
- if (queue.options.pauseOnError) {
285
- this.pause(queueName);
286
- this.warning(`Queue '${queueName}' paused due to job failure`);
287
- }
288
- }
289
- } else if (queuedJob.job.status === JobStatus.COMPLETED) {
290
- // Job completed successfully
291
- this.success(`Job ${queuedJob.job.id} completed successfully`);
292
-
293
- // Update job status to completed
294
- await this.sendJobStatusUpdate(
295
- queuedJob.job.id,
296
- queuedJob.job.dispatchId,
297
- queueName,
298
- JobStatus.COMPLETED
299
- );
300
- } else if ((queuedJob.job.status as JobStatus) === JobStatus.CANCELLED) {
301
- // Job was cancelled while running
302
- this.info(`Job ${queuedJob.job.id} cancelled while running`);
303
-
304
- // Update job status to cancelled
305
- await this.sendJobStatusUpdate(
306
- queuedJob.job.id,
307
- queuedJob.job.dispatchId,
308
- queueName,
309
- JobStatus.CANCELLED
310
- );
311
- }
312
- } catch (error) {
313
- // Handle unexpected errors during job execution
314
- this.error(`Unexpected error processing job ${queuedJob.job.id}: ${error}`);
315
-
316
- // Update job status to failed
317
- await this.sendJobStatusUpdate(
318
- queuedJob.job.id,
319
- queuedJob.job.dispatchId,
320
- queueName,
321
- JobStatus.FAILED,
322
- error instanceof Error ? error.message : String(error)
323
- );
324
-
325
- // Pause queue if configured to do so
326
- if (queue.options.pauseOnError) {
327
- this.pause(queueName);
328
- this.warning(`Queue '${queueName}' paused due to unexpected error`);
329
- }
330
- } finally {
331
- // Release lock if job has NO_OVERLAP behavior
332
- if (metadata?.overlapBehavior === JobOverlapBehavior.NO_OVERLAP) {
333
- // If there's an overlap delay, keep the lock for that duration
334
- if (metadata.overlapDelay && metadata.overlapDelay > 0) {
335
- setTimeout(() => {
336
- this.transport.releaseJobLock(queuedJob.job.id, queueName);
337
- }, metadata.overlapDelay);
338
- } else {
339
- // Release lock immediately
340
- await this.transport.releaseJobLock(queuedJob.job.id, queueName);
341
- }
342
- }
343
-
344
- // Remove job from active jobs
345
- queue.activeJobs.delete(queuedJob.job.id);
346
- queue.processing--;
347
-
348
- // Process next job in queue
349
- this.processQueueJobs(queueName);
350
- }
351
- }
352
-
353
- /**
354
- * Create a new queue with the specified options
355
- * @param options Queue options
356
- */
357
- public async createQueue(options: QueueOptions): Promise<BaseWorker> {
358
- const result = await super.createQueue(options);
359
-
360
- // Register the new queue with the transport if worker is running
361
- if (this.isRunning && !this.registeredQueues.has(options.name)) {
362
- this.registeredQueues.add(options.name);
363
- await this.transport.registerWorker(this.id, Array.from(this.registeredQueues));
364
- }
365
-
366
- return result as BaseWorker;
367
- }
368
-
369
- /**
370
- * Handle a message from the transport
371
- * @param message The message to handle
372
- */
373
- private async handleTransportMessage(message: TransportEvent): Promise<void> {
374
- try {
375
- switch (message.type) {
376
- case 'job:process': {
377
- // Add job to queue
378
- const {
379
- job: jobName,
380
- args = [],
381
- queue = 'default',
382
- options = {},
383
- jobId,
384
- dispatchId
385
- } = message;
386
- await this.handleProcessJob(jobId, dispatchId, jobName, args, queue, options);
387
- break;
388
- }
389
-
390
- case 'job:cancel': {
391
- // Cancel job
392
- const { jobId, dispatchId, queue = 'default' } = message;
393
- const cancelled = await this.cancelJob(jobId, queue);
394
-
395
- // Send response
396
- if (cancelled) {
397
- await this.sendJobStatusUpdate(jobId, dispatchId, queue, JobStatus.CANCELLED);
398
- }
399
- break;
400
- }
401
-
402
- case 'job:cancelAll': {
403
- // Cancel all jobs in queue
404
- const { queue = 'default' } = message;
405
- await this.cancelAllJobs(queue);
406
- break;
407
- }
408
- }
409
- } catch (error) {
410
- this.error(`Error handling transport message: ${error}`);
411
- }
412
- }
413
-
414
- /**
415
- * Handle a job:process message
416
- * @param jobName Job class name
417
- * @param args Job constructor arguments
418
- * @param queueName Queue name
419
- * @param options Job options
420
- */
421
- private async handleProcessJob(
422
- jobId: string,
423
- dispatchId: string,
424
- jobName: string,
425
- args: any[],
426
- queueName: string,
427
- messageOptions: any
428
- ): Promise<void> {
429
- try {
430
- // Get job class from Service container
431
- const JobClass = Service.get(jobName) as JobClass<Job>;
432
-
433
- if (!JobClass) {
434
- throw new Error(`Job class not found: ${jobName}`);
435
- }
436
-
437
- // Create job instance
438
- const job = new (WithId(JobClass, jobId, dispatchId))(...(args || []));
439
- job.queueName = queueName;
440
-
441
- // Parse job options
442
- const jobOptions: JobDispatchOptions = {};
443
-
444
- if (messageOptions.scheduledFor) {
445
- jobOptions.scheduledFor =
446
- typeof messageOptions.scheduledFor === 'string'
447
- ? new Date(messageOptions.scheduledFor)
448
- : messageOptions.scheduledFor;
449
- }
450
-
451
- if (messageOptions.priority !== undefined) {
452
- jobOptions.priority = Number(messageOptions.priority);
453
- }
454
-
455
- if (messageOptions.maxRetries !== undefined) {
456
- jobOptions.maxRetries = Number(messageOptions.maxRetries);
457
- }
458
-
459
- if (messageOptions.retryDelay !== undefined) {
460
- jobOptions.retryDelay = Number(messageOptions.retryDelay);
461
- }
462
-
463
- // Check if we should schedule the job for later
464
- if (jobOptions.scheduledFor && jobOptions.scheduledFor > new Date()) {
465
- // For scheduled jobs, set status to SCHEDULED_FOR_RETRY
466
- await this.sendJobStatusUpdate(
467
- job.id,
468
- job.dispatchId,
469
- queueName,
470
- JobStatus.SCHEDULED_FOR_RETRY
471
- );
472
- this.debug(
473
- `Scheduled job ${job.id} for future execution at ${jobOptions.scheduledFor.toISOString()}`
474
- );
475
- } else {
476
- // For immediate jobs, set status to PENDING
477
- await this.sendJobStatusUpdate(job.id, job.dispatchId, queueName, JobStatus.PENDING);
478
- this.debug(`Added job ${job.id} to queue ${queueName} with initial status: pending`);
479
- }
480
-
481
- // Add job to queue for processing
482
- await this.addJob(job, queueName, jobOptions);
483
- } catch (error) {
484
- this.error(`Error processing job ${jobName}: ${error}`);
485
- throw error;
486
- }
487
- }
488
-
489
- /**
490
- * Send job status update
491
- * @param jobId Job ID
492
- * @param queue Queue name
493
- * @param status Job status
494
- * @param error Optional error message
495
- */
496
- private async sendJobStatusUpdate(
497
- jobId: string,
498
- dispatchId: string,
499
- queue: string,
500
- status: JobStatus,
501
- error?: string
502
- ): Promise<void> {
503
- try {
504
- // Direct call to updateJobStatus (required by Transport interface)
505
- await this.transport.updateJobStatus(jobId, dispatchId, queue, {
506
- dispatchId,
507
- status,
508
- error,
509
- retries: 0,
510
- startedAt: status === JobStatus.RUNNING ? new Date().toISOString() : undefined,
511
- completedAt:
512
- status === JobStatus.COMPLETED ||
513
- status === JobStatus.FAILED ||
514
- status === JobStatus.CANCELLED
515
- ? new Date().toISOString()
516
- : undefined,
517
- updatedAt: Date.now().toString()
518
- });
519
- } catch (error) {
520
- this.error(`Error sending job status update: ${error}`);
521
- }
522
- }
523
- }
@@ -1,171 +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 'reflect-metadata';
16
-
17
- import type { Class } from 'type-fest';
18
- import type { Job } from '../job';
19
- import type { TransportEvent } from '../transport';
20
-
21
- import { Service } from '@elysiumjs/core';
22
-
23
- import { WorkerStatus } from '../worker';
24
- import { ThreadWorker } from './thread.worker';
25
-
26
- // Define self for TypeScript
27
- declare const self: Worker;
28
-
29
- // Ensure we're in a worker context
30
- if (typeof self === 'undefined') {
31
- throw new Error('This script must be run in a worker context');
32
- }
33
-
34
- // Log worker startup
35
- console.log(`Heracles thread worker starting...`);
36
-
37
- // Set a global worker reference
38
- let worker: ThreadWorker | null = null;
39
-
40
- // For storing pre-imported job classes
41
- const jobClasses = new Map<string, Class<Job>>();
42
-
43
- // Handle all messages from the main thread
44
- self.addEventListener('message', async (event) => {
45
- try {
46
- // Process the message based on its type
47
- if (event.data && typeof event.data === 'object') {
48
- const message = event.data as TransportEvent;
49
-
50
- switch (message.type) {
51
- case 'worker:init': {
52
- // Initialize worker if not already initialized
53
- if (!worker) {
54
- const { queues = ['default'], options = {} } = message as any;
55
-
56
- console.log(`Initializing worker with queues: ${queues.join(', ')}`);
57
-
58
- // Initialize worker with provided queues
59
- worker = new ThreadWorker(queues);
60
-
61
- // Configure worker with options
62
- if (options.concurrency) {
63
- for (const queue of queues) {
64
- await worker.setConcurrency(queue, options.concurrency);
65
- }
66
- }
67
-
68
- // Store worker in global scope for debug purposes
69
- (self as any).__worker = worker;
70
-
71
- // Start the worker
72
- await worker.start();
73
-
74
- // Send ready message back to main thread
75
- self.postMessage({
76
- type: 'worker:ready',
77
- id: worker.id,
78
- queues: Array.from(worker.getInfo().queues),
79
- status: worker.status,
80
- timestamp: Date.now()
81
- });
82
- }
83
- break;
84
- }
85
-
86
- case 'job:process': {
87
- // Process a job
88
- if (!worker) {
89
- throw new Error('Worker not initialized');
90
- }
91
-
92
- // Handle the job processing
93
- const { job: jobName, args = [], queue = 'default', options } = message;
94
- await handleMessage(message);
95
- break;
96
- }
97
-
98
- default:
99
- // Other messages will be handled by the ThreadWorker itself
100
- if (worker) {
101
- await handleMessage(message);
102
- } else {
103
- console.warn(`Received message before worker initialization: ${message.type}`);
104
-
105
- // Try to handle initialization on the fly
106
- worker = new ThreadWorker(['default']);
107
- await worker.start();
108
- await handleMessage(message);
109
- }
110
- break;
111
- }
112
- }
113
- } catch (error) {
114
- console.error('Error handling message in worker thread:', error);
115
-
116
- // Report error to main thread
117
- self.postMessage({
118
- type: 'worker:error',
119
- error: error instanceof Error ? error.message : String(error),
120
- timestamp: Date.now()
121
- });
122
- }
123
- });
124
-
125
- /**
126
- * Handle a transport message
127
- */
128
- async function handleMessage(message: TransportEvent): Promise<void> {
129
- if (!worker) {
130
- throw new Error('Worker not initialized');
131
- }
132
-
133
- // Forward the message to the worker instance for processing
134
- await worker.handleMessage(message);
135
- }
136
-
137
- // Send open message to main thread to indicate worker is ready for initialization
138
- self.postMessage({ type: 'worker:open' });
139
-
140
- // Handle uncaught errors
141
- self.addEventListener('error', (event) => {
142
- console.error('Uncaught error in worker thread:', event.error || event.message);
143
-
144
- // Report error to main thread
145
- self.postMessage({
146
- type: 'worker:error',
147
- error: event.error?.message || event.message || 'Unknown error',
148
- timestamp: Date.now()
149
- });
150
- });
151
-
152
- // Handle unhandled promise rejections
153
- self.addEventListener('unhandledrejection', (event) => {
154
- console.error('Unhandled rejection in worker thread:', event.reason);
155
-
156
- // Report error to main thread
157
- self.postMessage({
158
- type: 'worker:error',
159
- error: event.reason?.message || String(event.reason) || 'Unhandled promise rejection',
160
- timestamp: Date.now()
161
- });
162
- });
163
-
164
- // Initialize custom module loading if needed
165
- if (typeof Bun !== 'undefined') {
166
- // Bun-specific initialization
167
- console.log('Running in Bun environment');
168
- }
169
-
170
- // Export ThreadWorker and other components for testing/debugging
171
- export { ThreadWorker, WorkerStatus };