@elysiumjs/heracles 0.8.6 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/index.js +1 -1
- package/dist/job.d.ts +6 -0
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @elysiumjs/heracles
|
|
2
2
|
|
|
3
|
+
## 0.9.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#57](https://github.com/na2axl/elysiumjs/pull/57) [`d471a6e`](https://github.com/na2axl/elysiumjs/commit/d471a6e69736e016eb972a0715987df1d7b62438) Thanks [@na2axl](https://github.com/na2axl)! - Services, factory definitions, HTTP controllers, commands and jobs take an `env` option, as modules do: the `NODE_ENV` values in which they are active. In any other environment they are left out when their module loads: an inactive service is not registered (nor returned by `injectAll`), an inactive controller mounts no routes, an inactive command is not listed and an inactive job is neither dispatched nor scheduled. `Environments` reads and records them, and `ModuleGraph.own()` lists the active controllers, commands or jobs of a module. Commands no longer have a static `dev` flag: `@Command.register({ env })` (or the module's `env`) says where a command is available.
|
|
8
|
+
|
|
9
|
+
Elysium knows three environments: `development`, `test` and `production`. `Environment` types every `env` option and `currentEnvironment()`, still accepting an app's own names such as `staging`. `Environments.isDevelopment()`, `isTest()` and `isProduction()` tell which one is current, and `Environments.local` (`['development', 'test']`) lists the environments that run on a developer's machine. The detail and stack of server errors are shown in `test` as well as in `development`.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Updated dependencies [[`d471a6e`](https://github.com/na2axl/elysiumjs/commit/d471a6e69736e016eb972a0715987df1d7b62438)]:
|
|
14
|
+
- @elysiumjs/core@0.18.0
|
|
15
|
+
- @elysiumjs/redis@0.1.7
|
|
16
|
+
|
|
3
17
|
## 0.8.6
|
|
4
18
|
|
|
5
19
|
### Patch Changes
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
var __legacyDecorateClassTS=function(decorators,target,key,desc){var c=arguments.length,r=c<3?target:desc===null?desc=Object.getOwnPropertyDescriptor(target,key):desc,d;if(typeof Reflect==="object"&&typeof Reflect.decorate==="function")r=Reflect.decorate(decorators,target,key,desc);else for(var i=decorators.length-1;i>=0;i--)if(d=decorators[i])r=(c<3?d(r):c>3?d(target,key,r):d(target,key))||r;return c>3&&r&&Object.defineProperty(target,key,r),r};var __legacyMetadataTS=(k,v)=>{if(typeof Reflect==="object"&&typeof Reflect.metadata==="function")return Reflect.metadata(k,v)};import{ElysiumError}from"@elysiumjs/core";class InvalidCronError extends ElysiumError{expression;constructor(expression,reason){super(`Invalid cron expression "${expression}": ${reason}.`);this.expression=expression}}var FIELDS=[{min:0,max:59},{min:0,max:23},{min:1,max:31},{min:1,max:12,names:["jan","feb","mar","apr","may","jun","jul","aug","sep","oct","nov","dec"]},{min:0,max:7,names:["sun","mon","tue","wed","thu","fri","sat"]}],ALIASES={"@yearly":"0 0 1 1 *","@annually":"0 0 1 1 *","@monthly":"0 0 1 * *","@weekly":"0 0 * * 0","@daily":"0 0 * * *","@midnight":"0 0 * * *","@hourly":"0 * * * *"},value=(expression,field,raw)=>{let index=field.names?.indexOf(raw.toLowerCase())??-1,number=index>=0?index+(field.min===1?1:0):Number(raw);if(!Number.isInteger(number)||number<field.min||number>field.max)throw new InvalidCronError(expression,`"${raw}" is out of ${field.min}-${field.max}`);return number},parseField=(expression,field,source)=>{let values=new Set;for(let part of source.split(",")){let[range,stepText]=part.split("/"),step=stepText===void 0?1:Number(stepText);if(!Number.isInteger(step)||step<1)throw new InvalidCronError(expression,`"${part}" has an invalid step`);let from,to;if(range==="*")from=field.min,to=field.max;else if(range.includes("-")){let[a,b]=range.split("-");if(from=value(expression,field,a),to=value(expression,field,b),from>to)throw new InvalidCronError(expression,`"${range}" is a reversed range`)}else from=value(expression,field,range),to=stepText===void 0?from:field.max;for(let n=from;n<=to;n+=step)values.add(n)}return values},Cron;((Cron2)=>(Cron2.parse=(expression)=>{let parts=(ALIASES[expression.trim().toLowerCase()]??expression.trim()).split(/\s+/);if(parts.length!==5)throw new InvalidCronError(expression,"expected 5 fields");let[minutes,hours,days,months,weekdays]=parts.map((part,i)=>parseField(expression,FIELDS[i],part));if(weekdays.delete(7))weekdays.add(0);return{expression,minutes,hours,days,months,weekdays,anyDay:parts[2]==="*",anyWeekday:parts[4]==="*"}},Cron2.matches=(schedule,date)=>{if(!schedule.minutes.has(date.getUTCMinutes()))return!1;if(!schedule.hours.has(date.getUTCHours()))return!1;if(!schedule.months.has(date.getUTCMonth()+1))return!1;let day=schedule.days.has(date.getUTCDate()),weekday=schedule.weekdays.has(date.getUTCDay());if(schedule.anyDay&&schedule.anyWeekday)return!0;if(schedule.anyDay)return weekday;if(schedule.anyWeekday)return day;return day||weekday},Cron2.next=(schedule,from)=>{let date=new Date(from.getTime());date.setUTCSeconds(0,0),date.setUTCMinutes(date.getUTCMinutes()+1);let limit=from.getTime()+158112000000;while(date.getTime()<=limit){if(!schedule.months.has(date.getUTCMonth()+1)){date.setUTCMonth(date.getUTCMonth()+1,1),date.setUTCHours(0,0);continue}if(!Cron.matches({...schedule,minutes:ALL_MINUTES,hours:ALL_HOURS},date)){date.setUTCDate(date.getUTCDate()+1),date.setUTCHours(0,0);continue}if(!schedule.hours.has(date.getUTCHours())){date.setUTCHours(date.getUTCHours()+1,0);continue}if(!schedule.minutes.has(date.getUTCMinutes())){date.setUTCMinutes(date.getUTCMinutes()+1);continue}return date}throw new InvalidCronError(schedule.expression,"it matches no time in the next 5 years")}))(Cron||={});var ALL_MINUTES=new Set(Array.from({length:60},(_,i)=>i)),ALL_HOURS=new Set(Array.from({length:24},(_,i)=>i));import{ElysiumError as ElysiumError4,KERNEL_SERVICE,Module,runAll,Service as Service5}from"@elysiumjs/core";import{Command,CommandArgumentType,Runtime,Service}from"@elysiumjs/core";import{Redis}from"@elysiumjs/redis";import{InteractsWithConsole,Metadata as Metadata2}from"@elysiumjs/core";import{assign,uid}from"radash";import{Metadata}from"@elysiumjs/core";var Symbols;((Symbols)=>(Symbols.job=Symbol("elysium:job"),Symbols.queue=Symbol("elysium:queue"),Symbols.worker=Symbol("elysium:worker"),Symbols.transport=Symbol("elysium:transport"),Symbols.jobTransaction=Symbol("elysium:jobTransaction"),Symbols.workerPool=Symbol("elysium:workerPool")))(Symbols||={});var getJobMetadata=(jobClass)=>{if(!Metadata.has(Symbols.job,jobClass))return null;return Metadata.get(Symbols.job,jobClass)};var JobOverlapBehavior;((JobOverlapBehavior)=>{JobOverlapBehavior.ALLOW_OVERLAP="allow_overlap";JobOverlapBehavior.NO_OVERLAP="no_overlap"})(JobOverlapBehavior||={});class Job extends InteractsWithConsole{#startedAt;#completedAt;#status="pending";#err;#retries=0;#queueName;static register(options={}){return function(target){options=assign({name:target.name,queue:"default",overlapBehavior:"allow_overlap",overlapDelay:0,priority:0},options);let metadata={name:`elysium.heracles.job.${options.name}`,queue:options.queue,overlapBehavior:options.overlapBehavior,overlapDelay:options.overlapDelay,priority:options.priority,maxRetries:options.maxRetries,retryDelay:options.retryDelay};Metadata2.define(Symbols.job,metadata,target)}}static generateJobId(..._args){return`job_${Date.now()}_${uid(8)}`}static description="An Heracles job.";id;dispatchId;createdAt;get startedAt(){return this.#startedAt}get completedAt(){return this.#completedAt}get status(){return this.#status}get lastError(){return this.#err}get retries(){return this.#retries}get queueName(){return this.#queueName}set queueName(value){this.#queueName=value}incrementRetries(){this.#retries++}constructor(id,dispatchId){super();this.id=id??this.constructor.generateJobId(),this.dispatchId=dispatchId??`dispatch_${Date.now()}_${uid(8)}`,this.createdAt=new Date}async run(){if(this.#status==="cancelled")return;try{this.#status="running",this.#startedAt=new Date;let queueInfo=this.#queueName?` in queue '${this.#queueName}'`:"";if(this.debug(`Job ${this.id}${queueInfo} started at ${this.#startedAt.toISOString()}`),await this.execute(),this.#status==="running")this.#status="completed",this.#completedAt=new Date,this.debug(`Job ${this.id} completed successfully at ${this.#completedAt.toISOString()} after ${this.retries} retries`)}catch(err){this.fail(err instanceof Error?err:Error(String(err)))}}fail(error){if(this.#err=error,this.#status!=="cancelled")this.#status="failed",this.#completedAt=new Date,this.error(`Job ${this.id} failed at ${this.#completedAt.toISOString()} after ${this.retries} retries: ${this.#err.message}`),this.trace(this.#err)}cancel(){if(this.#status==="completed"||this.#status==="failed")return!1;let wasRunning=this.#status==="running";if(this.#status="cancelled",wasRunning)this.#completedAt=new Date;return this.info(`Job ${this.id} was cancelled${wasRunning?" while running":" before execution"}`),!0}}var WithId=(J,id,dispatchId)=>class extends J{constructor(...args){super(...args);this.id=id,this.dispatchId=dispatchId}},JobStatus;((JobStatus)=>{JobStatus.PENDING="pending";JobStatus.RUNNING="running";JobStatus.COMPLETED="completed";JobStatus.FAILED="failed";JobStatus.CANCELLED="cancelled";JobStatus.SCHEDULED_FOR_RETRY="scheduled_for_retry"})(JobStatus||={});class HeraclesCleanCommand extends Command{constructor(){super(...arguments);this.redis="default";this.queues=[];this.dryRun=!1;this.verbose=!1;this.all=!1;this.keyPrefix="elysium:heracles";this.retention=3600;this.max=1000}static command="heracles:clean";static description="Clean up Redis streams by removing completed and failed jobs.";async run(){try{if(!Redis.connectionExists(this.redis))this.error(`Redis connection '${this.redis}' does not exist`),Runtime.get().exit(1);let redisClient=await Service.resolve(Redis.getConnectionName(this.redis));if(this.queues.length===0){let keys=await redisClient.send("KEYS",[`${this.keyPrefix}:stream:*`]);this.queues=keys.map((key)=>key.replace(`${this.keyPrefix}:stream:`,""))}if(this.verbose)this.write("=== Redis Stream Cleanup ==="),this.write(`- Redis connection: ${this.redis}`),this.write(`- Key prefix: ${this.keyPrefix}`),this.write(`- Queues to clean: ${this.queues.join(", ")||"none found"}`),this.write(`- Retention period: ${this.retention} seconds`),this.write(`- Maximum stream size: ${this.max}`),this.write(`- Dry run mode: ${this.dryRun?"Yes":"No"}`);if(this.queues.length===0){this.newLine(),this.error("No queues found to clean");return}let totalRemoved=0,totalTrimmed=0,totalStatusRemoved=0,spinner=this.spinner("Cleaning streams...");for(let queue of this.queues){let streamKey=`${this.keyPrefix}:stream:${queue}`,streamLength=0;try{if(streamLength=await redisClient.send("XLEN",[streamKey]),this.verbose)spinner.pause(`
|
|
2
|
+
var __legacyDecorateClassTS=function(decorators,target,key,desc){var c=arguments.length,r=c<3?target:desc===null?desc=Object.getOwnPropertyDescriptor(target,key):desc,d;if(typeof Reflect==="object"&&typeof Reflect.decorate==="function")r=Reflect.decorate(decorators,target,key,desc);else for(var i=decorators.length-1;i>=0;i--)if(d=decorators[i])r=(c<3?d(r):c>3?d(target,key,r):d(target,key))||r;return c>3&&r&&Object.defineProperty(target,key,r),r};var __legacyMetadataTS=(k,v)=>{if(typeof Reflect==="object"&&typeof Reflect.metadata==="function")return Reflect.metadata(k,v)};import{ElysiumError}from"@elysiumjs/core";class InvalidCronError extends ElysiumError{expression;constructor(expression,reason){super(`Invalid cron expression "${expression}": ${reason}.`);this.expression=expression}}var FIELDS=[{min:0,max:59},{min:0,max:23},{min:1,max:31},{min:1,max:12,names:["jan","feb","mar","apr","may","jun","jul","aug","sep","oct","nov","dec"]},{min:0,max:7,names:["sun","mon","tue","wed","thu","fri","sat"]}],ALIASES={"@yearly":"0 0 1 1 *","@annually":"0 0 1 1 *","@monthly":"0 0 1 * *","@weekly":"0 0 * * 0","@daily":"0 0 * * *","@midnight":"0 0 * * *","@hourly":"0 * * * *"},value=(expression,field,raw)=>{let index=field.names?.indexOf(raw.toLowerCase())??-1,number=index>=0?index+(field.min===1?1:0):Number(raw);if(!Number.isInteger(number)||number<field.min||number>field.max)throw new InvalidCronError(expression,`"${raw}" is out of ${field.min}-${field.max}`);return number},parseField=(expression,field,source)=>{let values=new Set;for(let part of source.split(",")){let[range,stepText]=part.split("/"),step=stepText===void 0?1:Number(stepText);if(!Number.isInteger(step)||step<1)throw new InvalidCronError(expression,`"${part}" has an invalid step`);let from,to;if(range==="*")from=field.min,to=field.max;else if(range.includes("-")){let[a,b]=range.split("-");if(from=value(expression,field,a),to=value(expression,field,b),from>to)throw new InvalidCronError(expression,`"${range}" is a reversed range`)}else from=value(expression,field,range),to=stepText===void 0?from:field.max;for(let n=from;n<=to;n+=step)values.add(n)}return values},Cron;((Cron2)=>(Cron2.parse=(expression)=>{let parts=(ALIASES[expression.trim().toLowerCase()]??expression.trim()).split(/\s+/);if(parts.length!==5)throw new InvalidCronError(expression,"expected 5 fields");let[minutes,hours,days,months,weekdays]=parts.map((part,i)=>parseField(expression,FIELDS[i],part));if(weekdays.delete(7))weekdays.add(0);return{expression,minutes,hours,days,months,weekdays,anyDay:parts[2]==="*",anyWeekday:parts[4]==="*"}},Cron2.matches=(schedule,date)=>{if(!schedule.minutes.has(date.getUTCMinutes()))return!1;if(!schedule.hours.has(date.getUTCHours()))return!1;if(!schedule.months.has(date.getUTCMonth()+1))return!1;let day=schedule.days.has(date.getUTCDate()),weekday=schedule.weekdays.has(date.getUTCDay());if(schedule.anyDay&&schedule.anyWeekday)return!0;if(schedule.anyDay)return weekday;if(schedule.anyWeekday)return day;return day||weekday},Cron2.next=(schedule,from)=>{let date=new Date(from.getTime());date.setUTCSeconds(0,0),date.setUTCMinutes(date.getUTCMinutes()+1);let limit=from.getTime()+158112000000;while(date.getTime()<=limit){if(!schedule.months.has(date.getUTCMonth()+1)){date.setUTCMonth(date.getUTCMonth()+1,1),date.setUTCHours(0,0);continue}if(!Cron.matches({...schedule,minutes:ALL_MINUTES,hours:ALL_HOURS},date)){date.setUTCDate(date.getUTCDate()+1),date.setUTCHours(0,0);continue}if(!schedule.hours.has(date.getUTCHours())){date.setUTCHours(date.getUTCHours()+1,0);continue}if(!schedule.minutes.has(date.getUTCMinutes())){date.setUTCMinutes(date.getUTCMinutes()+1);continue}return date}throw new InvalidCronError(schedule.expression,"it matches no time in the next 5 years")}))(Cron||={});var ALL_MINUTES=new Set(Array.from({length:60},(_,i)=>i)),ALL_HOURS=new Set(Array.from({length:24},(_,i)=>i));import{ElysiumError as ElysiumError4,KERNEL_SERVICE,Module,runAll,Service as Service5}from"@elysiumjs/core";import{Command,CommandArgumentType,Runtime,Service}from"@elysiumjs/core";import{Redis}from"@elysiumjs/redis";import{Environments,InteractsWithConsole,Metadata as Metadata2}from"@elysiumjs/core";import{assign,uid}from"radash";import{Metadata}from"@elysiumjs/core";var Symbols;((Symbols)=>(Symbols.job=Symbol("elysium:job"),Symbols.queue=Symbol("elysium:queue"),Symbols.worker=Symbol("elysium:worker"),Symbols.transport=Symbol("elysium:transport"),Symbols.jobTransaction=Symbol("elysium:jobTransaction"),Symbols.workerPool=Symbol("elysium:workerPool")))(Symbols||={});var getJobMetadata=(jobClass)=>{if(!Metadata.has(Symbols.job,jobClass))return null;return Metadata.get(Symbols.job,jobClass)};var JobOverlapBehavior;((JobOverlapBehavior)=>{JobOverlapBehavior.ALLOW_OVERLAP="allow_overlap";JobOverlapBehavior.NO_OVERLAP="no_overlap"})(JobOverlapBehavior||={});class Job extends InteractsWithConsole{#startedAt;#completedAt;#status="pending";#err;#retries=0;#queueName;static register(options={}){return function(target){options=assign({name:target.name,queue:"default",overlapBehavior:"allow_overlap",overlapDelay:0,priority:0},options);let metadata={name:`elysium.heracles.job.${options.name}`,queue:options.queue,overlapBehavior:options.overlapBehavior,overlapDelay:options.overlapDelay,priority:options.priority,maxRetries:options.maxRetries,retryDelay:options.retryDelay};Metadata2.define(Symbols.job,metadata,target),Environments.set(target,options.env)}}static generateJobId(..._args){return`job_${Date.now()}_${uid(8)}`}static description="An Heracles job.";id;dispatchId;createdAt;get startedAt(){return this.#startedAt}get completedAt(){return this.#completedAt}get status(){return this.#status}get lastError(){return this.#err}get retries(){return this.#retries}get queueName(){return this.#queueName}set queueName(value){this.#queueName=value}incrementRetries(){this.#retries++}constructor(id,dispatchId){super();this.id=id??this.constructor.generateJobId(),this.dispatchId=dispatchId??`dispatch_${Date.now()}_${uid(8)}`,this.createdAt=new Date}async run(){if(this.#status==="cancelled")return;try{this.#status="running",this.#startedAt=new Date;let queueInfo=this.#queueName?` in queue '${this.#queueName}'`:"";if(this.debug(`Job ${this.id}${queueInfo} started at ${this.#startedAt.toISOString()}`),await this.execute(),this.#status==="running")this.#status="completed",this.#completedAt=new Date,this.debug(`Job ${this.id} completed successfully at ${this.#completedAt.toISOString()} after ${this.retries} retries`)}catch(err){this.fail(err instanceof Error?err:Error(String(err)))}}fail(error){if(this.#err=error,this.#status!=="cancelled")this.#status="failed",this.#completedAt=new Date,this.error(`Job ${this.id} failed at ${this.#completedAt.toISOString()} after ${this.retries} retries: ${this.#err.message}`),this.trace(this.#err)}cancel(){if(this.#status==="completed"||this.#status==="failed")return!1;let wasRunning=this.#status==="running";if(this.#status="cancelled",wasRunning)this.#completedAt=new Date;return this.info(`Job ${this.id} was cancelled${wasRunning?" while running":" before execution"}`),!0}}var WithId=(J,id,dispatchId)=>class extends J{constructor(...args){super(...args);this.id=id,this.dispatchId=dispatchId}},JobStatus;((JobStatus)=>{JobStatus.PENDING="pending";JobStatus.RUNNING="running";JobStatus.COMPLETED="completed";JobStatus.FAILED="failed";JobStatus.CANCELLED="cancelled";JobStatus.SCHEDULED_FOR_RETRY="scheduled_for_retry"})(JobStatus||={});class HeraclesCleanCommand extends Command{constructor(){super(...arguments);this.redis="default";this.queues=[];this.dryRun=!1;this.verbose=!1;this.all=!1;this.keyPrefix="elysium:heracles";this.retention=3600;this.max=1000}static command="heracles:clean";static description="Clean up Redis streams by removing completed and failed jobs.";async run(){try{if(!Redis.connectionExists(this.redis))this.error(`Redis connection '${this.redis}' does not exist`),Runtime.get().exit(1);let redisClient=await Service.resolve(Redis.getConnectionName(this.redis));if(this.queues.length===0){let keys=await redisClient.send("KEYS",[`${this.keyPrefix}:stream:*`]);this.queues=keys.map((key)=>key.replace(`${this.keyPrefix}:stream:`,""))}if(this.verbose)this.write("=== Redis Stream Cleanup ==="),this.write(`- Redis connection: ${this.redis}`),this.write(`- Key prefix: ${this.keyPrefix}`),this.write(`- Queues to clean: ${this.queues.join(", ")||"none found"}`),this.write(`- Retention period: ${this.retention} seconds`),this.write(`- Maximum stream size: ${this.max}`),this.write(`- Dry run mode: ${this.dryRun?"Yes":"No"}`);if(this.queues.length===0){this.newLine(),this.error("No queues found to clean");return}let totalRemoved=0,totalTrimmed=0,totalStatusRemoved=0,spinner=this.spinner("Cleaning streams...");for(let queue of this.queues){let streamKey=`${this.keyPrefix}:stream:${queue}`,streamLength=0;try{if(streamLength=await redisClient.send("XLEN",[streamKey]),this.verbose)spinner.pause(`
|
|
3
3
|
Queue: ${queue}`),spinner.pause(`- Current stream length: ${streamLength}`)}catch(e){if(this.verbose)spinner.pause(`
|
|
4
4
|
Queue: ${queue} - stream not found or empty`);continue}if(this.all){if(!this.dryRun){if(await redisClient.del(streamKey),totalRemoved+=streamLength,this.verbose)spinner.pause(`- Deleted entire stream (${streamLength} entries)`)}else if(this.verbose)spinner.pause(`- Would delete entire stream (${streamLength} entries)`)}else{if(this.retention>0){let statusKeys=await redisClient.send("KEYS",[`${this.keyPrefix}:status:${queue}:*`]),completedIds=[];for(let statusKey of statusKeys){let status=await redisClient.send("HGET",[statusKey,"status"]),updatedAt=await redisClient.send("HGET",[statusKey,"updatedAt"]);if((status==="completed"||status==="failed"||status==="cancelled")&&updatedAt&&parseInt(updatedAt)<Date.now()-this.retention*1000){let jobId=statusKey.split(":").pop()??"",messageId=await redisClient.send("HGET",[statusKey,"messageId"]);if(messageId)completedIds.push(messageId);else{let messages=await redisClient.send("XRANGE",[streamKey,"-","+","COUNT","1000"]);for(let[msgId,fields]of messages){let fieldsObj={};for(let i=0;i<fields.length;i+=2)fieldsObj[fields[i]]=fields[i+1];if((fieldsObj.jobId===jobId||msgId.includes(jobId))&&(fieldsObj.type==="job:result"||fieldsObj.type==="job:status"||fieldsObj.type==="job:process"))completedIds.push(msgId)}}if(!this.dryRun)await redisClient.send("DEL",[statusKey]),totalStatusRemoved++}}if(completedIds.length>0){if(this.verbose)spinner.pause(`- Found ${completedIds.length} expired completed job entries`);if(!this.dryRun){for(let i=0;i<completedIds.length;i+=100){let batch=completedIds.slice(i,i+100);await redisClient.send("XDEL",[streamKey,...batch])}if(totalRemoved+=completedIds.length,this.verbose)spinner.pause(`- Removed ${completedIds.length} expired completed job entries from stream`)}else if(this.verbose)spinner.pause(`- Would remove ${completedIds.length} expired completed job entries from stream`)}else if(this.verbose)spinner.pause("- No expired completed job entries found")}if(this.max>0&&streamLength>this.max){if(!this.dryRun){if(await redisClient.send("XTRIM",[streamKey,"MAXLEN","~",this.max.toString()]),totalTrimmed+=streamLength-this.max,this.verbose)spinner.pause(`- Trimmed stream from ${streamLength} to ~${this.max} entries`)}else if(this.verbose)spinner.pause(`- Would trim stream from ${streamLength} to ~${this.max} entries`)}}}spinner.complete("Cleanup operation completed")}catch(error){this.trace(error,"Error cleaning up Redis streams"),Runtime.get().exit(1)}}}__legacyDecorateClassTS([Command.arg({name:"redis",required:!0,description:"Name of the Redis connection to use",type:CommandArgumentType.STRING,default:"default"}),__legacyMetadataTS("design:type",String)],HeraclesCleanCommand.prototype,"redis",void 0),__legacyDecorateClassTS([Command.arg({name:"queues",description:"List of queues to clean. If empty, all queues will be cleaned",type:CommandArgumentType.ARRAY,arrayType:CommandArgumentType.STRING,default:[]}),__legacyMetadataTS("design:type",Array)],HeraclesCleanCommand.prototype,"queues",void 0),__legacyDecorateClassTS([Command.arg({name:"dry-run",description:"Show what would be removed without actually removing",type:CommandArgumentType.BOOLEAN,default:!1}),__legacyMetadataTS("design:type",Boolean)],HeraclesCleanCommand.prototype,"dryRun",void 0),__legacyDecorateClassTS([Command.arg({name:"verbose",description:"Enable verbose logging",type:CommandArgumentType.BOOLEAN,default:!1}),__legacyMetadataTS("design:type",Boolean)],HeraclesCleanCommand.prototype,"verbose",void 0),__legacyDecorateClassTS([Command.arg({name:"all",description:"Remove all entries, not only completed and failed jobs",type:CommandArgumentType.BOOLEAN,default:!1}),__legacyMetadataTS("design:type",Boolean)],HeraclesCleanCommand.prototype,"all",void 0),__legacyDecorateClassTS([Command.arg({name:"key-prefix",description:"Redis key prefix to clean",type:CommandArgumentType.STRING,default:"elysium:heracles"}),__legacyMetadataTS("design:type",String)],HeraclesCleanCommand.prototype,"keyPrefix",void 0),__legacyDecorateClassTS([Command.arg({name:"retention",description:"Number of seconds to retain completed jobs",type:CommandArgumentType.NUMBER,default:3600}),__legacyMetadataTS("design:type",Number)],HeraclesCleanCommand.prototype,"retention",void 0),__legacyDecorateClassTS([Command.arg({name:"max",description:"Maximum number of jobs to clean",type:CommandArgumentType.NUMBER,default:1000}),__legacyMetadataTS("design:type",Number)],HeraclesCleanCommand.prototype,"max",void 0),HeraclesCleanCommand=__legacyDecorateClassTS([Command.register()],HeraclesCleanCommand);import{Command as Command2,ConsoleFormat,InteractsWithConsole as InteractsWithConsole2}from"@elysiumjs/core";import{ElysiumError as ElysiumError2}from"@elysiumjs/core";class DuplicateJobError extends ElysiumError2{job;modules;constructor(job,modules){super(`Job "${job}" is declared by both ${modules[0]} and ${modules[1]}.`);this.job=job;this.modules=modules}}var QUEUES_SERVICE="heracles.queues",entries=new Map,JobRegistry;((JobRegistry)=>(JobRegistry.set=(next)=>{entries=new Map(next)},JobRegistry.get=(name)=>entries.get(name),JobRegistry.all=()=>[...entries].map(([name,entry])=>({name,...entry})),JobRegistry.clear=()=>{entries=new Map}))(JobRegistry||={});class HeraclesListCommand extends Command2{static command="heracles:list";static description="List all registered Heracles jobs and schedulers.";async run(){let jobs=JobRegistry.all();if(jobs.length===0)this.write("No registered jobs found.");else{this.title("Jobs:");for(let{name,jobClass:job}of jobs){let alias=name.replace("elysium.heracles.job.",""),args=alias===job.name?" ":` (${job.name}) `;this.write(` ${this.format(alias,ConsoleFormat.CYAN)}${args}${"\u2219".repeat(InteractsWithConsole2.SPACE_WIDTH-alias.length-args.length)} ${job.description}`)}}}}HeraclesListCommand=__legacyDecorateClassTS([Command2.register()],HeraclesListCommand);import{Command as Command3,CommandArgumentType as CommandArgumentType2,Runtime as Runtime3}from"@elysiumjs/core";import{Redis as Redis4}from"@elysiumjs/redis";import{ModuleContext}from"@elysiumjs/core";import{Redis as Redis3}from"@elysiumjs/redis";var TransportMode;((TransportMode)=>{TransportMode.CONSUMER="consumer";TransportMode.PRODUCER="producer"})(TransportMode||={});import{InteractsWithConsole as InteractsWithConsole3,Service as Service2}from"@elysiumjs/core";import{Redis as Redis2}from"@elysiumjs/redis";import{uid as uid2}from"radash";class RedisTransport extends InteractsWithConsole3{commandClient=null;subscriberClient=null;publisherClient=null;options;mode;messageHandlers=[];consumerId;isRunning=!1;cleanupTimer;jobLocks=new Map;constructor(mode,options={}){super();this.mode=mode,this.options={connection:options.connection||"default",keyPrefix:options.keyPrefix||"elysium:heracles",consumerGroup:options.consumerGroup||"workers",consumerName:options.consumerName||`worker-${uid2(8)}`,batchSize:options.batchSize||10,statusTTL:options.statusTTL||86400,cleanupCompletedJobs:options.cleanupCompletedJobs!==!1,completedJobRetention:options.completedJobRetention||3600,maxStreamSize:options.maxStreamSize||1000},this.consumerId=this.mode==="consumer"?this.options.consumerName:`producer-${uid2(8)}`}async initializeClients(){let connection=await Service2.resolve(Redis2.getConnectionName(this.options.connection));this.commandClient=await connection.duplicate(),this.subscriberClient=await connection.duplicate(),this.publisherClient=await connection.duplicate(),this.setupConnectionHandlers()}setupConnectionHandlers(){this.commandClient.onconnect=()=>{this.debug("Command client connected")},this.commandClient.onclose=(error)=>{this.error(`Command client disconnected: ${error}`)},this.subscriberClient.onconnect=()=>{this.debug("Subscriber client connected")},this.subscriberClient.onclose=(error)=>{this.error(`Subscriber client disconnected: ${error}`)},this.publisherClient.onconnect=()=>{this.debug("Publisher client connected")},this.publisherClient.onclose=(error)=>{this.error(`Publisher client disconnected: ${error}`)}}async start(){if(this.isRunning)return;try{if(await this.initializeClients(),await Promise.all([this.commandClient.connect(),this.subscriberClient.connect(),this.publisherClient.connect()]),await this.commandClient.send("PING",[]),await this.publisherClient.send("PING",[]),this.info(`RedisTransport started in ${this.mode} mode with ID: ${this.consumerId}`),this.mode==="consumer"){if(await this.subscribeToChannels(),this.options.cleanupCompletedJobs)this.scheduleCleanup()}this.isRunning=!0}catch(error){throw this.error(`Failed to start RedisTransport: ${error}`),Error(`Redis connection failed: ${error}`)}}async stop(){if(this.isRunning=!1,this.cleanupTimer)clearInterval(this.cleanupTimer),this.cleanupTimer=void 0;if(this.mode==="consumer")await this.subscriberClient?.unsubscribe();this.commandClient?.close(),this.subscriberClient?.close(),this.publisherClient?.close(),this.info("RedisTransport stopped")}async subscribeToChannels(){let queues=await this.getRegisteredQueues();for(let queue of queues)await this.subscriberClient.subscribe(`${this.options.keyPrefix}:queue:${queue}:new`,async(messageId,_)=>{this.debug(`New job notification for queue ${queue}: ${messageId}`),await this.processJobFromStream(queue,messageId)}),await this.subscriberClient.subscribe(`${this.options.keyPrefix}:queue:${queue}:control`,async(message,_)=>{try{let control=JSON.parse(message);await this.handleControlMessage(control)}catch(error){this.error(`Error parsing control message: ${error}`)}}),await this.subscriberClient.subscribe(`${this.options.keyPrefix}:lock:${queue}:released`,(jobId,_)=>{this.debug(`Lock released for job ${jobId} in queue ${queue}`),this.notifyLockRelease(jobId,queue)});await this.subscriberClient.subscribe(`${this.options.keyPrefix}:worker:coordination`,async(message,_)=>{try{let event=JSON.parse(message);await this.handleWorkerCoordination(event)}catch(error){this.error(`Error handling worker coordination: ${error}`)}})}async processJobFromStream(queue,messageId){let streamKey=this.getStreamKey(queue);try{let messages=await this.commandClient.send("XRANGE",[streamKey,messageId,messageId]);if(messages&&messages.length>0){let[_id,fields]=messages[0],message=this.deserializeMessage(fields,queue);for(let handler of this.messageHandlers)try{await handler(message)}catch(error){this.error(`Error in message handler: ${error}`)}await this.commandClient.send("XACK",[streamKey,this.options.consumerGroup,messageId])}}catch(error){this.error(`Error processing job from stream: ${error}`)}}async handleControlMessage(control){switch(control.type){case"pause":this.info(`Queue ${control.queue} paused`);break;case"resume":this.info(`Queue ${control.queue} resumed`);break;case"drain":this.info(`Queue ${control.queue} draining`);break;default:this.debug(`Unknown control message type: ${control.type}`)}}async handleWorkerCoordination(event){switch(event.type){case"worker:joined":this.debug(`Worker ${event.workerId} joined`);break;case"worker:left":this.debug(`Worker ${event.workerId} left`);break;case"rebalance":this.debug("Rebalancing work across workers");break}}async send(message){try{let queueName=this.extractQueueName(message),streamKey=this.getStreamKey(queueName);if(message.type==="job:process"){let messageData=this.serializeMessage(message),messageId=await this.commandClient.send("XADD",[streamKey,"*",...messageData]);await this.indexJob({jobId:message.jobId,dispatchId:message.dispatchId,queue:queueName,status:"pending",priority:message.options?.priority||10,timestamp:Date.now()}),await this.storeJobStatus({jobId:message.jobId,dispatchId:message.dispatchId,queue:queueName,status:"pending",retries:0,createdAt:new Date().toISOString(),updatedAt:new Date().toISOString(),messageId}),await this.publisherClient.send("PUBLISH",[`${this.options.keyPrefix}:queue:${queueName}:new`,messageId]),this.debug(`Job ${message.jobId} dispatched to queue ${queueName} with ID ${messageId}`)}else if(message.type==="job:status"||message.type==="job:result")await this.updateJobStatus(message.jobId,message.dispatchId,queueName,{status:message.status,error:message.error,retries:message.retries,completedAt:message.completedAt,updatedAt:new Date().toISOString()}),await this.updateJobIndex(message.jobId,message.dispatchId,queueName,message.status),await this.publisherClient.send("PUBLISH",[`${this.options.keyPrefix}:job:${message.jobId}:status`,JSON.stringify({status:message.status,error:message.error,timestamp:Date.now()})]);else if(message.type==="job:cancel")await this.publisherClient.send("PUBLISH",[`${this.options.keyPrefix}:queue:${queueName}:control`,JSON.stringify({type:"cancel",jobId:message.jobId,dispatchId:message.dispatchId})]);else{let messageData=this.serializeMessage(message);await this.commandClient.send("XADD",[streamKey,"*",...messageData])}}catch(error){throw this.error(`Failed to send message: ${error}`),Error(`Failed to send message: ${error}`)}}onMessage(handler){this.messageHandlers.push(handler)}async getJobStatus(jobId,dispatchId,queueName){try{let statusKey=this.getJobStatusKey(jobId,dispatchId,queueName),jobData=await this.commandClient.send("HMGET",[statusKey,"jobId","dispatchId","queue","status","error","retries","createdAt","startedAt","completedAt","updatedAt","messageId"]);if(jobData&&jobData[0])return{jobId:jobData[0],dispatchId:jobData[1],queue:jobData[2]||queueName,status:jobData[3]||"unknown",error:jobData[4],retries:parseInt(jobData[5]||"0",10),createdAt:jobData[6]||new Date().toISOString(),startedAt:jobData[7],completedAt:jobData[8],updatedAt:jobData[9],messageId:jobData[10]};let indexData=await this.getJobFromIndex(jobId,dispatchId,queueName);if(indexData)return{jobId,dispatchId,queue:queueName,status:indexData.status,retries:0,createdAt:new Date(indexData.timestamp).toISOString()};return{jobId,dispatchId,queue:queueName,status:"unknown",retries:0,createdAt:new Date().toISOString()}}catch(error){return this.warning(`Failed to get job status: ${error}`),{jobId,dispatchId,queue:queueName,status:"unknown",retries:0,createdAt:new Date().toISOString()}}}async updateJobStatus(jobId,dispatchId,queueName,updates){try{let statusKey=this.getJobStatusKey(jobId,dispatchId,queueName),fields=[];if(updates.status)fields.push("status",updates.status);if(updates.error!==void 0)fields.push("error",updates.error||"");if(updates.retries!==void 0)fields.push("retries",updates.retries.toString());if(updates.startedAt)fields.push("startedAt",updates.startedAt);if(updates.completedAt)fields.push("completedAt",updates.completedAt);if(updates.updatedAt)fields.push("updatedAt",updates.updatedAt);if(fields.length>0){if(await this.commandClient.send("HMSET",[statusKey,...fields]),await this.commandClient.expire(statusKey,this.options.statusTTL),updates.status)await this.updateJobIndex(jobId,dispatchId,queueName,updates.status);await this.publisherClient.send("PUBLISH",[`${this.options.keyPrefix}:job:${jobId}:status`,JSON.stringify({...updates,timestamp:Date.now()})]),this.debug(`Updated job ${jobId} status: ${updates.status||"fields updated"}`)}}catch(error){throw this.error(`Failed to update job status: ${error}`),error}}async registerWorker(workerId,queues){try{for(let queue of queues){let streamKey=this.getStreamKey(queue);await this.ensureConsumerGroupExists(streamKey)}let workerKey=this.getWorkerKey(workerId);await this.commandClient.send("HMSET",[workerKey,"id",workerId,"status","active","lastSeen",new Date().toISOString(),"queues",JSON.stringify(queues)]),await this.commandClient.expire(workerKey,60),await this.publisherClient.send("PUBLISH",[`${this.options.keyPrefix}:worker:coordination`,JSON.stringify({type:"worker:joined",workerId,queues,timestamp:Date.now()})]),this.debug(`Registered worker ${workerId} for queues: ${queues.join(", ")}`)}catch(error){throw this.error(`Failed to register worker: ${error}`),error}}async unregisterWorker(workerId){try{let workerKey=this.getWorkerKey(workerId);await this.commandClient.del(workerKey),await this.publisherClient.send("PUBLISH",[`${this.options.keyPrefix}:worker:coordination`,JSON.stringify({type:"worker:left",workerId,timestamp:Date.now()})]),this.debug(`Unregistered worker ${workerId}`)}catch(error){throw this.error(`Failed to unregister worker: ${error}`),error}}async indexJob(entry){let score=entry.timestamp,member=`${entry.jobId}:${entry.dispatchId}`;await Promise.all([this.commandClient.send("ZADD",[`${this.options.keyPrefix}:idx:queue:${entry.queue}`,score.toString(),member]),this.commandClient.send("ZADD",[`${this.options.keyPrefix}:idx:status:${entry.status}`,score.toString(),member]),this.commandClient.send("ZADD",[`${this.options.keyPrefix}:idx:priority`,entry.priority.toString(),member])])}async updateJobIndex(jobId,dispatchId,_queue,newStatus){let member=`${jobId}:${dispatchId}`,timestamp=Date.now(),removeOperations=["pending","running","completed","failed","cancelled","scheduled_for_retry"].filter((status)=>status!==newStatus).map((status)=>this.commandClient.send("ZREM",[`${this.options.keyPrefix}:idx:status:${status}`,member])),addOperation=this.commandClient.send("ZADD",[`${this.options.keyPrefix}:idx:status:${newStatus}`,timestamp.toString(),member]);await Promise.all([...removeOperations,addOperation])}async getJobFromIndex(jobId,dispatchId,queue){let member=`${jobId}:${dispatchId}`,score=await this.commandClient.send("ZSCORE",[`${this.options.keyPrefix}:idx:queue:${queue}`,member]);if(score){let statuses=["pending","running","completed","failed","cancelled","scheduled_for_retry"];for(let status of statuses)if(await this.commandClient.send("ZSCORE",[`${this.options.keyPrefix}:idx:status:${status}`,member]))return{jobId,dispatchId,queue,status,priority:10,timestamp:Number(score)}}return null}async storeJobStatus(status){let statusKey=this.getJobStatusKey(status.jobId,status.dispatchId,status.queue),fields=["jobId",status.jobId,"dispatchId",status.dispatchId,"queue",status.queue,"status",status.status,"retries",status.retries.toString(),"createdAt",status.createdAt,"updatedAt",status.updatedAt||Date.now().toString()];if(status.error)fields.push("error",status.error);if(status.startedAt)fields.push("startedAt",status.startedAt);if(status.completedAt)fields.push("completedAt",status.completedAt);if(status.messageId)fields.push("messageId",status.messageId);await this.commandClient.send("HMSET",[statusKey,...fields]),await this.commandClient.expire(statusKey,this.options.statusTTL)}async ensureConsumerGroupExists(streamKey){try{await this.commandClient.send("XGROUP",["CREATE",streamKey,this.options.consumerGroup,"0","MKSTREAM"]),this.debug(`Created consumer group ${this.options.consumerGroup} for ${streamKey}`)}catch(error){if(!error.message?.includes("BUSYGROUP"))this.error(`Error creating consumer group: ${error}`)}}async getRegisteredQueues(){try{let pattern=`${this.options.keyPrefix}:stream:*`,keys=await this.commandClient.send("KEYS",[pattern]);if(!Array.isArray(keys)||keys.length===0)return["default"];return keys.map((key)=>key.replace(`${this.options.keyPrefix}:stream:`,"")).filter(Boolean)}catch(error){return this.error(`Failed to get registered queues: ${error}`),["default"]}}scheduleCleanup(){this.cleanupTimer=setInterval(async()=>{try{await this.performCleanup()}catch(error){this.error(`Error during cleanup: ${error}`)}},300000),this.debug("Scheduled cleanup every 300 seconds")}async performCleanup(){this.debug("Starting cleanup operation");let cutoff=Date.now()-this.options.completedJobRetention*1000,queues=await this.getRegisteredQueues(),completedStatuses=["completed","failed","cancelled"];for(let status of completedStatuses){let indexKey=`${this.options.keyPrefix}:idx:status:${status}`;await this.commandClient.send("ZREMRANGEBYSCORE",[indexKey,"-inf",cutoff.toString()])}for(let queue of queues){let streamKey=this.getStreamKey(queue),length=await this.commandClient.send("XLEN",[streamKey]);if(length>this.options.maxStreamSize)await this.commandClient.send("XTRIM",[streamKey,"MAXLEN","~",this.options.maxStreamSize.toString()]),this.debug(`Trimmed stream ${streamKey} from ${length} to ~${this.options.maxStreamSize}`)}let statusPattern=`${this.options.keyPrefix}:status:*`,statusKeys=await this.commandClient.send("KEYS",[statusPattern]);for(let key of statusKeys){let ttl=await this.commandClient.send("TTL",[key]);if(ttl===-1||ttl===-2)await this.commandClient.del(key)}this.debug("Cleanup operation completed")}extractQueueName(message){if("queue"in message&&typeof message.queue==="string")return message.queue;return"default"}serializeMessage(message){let fields=["type",message.type,"timestamp",Date.now().toString()];if("queue"in message&&message.queue)fields.push("queue",message.queue);switch(message.type){case"job:process":if(fields.push("job",message.job,"args",JSON.stringify(message.args||[]),"jobId",message.jobId,"dispatchId",message.dispatchId),message.options)fields.push("options",JSON.stringify(message.options));break;case"job:cancel":case"job:status":case"job:result":if(fields.push("jobId",message.jobId),fields.push("dispatchId",message.dispatchId),message.type!=="job:cancel"){if(message.status)fields.push("status",message.status);if(message.error)fields.push("error",message.error);if(message.retries!==void 0)fields.push("retries",message.retries.toString());if("completedAt"in message&&message.completedAt)fields.push("completedAt",message.completedAt)}break;case"worker:register":if(fields.push("workerId",message.workerId),message.queues)fields.push("queues",JSON.stringify(message.queues));break;case"worker:unregister":fields.push("workerId",message.workerId);break}return fields}deserializeMessage(fields,queue){let data={};for(let i=0;i<fields.length;i+=2)data[fields[i]]=fields[i+1];let type=data.type,baseMessage={type,queue:data.queue||queue};switch(type){case"job:process":return{...baseMessage,type:"job:process",job:data.job,args:JSON.parse(data.args||"[]"),jobId:data.jobId,dispatchId:data.dispatchId,options:data.options?JSON.parse(data.options):void 0};case"job:cancel":return{...baseMessage,type:"job:cancel",jobId:data.jobId,dispatchId:data.dispatchId};case"job:status":return{...baseMessage,type:"job:status",jobId:data.jobId,dispatchId:data.dispatchId,status:data.status,error:data.error,retries:data.retries?parseInt(data.retries,10):void 0};case"job:result":return{...baseMessage,type:"job:result",jobId:data.jobId,dispatchId:data.dispatchId,status:data.status,error:data.error,completedAt:data.completedAt||new Date().toISOString()};default:return baseMessage}}notifyLockRelease(jobId,queue){this.jobLocks.delete(jobId);for(let handler of this.messageHandlers)try{handler({type:"job:update",jobId,dispatchId:"",queue,status:"lock_released",updates:{}})}catch(error){this.error(`Error notifying lock release: ${error}`)}}async isJobLocked(jobId,queue){let localLock=this.jobLocks.get(jobId);if(localLock&&localLock.lockedUntil>Date.now())return!0;let lockKey=`${this.options.keyPrefix}:lock:${queue}:${jobId}`,exists=await this.commandClient.send("EXISTS",[lockKey]);return exists===!0||Number(exists)>0}async acquireJobLock(jobId,queue,duration=60000){let lockKey=`${this.options.keyPrefix}:lock:${queue}:${jobId}`,lockValue=`${this.consumerId}:${Date.now()}`;if(await this.commandClient.send("SET",[lockKey,lockValue,"PX",duration.toString(),"NX"])==="OK")return this.jobLocks.set(jobId,{lockedUntil:Date.now()+duration,queue}),this.debug(`Acquired lock for job ${jobId} in queue ${queue}`),!0;return!1}async releaseJobLock(jobId,queue){let lockKey=`${this.options.keyPrefix}:lock:${queue}:${jobId}`;await this.commandClient.del(lockKey),this.jobLocks.delete(jobId),await this.publisherClient.send("PUBLISH",[`${this.options.keyPrefix}:lock:${queue}:released`,jobId]),this.debug(`Released lock for job ${jobId} in queue ${queue}`)}getStreamKey(queue){return`${this.options.keyPrefix}:stream:${queue||"default"}`}getJobStatusKey(jobId,dispatchId,queue){return`${this.options.keyPrefix}:status:${queue}:${jobId}:${dispatchId}`}getWorkerKey(workerId){return`${this.options.keyPrefix}:worker:${workerId}`}}import{InteractsWithConsole as InteractsWithConsole4,Runtime as Runtime2}from"@elysiumjs/core";import{sort,uid as uid3}from"radash";var WorkerStatus;((WorkerStatus)=>{WorkerStatus.IDLE="idle";WorkerStatus.ACTIVE="active";WorkerStatus.PAUSED="paused";WorkerStatus.DRAINING="draining";WorkerStatus.STOPPING="stopping";WorkerStatus.STOPPED="stopped";WorkerStatus.DISCONNECTED="disconnected"})(WorkerStatus||={});class BaseWorker extends InteractsWithConsole4{#id;transport=null;queues=new Map;defaultQueueName="default";defaultTransport;workerStatus="idle";startedAt=new Date;scheduledJobInterval=1000;scheduledJobTimer;constructor(id){super();this.#id=id??uid3(24),this.createQueue({name:this.defaultQueueName})}get id(){return this.#id}get status(){return this.workerStatus}async createQueue(options){if(this.queues.has(options.name))return this.debug(`Queue with name '${options.name}' already exists`),this;let queueState={options:{...options,concurrency:options.concurrency||1,maxRetries:options.maxRetries||0,retryDelay:options.retryDelay||1000,pauseOnError:options.pauseOnError||!1},jobs:[],activeJobs:new Map,retryingJobs:new Map,paused:!1,draining:!1};return this.queues.set(options.name,queueState),this.debug(`Created queue '${options.name}'`),this}async addJob(job,queueName=this.defaultQueueName,options={}){let queue=this.queues.get(queueName);if(!queue)throw this.error(`Queue '${queueName}' does not exist`),Error(`Queue '${queueName}' does not exist`);if(queue.draining)throw this.warning(`Queue '${queueName}' is draining, job ${job.id} will not be added`),Error(`Queue '${queueName}' is draining`);job.queueName=queueName;let queuedJob={job,retries:job.retries,queueName,enqueuedAt:new Date,scheduledFor:options.scheduledFor,priority:options.priority??10,maxRetries:options.maxRetries,retryDelay:options.retryDelay,overlapBehavior:options.overlapBehavior,overlapDelay:options.overlapDelay};if(queue.jobs.push(queuedJob),queue.jobs=sort(sort(sort(queue.jobs,(job)=>job.enqueuedAt.getTime()),(job)=>job.scheduledFor?job.scheduledFor.getTime():0),(job)=>job.priority??10),this.debug(`Added job ${job.id} to queue '${queueName}'`),this.workerStatus==="idle")this.workerStatus="active";if(!queue.paused&&queue.activeJobs.size<queue.options.concurrency)try{this.processQueueJobs(queueName)}catch(error){this.error(`Error processing next job in queue '${queueName}': ${error}`)}return this}async pause(queueName){if(queueName){let queue=this.queues.get(queueName);if(queue)queue.paused=!0,this.info(`Paused queue '${queueName}'`);else this.warning(`Cannot pause non-existent queue '${queueName}'`)}else{for(let name of this.queues.keys())await this.pause(name);if(Array.from(this.queues.values()).every((q)=>q.paused)&&Array.from(this.queues.values()).every((q)=>q.activeJobs.size===0))this.workerStatus="paused",this.info(`Worker ${this.id} is now paused`)}return this}async resume(queueName){if(queueName){let queue=this.queues.get(queueName);if(queue){if(queue.paused=!1,this.info(`Resumed queue '${queueName}'`),this.workerStatus==="paused")this.workerStatus="active";await this.processQueueJobs(queueName)}else this.warning(`Cannot resume non-existent queue '${queueName}'`)}else{for(let name of this.queues.keys())await this.resume(name);if(Array.from(this.queues.values()).some((q)=>q.jobs.length>0||q.activeJobs.size>0))this.workerStatus="active",this.info(`Worker ${this.id} is now active`)}return this}async drain(queueName){if(queueName){let queue=this.queues.get(queueName);if(queue)queue.draining=!0,this.info(`Draining queue '${queueName}'`);else this.warning(`Cannot drain non-existent queue '${queueName}'`)}else{for(let name of this.queues.keys())await this.drain(name);this.workerStatus="draining",this.info(`Worker ${this.id} is now draining`)}return this}async clear(queueName){if(queueName){let queue=this.queues.get(queueName);if(queue){let jobCount=queue.jobs.length;for(let queuedJob of queue.jobs)queuedJob.job.cancel();queue.jobs=[],this.info(`Cleared ${jobCount} jobs from queue '${queueName}'`)}else this.warning(`Cannot clear non-existent queue '${queueName}'`)}else{for(let[name,queue]of this.queues.entries()){let jobCount=queue.jobs.length;for(let queuedJob of queue.jobs)queuedJob.job.cancel();queue.jobs=[],this.info(`Cleared ${jobCount} jobs from queue '${name}'`)}if(Array.from(this.queues.values()).every((q)=>q.jobs.length===0&&q.activeJobs.size===0))this.workerStatus="idle"}return this}async size(queueName=this.defaultQueueName){let queue=this.queues.get(queueName);if(!queue)throw Error(`Queue '${queueName}' does not exist`);return queue.jobs.length+queue.activeJobs.size}async totalSize(){let total=0;for(let queue of this.queues.values())total+=queue.jobs.length+queue.activeJobs.size;return total}async getStats(){let stats={};for(let[name,queue]of this.queues.entries())stats[name]={waiting:queue.jobs.length,retrying:queue.retryingJobs.size,processing:queue.activeJobs.size,paused:queue.paused,draining:queue.draining};return stats}getInfo(){let activeJobs=0;for(let queue of this.queues.values())activeJobs+=queue.activeJobs.size;let queueNames=Array.from(this.queues.keys()),transportType=this.defaultTransport?this.defaultTransport.constructor.name:"None",addJobWrapper=async(job,queueName)=>{try{return await this.addJob(job,queueName),!0}catch(error){return!1}};return{id:this.id,status:this.workerStatus,queues:queueNames,activeJobs,transport:transportType,startedAt:this.startedAt,addJob:addJobWrapper,cancelJob:this.cancelJob.bind(this),cancelAllJobs:this.cancelAllJobs.bind(this)}}async cancelJob(jobId,queueName){if(queueName)return this.cancelJobInQueue(jobId,queueName);else{for(let name of this.queues.keys())if(await this.cancelJobInQueue(jobId,name))return!0;return!1}}async cancelAllJobs(queueName){let canceledCount=0;if(queueName){let queue=this.queues.get(queueName);if(queue){canceledCount=queue.jobs.length;for(let queuedJob of queue.jobs)queuedJob.job.cancel();for(let[_,activeJob]of queue.activeJobs.entries())if(activeJob.job.cancel())canceledCount++;this.info(`Canceled ${canceledCount} jobs in queue '${queueName}'`)}}else for(let[name,queue]of this.queues.entries()){let count=queue.jobs.length;for(let queuedJob of queue.jobs)queuedJob.job.cancel();for(let[_,activeJob]of queue.activeJobs.entries())if(activeJob.job.cancel())count++;canceledCount+=count,this.info(`Canceled ${count} jobs in queue '${name}'`)}if(canceledCount>0&&Array.from(this.queues.values()).every((q)=>q.jobs.length===0&&q.activeJobs.size===0))this.workerStatus="idle";return canceledCount}async getJob(jobId,queueName){if(queueName){let queue=this.queues.get(queueName);if(queue){let queuedJob=queue.jobs.find((j)=>j.job.id===jobId);if(queuedJob)return queuedJob.job;let activeJob=queue.activeJobs.get(jobId);if(activeJob)return activeJob.job}}else for(let queue of this.queues.values()){let queuedJob=queue.jobs.find((j)=>j.job.id===jobId);if(queuedJob)return queuedJob.job;let activeJob=queue.activeJobs.get(jobId);if(activeJob)return activeJob.job}return null}async start(){if(this.workerStatus="active",this.startedAt=new Date,this.defaultTransport)await this.defaultTransport.start();for(let queueName of this.queues.keys())await this.processQueueJobs(queueName);this.startScheduledJobTimer(),this.info(`Worker ${this.id} started`)}async stop(force=!1){if(this.workerStatus="stopping",this.stopScheduledJobTimer(),force){await this.cancelAllJobs();for(let queueName of this.queues.keys())await this.clear(queueName)}else{for(let queueName of this.queues.keys())await this.drain(queueName);let allQueuesEmpty=()=>Array.from(this.queues.values()).every((q)=>q.activeJobs.size===0);if(!allQueuesEmpty()){this.info(`Worker ${this.id} waiting for ${Array.from(this.queues.values()).reduce((acc,q)=>acc+q.activeJobs.size,0)} jobs to complete`);while(!allQueuesEmpty())await new Promise((resolve)=>setTimeout(resolve,100))}}if(this.defaultTransport)await this.defaultTransport.stop();this.workerStatus="stopped",this.info(`Worker ${this.id} stopped`)}async setConcurrency(queueName,concurrency){let queue=this.queues.get(queueName);if(!queue)throw this.error(`Cannot set concurrency: Queue '${queueName}' does not exist`),Error(`Queue '${queueName}' does not exist`);if(concurrency<1)this.warning(`Invalid concurrency value ${concurrency}, using 1 instead`),concurrency=1;return queue.options.concurrency=concurrency,this.info(`Set concurrency for queue '${queueName}' to ${concurrency}`),this.processQueueJobs(queueName)}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 now=new Date;for(let[queueName,queue]of this.queues.entries()){if(queue.paused||queue.draining)continue;let readyJobs=queue.jobs.filter((job)=>job.scheduledFor&&job.scheduledFor<=now);for(let job of readyJobs)job.scheduledFor=void 0;if(readyJobs.length>0&&queue.activeJobs.size<queue.options.concurrency)this.processQueueJobs(queueName)}}async processQueueJobs(queueName){let queue=this.queues.get(queueName);if(!queue||queue.paused)return;let promises=[];while(promises.length<queue.options.concurrency)promises.push(this.processNextJob(queueName));await Promise.all(promises);let states=Array.from(this.queues.values());if(states.some((q)=>q.activeJobs.size>0))this.workerStatus="active";else if(states.every((q)=>q.jobs.length===0&&q.activeJobs.size===0))this.workerStatus="idle";if(states.some((q)=>q.jobs.length>0))await Runtime2.get().sleep(10),setImmediate(()=>this.processQueueJobs(queueName))}async sendJobStatusUpdate(jobId,dispatchId,queue,status,error){try{await this.transport.updateJobStatus(jobId,dispatchId,queue,{dispatchId,status,error,retries:0,startedAt:status==="running"?new Date().toISOString():void 0,completedAt:status==="completed"||status==="failed"||status==="cancelled"?new Date().toISOString():void 0,updatedAt:Date.now().toString()})}catch(error){this.error(`Error sending job status update: ${error}`)}}async cancelJobInQueue(jobId,queueName){let queue=this.queues.get(queueName);if(!queue)return!1;let jobIndex=queue.jobs.findIndex((job)=>job.job.id===jobId);if(jobIndex>=0){if(queue.jobs[jobIndex].job.cancel())return queue.jobs.splice(jobIndex,1),this.info(`Canceled job ${jobId} in queue '${queueName}'`),!0}if(queue.activeJobs.has(jobId)){if(queue.activeJobs.get(jobId).job.cancel())return this.info(`Canceled running job ${jobId} in queue '${queueName}'`),!0}return!1}}var jobOwners=new WeakMap;class RedisWorker extends BaseWorker{isRunning=!1;registeredQueues=new Set;pendingBatches=new Map;batchTimer;metrics={jobsProcessed:0,jobsFailed:0,jobsRetried:0,averageProcessingTime:0};metricsClient;metricsTimer;maxBatchSize;batchInterval;constructor(connectionName="default",options={}){super(options.id);if(this.maxBatchSize=options.maxBatchSize||10,this.batchInterval=options.batchInterval||100,this.transport=new RedisTransport("consumer",{connection:connectionName,consumerName:`worker-${this.id}`,batchSize:this.maxBatchSize,...options}),this.transport.onMessage(this.handleTransportMessage.bind(this)),options.enableMetrics)this.initializeMetrics(connectionName);this.debug(`RedisWorker ${this.id} created with batch size: ${this.maxBatchSize}`)}async initializeMetrics(connectionName){this.metricsClient=await Redis3.getConnection(connectionName).duplicate()}async reportMetrics(){if(!this.metricsClient||!this.metricsClient.connected)return;let metricsKey=`worker:${this.id}:metrics`;try{await this.metricsClient.send("HMSET",[metricsKey,"jobsProcessed",this.metrics.jobsProcessed.toString(),"jobsFailed",this.metrics.jobsFailed.toString(),"jobsRetried",this.metrics.jobsRetried.toString(),"averageProcessingTime",this.metrics.averageProcessingTime.toString(),"lastProcessedAt",(this.metrics.lastProcessedAt||new Date).toISOString(),"timestamp",Date.now().toString()]),await this.metricsClient.expire(metricsKey,3600)}catch(error){this.error(`Failed to report metrics: ${error}`)}}async start(){if(this.isRunning)return;await super.start(),this.isRunning=!0;for(let queueName of this.queues.keys())this.registeredQueues.add(queueName);if(await this.transport.start(),this.metricsClient)this.metricsTimer=setInterval(()=>{this.reportMetrics()},1e4),await this.metricsClient.connect();await this.transport.registerWorker(this.id,Array.from(this.registeredQueues)),this.startBatchProcessing(),this.info(`Redis worker ${this.id} started for queues: ${Array.from(this.registeredQueues).join(", ")}`)}async stop(force=!1){if(this.isRunning=!1,this.stopBatchProcessing(),!force)await this.processAllPendingBatches();if(await super.stop(force),await this.transport.unregisterWorker(this.id),await this.transport.stop(),this.metricsClient)this.metricsClient.close(),clearInterval(this.metricsTimer),this.metricsClient=void 0;this.info(`Redis worker ${this.id} stopped`)}startBatchProcessing(){this.batchTimer=setInterval(()=>{this.processBatches()},this.batchInterval)}stopBatchProcessing(){if(this.batchTimer)clearInterval(this.batchTimer),this.batchTimer=void 0}async processAllPendingBatches(){for(let[_,batch]of this.pendingBatches)if(batch.jobs.length>0)await this.processBatch(batch);this.pendingBatches.clear()}async processBatches(){let now=Date.now();for(let[queueName,batch]of this.pendingBatches){if(!this.queues.get(queueName))continue;if(batch.jobs.length>=this.maxBatchSize||batch.jobs.length>0&&now-batch.timestamp>this.batchInterval)this.pendingBatches.delete(queueName),await this.processBatch(batch)}}async processBatch(batch){let queue=this.queues.get(batch.queue);if(!queue||queue.paused)return;let startTime=Date.now(),overlapGroups=new Map,regularJobs=[];for(let job of batch.jobs)if(getJobMetadata(job.job.constructor)?.overlapBehavior==="no_overlap"){let key=job.job.id;if(!overlapGroups.has(key))overlapGroups.set(key,[]);overlapGroups.get(key).push(job)}else regularJobs.push(job);let regularPromises=regularJobs.map((job)=>this.processJobInternal(job,batch.queue)),overlapPromises=[];for(let[_,jobs]of overlapGroups)overlapPromises.push(this.processOverlapJobs(jobs,batch.queue));await Promise.all([...regularPromises,...overlapPromises]);let processingTime=Date.now()-startTime;this.updateMetrics(batch.jobs.length,processingTime),this.debug(`Processed batch of ${batch.jobs.length} jobs from queue '${batch.queue}' in ${processingTime}ms`)}async processOverlapJobs(jobs,queueName){for(let job of jobs)if(await this.transport.acquireJobLock(job.job.id,queueName))try{await this.processJobInternal(job,queueName)}finally{let metadata=getJobMetadata(job.job.constructor);if(metadata?.overlapDelay&&metadata.overlapDelay>0)setTimeout(()=>{this.transport.releaseJobLock(job.job.id,queueName)},metadata.overlapDelay);else await this.transport.releaseJobLock(job.job.id,queueName)}else{let queue=this.queues.get(queueName);if(queue)queue.jobs.push(job)}}async processJobInternal(queuedJob,queueName){let queue=this.queues.get(queueName);if(!queue)return;queue.activeJobs.set(queuedJob.job.id,queuedJob),await this.sendJobStatusUpdate(queuedJob.job.id,queuedJob.job.dispatchId,queueName,"running");try{let owner=jobOwners.get(queuedJob.job);if(await(owner?ModuleContext.run(owner,()=>queuedJob.job.run()):queuedJob.job.run()),queuedJob.job.status==="completed")this.metrics.jobsProcessed++,await this.sendJobStatusUpdate(queuedJob.job.id,queuedJob.job.dispatchId,queueName,"completed");else if(queuedJob.job.status==="failed")await this.handleJobFailure(queuedJob,queue,queueName);else if(queuedJob.job.status==="cancelled")await this.sendJobStatusUpdate(queuedJob.job.id,queuedJob.job.dispatchId,queueName,"cancelled")}catch(error){this.error(`Error processing job ${queuedJob.job.id}: ${error}`),queuedJob.job.fail(error instanceof Error?error:Error(String(error))),await this.handleJobFailure(queuedJob,queue,queueName)}finally{queue.activeJobs.delete(queuedJob.job.id),this.metrics.lastProcessedAt=new Date}}async handleJobFailure(queuedJob,queue,queueName){let maxRetries=queuedJob.maxRetries!==void 0?queuedJob.maxRetries:queue.options.maxRetries;if(queuedJob.retries<maxRetries){queuedJob.retries++,queuedJob.job.incrementRetries(),this.metrics.jobsRetried++;let retryDelay=queuedJob.retryDelay!==void 0?queuedJob.retryDelay:queue.options.retryDelay;await this.sendJobStatusUpdate(queuedJob.job.id,queuedJob.job.dispatchId,queueName,"scheduled_for_retry",queuedJob.job.lastError?.message),queue.retryingJobs.set(queuedJob.job.id,queuedJob),setTimeout(()=>{if(queuedJob.job.status!=="cancelled")this.addJobToBatch(queuedJob,queueName);queue.retryingJobs.delete(queuedJob.job.id)},retryDelay)}else if(this.metrics.jobsFailed++,await this.sendJobStatusUpdate(queuedJob.job.id,queuedJob.job.dispatchId,queueName,"failed",queuedJob.job.lastError?.message),queue.options.pauseOnError)await this.pause(queueName)}addJobToBatch(queuedJob,queueName){if(!this.pendingBatches.has(queueName))this.pendingBatches.set(queueName,{queue:queueName,jobs:[],timestamp:Date.now()});let batch=this.pendingBatches.get(queueName);if(batch.jobs.push(queuedJob),batch.jobs.length>=this.maxBatchSize)this.pendingBatches.delete(queueName),this.processBatch(batch)}async processNextJob(queueName){let queue=this.queues.get(queueName);if(!queue||queue.paused||queue.activeJobs.size>=queue.options.concurrency)return;let now=new Date,eligibleJobs=[];for(let i=0;i<queue.jobs.length&&eligibleJobs.length<this.maxBatchSize;i++){let job=queue.jobs[i];if(job.scheduledFor&&job.scheduledFor>now)continue;if(job.job.status==="cancelled"){queue.jobs.splice(i,1),i--;continue}eligibleJobs.push(job)}for(let job of eligibleJobs){let index=queue.jobs.indexOf(job);if(index>=0)queue.jobs.splice(index,1)}if(eligibleJobs.length>0){let batch={queue:queueName,jobs:eligibleJobs,timestamp:Date.now()};if(eligibleJobs.length===1)await this.processBatch(batch);else this.pendingBatches.set(queueName,batch)}}async handleTransportMessage(message){try{switch(message.type){case"job:process":{let{job:jobName,args=[],queue="default",options={},jobId,dispatchId}=message;await this.handleProcessJob(jobId,dispatchId,jobName,args,queue,options);break}case"job:cancel":{let{jobId,queue="default"}=message;if(await this.cancelJob(jobId,queue))await this.sendJobStatusUpdate(jobId,message.dispatchId,queue,"cancelled");break}case"job:cancelAll":{let{queue="default"}=message;await this.cancelAllJobs(queue);break}case"job:update":{if(message.status==="lock_released"){let queueName=message.queue;await this.processQueueJobs(queueName)}break}}}catch(error){this.error(`Error handling transport message: ${error}`)}}async handleProcessJob(jobId,dispatchId,jobName,args,queueName,messageOptions){try{let entry=JobRegistry.get(jobName);if(!entry)throw Error(`Job class not found: ${jobName}`);let job=ModuleContext.run(entry.module,()=>new(WithId(entry.jobClass,jobId,dispatchId))(...args||[]));jobOwners.set(job,entry.module),job.queueName=queueName;let jobOptions={};if(messageOptions.scheduledFor)jobOptions.scheduledFor=typeof messageOptions.scheduledFor==="string"?new Date(messageOptions.scheduledFor):messageOptions.scheduledFor;if(messageOptions.priority!==void 0)jobOptions.priority=Number(messageOptions.priority);if(messageOptions.maxRetries!==void 0)jobOptions.maxRetries=Number(messageOptions.maxRetries);if(messageOptions.retryDelay!==void 0)jobOptions.retryDelay=Number(messageOptions.retryDelay);let initialStatus=jobOptions.scheduledFor&&jobOptions.scheduledFor>new Date?"scheduled_for_retry":"pending";await this.sendJobStatusUpdate(job.id,job.dispatchId,queueName,initialStatus),await this.addJob(job,queueName,jobOptions)}catch(error){this.error(`Error processing job ${jobName}: ${error.message}`),await this.sendJobStatusUpdate(jobId,dispatchId,queueName,"failed",error.message)}}updateMetrics(jobCount,processingTime){let currentAvg=this.metrics.averageProcessingTime,totalJobs=this.metrics.jobsProcessed+this.metrics.jobsFailed;this.metrics.averageProcessingTime=(currentAvg*totalJobs+processingTime)/(totalJobs+jobCount)}async createQueue(options){let result=await super.createQueue(options);if(this.isRunning&&!this.registeredQueues.has(options.name))this.registeredQueues.add(options.name),await this.transport.registerWorker(this.id,Array.from(this.registeredQueues));return result}}class HeraclesWorkCommand extends Command3{constructor(){super(...arguments);this.id=null;this.queues=["default"];this.concurrency=1;this.redis="default";this.maxRetries=3;this.retryDelay=5000;this.pauseOnError=!1;this.verbose=!1}static command="heracles:work";static description="Start an Heracles worker for specific queues.";static boot=!0;async run(){if(this.verbose)this.write(`Starting Heracles worker with id ${this.id} for queues ${this.queues.join(", ")}`),this.write(` Concurrency: ${this.concurrency} jobs`),this.write(` Redis Connection: ${this.redis}`),this.write(` Max Retries: ${this.maxRetries}`),this.write(` Retry Delay: ${this.retryDelay}ms`),this.write(` Pause on Error: ${this.pauseOnError}`);if(!Redis4.connectionExists(this.redis))this.error(`Redis connection "${this.redis}" does not exist`),Runtime3.get().exit(1);let worker=new RedisWorker(this.redis,{id:this.id});for(let queue of this.queues)await worker.createQueue({name:queue,concurrency:this.concurrency,maxRetries:this.maxRetries,retryDelay:this.retryDelay,pauseOnError:this.pauseOnError});let cleanup=async()=>{if(this.verbose)this.write("Stopping worker gracefully...");if(await worker.stop(!1),this.verbose)this.write(`Worker "${worker.id}" stopped successfully`);Runtime3.get().exit(0)};if(Runtime3.get().onShutdown(cleanup),await worker.start(),this.verbose)this.write(`Worker "${worker.id}" started successfully`),this.write("Press Ctrl+C to stop the worker");await new Promise(()=>{})}}__legacyDecorateClassTS([Command3.arg({name:"id",required:!0,description:"Unique identifier for this worker",type:CommandArgumentType2.STRING}),__legacyMetadataTS("design:type",String)],HeraclesWorkCommand.prototype,"id",void 0),__legacyDecorateClassTS([Command3.arg({name:"queues",required:!0,description:"List of queues to work on",type:CommandArgumentType2.ARRAY,arrayType:CommandArgumentType2.STRING,default:["default"]}),__legacyMetadataTS("design:type",Array)],HeraclesWorkCommand.prototype,"queues",void 0),__legacyDecorateClassTS([Command3.arg({name:"concurrency",required:!0,description:"Number of concurrent jobs to process",type:CommandArgumentType2.NUMBER,default:1}),__legacyMetadataTS("design:type",Number)],HeraclesWorkCommand.prototype,"concurrency",void 0),__legacyDecorateClassTS([Command3.arg({name:"redis",required:!0,description:"Name of the Redis connection to use",type:CommandArgumentType2.STRING,default:"default"}),__legacyMetadataTS("design:type",String)],HeraclesWorkCommand.prototype,"redis",void 0),__legacyDecorateClassTS([Command3.arg({name:"max-retries",required:!1,description:"Maximum retries for failed jobs before giving up",type:CommandArgumentType2.NUMBER,default:3}),__legacyMetadataTS("design:type",Number)],HeraclesWorkCommand.prototype,"maxRetries",void 0),__legacyDecorateClassTS([Command3.arg({name:"retry-delay",required:!1,description:"Delay between retries in milliseconds",type:CommandArgumentType2.NUMBER,default:5000}),__legacyMetadataTS("design:type",Number)],HeraclesWorkCommand.prototype,"retryDelay",void 0),__legacyDecorateClassTS([Command3.arg({name:"pause-on-error",required:!1,description:"Pause the worker when an error occurs",type:CommandArgumentType2.BOOLEAN,default:!1}),__legacyMetadataTS("design:type",Boolean)],HeraclesWorkCommand.prototype,"pauseOnError",void 0),__legacyDecorateClassTS([Command3.arg({name:"verbose",required:!1,description:"Enable verbose logging",type:CommandArgumentType2.BOOLEAN,default:!1}),__legacyMetadataTS("design:type",Boolean)],HeraclesWorkCommand.prototype,"verbose",void 0),HeraclesWorkCommand=__legacyDecorateClassTS([Command3.register()],HeraclesWorkCommand);import{Command as Command4,CommandArgumentType as CommandArgumentType3,Runtime as Runtime4}from"@elysiumjs/core";import{Redis as Redis6}from"@elysiumjs/redis";import{ElysiumError as ElysiumError3,getLogger,Metadata as Metadata3,Service as Service4}from"@elysiumjs/core";import{Redis as Redis5}from"@elysiumjs/redis";import{Service as Service3}from"@elysiumjs/core";import{uid as uid5}from"radash";import{InteractsWithConsole as InteractsWithConsole5,Lifecycle}from"@elysiumjs/core";import{map,uid as uid4}from"radash";class Queue extends InteractsWithConsole5{static queues=new Map;static remove(name){this.queues.delete(name)}static exists(name){return this.queues.has(name)}static get(name,config){if(!this.queues.has(name)){let queue=new Queue({name,...config});this.queues.set(name,queue)}return this.queues.get(name)}static getAll(){return new Map(this.queues)}name;starting;stopping;transport;transportClass;transportOptions;options;jobs=[];constructor(config){super();this.name=config.name,this.transportClass=config.transport,this.transportOptions=config.transportOptions,this.options={concurrency:config.concurrency??1,maxRetries:config.maxRetries??0,retryDelay:config.retryDelay??1000,pauseOnError:config.pauseOnError??!1,...config},this.transport=new this.transportClass("producer",this.transportOptions),this.transport.onMessage(this.handleTransportMessage.bind(this))}on(event,handler){Lifecycle.on(`elysium:heracles:queue:${this.name}:${event}`,handler)}once(event,handler){Lifecycle.once(`elysium:heracles:queue:${this.name}:${event}`,handler)}emit(event,data,source=null){Lifecycle.emit(`elysium:heracles:queue:${this.name}:${event}`,data,source)}async handleTransportMessage(message){switch(message.type){case"job:status":case"job:result":if(message.queue===this.name){if(message.type==="job:result")this.info(`Job ${message.jobId} completed with status: ${message.status}`);let existingStatus=await this.getJobStatus({jobId:message.jobId,dispatchId:message.dispatchId}),createdAt=existingStatus?.createdAt??new Date().toISOString();this.updateJobStatus(message.jobId,message.dispatchId,{jobId:message.jobId,dispatchId:message.dispatchId,queue:message.queue,status:message.status,error:message.error,retries:message.retries??existingStatus?.retries??0,createdAt,startedAt:message.startedAt??existingStatus?.startedAt,completedAt:message.completedAt??existingStatus?.completedAt,updatedAt:message.updatedAt??new Date().toISOString()})}break}this.emit(message.type,message,this)}updateJobStatus(jobId,dispatchId,status){if(status.status==="completed"||status.status==="failed"||status.status==="cancelled")this.debug(`Job ${jobId} (${dispatchId}) reached terminal state: ${status.status}`)}async dispatch(job,name,args=[],options={}){let now=new Date,createdAt=now.toISOString(),jobId=options.jobId??job.generateJobId(...args),dispatchId=options.dispatchId??`dispatch_${Date.now()}_${uid4(8)}`,initialStatus={jobId,dispatchId,queue:this.name,status:options.scheduledFor&&options.scheduledFor>now?"scheduled_for_retry":"pending",retries:0,createdAt,updatedAt:createdAt};this.updateJobStatus(jobId,dispatchId,initialStatus),this.debug(`Dispatching job ${jobId} (dispatch: ${dispatchId}, name: ${name}) to queue '${this.name}'`);try{let message={type:"job:process",job:name,jobId,dispatchId,args,queue:this.name,options:{scheduledFor:options.scheduledFor?.toISOString(),priority:options.priority,maxRetries:options.maxRetries,retryDelay:options.retryDelay,overlapBehavior:options.overlapBehavior,overlapDelay:options.overlapDelay}};return await this.transport.send(message),{jobId:initialStatus.jobId,dispatchId}}catch(error){throw this.error(`Failed to dispatch job ${name} to queue '${this.name}': ${error.message}`),this.updateJobStatus(jobId,dispatchId,{jobId:initialStatus.jobId,dispatchId,queue:this.name,status:"failed",error:error.message,retries:0,createdAt:initialStatus.createdAt,completedAt:new Date().toISOString(),updatedAt:new Date().toISOString()}),error}}async cancelJob(id){this.debug(`Canceling job ${id.jobId} (${id.dispatchId}) in queue '${this.name}'`);try{let message={type:"job:cancel",jobId:id.jobId,dispatchId:id.dispatchId,queue:this.name};return await this.transport.send(message),!0}catch(error){return this.error(`Failed to cancel job ${id.jobId}: ${error}`),!1}}async cancelAllJobs(){this.debug(`Canceling all jobs in queue '${this.name}'`);try{let message={type:"job:cancelAll",queue:this.name};await this.transport.send(message)}catch(error){this.error(`Failed to cancel all jobs: ${error}`)}return 0}async getJobStatus(id){try{let status=await this.transport.getJobStatus(id.jobId,id.dispatchId,this.name);if(status)return this.debug(`Retrieved job ${id.jobId} (${id.dispatchId}) status from transport: ${status.status}`),status}catch(error){this.warning(`Failed to get job status from transport: ${error}`)}return null}getAllJobs(){return Array.from(this.jobs)}async getJobsByStatus(status){let result=[];for(let[jobId,statusInfo]of await map(this.jobs,async(id)=>[id,await this.getJobStatus(id)]))if(statusInfo?.status===status)result.push(jobId);return result}start(){if(!this.transport)return Promise.resolve();if(!this.starting){let transport=this.transport,stopping=this.stopping,starting=(async()=>{await stopping?.catch(()=>{}),await transport.start(),this.info(`Queue '${this.name}' transport started`)})().catch((error)=>{if(this.starting===starting)this.starting=void 0;throw error});this.starting=starting}return this.starting}async stop(){if(!this.transport)return;let transport=this.transport,pending=[this.starting,this.stopping];this.starting=void 0;let stopping=(async()=>{for(let previous of pending)await previous?.catch(()=>{});await transport.stop(),this.info(`Queue '${this.name}' transport stopped`)})();this.stopping=stopping;try{await stopping}finally{if(this.stopping===stopping)this.stopping=void 0}}getOptions(){return{...this.options}}updateOptions(options){this.options={...this.options,...options}}}var Heracles;((Heracles)=>Heracles.dispatch=async(jobClass,args=[],options={})=>{let metadata=getJobMetadata(jobClass);if(!metadata)throw Error(`Job ${jobClass.name} is not marked with @Job.register() decorator`);if(!metadata.queue)throw Error(`Job ${jobClass.name} is not registered with a queue`);if(Service3.exists(QUEUES_SERVICE))await Service3.resolve(QUEUES_SERVICE);if(!Queue.exists(metadata.queue))throw Error(`Queue ${metadata.queue} does not exist. Please create the queue before dispatching the job.`);let queue=Queue.get(metadata.queue);await queue.start();let dispatchOptions={dispatchId:`dispatch_${Date.now()}_${uid5(8)}`,priority:metadata.priority,maxRetries:metadata.maxRetries,retryDelay:metadata.retryDelay,overlapBehavior:metadata.overlapBehavior,overlapDelay:metadata.overlapDelay,...options};return queue.dispatch(jobClass,metadata.name,args,dispatchOptions)})(Heracles||={});var SCHEDULES=Symbol.for("elysium:heracles:Schedules");class DuplicateSchedulerError extends ElysiumError3{constructor(name){super(`A scheduler named "${name}" is already registered.`)}}var schedulesOf=(jobClass)=>Metadata3.getOwn(SCHEDULES,jobClass)??[],SCHEDULERS=new Map;class Scheduler{static job(cron,options={}){let schedule={cron:Cron.parse(cron),args:options.args??[]};return(target)=>{let schedules=Metadata3.getOwn(SCHEDULES,target)??[];Metadata3.define(SCHEDULES,[...schedules,schedule],target)}}static register(props){let cron=Cron.parse(props.cron);return(target)=>{let name=props.name??target.name;if(SCHEDULERS.has(name))throw new DuplicateSchedulerError(name);SCHEDULERS.set(name,{schedulerClass:target,props:{...props,name,cron:cron.expression}})}}static jobs(){let scheduled=JobRegistry.all().flatMap(({name,jobClass})=>schedulesOf(jobClass).map((schedule)=>({name,jobClass,schedule})));for(let[name,{schedulerClass,props}]of SCHEDULERS)scheduled.push({name,jobClass:props.job,schedule:{cron:Cron.parse(props.cron),args:[],scheduler:schedulerClass}});return scheduled}static jobsFor(cron){return Scheduler.jobs().filter(({schedule})=>schedule.cron.expression===cron)}static async dispatch(job,at=new Date){let{schedule,jobClass,name}=job;if(!schedule.scheduler)return await Heracles.dispatch(jobClass,schedule.args),1;let tick=new Date(Math.floor(at.getTime()/60000)*60000),owner=schedule.scheduler,cases=await((Service4.exists(owner)?Service4.get(owner):null)??Service4.make(owner)).fanOut(tick);for(let{key,args}of cases)await Heracles.dispatch(jobClass,args,{dispatchId:Scheduler.dispatchIdOf(name,key,tick)});return cases.length}static dispatchIdOf(scheduler,key,tick){return`schedule:${scheduler}:${key}@${tick.toISOString()}`}}var MAX_WAIT=86400000;class ScheduleRunner{#options;#timers=new Set;#running=!1;constructor(options={}){this.#options=options}get running(){return this.#running}start(){if(this.#running)return Scheduler.jobs();this.#running=!0;let jobs=Scheduler.jobs();for(let job of jobs)this.#plan(job);return jobs}stop(){this.#running=!1;for(let timer of this.#timers)clearTimeout(timer);this.#timers.clear()}#plan(job,after=new Date){if(!this.#running)return;let at=Cron.next(job.schedule.cron,after),wait=at.getTime()-Date.now(),timer=setTimeout(()=>{if(this.#timers.delete(timer),wait>MAX_WAIT)return this.#plan(job,new Date(at.getTime()-60000));this.#plan(job,at),this.#tick(job,at)},Math.max(0,Math.min(wait,MAX_WAIT)));this.#timers.add(timer)}async#tick(job,at){try{if(this.#options.redis!==void 0&&this.#options.redis!==!1){let key=`heracles:schedule:${job.name}:${job.schedule.cron.expression}:${at.toISOString()}`,ttl=String(this.#options.lockTtl??60000);if(await Redis5.getConnection(this.#options.redis).send("SET",[key,"1","NX","PX",ttl])!=="OK")return}await Scheduler.dispatch(job,at)}catch(error){getLogger().error("Could not dispatch a scheduled job",{job:job.name,cron:job.schedule.cron.expression,at:at.toISOString(),error})}}}class HeraclesScheduleCommand extends Command4{static command="heracles:schedule";static description="Dispatch the scheduled jobs at their times.";static boot=!0;async run(){if(this.redis!==void 0&&!Redis6.connectionExists(this.redis))this.error(`Redis connection "${this.redis}" does not exist`),Runtime4.get().exit(1);let scheduler=new ScheduleRunner({redis:this.redis??!1}),jobs=scheduler.start();if(jobs.length===0)this.warning("No job is scheduled.");for(let{name,schedule}of jobs)this.info(`${this.bold(name)} runs at ${schedule.cron.expression} (UTC)`);Runtime4.get().onShutdown(async()=>{scheduler.stop(),Runtime4.get().exit(0)}),await new Promise(()=>{})}}__legacyDecorateClassTS([Command4.arg({name:"redis",required:!1,description:"The Redis connection holding the lock of each tick, so that several schedulers dispatch it once",type:CommandArgumentType3.STRING}),__legacyMetadataTS("design:type",String)],HeraclesScheduleCommand.prototype,"redis",void 0),HeraclesScheduleCommand=__legacyDecorateClassTS([Command4.register()],HeraclesScheduleCommand);class ConfiguredQueues{queues;constructor(config){this.queues=Object.entries(config).map(([name,options])=>Queue.get(name,options))}async onDestroy(){await runAll(this.queues.map((queue)=>async()=>{try{await queue.stop()}finally{Queue.remove(queue.name)}}))}}class HeraclesModule extends Module.configurable({defaults:{queues:{}}}){onInit(){let entries=new Map,kernel=Service5.get(KERNEL_SERVICE);if(!kernel)throw new ElysiumError4(`HeraclesModule needs the "${KERNEL_SERVICE}" service, which the kernel registers when it boots.`);for(let{item,module}of kernel.graph.collect("jobs")){let jobClass=item,metadata=getJobMetadata(jobClass);if(!metadata)continue;let existing=entries.get(metadata.name);if(existing)throw new DuplicateJobError(metadata.name,[existing.module.id,module.id]);entries.set(metadata.name,{jobClass,module})}JobRegistry.set(entries)}onDestroy(){JobRegistry.clear()}}HeraclesModule=__legacyDecorateClassTS([Module.register({commands:[HeraclesCleanCommand,HeraclesListCommand,HeraclesScheduleCommand,HeraclesWorkCommand],services:(config)=>[{name:QUEUES_SERVICE,global:!0,factory:()=>new ConfiguredQueues(config.queues??{})}]})],HeraclesModule);export{BaseWorker,Cron,DuplicateJobError,DuplicateSchedulerError,Heracles,HeraclesModule,InvalidCronError,Job,JobOverlapBehavior,JobRegistry,JobStatus,QUEUES_SERVICE,Queue,RedisTransport,RedisWorker,ScheduleRunner,Scheduler,TransportMode,WithId,WorkerStatus,schedulesOf};
|
package/dist/job.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Environment } from '@elysiumjs/core';
|
|
1
2
|
import type { Class } from 'type-fest';
|
|
2
3
|
import { InteractsWithConsole } from '@elysiumjs/core';
|
|
3
4
|
/**
|
|
@@ -95,6 +96,11 @@ export type JobProps = {
|
|
|
95
96
|
* @default 0
|
|
96
97
|
*/
|
|
97
98
|
overlapDelay?: number;
|
|
99
|
+
/**
|
|
100
|
+
* The environments (`NODE_ENV` values) in which the job is active. In any other, it is not
|
|
101
|
+
* registered: it is neither dispatched nor scheduled.
|
|
102
|
+
*/
|
|
103
|
+
env?: readonly Environment[];
|
|
98
104
|
};
|
|
99
105
|
/**
|
|
100
106
|
* Type for any job classes.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elysiumjs/heracles",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Background job processing system for Elysium.js apps.",
|
|
5
5
|
"module": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -51,11 +51,11 @@
|
|
|
51
51
|
"radash": "^12.1.1"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@elysiumjs/core": "0.
|
|
55
|
-
"@elysiumjs/redis": "0.1.
|
|
54
|
+
"@elysiumjs/core": "0.18.0",
|
|
55
|
+
"@elysiumjs/redis": "0.1.7"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
|
-
"@elysiumjs/core": "^0.
|
|
59
|
-
"@elysiumjs/redis": "^0.1.
|
|
58
|
+
"@elysiumjs/core": "^0.18.0",
|
|
59
|
+
"@elysiumjs/redis": "^0.1.7"
|
|
60
60
|
}
|
|
61
61
|
}
|