@elysiumjs/heracles 0.7.0 → 0.8.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 CHANGED
@@ -1,5 +1,114 @@
1
1
  # @elysiumjs/heracles
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#20](https://github.com/na2axl/elysiumjs/pull/20) [`1738567`](https://github.com/na2axl/elysiumjs/commit/17385674c14991f0ddb27eb6ee967afbcebcc307) Thanks [@na2axl](https://github.com/na2axl)! - Scheduled fan-out, dead letters and one consumer per queue.
8
+
9
+ - `@Scheduler.job(cron, { args })` dispatches a job on a cron with fixed arguments.
10
+ - A class that extends `Scheduler` and is marked with `@Scheduler.register({ cron, job })` dispatches one job per case its `fanOut(tick)` returns, with arguments typed against the job and a dispatch id made of the scheduler, the case key and the tick. Schedulers are services, so they inject what they read.
11
+ - `ScheduleRunner` runs the schedules on Bun and Node.js (the `heracles:schedule` command), and `Scheduler.jobsFor()` / `Scheduler.dispatch()` serve the cron triggers of Workers.
12
+ - `@Once()` of `@elysiumjs/moirai/heracles` runs a job once per dispatch id, in a unit of work with an inbox record.
13
+ - `HeraclesWorkersModule.with({ deadLetterQueue, onDead })` keeps the jobs that failed their last retry: sent to a dead letter queue with their failure, and handed to a hook.
14
+ - Cron triggers dispatch at the trigger's time.
15
+ - A named queue of a Worker has one consumer: two throw `DuplicateQueueConsumerError` when the Worker is created.
16
+
17
+ - [#13](https://github.com/na2axl/elysiumjs/pull/13) [`1ea417e`](https://github.com/na2axl/elysiumjs/commit/1ea417e3583250bc2f975d795f398b7ff736115d) Thanks [@na2axl](https://github.com/na2axl)! - Hardening of the runtimes, the container and the CLI, checked by a conformance suite that runs on Bun, Node.js and workerd.
18
+
19
+ Breaking changes:
20
+
21
+ - `Runtime` gains the required `argv()`, `write(stream, text)` and `terminal()` members: core reaches the host's arguments, output streams and terminal only through the runtime. Custom runtimes must implement them.
22
+ - The third parameter of `InteractsWithConsole.write` is now `'stdout' | 'stderr'`.
23
+ - The WebSocket options are narrowed to `idleTimeout`, `maxPayloadLength` and `perMessageDeflate`, and the `bun` WebSocket types are replaced by `ElysiumWebSocket`.
24
+ - Redis connections are `RedisConnection`s, a port any Redis client can implement, instead of `Bun.RedisClient`.
25
+ - `styx create @scope/name` creates the project in `name/`, and refuses project and module names with upper-case letters.
26
+ - Queue handlers no longer reject the batch: after the handlers ran, each message is acked or retried on its own. `@Workers.queue(name, { each: true })` receives one message at a time, so a failure retries only that message.
27
+
28
+ Fixes:
29
+
30
+ - Request-scoped services are destroyed when the request ends, also on failure, and an async `onInit` is awaited. A second dispose of a request scope waits for the one in progress, so a Worker's `waitUntil` covers an async `onDestroy`.
31
+ - A failed module or service `onInit` no longer breaks later requests: the boot is retried, and the module's event listeners are unwired.
32
+ - `@elysiumjs/runtime-node` serves through `node:http`, keeping the Host header authoritative.
33
+ - `styx create` validates names before creating files, ships `.gitignore`, declares the dependencies of created projects and imports their default controllers.
34
+
35
+ - [#16](https://github.com/na2axl/elysiumjs/pull/16) [`ac96e55`](https://github.com/na2axl/elysiumjs/commit/ac96e55874b0ffe350b287c4278cd85afecb7576) Thanks [@na2axl](https://github.com/na2axl)! - Redis, databases, logs, jobs and builds on Bun, Node.js and Cloudflare Workers.
36
+
37
+ Breaking:
38
+
39
+ - The level shortcuts of `InteractsWithConsole` (`info`, `success`, `warning`, `error` and `debug`) write a tag (`[I]`, `[S]`, `[W]`, `[E]` and `[D]`, in color) before the message, which `InteractsWithConsole.useTags()` changes. `trace()` writes to stderr.
40
+ - Outside Bun, a Drizzle connection needs a `driver`. Without one, the `connection` and `client` props are a Bun SQL connection, which is only available on Bun; elsewhere the connection throws `MissingDriverError`, which names the drivers.
41
+
42
+ Added:
43
+
44
+ - Redis adapters (`@elysiumjs/redis`): `ioredis(create)` plugs an ioredis client into the Redis port on Node.js, and `upstash({ token, url?, fetch? })` talks to Upstash over its REST API, for Workers (no pub/sub). `bunRedis` explains which adapter to use when it runs outside Bun.
45
+ - Drizzle drivers (`@elysiumjs/mnemosyne-drizzle`): a `DrizzleDriver` port set with the `driver` prop of a connection, and drivers on subpaths so that only the chosen database client is imported: `/bun-sql` (`bunSql()`), `/postgres-js` (`postgresJs()`), `/node-postgres` (`nodePostgres()`) and `/hyperdrive` (`hyperdrive({ binding, driver })`, a client per Worker invocation over a Cloudflare Hyperdrive binding). `DrizzleModule` closes connections through their driver, and `migration:run` runs on any driver.
46
+ - `InteractsWithConsole.write()` and `Runtime.write()` take the level of the line (`OutputLevel`), which the level shortcuts pass. On Cloudflare Workers, a line is written with the console method of its level, so Workers Logs keep it.
47
+ - `JsonConsoleLogger` (`@elysiumjs/hermes`): JSON lines through the console of the runtime (warnings and errors on stderr), with levels, error serialization and `child()`. `HermesModule.with({ transport: 'console' | 'pino' })` picks it, and it is the default on Workers. `RequestLogInterceptor` writes one structured line per request: method, route, status, duration, client address and request id.
48
+ - Scheduled jobs (`@elysiumjs/heracles`): `@Schedule(cron, { timezone, args })` on a job class, a five-field cron parser (`Cron`, with lists, ranges, steps and names), and a `Scheduler` that dispatches the jobs on time on Bun and Node.js, taking a Redis lock per tick when a Redis connection is set. The `heracles:schedule` command runs it.
49
+ - `@elysiumjs/heracles-workers`: `CloudflareQueueTransport` sends jobs to a Cloudflare Queue (dispatch delays become `delaySeconds`), and `HeraclesWorkersModule` consumes them in the Worker's `queue` handler (ack on success, retry with a delay up to the job's retries) and dispatches the `@Schedule()` jobs whose expression is the cron of a trigger.
50
+ - Queue handlers (`@elysiumjs/runtime-workers`) can settle a message themselves with `ack()` or `retry({ delaySeconds })`; the messages they leave are acked when no handler failed on them.
51
+ - `styx build --target node` (`@elysiumjs/hephaestus`) bundles the app into one ES module for Node.js, with decorator metadata emitted at build time, run with `node dist/server.js serve`. `styx build --target workers` runs from the CLI, resolves packages to their browser entry, and leaves Node.js modules imported by bare names to workerd (`nodejs_compat`).
52
+ - `styx create` (`@elysiumjs/styx`) takes `--runtime bun|node|workers`, `--database`, `--cache` and `--package-manager bun|npm|pnpm`. Each runtime gets its entry, tsconfig types, scripts and drivers: postgres.js and ioredis on Node.js; Hyperdrive, Upstash, console logs and a `wrangler.jsonc` on Workers.
53
+
54
+ ### Patch Changes
55
+
56
+ - [#5](https://github.com/na2axl/elysiumjs/pull/5) [`557a3ca`](https://github.com/na2axl/elysiumjs/commit/557a3cab7af8053a8bab9944e492e5022d91aa82) Thanks [@na2axl](https://github.com/na2axl)! - Core runs outside Bun. Environment variables, timers, the HTTP server, process exit and shutdown signals now come from a `Runtime` set with the new `runtime` option of `@Application.register()` or with `Runtime.use()`. Runtimes ship as packages, each exporting `runtime`: `@elysiumjs/runtime-bun`, `@elysiumjs/runtime-node` and `@elysiumjs/runtime-workers` (which also exports `createRuntime({ env })`).
57
+
58
+ Breaking: an application needs a runtime (add `runtime` from `@elysiumjs/runtime-bun`), and `Redis` and `RedisModule` moved from core to the new `@elysiumjs/redis` package, which also accepts a `client` factory for runtimes without `Bun.RedisClient`.
59
+
60
+ Console colours are plain ANSI codes instead of `Bun.color`, `node:readline` is loaded only when a prompt runs, and core's dist is built for the `node` target so `node:` imports keep their prefix. `check:runtime` scripts in core and runtime-workers fail when their bundles touch the `Bun` global.
61
+
62
+ `create-elysium` gets a runtime step: it installs the chosen runtime package and sets it in the generated app. Bun is selectable today; Node.js and Cloudflare Workers are listed as coming later.
63
+
64
+ - [#20](https://github.com/na2axl/elysiumjs/pull/20) [`87299fd`](https://github.com/na2axl/elysiumjs/commit/87299fd9da82d1a408ced1891c0c3d42991444e5) Thanks [@na2axl](https://github.com/na2axl)! - The framework event bus is `Lifecycle`: `Lifecycle.on`, `Lifecycle.once`, `Lifecycle.emit`, `Lifecycle.off`, `@Lifecycle.on()` and `@Lifecycle.once()`, with the `LifecycleEvent` and `LifecycleHandler` types. It carries the framework's own signals (`elysium:error`, app and server lifecycle, heracles queue events) and in-process listeners.
65
+
66
+ - [#14](https://github.com/na2axl/elysiumjs/pull/14) [`0956186`](https://github.com/na2axl/elysiumjs/commit/0956186db0ca5a83c0a6636aeb2a4c81a0033de4) Thanks [@na2axl](https://github.com/na2axl)! - The request pipeline: problem details, exception filters, interceptors, typed configuration, HTTP security options and an HTTP test client, checked on Bun, Node.js, workerd and AOT-compiled Workers.
67
+
68
+ Breaking changes:
69
+
70
+ - Error responses are RFC 9457 problem details (`application/problem+json`). Request validation errors answer 422 with an `errors` list of JSON pointers (`problems.validationStatus: 400` keeps 400). Server errors only show their message and stack in development. Thrown statuses (`c.status(...)`) are sent as they are.
71
+ - Client errors (4xx) are no longer reported to `onError`, the `elysium:error` event or the trace.
72
+ - `Runtime.serve` may return a handle with `close()`, which the application calls on shutdown.
73
+ - Request bodies are limited to 1 MiB by default (`http.bodyLimit`), also when chunked.
74
+ - The environment schema is validated when the app is built or run, with every error reported at once, instead of in the constructor.
75
+
76
+ Features:
77
+
78
+ - `DomainError`, with `status`, `type`, `title`, `detail` and extensions, and shortcuts such as `DomainError.notFound()`.
79
+ - Exception filters (`@Catch()`, `filters` on routes, controllers, modules and the app) and interceptors (`interceptors` on the same levels), with `TimingInterceptor`, `TransformInterceptor` and mnemosyne's `CacheInterceptor`.
80
+ - Typed configuration: `Secret` values, module `envSchema`s merged into the app's, `InvalidEnvironmentError`.
81
+ - `http` options: CORS, security headers, body limits and trusted proxies (`ctx.ip`). Runtimes report the client address through `clientAddress`.
82
+ - `createTestingHttpApp` and `createHttpClient` send requests to an app in memory, with `expectStatus()`.
83
+
84
+ Fixes:
85
+
86
+ - App-level `onRequest` middlewares run in the request scope.
87
+ - On Workers, the request scope of a streamed response is disposed when the body ends, not when `fetch` returns.
88
+ - `KeyvWorkersKV.clear()` stays within an operation budget (`maxOperations`).
89
+ - The Node.js bridge treats a destroyed socket as a client abort.
90
+ - Heracles performance metrics report through the Redis port (`connection` or `client`), not a Bun client.
91
+ - `styx routes:generate` names anonymous default controllers after their file; `styx create` pins `@elysiumjs/*` packages to the versions it was released with; invalid names are reported as Elysium errors.
92
+
93
+ - Updated dependencies [[`6bc48fe`](https://github.com/na2axl/elysiumjs/commit/6bc48febd51f7090c582e73f349eaeff7261241c), [`e9c3d98`](https://github.com/na2axl/elysiumjs/commit/e9c3d98dd2714b69100215f7c8a80046da463148), [`557a3ca`](https://github.com/na2axl/elysiumjs/commit/557a3cab7af8053a8bab9944e492e5022d91aa82), [`ccc3f5b`](https://github.com/na2axl/elysiumjs/commit/ccc3f5bb904d1bf52d8c72ebf7ea8d10f617a8c4), [`6789879`](https://github.com/na2axl/elysiumjs/commit/678987976ecfd46b534b65276e0de3f448977562), [`87299fd`](https://github.com/na2axl/elysiumjs/commit/87299fd9da82d1a408ced1891c0c3d42991444e5), [`bee66bd`](https://github.com/na2axl/elysiumjs/commit/bee66bdc874b8f23fea356f7d3adf7555a48ca64), [`0057b67`](https://github.com/na2axl/elysiumjs/commit/0057b67df0e3abecab8a9accff6b55a6a64c21df), [`632d77b`](https://github.com/na2axl/elysiumjs/commit/632d77b561fc43ade605fb69b06ac1b9b9a2ffc4), [`1ea417e`](https://github.com/na2axl/elysiumjs/commit/1ea417e3583250bc2f975d795f398b7ff736115d), [`0956186`](https://github.com/na2axl/elysiumjs/commit/0956186db0ca5a83c0a6636aeb2a4c81a0033de4), [`fbcc7da`](https://github.com/na2axl/elysiumjs/commit/fbcc7dad127db29da42e7a4a5c2124966731cd88), [`ac96e55`](https://github.com/na2axl/elysiumjs/commit/ac96e55874b0ffe350b287c4278cd85afecb7576), [`98a16aa`](https://github.com/na2axl/elysiumjs/commit/98a16aae889d46f7a9fb5573188f4cbe3629dd3b)]:
94
+ - @elysiumjs/core@0.11.0
95
+ - @elysiumjs/redis@0.1.0
96
+
97
+ ## 0.7.2
98
+
99
+ ### Patch Changes
100
+
101
+ - [`d563113`](https://github.com/na2axl/elysiumjs/commit/d563113316a54c2c52292910c0ee623c670e0009) Thanks [@na2axl](https://github.com/na2axl)! - A queue started while it is being stopped now starts its transport again once the stop finishes, instead of ending up stopped.
102
+ - Updated dependencies [[`68a0cdd`](https://github.com/na2axl/elysiumjs/commit/68a0cdd8b2e4a003d6e0af2d78985623cae40428), [`2c49acd`](https://github.com/na2axl/elysiumjs/commit/2c49acd3b552aa43765d70339fa216961499e1b5), [`76ba0ff`](https://github.com/na2axl/elysiumjs/commit/76ba0ff18f3c196cebc2189426328f67cb699f46), [`4ebf029`](https://github.com/na2axl/elysiumjs/commit/4ebf029611e6d753a4f1bfdbe247a75d954d9ded), [`bacce71`](https://github.com/na2axl/elysiumjs/commit/bacce7146f667a1a46bf474dc0344fb0111ae12a)]:
103
+ - @elysiumjs/core@0.10.0
104
+
105
+ ## 0.7.1
106
+
107
+ ### Patch Changes
108
+
109
+ - Updated dependencies [[`5fef4a7`](https://github.com/na2axl/elysiumjs/commit/5fef4a7199f502fc4fe035299e7670739459a108)]:
110
+ - @elysiumjs/core@0.9.0
111
+
3
112
  ## 0.7.0
4
113
 
5
114
  ### Minor Changes
package/README.md CHANGED
@@ -237,6 +237,46 @@ Jobs in Heracles go through several statuses:
237
237
 
238
238
  ## Advanced Usage
239
239
 
240
+ ### Schedules
241
+
242
+ `@Scheduler.job(cron, { args })` dispatches a job on a cron, in UTC, with fixed arguments. The `heracles:schedule` command runs the schedules on Bun and Node.js; on Cloudflare Workers, the cron triggers do (see `@elysiumjs/heracles-workers`).
243
+
244
+ ```ts
245
+ @Job.register({ queue: 'default' })
246
+ @Scheduler.job('0 3 * * *')
247
+ class PurgeExpiredSessions extends Job {
248
+ protected async execute() {}
249
+ }
250
+ ```
251
+
252
+ A scheduler class dispatches one job per case due at a tick, so that each case retries and fails on its own. It extends `Scheduler`, names its job in `@Scheduler.register()`, and its `fanOut()` receives the tick (to the minute) and returns the cases: a key and the arguments of the job, typed against its constructor. A scheduler is a service, so it can inject what it reads. Each job has a dispatch id made of the scheduler, the case key and the tick:
253
+
254
+ ```ts
255
+ @Job.register({ queue: 'billing' })
256
+ @Once() // from @elysiumjs/moirai/heracles: one run per dispatch id
257
+ class BillInvoice extends Job {
258
+ constructor(private readonly invoiceId: string) {
259
+ super();
260
+ }
261
+
262
+ protected async execute() {}
263
+ }
264
+
265
+ @Scheduler.register({ cron: '0 6 * * *', job: BillInvoice })
266
+ class DailyBilling extends Scheduler<typeof BillInvoice> {
267
+ constructor(@Service.inject() private readonly invoices: InvoiceRepository) {
268
+ super();
269
+ }
270
+
271
+ public async fanOut(tick: Date) {
272
+ const due = await this.invoices.dueAt(tick);
273
+ return due.map((invoice) => ({ key: invoice.id, args: [invoice.id] }));
274
+ }
275
+ }
276
+ ```
277
+
278
+ A scheduler fans out one job; another job gets its own scheduler.
279
+
240
280
  ### Job Cancellation
241
281
 
242
282
  ```typescript
@@ -1,3 +1,4 @@
1
1
  export * from './clean.command';
2
2
  export * from './list.command';
3
3
  export * from './work.command';
4
+ export * from './schedule.command';
@@ -0,0 +1,12 @@
1
+ import { Command } from '@elysiumjs/core';
2
+ /**
3
+ * Runs the scheduler: dispatches the scheduled jobs at their times.
4
+ * @author Axel Nana <axel.nana@workbud.com>
5
+ */
6
+ export declare class HeraclesScheduleCommand extends Command {
7
+ static readonly command: string;
8
+ static readonly description: string;
9
+ static readonly boot = true;
10
+ private redis?;
11
+ run(): Promise<void>;
12
+ }
package/dist/cron.d.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { ElysiumError } from '@elysiumjs/core';
2
+ /**
3
+ * Raised when a cron expression cannot be parsed.
4
+ * @author Axel Nana <axel.nana@workbud.com>
5
+ */
6
+ export declare class InvalidCronError extends ElysiumError {
7
+ readonly expression: string;
8
+ constructor(expression: string, reason: string);
9
+ }
10
+ /**
11
+ * A parsed cron expression: the values each field allows, and whether the day of the month and
12
+ * the day of the week were restricted (when both are, a day matching either one matches).
13
+ * @author Axel Nana <axel.nana@workbud.com>
14
+ */
15
+ export type CronSchedule = {
16
+ readonly expression: string;
17
+ readonly minutes: ReadonlySet<number>;
18
+ readonly hours: ReadonlySet<number>;
19
+ readonly days: ReadonlySet<number>;
20
+ readonly months: ReadonlySet<number>;
21
+ readonly weekdays: ReadonlySet<number>;
22
+ readonly anyDay: boolean;
23
+ readonly anyWeekday: boolean;
24
+ };
25
+ export declare namespace Cron {
26
+ /**
27
+ * Parses a 5-field cron expression (minute, hour, day of month, month, day of week), with
28
+ * lists, ranges, steps, month and day names, and the `@hourly`, `@daily`, `@weekly`,
29
+ * `@monthly` and `@yearly` aliases. Times are UTC, as in Cloudflare cron triggers.
30
+ * @author Axel Nana <axel.nana@workbud.com>
31
+ * @param expression The expression.
32
+ * @throws {InvalidCronError} When the expression is not valid.
33
+ */
34
+ const parse: (expression: string) => CronSchedule;
35
+ /**
36
+ * Whether a minute matches a schedule.
37
+ * @author Axel Nana <axel.nana@workbud.com>
38
+ * @param schedule The parsed schedule.
39
+ * @param date The minute, read in UTC.
40
+ */
41
+ const matches: (schedule: CronSchedule, date: Date) => boolean;
42
+ /**
43
+ * The first minute after `from` that matches a schedule.
44
+ * @author Axel Nana <axel.nana@workbud.com>
45
+ * @param schedule The parsed schedule.
46
+ * @param from The time to start after.
47
+ * @throws {InvalidCronError} When no minute matches within 5 years (such as `0 0 31 2 *`).
48
+ */
49
+ const next: (schedule: CronSchedule, from: Date) => Date;
50
+ }
@@ -22,7 +22,7 @@ declare const HeraclesModule_base: (abstract new () => {
22
22
  readonly config: HeraclesModuleConfig;
23
23
  onInit(): Promise<void> | void;
24
24
  onDestroy(): Promise<void> | void;
25
- onServe(_app: import("elysia").default<any>): Promise<void> | void;
25
+ onServe(_app: import("elysia").Elysia<any>): Promise<void> | void;
26
26
  }) & {
27
27
  readonly config: string;
28
28
  with(config: Partial<HeraclesModuleConfig> | (() => Partial<HeraclesModuleConfig> | Promise<Partial<HeraclesModuleConfig>>)): import("@elysiumjs/core").ConfiguredModule<HeraclesModuleConfig>;
package/dist/index.d.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  export type { JobProps, JobClass, JobMetadata } from './job';
2
2
  export type { JobDispatchOptions, QueueOptions, JobDispatchId } from './queue';
3
- export type { JobStatusInfo, Transport, TransportClass, TransportEvent } from './transport';
3
+ export type { JobStatusInfo, Transport, TransportClass, TransportEvent, TransportProcessMessage } from './transport';
4
4
  export type { QueuedJob, QueueState, Worker, WorkerInfo, WorkerQueueOptions } from './worker';
5
5
  export type { RedisTransportOptions } from './transports/redis.transport';
6
+ export { Cron, type CronSchedule, InvalidCronError } from './cron';
6
7
  export { HeraclesModule, type HeraclesModuleConfig } from './heracles.module';
7
- export { DuplicateJobError, QUEUES_SERVICE } from './registry';
8
+ export { DuplicateJobError, JobRegistry, type JobRegistryEntry, QUEUES_SERVICE } from './registry';
9
+ export { type FanOutCase, type JobSchedule, type ScheduledJob, type SchedulerClass, type SchedulerProps, type ScheduleOptions, type ScheduleRunnerOptions, DuplicateSchedulerError, ScheduleRunner, Scheduler, schedulesOf } from './schedule';
8
10
  export { Heracles } from './heracles';
9
- export { Job, JobStatus, JobOverlapBehavior } from './job';
11
+ export { Job, JobStatus, JobOverlapBehavior, WithId } from './job';
10
12
  export { Queue } from './queue';
11
13
  export { TransportMode } from './transport';
12
14
  export { RedisTransport } from './transports/redis.transport';
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 as ElysiumError2,KERNEL_SERVICE,Module,runAll,Service as Service3}from"@elysiumjs/core";import{Command,CommandArgumentType,Redis,Service}from"@elysiumjs/core";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`),process.exit(1);let redisClient=await Service.resolve(Redis.getConnectionName(this.redis));if(this.queues.length===0){let keys=await redisClient.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;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{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(`
3
3
  Queue: ${queue}`),spinner.pause(`- Current stream length: ${streamLength}`)}catch(e){if(this.verbose)spinner.pause(`
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.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"),process.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}from"@elysiumjs/core";class DuplicateJobError extends ElysiumError{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,Redis as Redis4}from"@elysiumjs/core";import{ModuleContext,Redis as Redis3}from"@elysiumjs/core";var TransportMode;((TransportMode)=>{TransportMode.CONSUMER="consumer";TransportMode.PRODUCER="producer"})(TransportMode||={});import{InteractsWithConsole as InteractsWithConsole3,Redis as Redis2,Service as Service2}from"@elysiumjs/core";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.ping(),await this.publisherClient.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.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.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.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.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.hmset(statusKey,fields),await this.commandClient.expire(statusKey,this.options.statusTTL),updates.status)await this.updateJobIndex(jobId,dispatchId,queueName,updates.status);await this.publisherClient.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.hmset(workerKey,["id",workerId,"status","active","lastSeen",new Date().toISOString(),"queues",JSON.stringify(queues)]),await this.commandClient.expire(workerKey,60),await this.publisherClient.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.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.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.zscore(`${this.options.keyPrefix}:idx:status:${status}`,member))return{jobId,dispatchId,queue,status,priority:10,timestamp: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.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.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.keys(statusPattern);for(let key of statusKeys){let ttl=await this.commandClient.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}`;return await this.commandClient.exists(lockKey)}async acquireJobLock(jobId,queue,duration=60000){let lockKey=`${this.options.keyPrefix}:lock:${queue}:${jobId}`,lockValue=`${this.consumerId}:${Date.now()}`;if(await this.commandClient.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.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}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 Bun.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.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`),process.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`);process.exit(0)};if(process.on("SIGINT",cleanup),process.on("SIGTERM",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{Event,InteractsWithConsole as InteractsWithConsole5}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;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){Event.on(`elysium:heracles:queue:${this.name}:${event}`,handler)}once(event,handler){Event.once(`elysium:heracles:queue:${this.name}:${event}`,handler)}emit(event,data,source=null){Event.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;this.starting=(async()=>{await transport.start(),this.info(`Queue '${this.name}' transport started`)})().catch((error)=>{throw this.starting=void 0,error})}return this.starting}async stop(){if(this.transport){let pending=this.starting;this.starting=void 0,await pending?.catch(()=>{}),await this.transport.stop(),this.info(`Queue '${this.name}' transport stopped`)}}getOptions(){return{...this.options}}updateOptions(options){this.options={...this.options,...options}}}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=Service3.get(KERNEL_SERVICE);if(!kernel)throw new ElysiumError2(`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,HeraclesWorkCommand],services:(config)=>[{name:QUEUES_SERVICE,global:!0,factory:()=>new ConfiguredQueues(config.queues??{})}]})],HeraclesModule);import{Service as Service4}from"@elysiumjs/core";import{uid as uid5}from"radash";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(Service4.exists(QUEUES_SERVICE))await Service4.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||={});export{BaseWorker,DuplicateJobError,Heracles,HeraclesModule,Job,JobOverlapBehavior,JobStatus,QUEUES_SERVICE,Queue,RedisTransport,RedisWorker,TransportMode,WorkerStatus};
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/queue.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { EventHandler } from '@elysiumjs/core';
1
+ import type { LifecycleHandler } from '@elysiumjs/core';
2
2
  import type { JobClass, JobOverlapBehavior } from './job';
3
3
  import type { JobStatusInfo, TransportClass, TransportEvent } from './transport';
4
4
  import { InteractsWithConsole } from '@elysiumjs/core';
@@ -142,6 +142,8 @@ export declare class Queue extends InteractsWithConsole {
142
142
  readonly name: string;
143
143
  /** The in-flight or settled start of the transport. */
144
144
  private starting;
145
+ /** The in-flight stop of the transport, which a later start waits for. */
146
+ private stopping;
145
147
  /**
146
148
  * The transport used by this queue
147
149
  */
@@ -168,8 +170,8 @@ export declare class Queue extends InteractsWithConsole {
168
170
  * @param config The queue configuration.
169
171
  */
170
172
  private constructor();
171
- on<TData, TSource = any>(event: TransportEvent['type'], handler: EventHandler<TData, TSource> | never): void;
172
- once<TData, TSource = any>(event: TransportEvent['type'], handler: EventHandler<TData, TSource> | never): void;
173
+ on<TData, TSource = any>(event: TransportEvent['type'], handler: LifecycleHandler<TData, TSource> | never): void;
174
+ once<TData, TSource = any>(event: TransportEvent['type'], handler: LifecycleHandler<TData, TSource> | never): void;
173
175
  private emit;
174
176
  /**
175
177
  * Handle messages from the transport
@@ -235,7 +237,7 @@ export declare class Queue extends InteractsWithConsole {
235
237
  */
236
238
  start(): Promise<void>;
237
239
  /**
238
- * Stop the queue's transport.
240
+ * Stop the queue's transport. A start requested while the stop is in flight runs after it.
239
241
  *
240
242
  * @returns A promise that resolves when the transport has stopped.
241
243
  */
@@ -0,0 +1,194 @@
1
+ import type { CronSchedule } from './cron';
2
+ import type { JobClass } from './job';
3
+ import { ElysiumError } from '@elysiumjs/core';
4
+ /**
5
+ * Options of {@link Scheduler.job}.
6
+ * @author Axel Nana <axel.nana@workbud.com>
7
+ */
8
+ export type ScheduleOptions = {
9
+ /** The arguments the job is dispatched with. */
10
+ args?: unknown[];
11
+ };
12
+ /**
13
+ * Raised when two scheduler classes have the same name.
14
+ * @author Axel Nana <axel.nana@workbud.com>
15
+ */
16
+ export declare class DuplicateSchedulerError extends ElysiumError {
17
+ constructor(name: string);
18
+ }
19
+ /**
20
+ * A schedule of a job class, or of a scheduler class that fans out its job.
21
+ * @author Axel Nana <axel.nana@workbud.com>
22
+ */
23
+ export type JobSchedule = {
24
+ readonly cron: CronSchedule;
25
+ /** The arguments of a job dispatched once per tick. */
26
+ readonly args: unknown[];
27
+ /** The scheduler class that selects the jobs of a tick. */
28
+ readonly scheduler?: SchedulerClass;
29
+ };
30
+ /**
31
+ * The schedules of a job class.
32
+ * @author Axel Nana <axel.nana@workbud.com>
33
+ * @param jobClass The job class.
34
+ */
35
+ export declare const schedulesOf: (jobClass: Function) => readonly JobSchedule[];
36
+ /**
37
+ * A scheduled job, with one of its schedules. For a scheduler class, `name` is the name of the
38
+ * scheduler.
39
+ * @author Axel Nana <axel.nana@workbud.com>
40
+ */
41
+ export type ScheduledJob = {
42
+ name: string;
43
+ jobClass: JobClass<any, any>;
44
+ schedule: JobSchedule;
45
+ };
46
+ /**
47
+ * A job a scheduler dispatches: the key that names it in its dispatch id, and the arguments of
48
+ * the job.
49
+ * @author Axel Nana <axel.nana@workbud.com>
50
+ */
51
+ export type FanOutCase<TJob extends JobClass<any, any> = JobClass<any, any>> = {
52
+ key: string;
53
+ args: ConstructorParameters<TJob>;
54
+ };
55
+ /**
56
+ * Properties of {@link Scheduler.register}.
57
+ * @author Axel Nana <axel.nana@workbud.com>
58
+ */
59
+ export type SchedulerProps<TJob extends JobClass<any, any> = JobClass<any, any>> = {
60
+ /** The cron expression (5 fields, or an alias such as `@daily`), in UTC. */
61
+ cron: string;
62
+ /** The job class the scheduler dispatches, marked with `@Job.register()`. */
63
+ job: TJob;
64
+ /** The name of the scheduler, in dispatch ids. Defaults to the class name. */
65
+ name?: string;
66
+ };
67
+ /**
68
+ * A scheduler class.
69
+ * @author Axel Nana <axel.nana@workbud.com>
70
+ */
71
+ export type SchedulerClass<TJob extends JobClass<any, any> = JobClass<any, any>> = new (...args: any[]) => Scheduler<TJob>;
72
+ /** @internal Forgets the registered scheduler classes, for tests. */
73
+ export declare const clearSchedulers: () => void;
74
+ /**
75
+ * Dispatches one job per case due at a cron tick, such as one billing job per invoice due, so that
76
+ * each case retries, fails and dead-letters on its own. A scheduler is a service: its constructor
77
+ * can inject the repositories that `fanOut()` reads. Each job has a dispatch id made of the
78
+ * scheduler, the case key and the tick, the same however often the tick is handled, so `@Once()`
79
+ * of `@elysiumjs/moirai/heracles` runs it once.
80
+ *
81
+ * The static methods run the schedules of the application: {@link ScheduleRunner} and
82
+ * `HeraclesWorkersModule` call them.
83
+ * @author Axel Nana <axel.nana@workbud.com>
84
+ * @example
85
+ * ```ts
86
+ * @Scheduler.register({ cron: '0 6 * * *', job: BillInvoice })
87
+ * class DailyBilling extends Scheduler<typeof BillInvoice> {
88
+ * constructor(@Service.inject() private readonly invoices: InvoiceRepository) {
89
+ * super();
90
+ * }
91
+ *
92
+ * public async fanOut(tick: Date) {
93
+ * const due = await this.invoices.dueAt(tick);
94
+ * return due.map((invoice) => ({ key: invoice.id, args: [invoice.id] }));
95
+ * }
96
+ * }
97
+ * ```
98
+ */
99
+ export declare abstract class Scheduler<TJob extends JobClass<any, any> = JobClass<any, any>> {
100
+ /**
101
+ * Dispatches a job on a cron schedule, in UTC, with fixed arguments. On Bun and Node.js, the
102
+ * `heracles:schedule` command runs the {@link ScheduleRunner}; on Cloudflare Workers,
103
+ * `HeraclesWorkersModule` (`@elysiumjs/heracles-workers`) dispatches the jobs whose expression is
104
+ * the one of the cron trigger, so list the same expression in the wrangler config. A job can have
105
+ * several schedules. To dispatch one job per case due at a tick, extend {@link Scheduler}.
106
+ * @author Axel Nana <axel.nana@workbud.com>
107
+ * @param cron The cron expression (5 fields, or an alias such as `@daily`).
108
+ * @param options The arguments of the dispatch.
109
+ * @throws {InvalidCronError} When the expression is not valid.
110
+ * @example
111
+ * ```ts
112
+ * @Job.register({ queue: 'default' })
113
+ * @Scheduler.job('0 3 * * *')
114
+ * class PurgeExpiredSessions extends Job {
115
+ * protected async execute() {}
116
+ * }
117
+ * ```
118
+ */
119
+ static job(cron: string, options?: ScheduleOptions): (target: Function) => void;
120
+ /**
121
+ * Marks a class as a scheduler of a job.
122
+ * @param props The cron expression, the job and the name of the scheduler.
123
+ * @throws {InvalidCronError} When the expression is not valid.
124
+ * @throws {DuplicateSchedulerError} When another scheduler has the same name.
125
+ */
126
+ static register<TJob extends JobClass<any, any>>(props: SchedulerProps<TJob>): (target: SchedulerClass<TJob>) => void;
127
+ /**
128
+ * The scheduled jobs of the application: the jobs of the registry marked with `@Scheduler.job()`,
129
+ * and the scheduler classes.
130
+ * @author Axel Nana <axel.nana@workbud.com>
131
+ */
132
+ static jobs(): ScheduledJob[];
133
+ /**
134
+ * The scheduled jobs whose expression is exactly the given one, such as the cron of a
135
+ * Cloudflare cron trigger.
136
+ * @author Axel Nana <axel.nana@workbud.com>
137
+ * @param cron The cron expression.
138
+ */
139
+ static jobsFor(cron: string): ScheduledJob[];
140
+ /**
141
+ * Dispatches a scheduled job: once, or once per case its scheduler selects.
142
+ * @author Axel Nana <axel.nana@workbud.com>
143
+ * @param job The scheduled job.
144
+ * @param at The tick. Defaults to now; it is taken to the minute, in UTC.
145
+ * @returns The number of jobs dispatched.
146
+ */
147
+ static dispatch(job: ScheduledJob, at?: Date): Promise<number>;
148
+ /**
149
+ * The dispatch id of a job of a scheduler: the same for one scheduler, case and tick.
150
+ * @author Axel Nana <axel.nana@workbud.com>
151
+ * @param scheduler The name of the scheduler.
152
+ * @param key The key of the case.
153
+ * @param tick The tick.
154
+ */
155
+ static dispatchIdOf(scheduler: string, key: string, tick: Date): string;
156
+ /**
157
+ * Selects the jobs due at a tick.
158
+ * @param tick The tick, in UTC, to the minute.
159
+ * @returns One case per job to dispatch, with its key and the arguments of the job.
160
+ */
161
+ abstract fanOut(tick: Date): Promise<FanOutCase<TJob>[]> | FanOutCase<TJob>[];
162
+ }
163
+ /**
164
+ * Options of the {@link ScheduleRunner}.
165
+ * @author Axel Nana <axel.nana@workbud.com>
166
+ */
167
+ export type ScheduleRunnerOptions = {
168
+ /**
169
+ * The Redis connection that holds the lock of each tick, so that one process dispatches it
170
+ * when several run the scheduler. `false` runs without a lock. Defaults to `false`.
171
+ */
172
+ redis?: string | false;
173
+ /** How long a tick's lock is kept, in milliseconds. Defaults to 60 seconds. */
174
+ lockTtl?: number;
175
+ };
176
+ /**
177
+ * Dispatches the scheduled jobs at their times, with in-process timers. With a Redis connection,
178
+ * each tick takes a lock first, so that running it in several processes dispatches each tick once.
179
+ * @author Axel Nana <axel.nana@workbud.com>
180
+ */
181
+ export declare class ScheduleRunner {
182
+ #private;
183
+ constructor(options?: ScheduleRunnerOptions);
184
+ /** Whether the scheduler is running. */
185
+ get running(): boolean;
186
+ /**
187
+ * Plans the next tick of every scheduled job.
188
+ * @author Axel Nana <axel.nana@workbud.com>
189
+ * @returns The scheduled jobs.
190
+ */
191
+ start(): ScheduledJob[];
192
+ /** Cancels the planned ticks. */
193
+ stop(): void;
194
+ }
@@ -1,3 +1,4 @@
1
+ import type { RedisClientFactory } from '@elysiumjs/redis';
1
2
  import { InteractsWithConsole } from '@elysiumjs/core';
2
3
  /**
3
4
  * Histogram for tracking distributions
@@ -116,7 +117,15 @@ export declare class PerformanceMonitor extends InteractsWithConsole {
116
117
  readonly workerConcurrency: Gauge;
117
118
  readonly workerMemoryUsage: Gauge;
118
119
  constructor(options?: {
120
+ /**
121
+ * The `RedisModule` connection to report to. The monitor reports on a duplicate of it,
122
+ * created with the connection's own client.
123
+ */
124
+ connection?: string;
125
+ /** The Redis server to report to, when no `connection` is given. */
119
126
  redisUrl?: string;
127
+ /** Creates the client of `redisUrl`. Defaults to `bunRedis`. */
128
+ client?: RedisClientFactory;
120
129
  keyPrefix?: string;
121
130
  enableReporting?: boolean;
122
131
  reportingInterval?: number;
package/package.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "name": "@elysiumjs/heracles",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Background job processing system for Elysium.js apps.",
5
5
  "module": "dist/index.js",
6
6
  "type": "module",
7
7
  "types": "dist/index.d.ts",
8
+ "files": [
9
+ "dist",
10
+ "CHANGELOG.md"
11
+ ],
8
12
  "sideEffects": false,
9
13
  "scripts": {
10
14
  "test": "bun test",
@@ -18,7 +22,8 @@
18
22
  "prepublishOnly": "bun run build",
19
23
  "example:optimized": "bun run examples/optimized_example.ts",
20
24
  "example:worker": "bun run examples/redis_worker_example.ts",
21
- "example:comprehensive": "bun run examples/heracles_comprehensive.ts"
25
+ "example:comprehensive": "bun run examples/heracles_comprehensive.ts",
26
+ "test:coverage": "bun test --coverage"
22
27
  },
23
28
  "keywords": [
24
29
  "elysium",
@@ -31,15 +36,26 @@
31
36
  "heracles"
32
37
  ],
33
38
  "author": "Workbud Technologies Inc.",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/na2axl/elysiumjs.git",
42
+ "directory": "@elysiumjs/heracles"
43
+ },
44
+ "homepage": "https://github.com/na2axl/elysiumjs/tree/main/@elysiumjs/heracles#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/na2axl/elysiumjs/issues"
47
+ },
34
48
  "license": "Apache-2.0",
35
49
  "dependencies": {
36
50
  "commander": "^15.0.0",
37
51
  "radash": "^12.1.1"
38
52
  },
39
53
  "devDependencies": {
40
- "@elysiumjs/core": "0.8.0"
54
+ "@elysiumjs/core": "0.11.0",
55
+ "@elysiumjs/redis": "0.1.0"
41
56
  },
42
57
  "peerDependencies": {
43
- "@elysiumjs/core": "^0.8.0"
58
+ "@elysiumjs/core": "^0.11.0",
59
+ "@elysiumjs/redis": "^0.1.0"
44
60
  }
45
61
  }
package/tsconfig.json DELETED
@@ -1,12 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "outDir": "dist",
5
- "declaration": true,
6
- "emitDeclarationOnly": true,
7
- "declarationMap": false,
8
- "declarationDir": "dist",
9
- "rootDir": "src"
10
- },
11
- "include": ["src"]
12
- }