@elysiumjs/heracles 0.6.1 → 0.7.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 +42 -0
- package/README.md +82 -74
- package/dist/commands/work.command.d.ts +1 -0
- package/dist/heracles.module.d.ts +47 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +3 -1
- package/dist/job.d.ts +0 -1
- package/dist/queue.d.ts +9 -1
- package/dist/registry.d.ts +39 -0
- package/package.json +7 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# @elysiumjs/heracles
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [`8ac91ab`](https://github.com/na2axl/elysiumjs/commit/8ac91ab676b1119b4eecf0ddb3fd0a3e84017cac) Thanks [@na2axl](https://github.com/na2axl)! - Upgrade dependencies to their latest major versions: wampy 8, zod 4, commander 15, @casl/ability 7, OpenTelemetry SDK 2 (sdk-node 0.300, resources 3), @sentry/bun 11. Generated projects now use TypeScript 7.
|
|
8
|
+
|
|
9
|
+
Breaking in `@elysiumjs/mnemosyne-drizzle`: it now requires drizzle-orm 1.0 (release candidate) and generated projects use drizzle-kit 1.0. `ColumnsWithTable` is no longer re-exported. `migration:generate` writes 1.0 migration folders (`<timestamp>_<name>/migration.sql`), and `migration:run` tracks those by folder name while still running flat `*.sql` files. Existing projects can upgrade their migrations folder with `drizzle-kit up`: `migration:run` recognises the moved migrations as already applied and renames their records. The `make:model` stub now generates a `pgTable` and a `DrizzleModel` built from it.
|
|
10
|
+
|
|
11
|
+
Breaking in `@elysiumjs/core`: the deprecated `@elysiajs/swagger` plugin is replaced by `@elysiajs/openapi`, and the app option `swagger` is renamed `openapi` (typed `ElysiaOpenAPIConfig | false`). Without a `path`, the docs now live at `/openapi` instead of `/swagger`. The UI options `scalarConfig` and `swaggerOptions` are now `scalar` and `swagger`. Elysia stays on 1.4 (now `^1.4.30`) until Elysia 2 and its OpenAPI plugin are stable.
|
|
12
|
+
|
|
13
|
+
Also in `@elysiumjs/core`: the WAMP `onChallenge` callback must return a string (or a promise of one); `null` is no longer accepted. `onError` now receives the error and `onReconnectSuccess` receives wampy's details object. `WampClient` is now wampy's own `Wampy` type. `onOpen` is called by Elysium once the connection is established, since wampy 8 no longer has that callback.
|
|
14
|
+
|
|
15
|
+
- [`3625883`](https://github.com/na2axl/elysiumjs/commit/3625883dba92f1596e7444ee52f9399d8e9b049f) Thanks [@na2axl](https://github.com/na2axl)! - Drop the `reflect-metadata` dependency. Decorator metadata now lives in a small store in `@elysiumjs/core`, exported as `Metadata` (`define`, `get`, `getOwn`, `has`). Use it instead of `Reflect.defineMetadata` and `Reflect.getMetadata` to read or write Elysium metadata.
|
|
16
|
+
|
|
17
|
+
Apps no longer need `import 'reflect-metadata'` in their entrypoint, and generated projects leave it out. Keep `emitDecoratorMetadata` enabled: `@Service.inject()` and `@Command.arg()` still infer types from it. An app that loads `reflect-metadata` for another library keeps working; Elysium also reads the types stored there.
|
|
18
|
+
|
|
19
|
+
- [`07236b3`](https://github.com/na2axl/elysiumjs/commit/07236b386c2506590d74e8753ce847c1e184b3ee) Thanks [@na2axl](https://github.com/na2axl)! - Introduce the module system. Modules are now the only way to register anything in an Elysium.js app. This is a breaking change with no compatibility layer.
|
|
20
|
+
|
|
21
|
+
Breaking changes:
|
|
22
|
+
|
|
23
|
+
- `@Module.register()` takes `imports`, `services`, `exports`, `controllers`, `commands`, `jobs`, `prefix` and `env`. Services are private to their module unless exported (or global). The `beforeRegister` and `afterRegister` hooks are replaced by `onInit`, `onServe` and `onDestroy`.
|
|
24
|
+
- Configurable modules are bound with `with()`, for example `HermesModule.with({ level: 'debug' })`. `with()` accepts an object or a function.
|
|
25
|
+
- Decorators only record metadata. Importing a file no longer registers commands, services or event wiring, and the side-effect imports of the packages are removed. List commands, jobs and services in the module that owns them.
|
|
26
|
+
- `@Application.register()` no longer takes `database`, `redis`, `wamp` or `'elysium:hermes'`/`'elysium:cerberus'` keys. Use `DrizzleModule`, `RedisModule`, `WampModule`, `HermesModule` and `CerberusModule`. Package plugins are replaced by modules: add `SentryModule` instead of the `plugin()` function. `plugins` still accepts plain Elysia plugin factories.
|
|
27
|
+
- Constructing an `Application` no longer runs it. Use `await App.start()`; `Application.testing()` boots a module graph for tests. The `styx` binary loads `src/app.ts` (or `index.ts`) and starts the exported application, so a project entry no longer needs to import commands.
|
|
28
|
+
- The job system of core (`Job`, `Worker`, `WorkerPool`, `commandWork`) is removed. `@elysiumjs/heracles` is the only job system: extend its `Job`, list jobs in a module's `jobs`, declare queues with `HeraclesModule.with({ queues })`, and dispatch with `Heracles.dispatch()`.
|
|
29
|
+
- `@elysiumjs/styx` ships `StyxModule`, active in development only. The `migration:generate` and `migration:run` commands move to `DrizzleModule` and are available in production.
|
|
30
|
+
- `@elysiumjs/mnemosyne` ships `MnemosyneModule` for its caches (`redis: false` or `memory: false` opts out). `DrizzleModule` imports it.
|
|
31
|
+
- New modules: `HephaestusModule`, `ArgusModule` (`serviceName` is required), `SentryModule` (needs a `dsn` or `SENTRY_DSN`) and `CerberusModule` (`getSubject` is required).
|
|
32
|
+
- Module and service code throws typed errors (for example `InvalidModuleConfigError`) instead of exiting the process. `Application.run()` prints them and exits; command `run()` methods may still exit, and package plugin factories are unchanged.
|
|
33
|
+
- `Application.register` merges subclass props over the parent's (own props, then own metadata, then parent, then defaults), and concatenates `modules` parent first. Previously defaults and parent values won, so `openapi` could not be enabled.
|
|
34
|
+
- `Heracles.dispatch` starts a stopped queue on first use, so producers no longer start queues. `Queue.start()` is idempotent.
|
|
35
|
+
- `create-elysium` generates apps that use modules, and adds Sentry and Hephaestus to the `modules` list.
|
|
36
|
+
- `@Event.on`/`@Event.once` method listeners on classes in a loaded module's `services` or `controllers` are wired by the kernel and run in their module's context. Jobs, commands and middlewares are not wired, and an instance listener on a class that is not a service throws `EventListenerNotSupportedError`. Listeners on classes not listed in a loaded module no longer fire.
|
|
37
|
+
- New typed errors: `CircularDependencyError` (services that depend on each other, in sync and async resolution), `DuplicateModuleNameError` (two different module classes sharing a name; module scopes and config tokens are name based) and `EventListenerNotSupportedError`.
|
|
38
|
+
- Lazy boot contract: a CLI command initialises only its own module and that module's imports. Sync helpers such as `Database.getConnection()` work when the owning module's `onInit` is synchronous; otherwise they throw `ServiceNotReadyError` naming the module. Use `await Service.resolve()` or set `static boot = true` on the command.
|
|
39
|
+
|
|
40
|
+
### Patch Changes
|
|
41
|
+
|
|
42
|
+
- Updated dependencies [[`8ac91ab`](https://github.com/na2axl/elysiumjs/commit/8ac91ab676b1119b4eecf0ddb3fd0a3e84017cac), [`3625883`](https://github.com/na2axl/elysiumjs/commit/3625883dba92f1596e7444ee52f9399d8e9b049f), [`07236b3`](https://github.com/na2axl/elysiumjs/commit/07236b386c2506590d74e8753ce847c1e184b3ee)]:
|
|
43
|
+
- @elysiumjs/core@0.8.0
|
|
44
|
+
|
|
3
45
|
## 0.6.0
|
|
4
46
|
|
|
5
47
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -20,7 +20,34 @@ bun add @elysiumjs/heracles
|
|
|
20
20
|
|
|
21
21
|
## Quick Start
|
|
22
22
|
|
|
23
|
-
### 1.
|
|
23
|
+
### 1. Register the Module
|
|
24
|
+
|
|
25
|
+
Queues are declared on `HeraclesModule`. The Redis transport reads its connection from a `RedisModule`.
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import { Application, RedisModule } from '@elysiumjs/core';
|
|
29
|
+
import { HeraclesModule, RedisTransport } from '@elysiumjs/heracles';
|
|
30
|
+
|
|
31
|
+
import { MainModule } from './main.module';
|
|
32
|
+
|
|
33
|
+
@Application.register({
|
|
34
|
+
modules: [
|
|
35
|
+
RedisModule.with({
|
|
36
|
+
default: 'cache',
|
|
37
|
+
connections: { cache: { url: process.env.REDIS_URL! } }
|
|
38
|
+
}),
|
|
39
|
+
HeraclesModule.with({
|
|
40
|
+
queues: {
|
|
41
|
+
emails: { transport: RedisTransport }
|
|
42
|
+
}
|
|
43
|
+
}),
|
|
44
|
+
MainModule
|
|
45
|
+
]
|
|
46
|
+
})
|
|
47
|
+
export class App extends Application {}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 2. Create a Job
|
|
24
51
|
|
|
25
52
|
```typescript
|
|
26
53
|
import { Job } from '@elysiumjs/heracles';
|
|
@@ -51,25 +78,28 @@ export class SendEmailJob extends Job {
|
|
|
51
78
|
}
|
|
52
79
|
```
|
|
53
80
|
|
|
54
|
-
|
|
81
|
+
List the job in the `jobs` of the module that owns it. Heracles collects the jobs of every loaded module:
|
|
55
82
|
|
|
56
83
|
```typescript
|
|
57
|
-
import {
|
|
84
|
+
import { Module } from '@elysiumjs/core';
|
|
58
85
|
|
|
59
86
|
import { SendEmailJob } from './jobs/send-email.job';
|
|
60
87
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
88
|
+
@Module.register({
|
|
89
|
+
jobs: [SendEmailJob]
|
|
90
|
+
})
|
|
91
|
+
export class MainModule extends Module {}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### 3. Dispatch a Job
|
|
95
|
+
|
|
96
|
+
Dispatching starts the queue's transport on first use, so a producer (such as your web server) needs no extra setup.
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
import { Heracles } from '@elysiumjs/heracles';
|
|
68
100
|
|
|
69
|
-
// Dispatch a job
|
|
70
101
|
await Heracles.dispatch(SendEmailJob, ['user@example.com', 'Welcome!', 'Welcome to our platform!']);
|
|
71
102
|
|
|
72
|
-
// Dispatch with options
|
|
73
103
|
await Heracles.dispatch(
|
|
74
104
|
SendEmailJob,
|
|
75
105
|
['user@example.com', 'Welcome!', 'Welcome to our platform!'],
|
|
@@ -81,9 +111,9 @@ await Heracles.dispatch(
|
|
|
81
111
|
);
|
|
82
112
|
```
|
|
83
113
|
|
|
84
|
-
###
|
|
114
|
+
### 4. Start Workers
|
|
85
115
|
|
|
86
|
-
|
|
116
|
+
Run workers with `bun run index.ts heracles:work` (or `styx heracles:work`) with `HeraclesModule` loaded. The `styx` binary runs your app and needs no `StyxModule`; `StyxModule` is skipped in production, where workers still run. `heracles:work` boots every module before it runs:
|
|
87
117
|
|
|
88
118
|
```bash
|
|
89
119
|
# Start a Redis worker for the 'emails' queue with concurrency of 3 jobs
|
|
@@ -98,7 +128,6 @@ Or programmatically:
|
|
|
98
128
|
```typescript
|
|
99
129
|
import { RedisWorker } from '@elysiumjs/heracles';
|
|
100
130
|
|
|
101
|
-
// Create and start a Redis worker
|
|
102
131
|
const worker = new RedisWorker('default', { id: 'worker-1' });
|
|
103
132
|
await worker.createQueue({ name: 'emails', concurrency: 3 });
|
|
104
133
|
await worker.start();
|
|
@@ -108,26 +137,30 @@ await worker.start();
|
|
|
108
137
|
|
|
109
138
|
### Queue Options
|
|
110
139
|
|
|
111
|
-
|
|
112
|
-
const queue = Queue.get('emails', {
|
|
113
|
-
// Maximum number of concurrent jobs to process in this queue
|
|
114
|
-
concurrency: 5,
|
|
115
|
-
|
|
116
|
-
// Maximum number of retries for failed jobs
|
|
117
|
-
maxRetries: 3,
|
|
118
|
-
|
|
119
|
-
// Delay in milliseconds between retries
|
|
120
|
-
retryDelay: 5000,
|
|
121
|
-
|
|
122
|
-
// Whether to pause processing when an error occurs
|
|
123
|
-
pauseOnError: false,
|
|
124
|
-
|
|
125
|
-
// Redis transport configuration
|
|
126
|
-
transport: RedisTransport,
|
|
140
|
+
Each entry of `queues` accepts the queue options:
|
|
127
141
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
142
|
+
```typescript
|
|
143
|
+
HeraclesModule.with({
|
|
144
|
+
queues: {
|
|
145
|
+
emails: {
|
|
146
|
+
// Maximum number of concurrent jobs to process in this queue
|
|
147
|
+
concurrency: 5,
|
|
148
|
+
|
|
149
|
+
// Maximum number of retries for failed jobs
|
|
150
|
+
maxRetries: 3,
|
|
151
|
+
|
|
152
|
+
// Delay in milliseconds between retries
|
|
153
|
+
retryDelay: 5000,
|
|
154
|
+
|
|
155
|
+
// Whether to pause processing when an error occurs
|
|
156
|
+
pauseOnError: false,
|
|
157
|
+
|
|
158
|
+
// Transport class and its options
|
|
159
|
+
transport: RedisTransport,
|
|
160
|
+
transportOptions: {
|
|
161
|
+
connection: 'default'
|
|
162
|
+
}
|
|
163
|
+
}
|
|
131
164
|
}
|
|
132
165
|
});
|
|
133
166
|
```
|
|
@@ -138,50 +171,25 @@ Heracles has a bundled Redis transport layer for job distribution, enabling dist
|
|
|
138
171
|
|
|
139
172
|
#### 1. Configure Redis Connection
|
|
140
173
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
```typescript
|
|
144
|
-
import { Redis } from '@elysiumjs/core';
|
|
145
|
-
|
|
146
|
-
// Register a Redis connection
|
|
147
|
-
Redis.registerConnection('default', {
|
|
148
|
-
url: 'redis://localhost:6379'
|
|
149
|
-
// Other Redis options
|
|
150
|
-
});
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
Or during the Application registration:
|
|
154
|
-
|
|
155
|
-
```typescript
|
|
156
|
-
import { Application } from '@elysiumjs/core';
|
|
157
|
-
|
|
158
|
-
@Application.register({
|
|
159
|
-
redis: {
|
|
160
|
-
default: 'jobs',
|
|
161
|
-
connections: {
|
|
162
|
-
jobs: { url: 'redis://localhost:6379' }
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
// ...other configurations...
|
|
166
|
-
})
|
|
167
|
-
export class App extends Application {}
|
|
168
|
-
```
|
|
174
|
+
Declare the connection with `RedisModule` in your application (see step 1 above). `connection: 'default'` resolves to the connection named by `RedisModule`'s `default`.
|
|
169
175
|
|
|
170
176
|
#### 2. Configure Queues with Redis Transport
|
|
171
177
|
|
|
172
178
|
```typescript
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
179
|
+
HeraclesModule.with({
|
|
180
|
+
queues: {
|
|
181
|
+
emails: {
|
|
182
|
+
transport: RedisTransport,
|
|
183
|
+
transportOptions: {
|
|
184
|
+
connection: 'default', // Redis connection name
|
|
185
|
+
keyPrefix: 'myapp:jobs', // Prefix for Redis keys (default: 'elysium:heracles')
|
|
186
|
+
consumerGroup: 'workers', // Redis consumer group name (default: 'workers')
|
|
187
|
+
statusTTL: 86400, // Job status TTL in seconds (default: 86400 - 24 hours)
|
|
188
|
+
cleanupCompletedJobs: true, // Auto-cleanup completed jobs (default: true)
|
|
189
|
+
completedJobRetention: 3600, // Time to retain completed jobs in seconds (default: 3600 - 1 hour)
|
|
190
|
+
maxStreamSize: 1000 // Maximum stream size (default: 1000)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
185
193
|
}
|
|
186
194
|
});
|
|
187
195
|
```
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { QueueOptions } from './queue';
|
|
2
|
+
import { Queue } from './queue';
|
|
3
|
+
/**
|
|
4
|
+
* Configuration of the {@link HeraclesModule}.
|
|
5
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
6
|
+
*/
|
|
7
|
+
export type HeraclesModuleConfig = {
|
|
8
|
+
/** The queues to create, by name. */
|
|
9
|
+
queues?: Record<string, Omit<QueueOptions, 'name'>>;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* The queues created from the module configuration. Built on first use; stops and removes
|
|
13
|
+
* the queues it created when the app shuts down.
|
|
14
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
15
|
+
*/
|
|
16
|
+
export declare class ConfiguredQueues {
|
|
17
|
+
readonly queues: readonly Queue[];
|
|
18
|
+
constructor(config: NonNullable<HeraclesModuleConfig['queues']>);
|
|
19
|
+
onDestroy(): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
declare const HeraclesModule_base: (abstract new () => {
|
|
22
|
+
readonly config: HeraclesModuleConfig;
|
|
23
|
+
onInit(): Promise<void> | void;
|
|
24
|
+
onDestroy(): Promise<void> | void;
|
|
25
|
+
onServe(_app: import("elysia").default<any>): Promise<void> | void;
|
|
26
|
+
}) & {
|
|
27
|
+
readonly config: string;
|
|
28
|
+
with(config: Partial<HeraclesModuleConfig> | (() => Partial<HeraclesModuleConfig> | Promise<Partial<HeraclesModuleConfig>>)): import("@elysiumjs/core").ConfiguredModule<HeraclesModuleConfig>;
|
|
29
|
+
resolveConfig(input?: Partial<HeraclesModuleConfig> | undefined): HeraclesModuleConfig;
|
|
30
|
+
register<TConfig_1 = any>(props?: import("@elysiumjs/core").ModuleProps<TConfig_1>): (target: import("@elysiumjs/core").ModuleClass) => void;
|
|
31
|
+
configurable<TConfig extends object>(options?: {
|
|
32
|
+
defaults?: Partial<TConfig>;
|
|
33
|
+
required?: (keyof TConfig)[];
|
|
34
|
+
}): any;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Collects the jobs that loaded modules declare in their `jobs` lists, runs each job inside
|
|
38
|
+
* its owning module's context, and provides the Heracles commands.
|
|
39
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
40
|
+
*/
|
|
41
|
+
export declare class HeraclesModule extends HeraclesModule_base {
|
|
42
|
+
/** Builds the job registry. Queues are created by the {@link ConfiguredQueues} service. */
|
|
43
|
+
onInit(): void;
|
|
44
|
+
/** Clears the module-level job registry. */
|
|
45
|
+
onDestroy(): void;
|
|
46
|
+
}
|
|
47
|
+
export {};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import '@elysiumjs/core';
|
|
2
|
-
import './commands';
|
|
3
1
|
export type { JobProps, JobClass, JobMetadata } from './job';
|
|
4
2
|
export type { JobDispatchOptions, QueueOptions, JobDispatchId } from './queue';
|
|
5
3
|
export type { JobStatusInfo, Transport, TransportClass, TransportEvent } from './transport';
|
|
6
4
|
export type { QueuedJob, QueueState, Worker, WorkerInfo, WorkerQueueOptions } from './worker';
|
|
7
5
|
export type { RedisTransportOptions } from './transports/redis.transport';
|
|
6
|
+
export { HeraclesModule, type HeraclesModuleConfig } from './heracles.module';
|
|
7
|
+
export { DuplicateJobError, QUEUES_SERVICE } from './registry';
|
|
8
8
|
export { Heracles } from './heracles';
|
|
9
9
|
export { Job, JobStatus, JobOverlapBehavior } from './job';
|
|
10
10
|
export { Queue } from './queue';
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
import"@elysiumjs/core";import"reflect-metadata";import{InteractsWithConsole as S,Service as h}from"@elysiumjs/core";import{assign as l,uid as W}from"radash";var E;(($)=>($.job=Symbol("elysium:job"),$.queue=Symbol("elysium:queue"),$.worker=Symbol("elysium:worker"),$.transport=Symbol("elysium:transport"),$.jobTransaction=Symbol("elysium:jobTransaction"),$.workerPool=Symbol("elysium:workerPool")))(E||={});var Q=(L)=>{if(!Reflect.hasMetadata(E.job,L))return null;return Reflect.getMetadata(E.job,L)};var G;((F)=>{F.ALLOW_OVERLAP="allow_overlap";F.NO_OVERLAP="no_overlap"})(G||={});class O extends S{#Y;#F;#L="pending";#H;#z=0;#U;static register(L={}){return function(Y){L=l({name:Y.name,queue:"default",overlapBehavior:"allow_overlap",overlapDelay:0,priority:0},L);let F=`elysium.heracles.job.${L.name}`;h.instance(F,Y);let H={name:F,queue:L.queue,overlapBehavior:L.overlapBehavior,overlapDelay:L.overlapDelay,priority:L.priority,maxRetries:L.maxRetries,retryDelay:L.retryDelay};Reflect.defineMetadata(E.job,H,Y)}}static generateJobId(...L){return`job_${Date.now()}_${W(8)}`}static description="An Heracles job.";id;dispatchId;createdAt;get startedAt(){return this.#Y}get completedAt(){return this.#F}get status(){return this.#L}get lastError(){return this.#H}get retries(){return this.#z}get queueName(){return this.#U}set queueName(L){this.#U=L}incrementRetries(){this.#z++}constructor(L,Y){super();this.id=L??this.constructor.generateJobId(),this.dispatchId=Y??`dispatch_${Date.now()}_${W(8)}`,this.createdAt=new Date}async run(){if(this.#L==="cancelled")return;try{this.#L="running",this.#Y=new Date;let L=this.#U?` in queue '${this.#U}'`:"";if(this.debug(`Job ${this.id}${L} started at ${this.#Y.toISOString()}`),await this.execute(),this.#L==="running")this.#L="completed",this.#F=new Date,this.debug(`Job ${this.id} completed successfully at ${this.#F.toISOString()} after ${this.retries} retries`)}catch(L){this.fail(L instanceof Error?L:Error(String(L)))}}fail(L){if(this.#H=L,this.#L!=="cancelled")this.#L="failed",this.#F=new Date,this.error(`Job ${this.id} failed at ${this.#F.toISOString()} after ${this.retries} retries: ${this.#H.message}`),this.trace(this.#H)}cancel(){if(this.#L==="completed"||this.#L==="failed")return!1;let L=this.#L==="running";if(this.#L="cancelled",L)this.#F=new Date;return this.info(`Job ${this.id} was cancelled${L?" while running":" before execution"}`),!0}}var C=(L,Y,F)=>{return class extends L{constructor(...H){super(...H);this.id=Y,this.dispatchId=F}}},A;((V)=>{V.PENDING="pending";V.RUNNING="running";V.COMPLETED="completed";V.FAILED="failed";V.CANCELLED="cancelled";V.SCHEDULED_FOR_RETRY="scheduled_for_retry"})(A||={});import{Redis as c,Service as j}from"@elysiumjs/core";var K;((F)=>{F.CONSUMER="consumer";F.PRODUCER="producer"})(K||={});import{InteractsWithConsole as J,Redis as T}from"@elysiumjs/core";import{uid as w}from"radash";class B extends J{commandClient=null;subscriberClient=null;publisherClient=null;options;mode;messageHandlers=[];consumerId;isRunning=!1;cleanupTimer;jobLocks=new Map;constructor(L,Y={}){super();this.mode=L,this.options={connection:Y.connection||"default",keyPrefix:Y.keyPrefix||"elysium:heracles",consumerGroup:Y.consumerGroup||"workers",consumerName:Y.consumerName||`worker-${w(8)}`,batchSize:Y.batchSize||10,statusTTL:Y.statusTTL||86400,cleanupCompletedJobs:Y.cleanupCompletedJobs!==!1,completedJobRetention:Y.completedJobRetention||3600,maxStreamSize:Y.maxStreamSize||1000},this.consumerId=this.mode==="consumer"?this.options.consumerName:`producer-${w(8)}`}async initializeClients(){this.commandClient=await T.getConnection(this.options.connection).duplicate(),this.subscriberClient=await T.getConnection(this.options.connection).duplicate(),this.publisherClient=await T.getConnection(this.options.connection).duplicate(),this.setupConnectionHandlers()}setupConnectionHandlers(){this.commandClient.onconnect=()=>{this.debug("Command client connected")},this.commandClient.onclose=(L)=>{this.error(`Command client disconnected: ${L}`)},this.subscriberClient.onconnect=()=>{this.debug("Subscriber client connected")},this.subscriberClient.onclose=(L)=>{this.error(`Subscriber client disconnected: ${L}`)},this.publisherClient.onconnect=()=>{this.debug("Publisher client connected")},this.publisherClient.onclose=(L)=>{this.error(`Publisher client disconnected: ${L}`)}}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(L){throw this.error(`Failed to start RedisTransport: ${L}`),Error(`Redis connection failed: ${L}`)}}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 L=await this.getRegisteredQueues();for(let Y of L)await this.subscriberClient.subscribe(`${this.options.keyPrefix}:queue:${Y}:new`,async(F,H)=>{this.debug(`New job notification for queue ${Y}: ${F}`),await this.processJobFromStream(Y,F)}),await this.subscriberClient.subscribe(`${this.options.keyPrefix}:queue:${Y}:control`,async(F,H)=>{try{let U=JSON.parse(F);await this.handleControlMessage(U)}catch(U){this.error(`Error parsing control message: ${U}`)}}),await this.subscriberClient.subscribe(`${this.options.keyPrefix}:lock:${Y}:released`,(F,H)=>{this.debug(`Lock released for job ${F} in queue ${Y}`),this.notifyLockRelease(F,Y)});await this.subscriberClient.subscribe(`${this.options.keyPrefix}:worker:coordination`,async(Y,F)=>{try{let H=JSON.parse(Y);await this.handleWorkerCoordination(H)}catch(H){this.error(`Error handling worker coordination: ${H}`)}})}async processJobFromStream(L,Y){let F=this.getStreamKey(L);try{let H=await this.commandClient.send("XRANGE",[F,Y,Y]);if(H&&H.length>0){let[U,z]=H[0],V=this.deserializeMessage(z,L);for(let X of this.messageHandlers)try{await X(V)}catch(Z){this.error(`Error in message handler: ${Z}`)}await this.commandClient.send("XACK",[F,this.options.consumerGroup,Y])}}catch(H){this.error(`Error processing job from stream: ${H}`)}}async handleControlMessage(L){switch(L.type){case"pause":this.info(`Queue ${L.queue} paused`);break;case"resume":this.info(`Queue ${L.queue} resumed`);break;case"drain":this.info(`Queue ${L.queue} draining`);break;default:this.debug(`Unknown control message type: ${L.type}`)}}async handleWorkerCoordination(L){switch(L.type){case"worker:joined":this.debug(`Worker ${L.workerId} joined`);break;case"worker:left":this.debug(`Worker ${L.workerId} left`);break;case"rebalance":this.debug("Rebalancing work across workers");break}}async send(L){try{let Y=this.extractQueueName(L),F=this.getStreamKey(Y);if(L.type==="job:process"){let H=this.serializeMessage(L),U=await this.commandClient.send("XADD",[F,"*",...H]);await this.indexJob({jobId:L.jobId,dispatchId:L.dispatchId,queue:Y,status:"pending",priority:L.options?.priority||10,timestamp:Date.now()}),await this.storeJobStatus({jobId:L.jobId,dispatchId:L.dispatchId,queue:Y,status:"pending",retries:0,createdAt:new Date().toISOString(),updatedAt:new Date().toISOString(),messageId:U}),await this.publisherClient.publish(`${this.options.keyPrefix}:queue:${Y}:new`,U),this.debug(`Job ${L.jobId} dispatched to queue ${Y} with ID ${U}`)}else if(L.type==="job:status"||L.type==="job:result")await this.updateJobStatus(L.jobId,L.dispatchId,Y,{status:L.status,error:L.error,retries:L.retries,completedAt:L.completedAt,updatedAt:new Date().toISOString()}),await this.updateJobIndex(L.jobId,L.dispatchId,Y,L.status),await this.publisherClient.publish(`${this.options.keyPrefix}:job:${L.jobId}:status`,JSON.stringify({status:L.status,error:L.error,timestamp:Date.now()}));else if(L.type==="job:cancel")await this.publisherClient.publish(`${this.options.keyPrefix}:queue:${Y}:control`,JSON.stringify({type:"cancel",jobId:L.jobId,dispatchId:L.dispatchId}));else{let H=this.serializeMessage(L);await this.commandClient.send("XADD",[F,"*",...H])}}catch(Y){throw this.error(`Failed to send message: ${Y}`),Error(`Failed to send message: ${Y}`)}}onMessage(L){this.messageHandlers.push(L)}async getJobStatus(L,Y,F){try{let H=this.getJobStatusKey(L,Y,F),U=await this.commandClient.hmget(H,["jobId","dispatchId","queue","status","error","retries","createdAt","startedAt","completedAt","updatedAt","messageId"]);if(U&&U[0])return{jobId:U[0],dispatchId:U[1],queue:U[2]||F,status:U[3]||"unknown",error:U[4],retries:parseInt(U[5]||"0",10),createdAt:U[6]||new Date().toISOString(),startedAt:U[7],completedAt:U[8],updatedAt:U[9],messageId:U[10]};let z=await this.getJobFromIndex(L,Y,F);if(z)return{jobId:L,dispatchId:Y,queue:F,status:z.status,retries:0,createdAt:new Date(z.timestamp).toISOString()};return{jobId:L,dispatchId:Y,queue:F,status:"unknown",retries:0,createdAt:new Date().toISOString()}}catch(H){return this.warning(`Failed to get job status: ${H}`),{jobId:L,dispatchId:Y,queue:F,status:"unknown",retries:0,createdAt:new Date().toISOString()}}}async updateJobStatus(L,Y,F,H){try{let U=this.getJobStatusKey(L,Y,F),z=[];if(H.status)z.push("status",H.status);if(H.error!==void 0)z.push("error",H.error||"");if(H.retries!==void 0)z.push("retries",H.retries.toString());if(H.startedAt)z.push("startedAt",H.startedAt);if(H.completedAt)z.push("completedAt",H.completedAt);if(H.updatedAt)z.push("updatedAt",H.updatedAt);if(z.length>0){if(await this.commandClient.hmset(U,z),await this.commandClient.expire(U,this.options.statusTTL),H.status)await this.updateJobIndex(L,Y,F,H.status);await this.publisherClient.publish(`${this.options.keyPrefix}:job:${L}:status`,JSON.stringify({...H,timestamp:Date.now()})),this.debug(`Updated job ${L} status: ${H.status||"fields updated"}`)}}catch(U){throw this.error(`Failed to update job status: ${U}`),U}}async registerWorker(L,Y){try{for(let H of Y){let U=this.getStreamKey(H);await this.ensureConsumerGroupExists(U)}let F=this.getWorkerKey(L);await this.commandClient.hmset(F,["id",L,"status","active","lastSeen",new Date().toISOString(),"queues",JSON.stringify(Y)]),await this.commandClient.expire(F,60),await this.publisherClient.publish(`${this.options.keyPrefix}:worker:coordination`,JSON.stringify({type:"worker:joined",workerId:L,queues:Y,timestamp:Date.now()})),this.debug(`Registered worker ${L} for queues: ${Y.join(", ")}`)}catch(F){throw this.error(`Failed to register worker: ${F}`),F}}async unregisterWorker(L){try{let Y=this.getWorkerKey(L);await this.commandClient.del(Y),await this.publisherClient.publish(`${this.options.keyPrefix}:worker:coordination`,JSON.stringify({type:"worker:left",workerId:L,timestamp:Date.now()})),this.debug(`Unregistered worker ${L}`)}catch(Y){throw this.error(`Failed to unregister worker: ${Y}`),Y}}async indexJob(L){let Y=L.timestamp,F=`${L.jobId}:${L.dispatchId}`;await Promise.all([this.commandClient.send("ZADD",[`${this.options.keyPrefix}:idx:queue:${L.queue}`,Y.toString(),F]),this.commandClient.send("ZADD",[`${this.options.keyPrefix}:idx:status:${L.status}`,Y.toString(),F]),this.commandClient.send("ZADD",[`${this.options.keyPrefix}:idx:priority`,L.priority.toString(),F])])}async updateJobIndex(L,Y,F,H){let U=`${L}:${Y}`,z=Date.now(),X=["pending","running","completed","failed","cancelled","scheduled_for_retry"].filter((_)=>_!==H).map((_)=>this.commandClient.send("ZREM",[`${this.options.keyPrefix}:idx:status:${_}`,U])),Z=this.commandClient.send("ZADD",[`${this.options.keyPrefix}:idx:status:${H}`,z.toString(),U]);await Promise.all([...X,Z])}async getJobFromIndex(L,Y,F){let H=`${L}:${Y}`,U=await this.commandClient.zscore(`${this.options.keyPrefix}:idx:queue:${F}`,H);if(U){let z=["pending","running","completed","failed","cancelled","scheduled_for_retry"];for(let V of z)if(await this.commandClient.zscore(`${this.options.keyPrefix}:idx:status:${V}`,H))return{jobId:L,dispatchId:Y,queue:F,status:V,priority:10,timestamp:U}}return null}async storeJobStatus(L){let Y=this.getJobStatusKey(L.jobId,L.dispatchId,L.queue),F=["jobId",L.jobId,"dispatchId",L.dispatchId,"queue",L.queue,"status",L.status,"retries",L.retries.toString(),"createdAt",L.createdAt,"updatedAt",L.updatedAt||Date.now().toString()];if(L.error)F.push("error",L.error);if(L.startedAt)F.push("startedAt",L.startedAt);if(L.completedAt)F.push("completedAt",L.completedAt);if(L.messageId)F.push("messageId",L.messageId);await this.commandClient.hmset(Y,F),await this.commandClient.expire(Y,this.options.statusTTL)}async ensureConsumerGroupExists(L){try{await this.commandClient.send("XGROUP",["CREATE",L,this.options.consumerGroup,"0","MKSTREAM"]),this.debug(`Created consumer group ${this.options.consumerGroup} for ${L}`)}catch(Y){if(!Y.message?.includes("BUSYGROUP"))this.error(`Error creating consumer group: ${Y}`)}}async getRegisteredQueues(){try{let L=`${this.options.keyPrefix}:stream:*`,Y=await this.commandClient.keys(L);if(!Array.isArray(Y)||Y.length===0)return["default"];return Y.map((F)=>F.replace(`${this.options.keyPrefix}:stream:`,"")).filter(Boolean)}catch(L){return this.error(`Failed to get registered queues: ${L}`),["default"]}}scheduleCleanup(){this.cleanupTimer=setInterval(async()=>{try{await this.performCleanup()}catch(Y){this.error(`Error during cleanup: ${Y}`)}},300000),this.debug("Scheduled cleanup every 300 seconds")}async performCleanup(){this.debug("Starting cleanup operation");let L=Date.now()-this.options.completedJobRetention*1000,Y=await this.getRegisteredQueues(),F=["completed","failed","cancelled"];for(let z of F){let V=`${this.options.keyPrefix}:idx:status:${z}`;await this.commandClient.send("ZREMRANGEBYSCORE",[V,"-inf",L.toString()])}for(let z of Y){let V=this.getStreamKey(z),X=await this.commandClient.send("XLEN",[V]);if(X>this.options.maxStreamSize)await this.commandClient.send("XTRIM",[V,"MAXLEN","~",this.options.maxStreamSize.toString()]),this.debug(`Trimmed stream ${V} from ${X} to ~${this.options.maxStreamSize}`)}let H=`${this.options.keyPrefix}:status:*`,U=await this.commandClient.keys(H);for(let z of U){let V=await this.commandClient.ttl(z);if(V===-1||V===-2)await this.commandClient.del(z)}this.debug("Cleanup operation completed")}extractQueueName(L){if("queue"in L&&typeof L.queue==="string")return L.queue;return"default"}serializeMessage(L){let Y=["type",L.type,"timestamp",Date.now().toString()];if("queue"in L&&L.queue)Y.push("queue",L.queue);switch(L.type){case"job:process":if(Y.push("job",L.job,"args",JSON.stringify(L.args||[]),"jobId",L.jobId,"dispatchId",L.dispatchId),L.options)Y.push("options",JSON.stringify(L.options));break;case"job:cancel":case"job:status":case"job:result":if(Y.push("jobId",L.jobId),Y.push("dispatchId",L.dispatchId),L.type!=="job:cancel"){if(L.status)Y.push("status",L.status);if(L.error)Y.push("error",L.error);if(L.retries!==void 0)Y.push("retries",L.retries.toString());if("completedAt"in L&&L.completedAt)Y.push("completedAt",L.completedAt)}break;case"worker:register":if(Y.push("workerId",L.workerId),L.queues)Y.push("queues",JSON.stringify(L.queues));break;case"worker:unregister":Y.push("workerId",L.workerId);break}return Y}deserializeMessage(L,Y){let F={};for(let z=0;z<L.length;z+=2)F[L[z]]=L[z+1];let H=F.type,U={type:H,queue:F.queue||Y};switch(H){case"job:process":return{...U,type:"job:process",job:F.job,args:JSON.parse(F.args||"[]"),jobId:F.jobId,dispatchId:F.dispatchId,options:F.options?JSON.parse(F.options):void 0};case"job:cancel":return{...U,type:"job:cancel",jobId:F.jobId,dispatchId:F.dispatchId};case"job:status":return{...U,type:"job:status",jobId:F.jobId,dispatchId:F.dispatchId,status:F.status,error:F.error,retries:F.retries?parseInt(F.retries,10):void 0};case"job:result":return{...U,type:"job:result",jobId:F.jobId,dispatchId:F.dispatchId,status:F.status,error:F.error,completedAt:F.completedAt||new Date().toISOString()};default:return U}}notifyLockRelease(L,Y){this.jobLocks.delete(L);for(let F of this.messageHandlers)try{F({type:"job:update",jobId:L,dispatchId:"",queue:Y,status:"lock_released",updates:{}})}catch(H){this.error(`Error notifying lock release: ${H}`)}}async isJobLocked(L,Y){let F=this.jobLocks.get(L);if(F&&F.lockedUntil>Date.now())return!0;let H=`${this.options.keyPrefix}:lock:${Y}:${L}`;return await this.commandClient.exists(H)}async acquireJobLock(L,Y,F=60000){let H=`${this.options.keyPrefix}:lock:${Y}:${L}`,U=`${this.consumerId}:${Date.now()}`;if(await this.commandClient.set(H,U,"PX",F.toString(),"NX")==="OK")return this.jobLocks.set(L,{lockedUntil:Date.now()+F,queue:Y}),this.debug(`Acquired lock for job ${L} in queue ${Y}`),!0;return!1}async releaseJobLock(L,Y){let F=`${this.options.keyPrefix}:lock:${Y}:${L}`;await this.commandClient.del(F),this.jobLocks.delete(L),await this.publisherClient.publish(`${this.options.keyPrefix}:lock:${Y}:released`,L),this.debug(`Released lock for job ${L} in queue ${Y}`)}getStreamKey(L){return`${this.options.keyPrefix}:stream:${L||"default"}`}getJobStatusKey(L,Y,F){return`${this.options.keyPrefix}:status:${F}:${L}:${Y}`}getWorkerKey(L){return`${this.options.keyPrefix}:worker:${L}`}}import{InteractsWithConsole as I}from"@elysiumjs/core";import{sort as M,uid as N}from"radash";var v;((X)=>{X.IDLE="idle";X.ACTIVE="active";X.PAUSED="paused";X.DRAINING="draining";X.STOPPING="stopping";X.STOPPED="stopped";X.DISCONNECTED="disconnected"})(v||={});class P extends I{#Y;transport=null;queues=new Map;defaultQueueName="default";defaultTransport;workerStatus="idle";startedAt=new Date;scheduledJobInterval=1000;scheduledJobTimer;constructor(L){super();this.#Y=L??N(24),this.createQueue({name:this.defaultQueueName})}get id(){return this.#Y}get status(){return this.workerStatus}async createQueue(L){if(this.queues.has(L.name))return this.debug(`Queue with name '${L.name}' already exists`),this;let Y={options:{...L,concurrency:L.concurrency||1,maxRetries:L.maxRetries||0,retryDelay:L.retryDelay||1000,pauseOnError:L.pauseOnError||!1},jobs:[],activeJobs:new Map,retryingJobs:new Map,paused:!1,draining:!1};return this.queues.set(L.name,Y),this.debug(`Created queue '${L.name}'`),this}async addJob(L,Y=this.defaultQueueName,F={}){let H=this.queues.get(Y);if(!H)throw this.error(`Queue '${Y}' does not exist`),Error(`Queue '${Y}' does not exist`);if(H.draining)throw this.warning(`Queue '${Y}' is draining, job ${L.id} will not be added`),Error(`Queue '${Y}' is draining`);L.queueName=Y;let U={job:L,retries:L.retries,queueName:Y,enqueuedAt:new Date,scheduledFor:F.scheduledFor,priority:F.priority??10,maxRetries:F.maxRetries,retryDelay:F.retryDelay,overlapBehavior:F.overlapBehavior,overlapDelay:F.overlapDelay};if(H.jobs.push(U),H.jobs=M(M(M(H.jobs,(z)=>z.enqueuedAt.getTime()),(z)=>z.scheduledFor?z.scheduledFor.getTime():0),(z)=>z.priority??10),this.debug(`Added job ${L.id} to queue '${Y}'`),this.workerStatus==="idle")this.workerStatus="active";if(!H.paused&&H.activeJobs.size<H.options.concurrency)try{this.processQueueJobs(Y)}catch(z){this.error(`Error processing next job in queue '${Y}': ${z}`)}return this}async pause(L){if(L){let Y=this.queues.get(L);if(Y)Y.paused=!0,this.info(`Paused queue '${L}'`);else this.warning(`Cannot pause non-existent queue '${L}'`)}else{for(let Y of this.queues.keys())await this.pause(Y);if(Array.from(this.queues.values()).every((Y)=>Y.paused)&&Array.from(this.queues.values()).every((Y)=>Y.activeJobs.size===0))this.workerStatus="paused",this.info(`Worker ${this.id} is now paused`)}return this}async resume(L){if(L){let Y=this.queues.get(L);if(Y){if(Y.paused=!1,this.info(`Resumed queue '${L}'`),this.workerStatus==="paused")this.workerStatus="active";await this.processQueueJobs(L)}else this.warning(`Cannot resume non-existent queue '${L}'`)}else{for(let Y of this.queues.keys())await this.resume(Y);if(Array.from(this.queues.values()).some((Y)=>Y.jobs.length>0||Y.activeJobs.size>0))this.workerStatus="active",this.info(`Worker ${this.id} is now active`)}return this}async drain(L){if(L){let Y=this.queues.get(L);if(Y)Y.draining=!0,this.info(`Draining queue '${L}'`);else this.warning(`Cannot drain non-existent queue '${L}'`)}else{for(let Y of this.queues.keys())await this.drain(Y);this.workerStatus="draining",this.info(`Worker ${this.id} is now draining`)}return this}async clear(L){if(L){let Y=this.queues.get(L);if(Y){let F=Y.jobs.length;for(let H of Y.jobs)H.job.cancel();Y.jobs=[],this.info(`Cleared ${F} jobs from queue '${L}'`)}else this.warning(`Cannot clear non-existent queue '${L}'`)}else{for(let[Y,F]of this.queues.entries()){let H=F.jobs.length;for(let U of F.jobs)U.job.cancel();F.jobs=[],this.info(`Cleared ${H} jobs from queue '${Y}'`)}if(Array.from(this.queues.values()).every((Y)=>Y.jobs.length===0&&Y.activeJobs.size===0))this.workerStatus="idle"}return this}async size(L=this.defaultQueueName){let Y=this.queues.get(L);if(!Y)throw Error(`Queue '${L}' does not exist`);return Y.jobs.length+Y.activeJobs.size}async totalSize(){let L=0;for(let Y of this.queues.values())L+=Y.jobs.length+Y.activeJobs.size;return L}async getStats(){let L={};for(let[Y,F]of this.queues.entries())L[Y]={waiting:F.jobs.length,retrying:F.retryingJobs.size,processing:F.activeJobs.size,paused:F.paused,draining:F.draining};return L}getInfo(){let L=0;for(let U of this.queues.values())L+=U.activeJobs.size;let Y=Array.from(this.queues.keys()),F=this.defaultTransport?this.defaultTransport.constructor.name:"None",H=async(U,z)=>{try{return await this.addJob(U,z),!0}catch(V){return!1}};return{id:this.id,status:this.workerStatus,queues:Y,activeJobs:L,transport:F,startedAt:this.startedAt,addJob:H,cancelJob:this.cancelJob.bind(this),cancelAllJobs:this.cancelAllJobs.bind(this)}}async cancelJob(L,Y){if(Y)return this.cancelJobInQueue(L,Y);else{for(let F of this.queues.keys())if(await this.cancelJobInQueue(L,F))return!0;return!1}}async cancelAllJobs(L){let Y=0;if(L){let F=this.queues.get(L);if(F){Y=F.jobs.length;for(let H of F.jobs)H.job.cancel();for(let[H,U]of F.activeJobs.entries())if(U.job.cancel())Y++;this.info(`Canceled ${Y} jobs in queue '${L}'`)}}else for(let[F,H]of this.queues.entries()){let U=H.jobs.length;for(let z of H.jobs)z.job.cancel();for(let[z,V]of H.activeJobs.entries())if(V.job.cancel())U++;Y+=U,this.info(`Canceled ${U} jobs in queue '${F}'`)}if(Y>0&&Array.from(this.queues.values()).every((F)=>F.jobs.length===0&&F.activeJobs.size===0))this.workerStatus="idle";return Y}async getJob(L,Y){if(Y){let F=this.queues.get(Y);if(F){let H=F.jobs.find((z)=>z.job.id===L);if(H)return H.job;let U=F.activeJobs.get(L);if(U)return U.job}}else for(let F of this.queues.values()){let H=F.jobs.find((z)=>z.job.id===L);if(H)return H.job;let U=F.activeJobs.get(L);if(U)return U.job}return null}async start(){if(this.workerStatus="active",this.startedAt=new Date,this.defaultTransport)await this.defaultTransport.start();for(let L of this.queues.keys())await this.processQueueJobs(L);this.startScheduledJobTimer(),this.info(`Worker ${this.id} started`)}async stop(L=!1){if(this.workerStatus="stopping",this.stopScheduledJobTimer(),L){await this.cancelAllJobs();for(let Y of this.queues.keys())await this.clear(Y)}else{for(let F of this.queues.keys())await this.drain(F);let Y=()=>{return Array.from(this.queues.values()).every((F)=>F.activeJobs.size===0)};if(!Y()){this.info(`Worker ${this.id} waiting for ${Array.from(this.queues.values()).reduce((F,H)=>F+H.activeJobs.size,0)} jobs to complete`);while(!Y())await new Promise((F)=>setTimeout(F,100))}}if(this.defaultTransport)await this.defaultTransport.stop();this.workerStatus="stopped",this.info(`Worker ${this.id} stopped`)}async setConcurrency(L,Y){let F=this.queues.get(L);if(!F)throw this.error(`Cannot set concurrency: Queue '${L}' does not exist`),Error(`Queue '${L}' does not exist`);if(Y<1)this.warning(`Invalid concurrency value ${Y}, using 1 instead`),Y=1;return F.options.concurrency=Y,this.info(`Set concurrency for queue '${L}' to ${Y}`),this.processQueueJobs(L)}startScheduledJobTimer(){if(this.scheduledJobTimer)clearInterval(this.scheduledJobTimer);this.scheduledJobTimer=setInterval(()=>{this.checkScheduledJobs()},this.scheduledJobInterval)}stopScheduledJobTimer(){if(this.scheduledJobTimer)clearInterval(this.scheduledJobTimer),this.scheduledJobTimer=void 0}checkScheduledJobs(){let L=new Date;for(let[Y,F]of this.queues.entries()){if(F.paused||F.draining)continue;let H=F.jobs.filter((U)=>U.scheduledFor&&U.scheduledFor<=L);for(let U of H)U.scheduledFor=void 0;if(H.length>0&&F.activeJobs.size<F.options.concurrency)this.processQueueJobs(Y)}}async processQueueJobs(L){let Y=this.queues.get(L);if(!Y||Y.paused)return;let F=[];while(F.length<Y.options.concurrency)F.push(this.processNextJob(L));await Promise.all(F);let H=Array.from(this.queues.values());if(H.some((U)=>U.activeJobs.size>0))this.workerStatus="active";else if(H.every((U)=>U.jobs.length===0&&U.activeJobs.size===0))this.workerStatus="idle";if(H.some((U)=>U.jobs.length>0))await Bun.sleep(10),setImmediate(()=>this.processQueueJobs(L))}async sendJobStatusUpdate(L,Y,F,H,U){try{await this.transport.updateJobStatus(L,Y,F,{dispatchId:Y,status:H,error:U,retries:0,startedAt:H==="running"?new Date().toISOString():void 0,completedAt:H==="completed"||H==="failed"||H==="cancelled"?new Date().toISOString():void 0,updatedAt:Date.now().toString()})}catch(z){this.error(`Error sending job status update: ${z}`)}}async cancelJobInQueue(L,Y){let F=this.queues.get(Y);if(!F)return!1;let H=F.jobs.findIndex((U)=>U.job.id===L);if(H>=0){if(F.jobs[H].job.cancel())return F.jobs.splice(H,1),this.info(`Canceled job ${L} in queue '${Y}'`),!0}if(F.activeJobs.has(L)){if(F.activeJobs.get(L).job.cancel())return this.info(`Canceled running job ${L} in queue '${Y}'`),!0}return!1}}class f extends P{isRunning=!1;registeredQueues=new Set;pendingBatches=new Map;batchTimer;metrics={jobsProcessed:0,jobsFailed:0,jobsRetried:0,averageProcessingTime:0};metricsClient;metricsTimer;maxBatchSize;batchInterval;constructor(L="default",Y={}){super(Y.id);if(this.maxBatchSize=Y.maxBatchSize||10,this.batchInterval=Y.batchInterval||100,this.transport=new B("consumer",{connection:L,consumerName:`worker-${this.id}`,batchSize:this.maxBatchSize,...Y}),this.transport.onMessage(this.handleTransportMessage.bind(this)),Y.enableMetrics)this.initializeMetrics(L);this.debug(`RedisWorker ${this.id} created with batch size: ${this.maxBatchSize}`)}async initializeMetrics(L){this.metricsClient=await c.getConnection(L).duplicate()}async reportMetrics(){if(!this.metricsClient||!this.metricsClient.connected)return;let L=`worker:${this.id}:metrics`;try{await this.metricsClient.hmset(L,["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(L,3600)}catch(Y){this.error(`Failed to report metrics: ${Y}`)}}async start(){if(this.isRunning)return;await super.start(),this.isRunning=!0;for(let L of this.queues.keys())this.registeredQueues.add(L);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(L=!1){if(this.isRunning=!1,this.stopBatchProcessing(),!L)await this.processAllPendingBatches();if(await super.stop(L),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[L,Y]of this.pendingBatches)if(Y.jobs.length>0)await this.processBatch(Y);this.pendingBatches.clear()}async processBatches(){let L=Date.now();for(let[Y,F]of this.pendingBatches){if(!this.queues.get(Y))continue;if(F.jobs.length>=this.maxBatchSize||F.jobs.length>0&&L-F.timestamp>this.batchInterval)this.pendingBatches.delete(Y),await this.processBatch(F)}}async processBatch(L){let Y=this.queues.get(L.queue);if(!Y||Y.paused)return;let F=Date.now(),H=new Map,U=[];for(let Z of L.jobs)if(Q(Z.job.constructor)?.overlapBehavior==="no_overlap"){let R=Z.job.id;if(!H.has(R))H.set(R,[]);H.get(R).push(Z)}else U.push(Z);let z=U.map((Z)=>this.processJobInternal(Z,L.queue)),V=[];for(let[Z,_]of H)V.push(this.processOverlapJobs(_,L.queue));await Promise.all([...z,...V]);let X=Date.now()-F;this.updateMetrics(L.jobs.length,X),this.debug(`Processed batch of ${L.jobs.length} jobs from queue '${L.queue}' in ${X}ms`)}async processOverlapJobs(L,Y){for(let F of L)if(await this.transport.acquireJobLock(F.job.id,Y))try{await this.processJobInternal(F,Y)}finally{let U=Q(F.job.constructor);if(U?.overlapDelay&&U.overlapDelay>0)setTimeout(()=>{this.transport.releaseJobLock(F.job.id,Y)},U.overlapDelay);else await this.transport.releaseJobLock(F.job.id,Y)}else{let U=this.queues.get(Y);if(U)U.jobs.push(F)}}async processJobInternal(L,Y){let F=this.queues.get(Y);if(!F)return;F.activeJobs.set(L.job.id,L),await this.sendJobStatusUpdate(L.job.id,L.job.dispatchId,Y,"running");try{if(await L.job.run(),L.job.status==="completed")this.metrics.jobsProcessed++,await this.sendJobStatusUpdate(L.job.id,L.job.dispatchId,Y,"completed");else if(L.job.status==="failed")await this.handleJobFailure(L,F,Y);else if(L.job.status==="cancelled")await this.sendJobStatusUpdate(L.job.id,L.job.dispatchId,Y,"cancelled")}catch(H){this.error(`Error processing job ${L.job.id}: ${H}`),L.job.fail(H instanceof Error?H:Error(String(H))),await this.handleJobFailure(L,F,Y)}finally{F.activeJobs.delete(L.job.id),this.metrics.lastProcessedAt=new Date}}async handleJobFailure(L,Y,F){let H=L.maxRetries!==void 0?L.maxRetries:Y.options.maxRetries;if(L.retries<H){L.retries++,L.job.incrementRetries(),this.metrics.jobsRetried++;let U=L.retryDelay!==void 0?L.retryDelay:Y.options.retryDelay;await this.sendJobStatusUpdate(L.job.id,L.job.dispatchId,F,"scheduled_for_retry",L.job.lastError?.message),Y.retryingJobs.set(L.job.id,L),setTimeout(()=>{if(L.job.status!=="cancelled")this.addJobToBatch(L,F);Y.retryingJobs.delete(L.job.id)},U)}else if(this.metrics.jobsFailed++,await this.sendJobStatusUpdate(L.job.id,L.job.dispatchId,F,"failed",L.job.lastError?.message),Y.options.pauseOnError)await this.pause(F)}addJobToBatch(L,Y){if(!this.pendingBatches.has(Y))this.pendingBatches.set(Y,{queue:Y,jobs:[],timestamp:Date.now()});let F=this.pendingBatches.get(Y);if(F.jobs.push(L),F.jobs.length>=this.maxBatchSize)this.pendingBatches.delete(Y),this.processBatch(F)}async processNextJob(L){let Y=this.queues.get(L);if(!Y||Y.paused||Y.activeJobs.size>=Y.options.concurrency)return;let F=new Date,H=[];for(let U=0;U<Y.jobs.length&&H.length<this.maxBatchSize;U++){let z=Y.jobs[U];if(z.scheduledFor&&z.scheduledFor>F)continue;if(z.job.status==="cancelled"){Y.jobs.splice(U,1),U--;continue}H.push(z)}for(let U of H){let z=Y.jobs.indexOf(U);if(z>=0)Y.jobs.splice(z,1)}if(H.length>0){let U={queue:L,jobs:H,timestamp:Date.now()};if(H.length===1)await this.processBatch(U);else this.pendingBatches.set(L,U)}}async handleTransportMessage(L){try{switch(L.type){case"job:process":{let{job:Y,args:F=[],queue:H="default",options:U={},jobId:z,dispatchId:V}=L;await this.handleProcessJob(z,V,Y,F,H,U);break}case"job:cancel":{let{jobId:Y,queue:F="default"}=L;if(await this.cancelJob(Y,F))await this.sendJobStatusUpdate(Y,L.dispatchId,F,"cancelled");break}case"job:cancelAll":{let{queue:Y="default"}=L;await this.cancelAllJobs(Y);break}case"job:update":{if(L.status==="lock_released"){let Y=L.queue;await this.processQueueJobs(Y)}break}}}catch(Y){this.error(`Error handling transport message: ${Y}`)}}async handleProcessJob(L,Y,F,H,U,z){try{let V=j.get(F);if(!V)throw Error(`Job class not found: ${F}`);let X=new(C(V,L,Y))(...H||[]);X.queueName=U;let Z={};if(z.scheduledFor)Z.scheduledFor=typeof z.scheduledFor==="string"?new Date(z.scheduledFor):z.scheduledFor;if(z.priority!==void 0)Z.priority=Number(z.priority);if(z.maxRetries!==void 0)Z.maxRetries=Number(z.maxRetries);if(z.retryDelay!==void 0)Z.retryDelay=Number(z.retryDelay);let _=Z.scheduledFor&&Z.scheduledFor>new Date?"scheduled_for_retry":"pending";await this.sendJobStatusUpdate(X.id,X.dispatchId,U,_),await this.addJob(X,U,Z)}catch(V){this.error(`Error processing job ${F}: ${V.message}`),await this.sendJobStatusUpdate(L,Y,U,"failed",V.message)}}updateMetrics(L,Y){let F=this.metrics.averageProcessingTime,H=this.metrics.jobsProcessed+this.metrics.jobsFailed;this.metrics.averageProcessingTime=(F*H+Y)/(H+L)}async createQueue(L){let Y=await super.createQueue(L);if(this.isRunning&&!this.registeredQueues.has(L.name))this.registeredQueues.add(L.name),await this.transport.registerWorker(this.id,Array.from(this.registeredQueues));return Y}}import{uid as i}from"radash";import{Event as D,InteractsWithConsole as p}from"@elysiumjs/core";import{map as g,uid as n}from"radash";class x extends p{static queues=new Map;static exists(L){return this.queues.has(L)}static get(L,Y){if(!this.queues.has(L)){let F=new x({name:L,...Y});this.queues.set(L,F)}return this.queues.get(L)}static getAll(){return new Map(this.queues)}name;transport;transportClass;transportOptions;options;jobs=[];constructor(L){super();this.name=L.name,this.transportClass=L.transport,this.transportOptions=L.transportOptions,this.options={concurrency:L.concurrency??1,maxRetries:L.maxRetries??0,retryDelay:L.retryDelay??1000,pauseOnError:L.pauseOnError??!1,...L},this.transport=new this.transportClass("producer",this.transportOptions),this.transport.onMessage(this.handleTransportMessage.bind(this))}on(L,Y){D.on(`elysium:heracles:queue:${this.name}:${L}`,Y)}once(L,Y){D.once(`elysium:heracles:queue:${this.name}:${L}`,Y)}emit(L,Y,F=null){D.emit(`elysium:heracles:queue:${this.name}:${L}`,Y,F)}async handleTransportMessage(L){switch(L.type){case"job:status":case"job:result":if(L.queue===this.name){if(L.type==="job:result")this.info(`Job ${L.jobId} completed with status: ${L.status}`);let Y=await this.getJobStatus({jobId:L.jobId,dispatchId:L.dispatchId}),F=Y?.createdAt??new Date().toISOString();this.updateJobStatus(L.jobId,L.dispatchId,{jobId:L.jobId,dispatchId:L.dispatchId,queue:L.queue,status:L.status,error:L.error,retries:L.retries??Y?.retries??0,createdAt:F,startedAt:L.startedAt??Y?.startedAt,completedAt:L.completedAt??Y?.completedAt,updatedAt:L.updatedAt??new Date().toISOString()})}break}this.emit(L.type,L,this)}updateJobStatus(L,Y,F){if(F.status==="completed"||F.status==="failed"||F.status==="cancelled")this.debug(`Job ${L} (${Y}) reached terminal state: ${F.status}`)}async dispatch(L,Y,F=[],H={}){let U=new Date,z=U.toISOString(),V=H.jobId??L.generateJobId(...F),X=H.dispatchId??`dispatch_${Date.now()}_${n(8)}`,Z={jobId:V,dispatchId:X,queue:this.name,status:H.scheduledFor&&H.scheduledFor>U?"scheduled_for_retry":"pending",retries:0,createdAt:z,updatedAt:z};this.updateJobStatus(V,X,Z),this.debug(`Dispatching job ${V} (dispatch: ${X}, name: ${Y}) to queue '${this.name}'`);try{let _={type:"job:process",job:Y,jobId:V,dispatchId:X,args:F,queue:this.name,options:{scheduledFor:H.scheduledFor?.toISOString(),priority:H.priority,maxRetries:H.maxRetries,retryDelay:H.retryDelay,overlapBehavior:H.overlapBehavior,overlapDelay:H.overlapDelay}};return await this.transport.send(_),{jobId:Z.jobId,dispatchId:X}}catch(_){throw this.error(`Failed to dispatch job ${Y} to queue '${this.name}': ${_.message}`),this.updateJobStatus(V,X,{jobId:Z.jobId,dispatchId:X,queue:this.name,status:"failed",error:_.message,retries:0,createdAt:Z.createdAt,completedAt:new Date().toISOString(),updatedAt:new Date().toISOString()}),_}}async cancelJob(L){this.debug(`Canceling job ${L.jobId} (${L.dispatchId}) in queue '${this.name}'`);try{let Y={type:"job:cancel",jobId:L.jobId,dispatchId:L.dispatchId,queue:this.name};return await this.transport.send(Y),!0}catch(Y){return this.error(`Failed to cancel job ${L.jobId}: ${Y}`),!1}}async cancelAllJobs(){this.debug(`Canceling all jobs in queue '${this.name}'`);try{let L={type:"job:cancelAll",queue:this.name};await this.transport.send(L)}catch(L){this.error(`Failed to cancel all jobs: ${L}`)}return 0}async getJobStatus(L){try{let Y=await this.transport.getJobStatus(L.jobId,L.dispatchId,this.name);if(Y)return this.debug(`Retrieved job ${L.jobId} (${L.dispatchId}) status from transport: ${Y.status}`),Y}catch(Y){this.warning(`Failed to get job status from transport: ${Y}`)}return null}getAllJobs(){return Array.from(this.jobs)}async getJobsByStatus(L){let Y=[];for(let[F,H]of await g(this.jobs,async(U)=>[U,await this.getJobStatus(U)]))if(H?.status===L)Y.push(F);return Y}async start(){if(this.transport)await this.transport.start(),this.info(`Queue '${this.name}' transport started`)}async stop(){if(this.transport)await this.transport.stop(),this.info(`Queue '${this.name}' transport stopped`)}getOptions(){return{...this.options}}updateOptions(L){this.options={...this.options,...L}}}var y;((k)=>k.dispatch=async(Y,F=[],H={})=>{let U=Q(Y);if(!U)throw Error(`Job ${Y.name} is not marked with @Job.register() decorator`);if(!U.queue)throw Error(`Job ${Y.name} is not registered with a queue`);if(!x.exists(U.queue))throw Error(`Queue ${U.queue} does not exist. Please create the queue before dispatching the job.`);let z=x.get(U.queue),X={dispatchId:`dispatch_${Date.now()}_${i(8)}`,priority:U.priority,maxRetries:U.maxRetries,retryDelay:U.retryDelay,overlapBehavior:U.overlapBehavior,overlapDelay:U.overlapDelay,...H};return z.dispatch(Y,U.name,F,X)})(y||={});export{v as WorkerStatus,K as TransportMode,f as RedisWorker,B as RedisTransport,x as Queue,A as JobStatus,G as JobOverlapBehavior,O as Job,y as Heracles,P as BaseWorker};
|
|
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(`
|
|
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};
|
package/dist/job.d.ts
CHANGED
package/dist/queue.d.ts
CHANGED
|
@@ -111,6 +111,11 @@ export declare class Queue extends InteractsWithConsole {
|
|
|
111
111
|
* Map of all registered queues.
|
|
112
112
|
*/
|
|
113
113
|
private static queues;
|
|
114
|
+
/**
|
|
115
|
+
* Removes a queue from the registry. The caller is responsible for stopping it.
|
|
116
|
+
* @param name The name of the queue.
|
|
117
|
+
*/
|
|
118
|
+
static remove(name: string): void;
|
|
114
119
|
/**
|
|
115
120
|
* Checks whether a queue exists.
|
|
116
121
|
* @param name The name of the queue.
|
|
@@ -135,6 +140,8 @@ export declare class Queue extends InteractsWithConsole {
|
|
|
135
140
|
* The name of this queue
|
|
136
141
|
*/
|
|
137
142
|
readonly name: string;
|
|
143
|
+
/** The in-flight or settled start of the transport. */
|
|
144
|
+
private starting;
|
|
138
145
|
/**
|
|
139
146
|
* The transport used by this queue
|
|
140
147
|
*/
|
|
@@ -221,7 +228,8 @@ export declare class Queue extends InteractsWithConsole {
|
|
|
221
228
|
*/
|
|
222
229
|
getJobsByStatus(status: JobStatus): Promise<JobDispatchId[]>;
|
|
223
230
|
/**
|
|
224
|
-
* Start the queue's transport.
|
|
231
|
+
* Start the queue's transport. Starting is idempotent: concurrent and repeated calls share
|
|
232
|
+
* one in-flight start, and the transport is started once until {@link Queue.stop} is called.
|
|
225
233
|
*
|
|
226
234
|
* @returns A promise that resolves when the transport has started.
|
|
227
235
|
*/
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ModuleNode } from '@elysiumjs/core';
|
|
2
|
+
import type { Job, JobClass } from './job';
|
|
3
|
+
import { ElysiumError } from '@elysiumjs/core';
|
|
4
|
+
/**
|
|
5
|
+
* Thrown when two modules declare jobs with the same name.
|
|
6
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
7
|
+
*/
|
|
8
|
+
export declare class DuplicateJobError extends ElysiumError {
|
|
9
|
+
readonly job: string;
|
|
10
|
+
readonly modules: [string, string];
|
|
11
|
+
constructor(job: string, modules: [string, string]);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A job class with the module that declares it.
|
|
15
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
16
|
+
*/
|
|
17
|
+
export type JobRegistryEntry = {
|
|
18
|
+
jobClass: JobClass<Job>;
|
|
19
|
+
module: ModuleNode;
|
|
20
|
+
};
|
|
21
|
+
/** The name of the global service that creates the configured queues. */
|
|
22
|
+
export declare const QUEUES_SERVICE = "heracles.queues";
|
|
23
|
+
/**
|
|
24
|
+
* The jobs declared by the loaded modules, keyed by job service name. Filled by
|
|
25
|
+
* `HeraclesModule` on init and cleared on destroy.
|
|
26
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
27
|
+
*/
|
|
28
|
+
export declare namespace JobRegistry {
|
|
29
|
+
/** Replaces the registry contents. */
|
|
30
|
+
const set: (next: Iterable<readonly [string, JobRegistryEntry]>) => void;
|
|
31
|
+
/** Finds a job by its service name. */
|
|
32
|
+
const get: (name: string) => JobRegistryEntry | undefined;
|
|
33
|
+
/** Lists every registered job with its name. */
|
|
34
|
+
const all: () => readonly (JobRegistryEntry & {
|
|
35
|
+
name: string;
|
|
36
|
+
})[];
|
|
37
|
+
/** Empties the registry. */
|
|
38
|
+
const clear: () => void;
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elysiumjs/heracles",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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
|
-
"sideEffects":
|
|
9
|
-
"./src/index.ts"
|
|
10
|
-
],
|
|
8
|
+
"sideEffects": false,
|
|
11
9
|
"scripts": {
|
|
12
10
|
"test": "bun test",
|
|
13
11
|
"dev": "bun --watch src/index.ts",
|
|
14
12
|
"format": "prettier --write 'src/**/*.{ts,tsx}'",
|
|
15
13
|
"clean": "rimraf dist",
|
|
16
|
-
"build:js": "bun build ./src/index.ts --outdir ./dist --target bun --packages external --root ./src --minify",
|
|
14
|
+
"build:js": "bun build ./src/index.ts --outdir ./dist --target bun --packages external --root ./src --minify-syntax --minify-whitespace",
|
|
17
15
|
"build:ts": "tsc",
|
|
18
16
|
"build": "bun run clean && bun run build:js && bun run build:ts",
|
|
19
17
|
"typecheck": "tsc --noEmit",
|
|
@@ -35,14 +33,13 @@
|
|
|
35
33
|
"author": "Workbud Technologies Inc.",
|
|
36
34
|
"license": "Apache-2.0",
|
|
37
35
|
"dependencies": {
|
|
38
|
-
"commander": "^
|
|
39
|
-
"radash": "^12.1.1"
|
|
40
|
-
"reflect-metadata": "^0.2.2"
|
|
36
|
+
"commander": "^15.0.0",
|
|
37
|
+
"radash": "^12.1.1"
|
|
41
38
|
},
|
|
42
39
|
"devDependencies": {
|
|
43
|
-
"@elysiumjs/core": "0.
|
|
40
|
+
"@elysiumjs/core": "0.8.0"
|
|
44
41
|
},
|
|
45
42
|
"peerDependencies": {
|
|
46
|
-
"@elysiumjs/core": "^0.
|
|
43
|
+
"@elysiumjs/core": "^0.8.0"
|
|
47
44
|
}
|
|
48
45
|
}
|