@elysiumjs/heracles 0.4.4 → 0.5.1

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 CHANGED
@@ -8,11 +8,9 @@ Heracles is a powerful background job processing system for Elysium.js applicati
8
8
 
9
9
  - **Job queues**: Organize jobs into different queues with custom settings
10
10
  - **Transports**: Abstract communication layer between queues and workers
11
- - **Worker pool**: Efficiently coordinate workers across multiple processes or servers
12
11
  - **Scheduling**: Schedule jobs to run at specific times
13
12
  - **Retries**: Automatically retry failed jobs with configurable backoff
14
13
  - **Prioritization**: Assign priorities to jobs within a queue
15
- - **CLI tool**: Manage workers through a simple command-line interface
16
14
 
17
15
  ## Installation
18
16
 
@@ -43,12 +41,12 @@ export class SendEmailJob extends Job {
43
41
 
44
42
  protected async execute(): Promise<void> {
45
43
  // Implementation for sending an email
46
- console.log(`Sending email to ${this.email}: ${this.subject}`);
44
+ this.info(`Sending email to ${this.email}: ${this.subject}`);
47
45
 
48
46
  // Simulate email sending
49
- await new Promise((resolve) => setTimeout(resolve, 1000));
47
+ await Bun.sleep(1000);
50
48
 
51
- console.log(`Email sent successfully to ${this.email}`);
49
+ this.success(`Email sent successfully to ${this.email}`);
52
50
  }
53
51
  }
54
52
  ```
@@ -56,7 +54,7 @@ export class SendEmailJob extends Job {
56
54
  ### 2. Dispatch a Job
57
55
 
58
56
  ```typescript
59
- import { Queue, RedisTransport } from '@elysiumjs/heracles';
57
+ import { Heracles, Queue, RedisTransport } from '@elysiumjs/heracles';
60
58
 
61
59
  import { SendEmailJob } from './jobs/send-email.job';
62
60
 
@@ -69,14 +67,10 @@ const emailQueue = Queue.get('emails', {
69
67
  });
70
68
 
71
69
  // Dispatch a job
72
- await emailQueue.dispatch(SendEmailJob, [
73
- 'user@example.com',
74
- 'Welcome!',
75
- 'Welcome to our platform!'
76
- ]);
70
+ await Heracles.dispatch(SendEmailJob, ['user@example.com', 'Welcome!', 'Welcome to our platform!']);
77
71
 
78
72
  // Dispatch with options
79
- await emailQueue.dispatch(
73
+ await Heracles.dispatch(
80
74
  SendEmailJob,
81
75
  ['user@example.com', 'Welcome!', 'Welcome to our platform!'],
82
76
  {
@@ -89,14 +83,14 @@ await emailQueue.dispatch(
89
83
 
90
84
  ### 3. Start Workers
91
85
 
92
- Using the CLI:
86
+ You can use [`styx`](https://www.npmjs.com/package/@elysiumjs/styx) to launch Heracles workers through the CLI:
93
87
 
94
88
  ```bash
95
89
  # Start a Redis worker for the 'emails' queue with concurrency of 3 jobs
96
- bun heracles work --id worker-1 --queues=emails --concurrency=3 --redis=default
90
+ bun styx heracles:work --id worker-1 --queues emails --concurrency 3 --redis default
97
91
 
98
92
  # Start workers for multiple queues
99
- bun heracles work --id worker-2 --queues=emails,notifications --redis=default
93
+ bun styx heracles:work --id worker-2 --queues emails,notifications --redis default
100
94
  ```
101
95
 
102
96
  Or programmatically:
@@ -140,7 +134,7 @@ const queue = Queue.get('emails', {
140
134
 
141
135
  ### Redis Transport Configuration
142
136
 
143
- Heracles uses Redis as its transport layer for job distribution, enabling distributed processing across multiple servers or processes. It uses Redis Streams for reliable message delivery and job coordination.
137
+ Heracles has a bundled Redis transport layer for job distribution, enabling distributed processing across multiple servers or processes. It uses Redis Streams for reliable message delivery and job coordination.
144
138
 
145
139
  #### 1. Configure Redis Connection
146
140
 
@@ -156,6 +150,23 @@ Redis.registerConnection('default', {
156
150
  });
157
151
  ```
158
152
 
153
+ Or during the Application registration:
154
+
155
+ ```typescript
156
+ import { Application } from '@elysiumjs/core';
157
+
158
+ @Application.register({
159
+ redis: {
160
+ default: 'jobs',
161
+ connections: {
162
+ jobs: { url: 'redis://localhost:6379' }
163
+ }
164
+ }
165
+ // ...other configurations...
166
+ })
167
+ export class App extends Application {}
168
+ ```
169
+
159
170
  #### 2. Configure Queues with Redis Transport
160
171
 
161
172
  ```typescript
@@ -167,7 +178,6 @@ const queue = Queue.get('emails', {
167
178
  connection: 'default', // Redis connection name
168
179
  keyPrefix: 'myapp:jobs', // Prefix for Redis keys (default: 'elysium:heracles')
169
180
  consumerGroup: 'workers', // Redis consumer group name (default: 'workers')
170
- pollInterval: 1000, // Poll interval in ms (default: 1000)
171
181
  statusTTL: 86400, // Job status TTL in seconds (default: 86400 - 24 hours)
172
182
  cleanupCompletedJobs: true, // Auto-cleanup completed jobs (default: true)
173
183
  completedJobRetention: 3600, // Time to retain completed jobs in seconds (default: 3600 - 1 hour)
@@ -186,8 +196,11 @@ import { RedisWorker } from '@elysiumjs/heracles';
186
196
  // Create a Redis worker with a unique ID
187
197
  const worker = new RedisWorker('default', {
188
198
  id: 'worker-1', // Unique worker ID
199
+ consumerGroup: 'workers', // Redis consumer group name (default: 'workers')
189
200
  consumerName: 'worker-1', // Unique consumer name (optional)
190
- keyPrefix: 'myapp:jobs' // Use same prefix as queue configuration
201
+ keyPrefix: 'myapp:jobs', // Use same prefix as queue configuration
202
+ pollInterval: 1000, // Poll interval in ms (default: 1000)
203
+ batchSize: 10 // Batch size for processing jobs (default: 10)
191
204
  });
192
205
 
193
206
  // Configure queue settings
@@ -220,7 +233,8 @@ Jobs in Heracles go through several statuses:
220
233
 
221
234
  ```typescript
222
235
  // Cancel a specific job
223
- const cancelled = await queue.cancelJob('job_123456789');
236
+ const jobId = await Heracles.dispatch(HelloWorldJob);
237
+ const cancelled = await queue.cancelJob(jobId);
224
238
 
225
239
  // Cancel all jobs in a queue
226
240
  const cancelledCount = await queue.cancelAllJobs();
@@ -242,78 +256,72 @@ await queue.drain();
242
256
  await queue.clear();
243
257
  ```
244
258
 
245
- ### Worker Pool
246
-
247
- The worker pool manages worker availability and distributes jobs using a round-robin algorithm:
248
-
249
- ```typescript
250
- import { ThreadWorker, WorkerPool } from '@elysiumjs/heracles';
259
+ ## CLI Reference
251
260
 
252
- // Get the worker pool instance
253
- const pool = WorkerPool.instance;
261
+ Heracles commands are available via the [`styx`](https://www.npmjs.com/package/@elysiumjs/styx) CLI tool:
254
262
 
255
- // Add a worker to the pool
256
- const worker = new ThreadWorker(['emails']);
257
- await worker.start();
258
- await pool.addWorker(worker.getInfo(), ['emails']);
263
+ ### Start Workers
259
264
 
260
- // Get workers for a specific queue
261
- const emailWorkers = pool.getWorkersForQueue('emails');
262
265
  ```
266
+ Usage: styx heracles:work [options]
267
+ Description: Start an Heracles worker for specific queues.
263
268
 
264
- ## CLI Reference
269
+ Arguments:
270
+ --id <string> (required)
271
+ Unique identifier for this worker
265
272
 
266
- Heracles includes a CLI tool for managing workers and Redis maintenance:
273
+ --queues <array of strings> (required) [default: default]
274
+ List of queues to work on
267
275
 
268
- ```
269
- Usage: heracles [options] [command]
276
+ --concurrency <number> (required) [default: 1]
277
+ Number of concurrent jobs to process
270
278
 
271
- Options:
272
- -V, --version output the version number
273
- -h, --help display help for command
279
+ --redis <string> (required) [default: default]
280
+ Name of the Redis connection to use
274
281
 
275
- Commands:
276
- work [options] Start a Redis worker for specific queues
277
- cleanup [options] Clean up Redis streams
278
- help [command] display help for command
279
- ```
282
+ --max-retries <number> (optional) [default: 3]
283
+ Maximum retries for failed jobs before giving up
280
284
 
281
- ### Start Workers
285
+ --retry-delay <number> (optional) [default: 5000]
286
+ Delay between retries in milliseconds
282
287
 
283
- ```
284
- Usage: heracles work [options]
285
-
286
- Start a Redis worker for specific queues
287
-
288
- Options:
289
- --id <ID> Unique identifier for this worker (required)
290
- -q, --queues <queues> Comma-separated list of queues (default: "default")
291
- -c, --concurrency <count> Number of concurrent jobs per worker (default: "1")
292
- -r, --redis <n> Redis connection name (default: "default")
293
- --config <path> Path to configuration file (default: "")
294
- --max-retries <count> Maximum retries for failed jobs (default: "3")
295
- --retry-delay <ms> Delay between retries in milliseconds (default: "5000")
296
- --pause-on-error Pause queue when error occurs (default: false)
297
- -h, --help display help for command
288
+ --pause-on-error <boolean> (optional) [default: false]
289
+ Pause the worker when an error occurs
290
+
291
+ --verbose <boolean> (optional) [default: false]
292
+ Enable verbose logging
298
293
  ```
299
294
 
300
295
  ### Clean Up Redis Streams
301
296
 
302
297
  ```
303
- Usage: heracles cleanup [options]
304
-
305
- Clean up Redis streams by removing completed and failed jobs
306
-
307
- Options:
308
- -r, --redis <name> Redis connection name (default: "default")
309
- -q, --queues <queues> Comma-separated list of queues (all if not specified)
310
- -a, --all Remove all entries, not just completed ones (default: false)
311
- -k, --keyprefix <prefix> Redis key prefix (default: "elysium:heracles")
312
- -t, --retention <seconds> Time to retain completed jobs in seconds (default: "3600")
313
- -m, --max <count> Maximum stream size to trim to (default: "1000")
314
- -s, --status-only Only clean up job status keys, not stream entries (default: false)
315
- --dry-run Show what would be removed without actually removing (default: false)
316
- -h, --help display help for command
298
+ Usage: styx heracles:clean [options]
299
+ Description: Clean up Redis streams by removing completed and failed jobs.
300
+
301
+ Arguments:
302
+ --redis <string> (required) [default: default]
303
+ Name of the Redis connection to use
304
+
305
+ --queues <array of strings> (optional) [default: ]
306
+ List of queues to clean. If empty, all queues will be cleaned
307
+
308
+ --dry-run <boolean> (optional) [default: false]
309
+ Show what would be removed without actually removing
310
+
311
+ --verbose <boolean> (optional) [default: false]
312
+ Enable verbose logging
313
+
314
+ --all <boolean> (optional) [default: false]
315
+ Remove all entries, not only completed and failed jobs
316
+
317
+ --key-prefix <string> (optional) [default: elysium:heracles]
318
+ Redis key prefix to clean
319
+
320
+ --retention <number> (optional) [default: 3600]
321
+ Number of seconds to retain completed jobs
322
+
323
+ --max <number> (optional) [default: 1000]
324
+ Maximum number of jobs to clean
317
325
  ```
318
326
 
319
327
  ## License
@@ -0,0 +1,13 @@
1
+ import type { Job, JobClass } from './job';
2
+ import type { JobDispatchId, JobDispatchOptions } from './queue';
3
+ export declare namespace Heracles {
4
+ /**
5
+ * Dispatch a job to be executed asynchronously.
6
+ * @author Axel Nana <axel.nana@workbud.com>
7
+ * @param jobClass The job class to dispatch. Should extend the base {@link Job} class and have the {@link Job.register} decorator set.
8
+ * @param args The arguments to pass to the job constructor.
9
+ * @param options Additional options for the job dispatch.
10
+ * @returns The ID of the dispatched job.
11
+ */
12
+ const dispatch: <T extends Job, TClass extends JobClass<T> = JobClass<T>>(jobClass: TClass, args?: ConstructorParameters<TClass>, options?: JobDispatchOptions) => Promise<JobDispatchId>;
13
+ }
@@ -0,0 +1,14 @@
1
+ import '@elysiumjs/core';
2
+ export type { JobProps, JobClass, JobMetadata } from './job';
3
+ export type { JobDispatchOptions, QueueOptions, JobDispatchId } from './queue';
4
+ export type { JobStatusInfo, Transport, TransportClass, TransportEvent } from './transport';
5
+ export type { QueuedJob, QueueState, Worker, WorkerInfo, WorkerQueueOptions } from './worker';
6
+ export type { RedisTransportOptions } from './transports/redis.transport';
7
+ export { Heracles } from './heracles';
8
+ export { Job, JobStatus, JobOverlapBehavior } from './job';
9
+ export { Queue } from './queue';
10
+ export { TransportMode } from './transport';
11
+ export { RedisTransport } from './transports/redis.transport';
12
+ export { WorkerStatus } from './worker';
13
+ export { BaseWorker } from './workers/base.worker';
14
+ export { RedisWorker } from './workers/redis.worker';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ // @bun
2
+ import"@elysiumjs/core";import{uid as b}from"radash";import{Event as W,InteractsWithConsole as N}from"@elysiumjs/core";import{map as p,uid as n}from"radash";import"reflect-metadata";import{InteractsWithConsole as c,Service as j}from"@elysiumjs/core";import{assign as I,uid as v}from"radash";var R;((Q)=>(Q.job=Symbol("elysium:job"),Q.queue=Symbol("elysium:queue"),Q.worker=Symbol("elysium:worker"),Q.transport=Symbol("elysium:transport"),Q.jobTransaction=Symbol("elysium:jobTransaction"),Q.workerPool=Symbol("elysium:workerPool")))(R||={});var A=(L)=>{if(!Reflect.hasMetadata(R.job,L))return null;return Reflect.getMetadata(R.job,L)};var F;((H)=>{H.ALLOW_OVERLAP="allow_overlap";H.NO_OVERLAP="no_overlap"})(F||={});class K extends c{#Y;#H;#L="pending";#U;#z=0;#V;static register(L={}){return function(Y){L=I({name:Y.name,queue:"default",overlapBehavior:"allow_overlap",overlapDelay:0,priority:0},L);let H=`elysium.heracles.job.${L.name}`;j.instance(H,Y);let U={name:H,queue:L.queue,overlapBehavior:L.overlapBehavior,overlapDelay:L.overlapDelay,priority:L.priority,maxRetries:L.maxRetries,retryDelay:L.retryDelay};Reflect.defineMetadata(R.job,U,Y)}}static generateJobId(...L){return`job_${Date.now()}_${v(8)}`}static description="An Heracles job.";id;dispatchId;createdAt;get startedAt(){return this.#Y}get completedAt(){return this.#H}get status(){return this.#L}get lastError(){return this.#U}get retries(){return this.#z}get queueName(){return this.#V}set queueName(L){this.#V=L}incrementRetries(){this.#z++}constructor(L,Y){super();this.id=L??this.constructor.generateJobId(),this.dispatchId=Y??`dispatch_${Date.now()}_${v(8)}`,this.createdAt=new Date}async run(){if(this.#L==="cancelled")return;try{this.#L="running",this.#Y=new Date;let L=this.#V?` in queue '${this.#V}'`:"";if(this.debug(`Job ${this.id}${L} started at ${this.#Y.toISOString()}`),await this.execute(),this.#L!=="cancelled")this.#L="completed",this.#H=new Date,this.debug(`Job ${this.id} completed successfully at ${this.#H.toISOString()} after ${this.retries} retries`)}catch(L){if(this.#U=L instanceof Error?L:new Error(String(L)),this.#L!=="cancelled")this.#L="failed",this.#H=new Date,this.error(`Job ${this.id} failed at ${this.#H.toISOString()} after ${this.retries} retries: ${this.#U.message}`),this.trace(this.#U)}}cancel(){if(this.#L==="completed"||this.#L==="failed")return!1;let L=this.#L==="running";if(this.#L="cancelled",L)this.#H=new Date;return this.info(`Job ${this.id} was cancelled${L?" while running":" before execution"}`),!0}}var k=(L,Y,H)=>{return class extends L{constructor(...U){super(...U);this.id=Y,this.dispatchId=H}}},T;((X)=>{X.PENDING="pending";X.RUNNING="running";X.COMPLETED="completed";X.FAILED="failed";X.CANCELLED="cancelled";X.SCHEDULED_FOR_RETRY="scheduled_for_retry"})(T||={});var x;((H)=>{H.CONSUMER="consumer";H.PRODUCER="producer"})(x||={});class E extends N{static queues=new Map;static exists(L){return this.queues.has(L)}static get(L,Y){if(!this.queues.has(L)){let H=new E({name:L,...Y});this.queues.set(L,H)}return this.queues.get(L)}static getAll(){return new Map(this.queues)}name;transport;transportClass;transportOptions;options;jobs=[];constructor(L){super();this.name=L.name,this.transportClass=L.transport,this.transportOptions=L.transportOptions,this.options={concurrency:L.concurrency??1,maxRetries:L.maxRetries??0,retryDelay:L.retryDelay??1000,pauseOnError:L.pauseOnError??!1,...L},this.transport=new this.transportClass("producer",this.transportOptions),this.transport.onMessage(this.handleTransportMessage.bind(this))}on(L,Y){W.on(`elysium:heracles:queue:${this.name}:${L}`,Y)}once(L,Y){W.once(`elysium:heracles:queue:${this.name}:${L}`,Y)}emit(L,Y,H=null){W.emit(`elysium:heracles:queue:${this.name}:${L}`,Y,H)}async handleTransportMessage(L){switch(L.type){case"job:status":case"job:result":if(L.queue===this.name){if(L.type==="job:result")this.info(`Job ${L.jobId} completed with status: ${L.status}`);let Y=await this.getJobStatus({jobId:L.jobId,dispatchId:L.dispatchId}),H=Y?.createdAt??new Date().toISOString();this.updateJobStatus(L.jobId,L.dispatchId,{jobId:L.jobId,dispatchId:L.dispatchId,queue:L.queue,status:L.status,error:L.error,retries:L.retries??Y?.retries??0,createdAt:H,startedAt:L.startedAt??Y?.startedAt,completedAt:L.completedAt??Y?.completedAt,updatedAt:L.updatedAt??new Date().toISOString()})}break}this.emit(L.type,L,this)}updateJobStatus(L,Y,H){if(H.status==="completed"||H.status==="failed"||H.status==="cancelled")this.debug(`Job ${L} (${Y}) reached terminal state: ${H.status}`)}async dispatch(L,Y,H=[],U={}){let V=new Date,z=V.toISOString(),X=U.jobId??L.generateJobId(...H),P=U.dispatchId??`dispatch_${Date.now()}_${n(8)}`,Z={jobId:X,dispatchId:P,queue:this.name,status:U.scheduledFor&&U.scheduledFor>V?"scheduled_for_retry":"pending",retries:0,createdAt:z,updatedAt:z};this.updateJobStatus(X,P,Z),this.debug(`Dispatching job ${X} (dispatch: ${P}, name: ${Y}) to queue '${this.name}'`);try{let _={type:"job:process",job:Y,jobId:X,dispatchId:P,args:H,queue:this.name,options:{scheduledFor:U.scheduledFor?.toISOString(),priority:U.priority,maxRetries:U.maxRetries,retryDelay:U.retryDelay,overlapBehavior:U.overlapBehavior,overlapDelay:U.overlapDelay}};return await this.transport.send(_),{jobId:Z.jobId,dispatchId:P}}catch(_){throw this.error(`Failed to dispatch job ${Y} to queue '${this.name}': ${_.message}`),this.updateJobStatus(X,P,{jobId:Z.jobId,dispatchId:P,queue:this.name,status:"failed",error:_.message,retries:0,createdAt:Z.createdAt,completedAt:new Date().toISOString(),updatedAt:new Date().toISOString()}),_}}async cancelJob(L){this.debug(`Canceling job ${L.jobId} (${L.dispatchId}) in queue '${this.name}'`);try{let Y={type:"job:cancel",jobId:L.jobId,dispatchId:L.dispatchId,queue:this.name};return await this.transport.send(Y),!0}catch(Y){return this.error(`Failed to cancel job ${L.jobId}: ${Y}`),!1}}async cancelAllJobs(){this.debug(`Canceling all jobs in queue '${this.name}'`);try{let L={type:"job:cancelAll",queue:this.name};await this.transport.send(L)}catch(L){this.error(`Failed to cancel all jobs: ${L}`)}return 0}async getJobStatus(L){try{let Y=await this.transport.getJobStatus(L.jobId,L.dispatchId,this.name);if(Y)return this.debug(`Retrieved job ${L.jobId} (${L.dispatchId}) status from transport: ${Y.status}`),Y}catch(Y){this.warning(`Failed to get job status from transport: ${Y}`)}return null}getAllJobs(){return Array.from(this.jobs)}async getJobsByStatus(L){let Y=[];for(let[H,U]of await p(this.jobs,async(V)=>[V,await this.getJobStatus(V)]))if(U?.status===L)Y.push(H);return Y}async start(){if(this.transport)await this.transport.start(),this.info(`Queue '${this.name}' transport started`)}async stop(){if(this.transport)await this.transport.stop(),this.info(`Queue '${this.name}' transport stopped`)}getOptions(){return{...this.options}}updateOptions(L){this.options={...this.options,...L}}}var y;((f)=>f.dispatch=async(Y,H=[],U={})=>{let V=A(Y);if(!V)throw new Error(`Job ${Y.name} is not marked with @Job.register() decorator`);if(!V.queue)throw new Error(`Job ${Y.name} is not registered with a queue`);if(!E.exists(V.queue))throw new Error(`Queue ${V.queue} does not exist. Please create the queue before dispatching the job.`);let z=E.get(V.queue),P={dispatchId:`dispatch_${Date.now()}_${b(8)}`,priority:V.priority,maxRetries:V.maxRetries,retryDelay:V.retryDelay,overlapBehavior:V.overlapBehavior,overlapDelay:V.overlapDelay,...U};return z.dispatch(Y,V.name,H,P)})(y||={});import{InteractsWithConsole as i,Redis as g}from"@elysiumjs/core";import{uid as S}from"radash";class D extends i{client;options;mode;messageHandlers=[];pollTimer;cleanupTimer;lastIds=new Map;checkLogs=new Map;consumerId;consumerGroupCreated=new Set;completedMessageIds=new Map;jobLocks=new Map;constructor(L,Y={}){super();this.mode=L,this.options={connection:Y.connection??"default",keyPrefix:Y.keyPrefix??"elysium:heracles",consumerGroup:Y.consumerGroup??"workers",consumerName:Y.consumerName??`worker-${S(8)}`,pollInterval:Y.pollInterval??1000,batchSize:Y.batchSize??10,statusTTL:Y.statusTTL??86400,cleanupCompletedJobs:Y.cleanupCompletedJobs!==!1,completedJobRetention:Y.completedJobRetention??3600,maxStreamSize:Y.maxStreamSize??1000},this.client=g.getConnection(this.options.connection),this.consumerId=this.mode==="consumer"?this.options.consumerName:`producer-${S(8)}`}redisCommand(L,Y){let H=Y.map((U)=>{if(typeof U==="number")return U.toString();else if(U===null||U===void 0)return"";else if(Array.isArray(U))return JSON.stringify(U);else if(typeof U==="object")return JSON.stringify(U);return U});try{return this.client.send(L,H)}catch(U){throw new Error(`Redis command failed: ${L} - ${U}`)}}async findJobMessageId(L,Y,H){let U=this.getStreamKey(H);try{let V=await this.redisCommand("XRANGE",[U,"-","+","COUNT","1000"]);for(let[z,X]of V){let P={};for(let Z=0;Z<X.length;Z+=2)P[X[Z]]=X[Z+1];if(P.jobId===L&&P.dispatchId===Y)return z}}catch(V){this.error(`Error finding job message: ${V}`)}return null}async start(){try{if(await this.redisCommand("PING",[]),this.info(`RedisTransport started in ${this.mode} mode with ID: ${this.consumerId}`),this.mode==="consumer"){if(this.startPolling(),this.options.cleanupCompletedJobs)this.scheduleStreamCleanup()}}catch(L){throw this.error(`Failed to start RedisTransport: ${L}`),new Error(`Redis connection failed: ${L}`)}}async stop(){if(this.pollTimer)clearInterval(this.pollTimer),this.pollTimer=void 0;if(this.cleanupTimer)clearInterval(this.cleanupTimer),this.cleanupTimer=void 0;this.info("RedisTransport stopped")}async send(L){try{let Y=["default"];if("queue"in L&&typeof L.queue==="string")Y=[L.queue];else if("queues"in L&&Array.isArray(L.queues))Y=L.queues;for(let H of Y){let U=this.getStreamKey(H),V=this.serializeMessage(L),z="0";if(L.type==="job:status"||L.type==="job:result")if(await this.findJobMessageId(L.jobId,L.dispatchId,H)){let P={status:L.status,updatedAt:Date.now().toString()};if(L.error)P.error=L.error;if(L.retries!==void 0)P.retries=L.retries.toString();if(L.startedAt)P.startedAt=L.startedAt;if(L.completedAt)P.completedAt=L.completedAt;await this.redisCommand("HSET",[this.getJobStatusKey(L.jobId,L.dispatchId,H),...Object.entries(P).flat()]),this.debug(`Updated status for job ${L.jobId} to ${L.status}`),await this.storeJobStatus({jobId:L.jobId,dispatchId:L.dispatchId,queue:H,status:L.status,error:L.error,retries:L.retries??0,createdAt:L.createdAt??new Date().toISOString(),startedAt:L.startedAt,completedAt:L.completedAt});return}else{let{jobId:P,dispatchId:Z}=L,_=this.getJobStatusKey(P,Z,H),$=await this.redisCommand("EXISTS",[_]);if(z=await this.redisCommand("XADD",[U,"*",...V]),$)await this.updateJobStatus(L.jobId,L.dispatchId,H,{status:L.status,error:L.error,retries:L.retries??0,startedAt:L.startedAt,completedAt:L.completedAt,updatedAt:Date.now().toString()});else await this.storeJobStatus({jobId:L.jobId,dispatchId:L.dispatchId,queue:H,status:L.status,error:L.error,retries:L.retries??0,createdAt:L.createdAt??new Date().toISOString(),startedAt:L.startedAt,completedAt:L.completedAt,messageId:z,updatedAt:Date.now().toString()})}else if(L.type==="job:process")z=await this.redisCommand("XADD",[U,"*",...V]),await this.storeJobStatus({jobId:L.jobId,dispatchId:L.dispatchId,queue:H,status:"pending",retries:0,createdAt:new Date().toISOString(),messageId:z,updatedAt:Date.now().toString()});else if(L.type==="job:update")await this.updateJobStatus(L.jobId,L.dispatchId,H,L.updates);else z=await this.redisCommand("XADD",[U,"*",...V]);this.debug(`Sent ${L.type} message to stream ${U} with ID ${z}`)}}catch(Y){throw this.error(`Failed to send message: ${Y}`),new Error(`Failed to send message: ${Y}`)}}onMessage(L){this.messageHandlers.push(L)}async getJobStatus(L,Y,H){try{let U=this.getJobStatusKey(L,Y,H),V=await this.redisCommand("HGETALL",[U]);if(V){let z={jobId:L,dispatchId:V.dispatchId,queue:H,status:V.status||"unknown",error:V.error,retries:parseInt(V.retries||"0",10),createdAt:V.createdAt||new Date().toISOString(),startedAt:V.startedAt,completedAt:V.completedAt,updatedAt:V.updatedAt,messageId:V.messageId};return this.debug(`Retrieved job status for ${L} from Redis: ${z.status}`),z}}catch(U){this.warning(`Failed to get job status from Redis: ${U}`)}try{let U=await this.findJobMessageId(L,Y,H);if(U){let V={jobId:L,dispatchId:Y,queue:H,status:"pending",retries:0,createdAt:new Date().toISOString(),messageId:U,updatedAt:Date.now().toString()};return await this.storeJobStatus(V),V}}catch(U){this.debug(`Error checking job existence in stream: ${U}`)}return{jobId:L,dispatchId:Y,queue:H,status:"unknown",retries:0,createdAt:new Date().toISOString()}}async updateJobStatus(L,Y,H,U){try{let V=this.getJobStatusKey(L,Y,H),z=Date.now().toString();if(!await this.redisCommand("EXISTS",[V])){if(U.status){let _={jobId:L,dispatchId:Y,queue:H,status:U.status,error:U.error,retries:U.retries||0,createdAt:U.createdAt||new Date().toISOString(),startedAt:U.startedAt,completedAt:U.completedAt,updatedAt:z,messageId:U.messageId};if(await this.storeJobStatus(_),U.status==="completed"||U.status==="failed"||U.status==="cancelled")for(let $ of this.messageHandlers)try{$({type:"job:result",jobId:L,dispatchId:Y,queue:H,status:U.status,error:U.error,completedAt:U.completedAt??new Date().toISOString()})}catch(G){this.error(`Error in message handler: ${G}`)}else for(let $ of this.messageHandlers)try{$({type:"job:status",jobId:L,dispatchId:Y,queue:H,status:U.status,error:U.error,updatedAt:U.updatedAt??new Date().toISOString()})}catch(G){this.error(`Error in message handler: ${G}`)}}return}let P=[],Z={};if(U.status)P.push("status",U.status),Z.status=U.status;if(U.error!==void 0)P.push("error",U.error||""),Z.error=U.error||"";if(U.retries!==void 0)P.push("retries",U.retries.toString()),Z.retries=U.retries;if(U.startedAt)P.push("startedAt",U.startedAt),Z.startedAt=U.startedAt;if(U.completedAt)P.push("completedAt",U.completedAt),Z.completedAt=U.completedAt;if(P.push("updatedAt",z),Z.updatedAt=z,P.length>0)if(await this.redisCommand("HSET",[V,...P]),await this.redisCommand("EXPIRE",[V,this.options.statusTTL]),this.debug(`Updated job ${L} status: ${U.status??"fields updated"}`),U.status==="completed"||U.status==="failed"||U.status==="cancelled"){let _=await this.getJobStatus(L,Y,H);if(_.messageId){this.trackCompletedMessage(_.messageId,this.getStreamKey(H));for(let $ of this.messageHandlers)try{$({type:"job:result",jobId:L,dispatchId:Y,queue:H,status:U.status,error:U.error,completedAt:U.completedAt||z,retries:parseInt(_.retries.toString(),10)})}catch(G){this.error(`Error in message handler: ${G}`)}}}else for(let _ of this.messageHandlers)try{_({...Z,type:"job:status",jobId:L,queue:H})}catch($){this.error(`Error in message handler: ${$}`)}}catch(V){throw this.error(`Failed to update job status: ${V}`),new Error(`Failed to update job status: ${V}`)}}async registerWorker(L,Y){try{for(let H of Y){let U=this.getStreamKey(H);await this.ensureStreamExists(U),await this.ensureConsumerGroupExists(U);let V=this.getWorkerKey(L);await this.redisCommand("HSET",[V,"id",L,"status","active","lastSeen",new Date().toISOString(),"queues",JSON.stringify(Y)]),await this.redisCommand("EXPIRE",[V,"60"]),this.debug(`Registered worker ${L} for queue ${H}`)}await this.send({type:"worker:register",workerId:L,queues:Y})}catch(H){throw this.error(`Failed to register worker: ${H}`),new Error(`Failed to register worker: ${H}`)}}async unregisterWorker(L){try{let Y=this.getWorkerKey(L);await this.redisCommand("DEL",[Y]),await this.send({type:"worker:unregister",workerId:L}),this.debug(`Unregistered worker ${L}`)}catch(Y){throw this.error(`Failed to unregister worker: ${Y}`),new Error(`Failed to unregister worker: ${Y}`)}}startPolling(){this.pollTimer=setInterval(()=>{this.pollMessages().catch((L)=>{this.error(`Error polling messages: ${L}`)})},this.options.pollInterval)}async pollMessages(){if(this.mode!=="consumer")return;let L=await this.getRegisteredQueues();for(let Y of L){let H=this.getStreamKey(Y);try{await this.ensureConsumerGroupExists(H);let U;if(this.checkLogs.get(Y)??!0)U=await this.redisCommand("XREADGROUP",["GROUP",this.options.consumerGroup,this.consumerId,"COUNT",this.options.batchSize,"STREAMS",H,0])??{};else U=await this.redisCommand("XREADGROUP",["GROUP",this.options.consumerGroup,this.consumerId,"COUNT",this.options.batchSize,"STREAMS",H,">"])??{};await this.processMessages(U[H],Y)}catch(U){if(console.error(U),!String(U).includes("timeout"))this.error(`Error reading from stream ${H}: ${U}`)}}}async processMessages(L,Y){if(!L||L.length===0){this.checkLogs.set(Y,!(this.checkLogs.get(Y)??!0));return}let H=this.getStreamKey(Y);for(let[U,V]of L)try{let z=this.deserializeMessage(V,Y);if(this.lastIds.set(H,U),z.type==="job:process"){let X=this.getJobStatusKey(z.jobId,z.dispatchId,Y);if(await this.redisCommand("EXISTS",[X])){if((await this.getJobStatus(z.jobId,z.dispatchId,Y)).status!=="pending"){this.debug(`Skipping duplicate job message for ${z.jobId}`),await this.redisCommand("XACK",[H,this.options.consumerGroup,U]);continue}}}for(let X of this.messageHandlers)try{await X(z)}catch(P){this.error(`Error in message handler: ${P}`)}if((z.type==="job:result"||z.type==="job:status")&&(z.status==="completed"||z.status==="failed"||z.status==="cancelled"))this.trackCompletedMessage(U,H);await this.redisCommand("XACK",[H,this.options.consumerGroup,U])}catch(z){this.error(`Error processing message ${U}: ${z}`)}}async getRegisteredQueues(){try{let L=`${this.options.keyPrefix}:stream:*`,Y=await this.redisCommand("KEYS",[L]);if(!Array.isArray(Y))return["default"];return Y.map((H)=>H.replace(`${this.options.keyPrefix}:stream:`,"")).filter(Boolean)}catch(L){return this.error(`Failed to get registered queues: ${L}`),["default"]}}async ensureStreamExists(L){try{if(!await this.redisCommand("XINFO",["STREAM",L]).catch(()=>null)){let H=await this.redisCommand("XADD",[L,"*","init","true"]);await this.redisCommand("XDEL",[L,H]),this.debug(`Created stream ${L}`)}}catch(Y){this.error(`Failed to ensure stream ${L} exists: ${Y}`)}}async ensureConsumerGroupExists(L){if(this.consumerGroupCreated.has(L))return;try{let Y=await this.redisCommand("XINFO",["GROUPS",L]).catch(()=>[]);if(!(Array.isArray(Y)&&Y.some((U)=>U[1]===this.options.consumerGroup)))await this.redisCommand("XGROUP",["CREATE",L,this.options.consumerGroup,"0","MKSTREAM"]),this.debug(`Created consumer group ${this.options.consumerGroup} for stream ${L}`);this.consumerGroupCreated.add(L)}catch(Y){if(Y.toString().includes("BUSYGROUP"))this.debug(`Consumer group ${this.options.consumerGroup} already exists for stream ${L}`),this.consumerGroupCreated.add(L);else throw this.error(`Failed to ensure consumer group exists: ${Y}`),Y}}async storeJobStatus(L){try{let Y=this.getJobStatusKey(L.jobId,L.dispatchId,L.queue),H=["jobId",L.jobId,"queue",L.queue,"status",L.status,"retries",L.retries.toString(),"createdAt",L.createdAt];if(L.startedAt)H.push("startedAt",L.startedAt);if(L.completedAt)H.push("completedAt",L.completedAt);if(L.error)H.push("error",L.error);if(L.messageId)H.push("messageId",L.messageId);let U=L.updatedAt||Date.now().toString();if(H.push("updatedAt",U),await this.redisCommand("HSET",[Y,...H]),await this.redisCommand("EXPIRE",[Y,this.options.statusTTL]),L.status==="pending"||L.status==="scheduled_for_retry")for(let V of this.messageHandlers)try{V({type:"job:status",jobId:L.jobId,dispatchId:L.dispatchId,queue:L.queue,status:L.status,createdAt:L.createdAt,updatedAt:U})}catch(z){this.error(`Error in message handler: ${z}`)}if(L.status==="completed"||L.status==="failed"||L.status==="cancelled"){let V=L.messageId??null;if(!V)V=await this.findJobMessageId(L.jobId,L.dispatchId,L.queue);if(V){let z=this.getStreamKey(L.queue);this.trackCompletedMessage(V,z);for(let X of this.messageHandlers)try{X({type:"job:result",jobId:L.jobId,dispatchId:L.dispatchId,queue:L.queue,status:L.status,error:L.error,completedAt:L.completedAt||new Date().toISOString(),retries:L.retries})}catch(P){this.error(`Error in message handler: ${P}`)}}}}catch(Y){throw this.error(`Failed to store job status: ${Y}`),new Error(`Failed to store job status: ${Y}`)}}serializeMessage(L){let Y=["type",L.type,"timestamp",Date.now().toString()];if("queue"in L&&typeof L.queue==="string")Y.push("queue",L.queue);switch(L.type){case"job:process":if(Y.push("job",L.job,"args",JSON.stringify(L.args||[]),"jobId",L.jobId,"dispatchId",L.dispatchId),L.options)Y.push("options",JSON.stringify(L.options));break;case"job:cancel":case"job:status":case"job:result":if(Y.push("jobId",L.jobId),Y.push("dispatchId",L.dispatchId),L.type!=="job:cancel"&&L.status)Y.push("status",L.status);if(L.type!=="job:cancel"&&L.error)Y.push("error",L.error);if(L.retries!==void 0)Y.push("retries",L.retries);if(L.startedAt)Y.push("startedAt",L.startedAt);if(L.completedAt)Y.push("completedAt",L.completedAt);break;case"job:cancelAll":break;case"worker:register":Y.push("workerId",L.workerId,"queues",JSON.stringify(L.queues??[]));break;case"worker:unregister":Y.push("workerId",L.workerId);break;case"worker:status":Y.push("workerId",L.workerId,"status",L.status,"queues",JSON.stringify(L.queues),"processing",L.processing.toString(),"waiting",L.waiting.toString());break;case"worker:ready":if(Y.push("id",L.id,"queues",JSON.stringify(L.queues)),L.status)Y.push("status",L.status);if(L.timestamp)Y.push("timestamp",L.timestamp.toString());break}return Y}deserializeMessage(L,Y){let H={};for(let z=0;z<L.length;z+=2)H[L[z]]=L[z+1];let U=H.type,V={type:U,queue:H.queue||Y};switch(U){case"job:process":return{...V,type:"job:process",job:H.job,args:JSON.parse(H.args||"[]"),options:H.options?JSON.parse(H.options):void 0,jobId:H.jobId,dispatchId:H.dispatchId};case"job:cancel":return{...V,type:"job:cancel",jobId:H.jobId,dispatchId:H.dispatchId};case"job:cancelAll":return{...V,type:"job:cancelAll"};case"job:status":return{...V,type:"job:status",jobId:H.jobId,dispatchId:H.dispatchId,status:H.status,error:H.error,retries:H.retries?parseInt(H.retries,10):0,startedAt:H.startedAt,completedAt:H.completedAt};case"job:result":return{...V,type:"job:result",jobId:H.jobId,dispatchId:H.dispatchId,status:H.status,error:H.error,completedAt:H.completedAt||new Date().toISOString()};case"worker:register":return{...V,type:"worker:register",workerId:H.workerId,queues:H.queues?JSON.parse(H.queues):[]};case"worker:unregister":return{...V,type:"worker:unregister",workerId:H.workerId};case"worker:status":return{...V,type:"worker:status",workerId:H.workerId,queues:H.queues?JSON.parse(H.queues):[],status:H.status,processing:parseInt(H.processing||"0",10),waiting:parseInt(H.waiting||"0",10)};case"worker:ready":return{...V,type:"worker:ready",id:H.id,queues:H.queues?JSON.parse(H.queues):[],status:H.status,timestamp:H.timestamp?parseInt(H.timestamp,10):void 0};default:return V}}getStreamKey(L){return`${this.options.keyPrefix}:stream:${L||"default"}`}getJobStatusKey(L,Y,H){return`${this.options.keyPrefix}:status:${H}:${L}:${Y}`}getWorkerKey(L){return`${this.options.keyPrefix}:worker:${L}`}trackCompletedMessage(L,Y){if(this.options.cleanupCompletedJobs)this.completedMessageIds.set(L,{streamKey:Y,timestamp:Date.now()}),this.debug(`Marked message ${L} for future cleanup`)}scheduleStreamCleanup(){if(this.cleanupTimer)clearInterval(this.cleanupTimer);let L=300000;this.cleanupTimer=setInterval(()=>{this.cleanupStreams().catch((Y)=>{this.error(`Error during stream cleanup: ${Y}`)})},L),this.debug(`Scheduled stream cleanup every ${L/1000} seconds`)}async cleanupStreams(){if(!this.options.cleanupCompletedJobs)return;this.debug("Starting stream cleanup for completed jobs");let L=Date.now(),Y=this.options.completedJobRetention*1000,H=new Map,U=[],V=await this.getRegisteredQueues();for(let z of V){let X=this.getStreamKey(z);try{let P=`${this.options.keyPrefix}:status:${z}:*`,Z=await this.redisCommand("KEYS",[P]);for(let _ of Z){let $=await this.redisCommand("HGETALL",[_]);if(!$||$.length===0)continue;let G={};for(let B=0;B<$.length;B+=2)G[$[B]]=$[B+1];if((G.status==="completed"||G.status==="failed"||G.status==="cancelled")&&G.updatedAt&&L-parseInt(G.updatedAt)>Y){let B=_.split(":"),J=B.pop(),l=B.pop();if(U.push(_),G.messageId){if(!H.has(X))H.set(X,[]);H.get(X).push(G.messageId)}else{let O=await this.findJobMessageId(l,J,z);if(O){if(!H.has(X))H.set(X,[]);H.get(X).push(O)}}}}}catch(P){this.error(`Error finding completed jobs for queue ${z}: ${P}`)}}for(let[z,X]of H.entries())if(X.length>0)try{for(let P=0;P<X.length;P+=100){let Z=X.slice(P,P+100);await this.redisCommand("XDEL",[z,...Z])}this.debug(`Cleaned up ${X.length} completed messages from stream ${z}`);for(let P of X)this.completedMessageIds.delete(P)}catch(P){this.error(`Error deleting completed messages from stream ${z}: ${P}`)}if(U.length>0)try{for(let z=0;z<U.length;z+=100){let X=U.slice(z,z+100);await this.redisCommand("DEL",X)}this.debug(`Cleaned up ${U.length} job status records`)}catch(z){this.error(`Error deleting expired status keys: ${z}`)}if(this.options.maxStreamSize>0)await this.trimStreamsToMaxSize()}async trimStreamsToMaxSize(){try{let L=await this.getRegisteredQueues();for(let Y of L){let H=this.getStreamKey(Y),U=await this.redisCommand("XLEN",[H]);if(U>this.options.maxStreamSize)await this.redisCommand("XTRIM",[H,"MAXLEN","~",this.options.maxStreamSize.toString()]),this.debug(`Trimmed stream ${H} from ${U} to ~${this.options.maxStreamSize} entries`)}}catch(L){this.error(`Error trimming streams to max size: ${L}`)}}async isJobLocked(L,Y){let H=this.jobLocks.get(L);if(H&&H.lockedUntil>Date.now())return!0;let U=`${this.options.keyPrefix}:lock:${Y}:${L}`;return!!await this.redisCommand("EXISTS",[U])}async acquireJobLock(L,Y,H=60000){let U=`${this.options.keyPrefix}:lock:${Y}:${L}`;if(await this.redisCommand("SET",[U,Date.now().toString(),"PX",H.toString(),"NX"])==="OK")return this.jobLocks.set(L,{lockedUntil:Date.now()+H,queue:Y}),!0;return!1}async releaseJobLock(L,Y){let H=`${this.options.keyPrefix}:lock:${Y}:${L}`;await this.redisCommand("DEL",[H]),this.jobLocks.delete(L)}}var w;((P)=>{P.IDLE="idle";P.ACTIVE="active";P.PAUSED="paused";P.DRAINING="draining";P.STOPPING="stopping";P.STOPPED="stopped";P.DISCONNECTED="disconnected"})(w||={});import{InteractsWithConsole as q}from"@elysiumjs/core";import{sort as C,uid as r}from"radash";class M extends q{#Y;transport=null;queues=new Map;defaultQueueName="default";defaultTransport;workerStatus="idle";startedAt=new Date;scheduledJobInterval=1000;scheduledJobTimer;constructor(L){super();this.#Y=L??r(24),this.createQueue({name:this.defaultQueueName})}get id(){return this.#Y}get status(){return this.workerStatus}async createQueue(L){if(this.queues.has(L.name))return this.debug(`Queue with name '${L.name}' already exists`),this;let Y={options:{...L,concurrency:L.concurrency||1,maxRetries:L.maxRetries||0,retryDelay:L.retryDelay||1000,pauseOnError:L.pauseOnError||!1},jobs:[],activeJobs:new Map,processing:0,paused:!1,draining:!1};return this.queues.set(L.name,Y),this.debug(`Created queue '${L.name}'`),this}async addJob(L,Y=this.defaultQueueName,H={}){let U=this.queues.get(Y);if(!U)throw this.error(`Queue '${Y}' does not exist`),new Error(`Queue '${Y}' does not exist`);if(U.draining)throw this.warning(`Queue '${Y}' is draining, job ${L.id} will not be added`),new Error(`Queue '${Y}' is draining`);L.queueName=Y;let V={job:L,retries:L.retries,queueName:Y,enqueuedAt:new Date,scheduledFor:H.scheduledFor,priority:H.priority??10,maxRetries:H.maxRetries,retryDelay:H.retryDelay,overlapBehavior:H.overlapBehavior,overlapDelay:H.overlapDelay};if(U.jobs.push(V),U.jobs=C(C(C(U.jobs,(z)=>z.enqueuedAt.getTime()),(z)=>z.scheduledFor?z.scheduledFor.getTime():0),(z)=>z.priority??10),this.debug(`Added job ${L.id} to queue '${Y}'`),this.workerStatus==="idle")this.workerStatus="active";if(!U.paused&&U.processing<U.options.concurrency)try{this.processQueueJobs(Y)}catch(z){this.error(`Error processing next job in queue '${Y}': ${z}`)}return this}async pause(L){if(L){let Y=this.queues.get(L);if(Y)Y.paused=!0,this.info(`Paused queue '${L}'`);else this.warning(`Cannot pause non-existent queue '${L}'`)}else{for(let Y of this.queues.keys())await this.pause(Y);if(Array.from(this.queues.values()).every((Y)=>Y.paused)&&Array.from(this.queues.values()).every((Y)=>Y.processing===0))this.workerStatus="paused",this.info(`Worker ${this.id} is now paused`)}return this}async resume(L){if(L){let Y=this.queues.get(L);if(Y){if(Y.paused=!1,this.info(`Resumed queue '${L}'`),this.workerStatus==="paused")this.workerStatus="active";await this.processQueueJobs(L)}else this.warning(`Cannot resume non-existent queue '${L}'`)}else{for(let Y of this.queues.keys())await this.resume(Y);if(Array.from(this.queues.values()).some((Y)=>Y.jobs.length>0||Y.processing>0))this.workerStatus="active",this.info(`Worker ${this.id} is now active`)}return this}async drain(L){if(L){let Y=this.queues.get(L);if(Y)Y.draining=!0,this.info(`Draining queue '${L}'`);else this.warning(`Cannot drain non-existent queue '${L}'`)}else{for(let Y of this.queues.keys())await this.drain(Y);this.workerStatus="draining",this.info(`Worker ${this.id} is now draining`)}return this}async clear(L){if(L){let Y=this.queues.get(L);if(Y){let H=Y.jobs.length;for(let U of Y.jobs)U.job.cancel();Y.jobs=[],this.info(`Cleared ${H} jobs from queue '${L}'`)}else this.warning(`Cannot clear non-existent queue '${L}'`)}else{for(let[Y,H]of this.queues.entries()){let U=H.jobs.length;for(let V of H.jobs)V.job.cancel();H.jobs=[],this.info(`Cleared ${U} jobs from queue '${Y}'`)}if(Array.from(this.queues.values()).every((Y)=>Y.jobs.length===0&&Y.processing===0))this.workerStatus="idle"}return this}async size(L=this.defaultQueueName){let Y=this.queues.get(L);if(!Y)throw new Error(`Queue '${L}' does not exist`);return Y.jobs.length+Y.processing}async totalSize(){let L=0;for(let Y of this.queues.values())L+=Y.jobs.length+Y.processing;return L}async getStats(){let L={};for(let[Y,H]of this.queues.entries())L[Y]={waiting:H.jobs.length,processing:H.processing,paused:H.paused,draining:H.draining};return L}getInfo(){let L=0;for(let V of this.queues.values())L+=V.processing;let Y=Array.from(this.queues.keys()),H=this.defaultTransport?this.defaultTransport.constructor.name:"None",U=async(V,z)=>{try{return await this.addJob(V,z),!0}catch(X){return!1}};return{id:this.id,status:this.workerStatus,queues:Y,activeJobs:L,transport:H,startedAt:this.startedAt,addJob:U,cancelJob:this.cancelJob.bind(this),cancelAllJobs:this.cancelAllJobs.bind(this)}}async cancelJob(L,Y){if(Y)return this.cancelJobInQueue(L,Y);else{for(let H of this.queues.keys())if(await this.cancelJobInQueue(L,H))return!0;return!1}}async cancelAllJobs(L){let Y=0;if(L){let H=this.queues.get(L);if(H){Y=H.jobs.length;for(let U of H.jobs)U.job.cancel();for(let[U,V]of H.activeJobs.entries())if(V.job.cancel())Y++;this.info(`Canceled ${Y} jobs in queue '${L}'`)}}else for(let[H,U]of this.queues.entries()){let V=U.jobs.length;for(let z of U.jobs)z.job.cancel();for(let[z,X]of U.activeJobs.entries())if(X.job.cancel())V++;Y+=V,this.info(`Canceled ${V} jobs in queue '${H}'`)}if(Y>0&&Array.from(this.queues.values()).every((H)=>H.jobs.length===0&&H.activeJobs.size===0))this.workerStatus="idle";return Y}async getJob(L,Y){if(Y){let H=this.queues.get(Y);if(H){let U=H.jobs.find((z)=>z.job.id===L);if(U)return U.job;let V=H.activeJobs.get(L);if(V)return V.job}}else for(let H of this.queues.values()){let U=H.jobs.find((z)=>z.job.id===L);if(U)return U.job;let V=H.activeJobs.get(L);if(V)return V.job}return null}async start(){if(this.workerStatus="active",this.startedAt=new Date,this.defaultTransport)await this.defaultTransport.start();for(let L of this.queues.keys())await this.processQueueJobs(L);this.startScheduledJobTimer(),this.info(`Worker ${this.id} started`)}async stop(L=!1){if(this.workerStatus="stopping",this.stopScheduledJobTimer(),L){await this.cancelAllJobs();for(let Y of this.queues.keys())await this.clear(Y)}else{for(let H of this.queues.keys())await this.drain(H);let Y=()=>{return Array.from(this.queues.values()).every((H)=>H.processing===0)};if(!Y()){this.info(`Worker ${this.id} waiting for ${Array.from(this.queues.values()).reduce((H,U)=>H+U.processing,0)} jobs to complete`);while(!Y())await new Promise((H)=>setTimeout(H,100))}}if(this.defaultTransport)await this.defaultTransport.stop();this.workerStatus="stopped",this.info(`Worker ${this.id} stopped`)}async setConcurrency(L,Y){let H=this.queues.get(L);if(!H)throw this.error(`Cannot set concurrency: Queue '${L}' does not exist`),new Error(`Queue '${L}' does not exist`);if(Y<1)this.warning(`Invalid concurrency value ${Y}, using 1 instead`),Y=1;return H.options.concurrency=Y,this.info(`Set concurrency for queue '${L}' to ${Y}`),this.processQueueJobs(L)}startScheduledJobTimer(){if(this.scheduledJobTimer)clearInterval(this.scheduledJobTimer);this.scheduledJobTimer=setInterval(()=>{this.checkScheduledJobs()},this.scheduledJobInterval)}stopScheduledJobTimer(){if(this.scheduledJobTimer)clearInterval(this.scheduledJobTimer),this.scheduledJobTimer=void 0}checkScheduledJobs(){let L=new Date;for(let[Y,H]of this.queues.entries()){if(H.paused||H.draining)continue;let U=H.jobs.filter((V)=>V.scheduledFor&&V.scheduledFor<=L);for(let V of U)V.scheduledFor=void 0;if(U.length>0&&H.processing<H.options.concurrency)this.processQueueJobs(Y)}}async processQueueJobs(L){let Y=this.queues.get(L);if(!Y||Y.paused)return;let H=[];while(H.length<Y.options.concurrency)H.push(this.processNextJob(L));await Promise.all(H);let U=Array.from(this.queues.values());if(U.some((V)=>V.processing>0))this.workerStatus="active";else if(U.every((V)=>V.jobs.length===0&&V.processing===0))this.workerStatus="idle";if(U.some((V)=>V.jobs.length>0))await Bun.sleep(10),setImmediate(()=>this.processQueueJobs(L))}async sendJobStatusUpdate(L,Y,H,U,V){try{await this.transport.updateJobStatus(L,Y,H,{dispatchId:Y,status:U,error:V,retries:0,startedAt:U==="running"?new Date().toISOString():void 0,completedAt:U==="completed"||U==="failed"||U==="cancelled"?new Date().toISOString():void 0,updatedAt:Date.now().toString()})}catch(z){this.error(`Error sending job status update: ${z}`)}}async cancelJobInQueue(L,Y){let H=this.queues.get(Y);if(!H)return!1;let U=H.jobs.findIndex((V)=>V.job.id===L);if(U>=0){if(H.jobs[U].job.cancel())return H.jobs.splice(U,1),this.info(`Canceled job ${L} in queue '${Y}'`),!0}if(H.activeJobs.has(L)){if(H.activeJobs.get(L).job.cancel())return this.info(`Canceled running job ${L} in queue '${Y}'`),!0}return!1}}import{Service as d}from"@elysiumjs/core";class h extends M{isRunning=!1;registeredQueues=new Set;constructor(L="default",Y={}){super(Y.id);this.transport=new D("consumer",{connection:L,consumerName:`worker-${this.id}`,...Y}),this.transport.onMessage(this.handleTransportMessage.bind(this)),this.debug(`RedisWorker ${this.id} created with connection: ${L}`)}async start(){if(await super.start(),this.isRunning)return;this.isRunning=!0;for(let L of this.queues.keys())this.registeredQueues.add(L);await this.transport.start(),await this.transport.registerWorker(this.id,Array.from(this.registeredQueues)),this.info(`Redis worker ${this.id} started for queues: ${Array.from(this.registeredQueues).join(", ")}`)}async stop(L=!1){this.isRunning=!1,await super.stop(L),await this.transport.unregisterWorker(this.id),await this.transport.stop(),this.info(`Redis worker ${this.id} stopped`)}async processNextJob(L){let Y=this.queues.get(L);if(!Y||Y.paused||Y.processing>=Y.options.concurrency)return;let H=-1,U=new Date;for(let P=0;P<Y.jobs.length;P++){let Z=Y.jobs[P];if(Z.scheduledFor&&Z.scheduledFor>U)continue;if(Z.job.status==="cancelled"){H=P;break}let _=Z.job.constructor;if(A(_)?.overlapBehavior==="no_overlap"){if(await this.transport.isJobLocked(Z.job.id,L))continue}H=P;break}if(H===-1)return;let V=Y.jobs.splice(H,1)[0];if(!V)return;if(V.job.status==="cancelled")return this.info(`Skipping cancelled job ${V.job.id}`),await this.sendJobStatusUpdate(V.job.id,V.job.dispatchId,L,"cancelled"),this.processQueueJobs(L);let z=V.job.constructor,X=A(z);if(X?.overlapBehavior==="no_overlap"){if(!await this.transport.acquireJobLock(V.job.id,L)){Y.jobs.push(V);return}}Y.activeJobs.set(V.job.id,V),Y.processing++,await this.sendJobStatusUpdate(V.job.id,V.job.dispatchId,L,"running",void 0);try{if(this.debug(`Processing job ${V.job.id} (dispatch: ${V.job.dispatchId}) from queue '${L}'`),await V.job.run(),V.job.status==="failed"){let P=V.maxRetries!==void 0?V.maxRetries:Y.options.maxRetries;if(V.retries<P){V.retries++,V.job.incrementRetries(),this.warning(`Job ${V.job.id} failed, retrying (${V.retries}/${P})`);let Z=V.retryDelay!==void 0?V.retryDelay:Y.options.retryDelay;await this.sendJobStatusUpdate(V.job.id,V.job.dispatchId,L,"scheduled_for_retry",V.job.lastError?.message),setTimeout(()=>{if(V.job.status!=="cancelled")Y.jobs.push(V),this.processQueueJobs(L);else this.info(`Skipping cancelled job ${V.job.id} during retry delay`),this.sendJobStatusUpdate(V.job.id,V.job.dispatchId,L,"cancelled")},Z)}else if(this.error(`Job ${V.job.id} failed after ${V.retries} retries (max: ${P}): ${V.job.lastError?.message}`),await this.sendJobStatusUpdate(V.job.id,V.job.dispatchId,L,"failed",V.job.lastError?.message),Y.options.pauseOnError)this.pause(L),this.warning(`Queue '${L}' paused due to job failure`)}else if(V.job.status==="completed")this.success(`Job ${V.job.id} completed successfully`),await this.sendJobStatusUpdate(V.job.id,V.job.dispatchId,L,"completed");else if(V.job.status==="cancelled")this.info(`Job ${V.job.id} cancelled while running`),await this.sendJobStatusUpdate(V.job.id,V.job.dispatchId,L,"cancelled")}catch(P){if(this.error(`Unexpected error processing job ${V.job.id}: ${P}`),await this.sendJobStatusUpdate(V.job.id,V.job.dispatchId,L,"failed",P instanceof Error?P.message:String(P)),Y.options.pauseOnError)this.pause(L),this.warning(`Queue '${L}' paused due to unexpected error`)}finally{if(X?.overlapBehavior==="no_overlap")if(X.overlapDelay&&X.overlapDelay>0)setTimeout(()=>{this.transport.releaseJobLock(V.job.id,L)},X.overlapDelay);else await this.transport.releaseJobLock(V.job.id,L);Y.activeJobs.delete(V.job.id),Y.processing--,this.processQueueJobs(L)}}async createQueue(L){let Y=await super.createQueue(L);if(this.isRunning&&!this.registeredQueues.has(L.name))this.registeredQueues.add(L.name),await this.transport.registerWorker(this.id,Array.from(this.registeredQueues));return Y}async handleTransportMessage(L){try{switch(L.type){case"job:process":{let{job:Y,args:H=[],queue:U="default",options:V={},jobId:z,dispatchId:X}=L;await this.handleProcessJob(z,X,Y,H,U,V);break}case"job:cancel":{let{jobId:Y,dispatchId:H,queue:U="default"}=L;if(await this.cancelJob(Y,U))await this.sendJobStatusUpdate(Y,H,U,"cancelled");break}case"job:cancelAll":{let{queue:Y="default"}=L;await this.cancelAllJobs(Y);break}}}catch(Y){this.error(`Error handling transport message: ${Y}`)}}async handleProcessJob(L,Y,H,U,V,z){try{let X=d.get(H);if(!X)throw new Error(`Job class not found: ${H}`);let P=new(k(X,L,Y))(...U||[]);P.queueName=V;let Z={};if(z.scheduledFor)Z.scheduledFor=typeof z.scheduledFor==="string"?new Date(z.scheduledFor):z.scheduledFor;if(z.priority!==void 0)Z.priority=Number(z.priority);if(z.maxRetries!==void 0)Z.maxRetries=Number(z.maxRetries);if(z.retryDelay!==void 0)Z.retryDelay=Number(z.retryDelay);if(Z.scheduledFor&&Z.scheduledFor>new Date)await this.sendJobStatusUpdate(P.id,P.dispatchId,V,"scheduled_for_retry"),this.debug(`Scheduled job ${P.id} for future execution at ${Z.scheduledFor.toISOString()}`);else await this.sendJobStatusUpdate(P.id,P.dispatchId,V,"pending"),this.debug(`Added job ${P.id} to queue ${V} with initial status: pending`);await this.addJob(P,V,Z)}catch(X){throw this.error(`Error processing job ${H}: ${X.message}`),this.sendJobStatusUpdate(L,Y,V,"failed",X.message),X}}}export{w as WorkerStatus,x as TransportMode,h as RedisWorker,D as RedisTransport,E as Queue,T as JobStatus,F as JobOverlapBehavior,K as Job,y as Heracles,M as BaseWorker};
package/dist/job.d.ts ADDED
@@ -0,0 +1,249 @@
1
+ import 'reflect-metadata';
2
+ import type { Class } from 'type-fest';
3
+ import { InteractsWithConsole } from '@elysiumjs/core';
4
+ /**
5
+ * Defines how a job should behave when multiple instances with the same ID are dispatched.
6
+ * @author Axel Nana <axel.nana@workbud.com>
7
+ */
8
+ export declare enum JobOverlapBehavior {
9
+ /**
10
+ * Allow jobs with the same ID to execute concurrently.
11
+ */
12
+ ALLOW_OVERLAP = "allow_overlap",
13
+ /**
14
+ * Only allow one job with a given ID to execute at a time.
15
+ * Additional jobs with the same ID will be queued and executed in sequence.
16
+ */
17
+ NO_OVERLAP = "no_overlap"
18
+ }
19
+ /**
20
+ * Represents job metadata when registered.
21
+ * @author Axel Nana <axel.nana@workbud.com>
22
+ */
23
+ export interface JobMetadata {
24
+ /**
25
+ * The service name of the job in the container.
26
+ */
27
+ name: string;
28
+ /**
29
+ * The name of the queue the job is assigned to.
30
+ */
31
+ queue: string;
32
+ /**
33
+ * Defines how the job handles overlap with other instances of the same job ID.
34
+ */
35
+ overlapBehavior: JobOverlapBehavior;
36
+ /**
37
+ * When using NO_OVERLAP behavior, specifies the delay in milliseconds
38
+ * between sequential executions of jobs with the same ID.
39
+ */
40
+ overlapDelay: number;
41
+ /**
42
+ * Priority of this job (lower number = higher priority).
43
+ */
44
+ priority: number;
45
+ /**
46
+ * The maximum number of retries for this job type.
47
+ * This overrides queue-level retry settings.
48
+ * @default undefined - Uses queue setting
49
+ */
50
+ maxRetries?: number;
51
+ /**
52
+ * The delay between retries in milliseconds.
53
+ * This overrides queue-level retry delay settings.
54
+ * @default undefined - Uses queue setting
55
+ */
56
+ retryDelay?: number;
57
+ }
58
+ /**
59
+ * Properties required when declaring a job using the `@Job.register()` decorator.
60
+ * @author Axel Nana <axel.nana@workbud.com>
61
+ */
62
+ export type JobProps = {
63
+ /**
64
+ * The name of the job.
65
+ */
66
+ name?: string;
67
+ /**
68
+ * The queue on which the job should be processed.
69
+ */
70
+ queue?: string;
71
+ /**
72
+ * Priority of this job (lower number = higher priority).
73
+ * @default 0
74
+ */
75
+ priority?: number;
76
+ /**
77
+ * The maximum number of retries for this job type.
78
+ * This overrides queue-level retry settings.
79
+ * @default undefined - Uses queue setting
80
+ */
81
+ maxRetries?: number;
82
+ /**
83
+ * The delay between retries in milliseconds.
84
+ * This overrides queue-level retry delay settings.
85
+ * @default undefined - Uses queue setting
86
+ */
87
+ retryDelay?: number;
88
+ /**
89
+ * Defines how the job handles overlap with other instances of the same job ID.
90
+ * @default JobOverlapBehavior.ALLOW_OVERLAP
91
+ */
92
+ overlapBehavior?: JobOverlapBehavior;
93
+ /**
94
+ * When using NO_OVERLAP behavior, specifies the delay in milliseconds
95
+ * between sequential executions of jobs with the same ID.
96
+ * @default 0
97
+ */
98
+ overlapDelay?: number;
99
+ };
100
+ /**
101
+ * Type for any job classes.
102
+ * @author Axel Nana <axel.nana@workbud.com>
103
+ */
104
+ export type JobClass<T extends Job = Job, TClass extends Class<T> = Class<T>> = {
105
+ /**
106
+ * Description of the job.
107
+ */
108
+ readonly description: string;
109
+ /**
110
+ * Generates the ID for instances of this Job.
111
+ */
112
+ generateJobId(...args: ConstructorParameters<TClass>): string;
113
+ new (...args: ConstructorParameters<TClass>): T;
114
+ };
115
+ /**
116
+ * Base class for background jobs.
117
+ *
118
+ * Jobs are classes that can be executed in the background. They provide
119
+ * an abstract execute method that serves as the entry point for the job.
120
+ *
121
+ * @author Axel Nana <axel.nana@workbud.com>
122
+ */
123
+ export declare abstract class Job extends InteractsWithConsole {
124
+ #private;
125
+ /**
126
+ * Marks a class as a job.
127
+ * @author Axel Nana <axel.nana@workbud.com>
128
+ * @param options The decorator options.
129
+ */
130
+ static register(options?: JobProps): (target: JobClass) => void;
131
+ /**
132
+ * Generate a random job ID.
133
+ * @param args Arguments passed to the constructor.
134
+ * @returns A random job ID.
135
+ */
136
+ static generateJobId(..._args: any[]): string;
137
+ /**
138
+ * Description of the job.
139
+ */
140
+ static readonly description: string;
141
+ /**
142
+ * Unique identifier for the job.
143
+ */
144
+ readonly id: string;
145
+ /**
146
+ * The dispatch ID for this job instance.
147
+ * This is unique for each dispatch of the job, even if the job ID is the same.
148
+ */
149
+ readonly dispatchId: string;
150
+ /**
151
+ * The timestamp when the job was created.
152
+ */
153
+ readonly createdAt: Date;
154
+ /**
155
+ * The timestamp when the job started execution.
156
+ */
157
+ get startedAt(): Date | undefined;
158
+ /**
159
+ * The timestamp when the job completed execution.
160
+ */
161
+ get completedAt(): Date | undefined;
162
+ /**
163
+ * The current status of the job.
164
+ */
165
+ get status(): JobStatus;
166
+ /**
167
+ * The error that occurred during job execution, if any.
168
+ */
169
+ get lastError(): Error | undefined;
170
+ /**
171
+ * The number of times this job has been retried.
172
+ */
173
+ get retries(): number;
174
+ /**
175
+ * The name of the queue this job is assigned to.
176
+ */
177
+ get queueName(): string | undefined;
178
+ /**
179
+ * Set the queue for this job instance.
180
+ * @internal This should only be called by the queue implementation.
181
+ */
182
+ set queueName(value: string | undefined);
183
+ /**
184
+ * Increment the retry count for this job.
185
+ * @internal This should only be called by the worker implementation.
186
+ */
187
+ incrementRetries(): void;
188
+ /**
189
+ * Creates a new job instance.
190
+ *
191
+ * @param id Optional job ID. If not provided, a random ID will be generated.
192
+ */
193
+ constructor(id?: string, dispatchId?: string);
194
+ /**
195
+ * Run the job.
196
+ * This method handles the job lifecycle and calls the execute method.
197
+ * @returns A promise that resolves when the job completes (successfully or not).
198
+ */
199
+ run(): Promise<void>;
200
+ /**
201
+ * Cancels the job.
202
+ * @returns True if the job was cancelled, false if it was already in a terminal state.
203
+ */
204
+ cancel(): boolean;
205
+ /**
206
+ * The main execution method for the job.
207
+ * This must be implemented by all job classes.
208
+ */
209
+ protected abstract execute(): Promise<void>;
210
+ }
211
+ /**
212
+ * Wraps a Job class to assign a specific ID to all instances.
213
+ * @author Axel Nana <axel.nana@workbud.com>
214
+ * @internal
215
+ * @param J The Job class.
216
+ * @param id The ID to assign to the job.
217
+ * @returns A Job class which creates job instances with the specified ID.
218
+ */
219
+ export declare const WithId: <T extends Job, TClass extends Class<T> = Class<T>>(J: TClass, id: string, dispatchId: string) => TClass;
220
+ /**
221
+ * Possible statuses for a job.
222
+ * @author Axel Nana <axel.nana@workbud.com>
223
+ */
224
+ export declare enum JobStatus {
225
+ /**
226
+ * The job is in pending state; queued to run, but not started.
227
+ */
228
+ PENDING = "pending",
229
+ /**
230
+ * The job is currently running.
231
+ */
232
+ RUNNING = "running",
233
+ /**
234
+ * The job has completed successfully.
235
+ */
236
+ COMPLETED = "completed",
237
+ /**
238
+ * The job has failed to complete successfully. No retries left.
239
+ */
240
+ FAILED = "failed",
241
+ /**
242
+ * The job was cancelled and won't be retried or completed.
243
+ */
244
+ CANCELLED = "cancelled",
245
+ /**
246
+ * The job is scheduled to be retried after a failure.
247
+ */
248
+ SCHEDULED_FOR_RETRY = "scheduled_for_retry"
249
+ }