@elysiumjs/heracles 0.4.2 → 0.4.3
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/package.json +1 -1
- package/src/transports/thread.transport.ts +289 -0
- package/src/worker_pool.ts +393 -0
- package/src/workers/thread.worker.entry.ts +171 -0
- package/src/workers/thread.worker.ts +525 -0
package/package.json
CHANGED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// Copyright (c) 2025-present Workbud Technologies Inc. All rights reserved.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
|
|
15
|
+
import type { JobStatusInfo, Transport, TransportEvent } from '../transport';
|
|
16
|
+
|
|
17
|
+
import { InteractsWithConsole } from '@elysiumjs/core';
|
|
18
|
+
|
|
19
|
+
import { JobStatus } from '../job';
|
|
20
|
+
import { TransportMode } from '../transport';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Manages a Bun Worker as a "thread" channel (producer driven).
|
|
24
|
+
*/
|
|
25
|
+
export class ThreadTransport extends InteractsWithConsole implements Transport {
|
|
26
|
+
private worker: Worker | null = null;
|
|
27
|
+
private workerReady = false;
|
|
28
|
+
private messageHandlers: Array<(message: TransportEvent) => void> = [];
|
|
29
|
+
private workerScript = './src/workers/thread.worker.ts'; // Path to ThreadWorker
|
|
30
|
+
// No local cache needed since Queue already manages job statuses
|
|
31
|
+
private registeredWorkers: Map<string, Set<string>> = new Map(); // workerId -> Set of queues
|
|
32
|
+
private options: Record<string, any>;
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
private readonly mode: TransportMode,
|
|
36
|
+
options: Record<string, any> = {}
|
|
37
|
+
) {
|
|
38
|
+
super();
|
|
39
|
+
|
|
40
|
+
this.options = options;
|
|
41
|
+
this.workerScript = ELYSIUM_BUILD
|
|
42
|
+
? './thread.worker.js'
|
|
43
|
+
: this.options.workerScript || __dirname + '/../workers/thread.worker.ts';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async start(): Promise<void> {
|
|
47
|
+
if (this.worker) return; // Already started
|
|
48
|
+
|
|
49
|
+
this.debug(`Starting ThreadTransport with worker script: ${this.workerScript}`);
|
|
50
|
+
this.worker = new Worker(this.workerScript);
|
|
51
|
+
|
|
52
|
+
// Thread ready flag
|
|
53
|
+
this.worker.addEventListener('open', () => {
|
|
54
|
+
this.workerReady = true;
|
|
55
|
+
this.debug('ThreadTransport worker ready');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Message event listener for handling responses
|
|
59
|
+
this.worker.addEventListener('message', (event: MessageEvent) => {
|
|
60
|
+
const message = event.data as TransportEvent;
|
|
61
|
+
|
|
62
|
+
// Handle specific message types
|
|
63
|
+
if (message.type === 'job:status' || message.type === 'job:result') {
|
|
64
|
+
this.handleStatusMessage(message);
|
|
65
|
+
} else if (message.type === 'worker:register' || message.type === 'worker:unregister') {
|
|
66
|
+
this.updateWorkerRegistration(message);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Forward message to all handlers
|
|
70
|
+
for (const handler of this.messageHandlers) {
|
|
71
|
+
try {
|
|
72
|
+
handler(message);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
this.error(`Error in message handler: ${error}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Handle worker errors
|
|
80
|
+
this.worker.addEventListener('error', (event) => {
|
|
81
|
+
this.error(`Worker error: ${event}`);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Process job status messages
|
|
87
|
+
* This method is called when a job status message is received from a worker thread
|
|
88
|
+
* The message is then forwarded to the Queue via the message handlers
|
|
89
|
+
*/
|
|
90
|
+
private handleStatusMessage(message: TransportEvent): void {
|
|
91
|
+
// Simply forward the message to handlers (Queue will store the status)
|
|
92
|
+
this.debug(
|
|
93
|
+
`Received status update for job ${(message as any).jobId}: ${(message as any).status}`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Update worker registration information
|
|
99
|
+
*/
|
|
100
|
+
private updateWorkerRegistration(message: TransportEvent): void {
|
|
101
|
+
if (message.type === 'worker:register' && message.queues) {
|
|
102
|
+
const queues = new Set(message.queues);
|
|
103
|
+
this.registeredWorkers.set(message.workerId, queues);
|
|
104
|
+
this.debug(`Worker ${message.workerId} registered for queues: ${message.queues.join(', ')}`);
|
|
105
|
+
} else if (message.type === 'worker:unregister') {
|
|
106
|
+
this.registeredWorkers.delete(message.workerId);
|
|
107
|
+
this.debug(`Worker ${message.workerId} unregistered`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async stop(): Promise<void> {
|
|
112
|
+
if (this.worker) {
|
|
113
|
+
this.debug('Stopping ThreadTransport worker');
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
// Send terminate message to gracefully shut down
|
|
117
|
+
await this.send({
|
|
118
|
+
type: 'worker:unregister',
|
|
119
|
+
workerId: 'main'
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Wait a moment to allow graceful shutdown
|
|
123
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
124
|
+
|
|
125
|
+
// Force terminate if still running
|
|
126
|
+
this.worker.terminate();
|
|
127
|
+
} catch (error) {
|
|
128
|
+
this.error(`Error stopping ThreadTransport: ${error}`);
|
|
129
|
+
} finally {
|
|
130
|
+
this.worker = null;
|
|
131
|
+
this.workerReady = false;
|
|
132
|
+
this.messageHandlers = [];
|
|
133
|
+
this.debug('ThreadTransport stopped');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async send(message: TransportEvent): Promise<void> {
|
|
139
|
+
if (!this.worker) {
|
|
140
|
+
this.debug('Starting worker before sending message');
|
|
141
|
+
await this.start();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Ensure worker is ready
|
|
145
|
+
if (!this.workerReady) {
|
|
146
|
+
this.debug('Waiting for worker to be ready');
|
|
147
|
+
await new Promise((resolve) =>
|
|
148
|
+
this.worker!.addEventListener('open', () => resolve(undefined), { once: true })
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
this.debug(`Sending ${message.type} message to worker`);
|
|
154
|
+
this.worker!.postMessage(message);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
this.error(`Error sending message to worker: ${error}`);
|
|
157
|
+
throw new Error(`Failed to send message: ${error}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Register a callback to handle messages from the transport
|
|
163
|
+
*/
|
|
164
|
+
onMessage(handler: (message: TransportEvent) => void): void {
|
|
165
|
+
this.messageHandlers.push(handler);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Updates the status of a job directly
|
|
170
|
+
* @param jobId The ID of the job to update
|
|
171
|
+
* @param queueName The name of the queue the job is in
|
|
172
|
+
* @param updates The updates to apply to the job status
|
|
173
|
+
*/
|
|
174
|
+
async updateJobStatus(
|
|
175
|
+
jobId: string,
|
|
176
|
+
dispatchId: string,
|
|
177
|
+
queueName: string,
|
|
178
|
+
updates: Partial<JobStatusInfo>
|
|
179
|
+
): Promise<void> {
|
|
180
|
+
// Get the current timestamp for all updates
|
|
181
|
+
const now = Date.now().toString();
|
|
182
|
+
const statusValue = updates.status || 'unknown';
|
|
183
|
+
|
|
184
|
+
// Determine if this is a terminal status that should use job:result type
|
|
185
|
+
const isTerminalStatus =
|
|
186
|
+
statusValue === JobStatus.COMPLETED ||
|
|
187
|
+
statusValue === JobStatus.FAILED ||
|
|
188
|
+
statusValue === JobStatus.CANCELLED;
|
|
189
|
+
|
|
190
|
+
// Create the message with the appropriate type
|
|
191
|
+
const messageType = isTerminalStatus ? 'job:result' : 'job:status';
|
|
192
|
+
const message = {
|
|
193
|
+
type: messageType,
|
|
194
|
+
jobId,
|
|
195
|
+
queue: queueName,
|
|
196
|
+
status: statusValue,
|
|
197
|
+
error: updates.error,
|
|
198
|
+
retries: updates.retries || 0,
|
|
199
|
+
startedAt: updates.startedAt,
|
|
200
|
+
completedAt: updates.completedAt || (isTerminalStatus ? now : undefined),
|
|
201
|
+
updatedAt: updates.updatedAt || now
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
if (this.mode === TransportMode.PRODUCER) {
|
|
205
|
+
// In producer mode, forward the status update to the worker thread
|
|
206
|
+
await this.send(message as TransportEvent);
|
|
207
|
+
this.debug(
|
|
208
|
+
`[Producer] Sent ${messageType} for job ${jobId} to worker thread: ${statusValue}`
|
|
209
|
+
);
|
|
210
|
+
} else {
|
|
211
|
+
// In consumer mode, we're in the worker thread, so send message to main thread
|
|
212
|
+
if (typeof self !== 'undefined') {
|
|
213
|
+
// @ts-ignore - self is available in worker threads
|
|
214
|
+
self.postMessage(message);
|
|
215
|
+
this.debug(
|
|
216
|
+
`[Consumer] Sent ${messageType} for job ${jobId} to main thread: ${statusValue}`
|
|
217
|
+
);
|
|
218
|
+
} else {
|
|
219
|
+
this.warning(`Unable to send job status update: not in a worker context`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Get the current status of a job
|
|
226
|
+
*/
|
|
227
|
+
async getJobStatus(jobId: string, dispatchId: string, queueName: string): Promise<JobStatusInfo> {
|
|
228
|
+
try {
|
|
229
|
+
// Request status from worker
|
|
230
|
+
await this.send({
|
|
231
|
+
type: 'job:status',
|
|
232
|
+
jobId,
|
|
233
|
+
dispatchId,
|
|
234
|
+
queue: queueName,
|
|
235
|
+
status: 'unknown'
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Since we don't maintain a local cache, we'll return a pending status
|
|
239
|
+
// The Queue will receive the actual status via the message handler when the worker responds
|
|
240
|
+
return {
|
|
241
|
+
jobId,
|
|
242
|
+
dispatchId,
|
|
243
|
+
queue: queueName,
|
|
244
|
+
status: 'unknown',
|
|
245
|
+
retries: 0,
|
|
246
|
+
createdAt: new Date().toISOString()
|
|
247
|
+
};
|
|
248
|
+
} catch (error) {
|
|
249
|
+
this.error(`Error getting job status for ${jobId}: ${error}`);
|
|
250
|
+
// Return a basic status object on error
|
|
251
|
+
return {
|
|
252
|
+
jobId,
|
|
253
|
+
dispatchId,
|
|
254
|
+
queue: queueName,
|
|
255
|
+
status: 'unknown',
|
|
256
|
+
retries: 0,
|
|
257
|
+
createdAt: new Date().toISOString(),
|
|
258
|
+
error: `Failed to get status: ${error}`
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Register a worker with the transport
|
|
265
|
+
*/
|
|
266
|
+
async registerWorker(workerId: string, queues: string[]): Promise<void> {
|
|
267
|
+
await this.send({
|
|
268
|
+
type: 'worker:register',
|
|
269
|
+
workerId,
|
|
270
|
+
queues
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Store locally too
|
|
274
|
+
this.registeredWorkers.set(workerId, new Set(queues));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Unregister a worker from the transport
|
|
279
|
+
*/
|
|
280
|
+
async unregisterWorker(workerId: string): Promise<void> {
|
|
281
|
+
await this.send({
|
|
282
|
+
type: 'worker:unregister',
|
|
283
|
+
workerId
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Remove from local storage
|
|
287
|
+
this.registeredWorkers.delete(workerId);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// Copyright (c) 2025-present Workbud Technologies Inc. All rights reserved.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
|
|
15
|
+
import type { Job, JobDispatchOptions } from './job';
|
|
16
|
+
import type { WorkerInfo } from './worker';
|
|
17
|
+
|
|
18
|
+
import { InteractsWithConsole } from '@elysiumjs/core';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Interface for tracking worker selection state per queue.
|
|
22
|
+
*/
|
|
23
|
+
interface QueueWorkerState {
|
|
24
|
+
/**
|
|
25
|
+
* All workers assigned to this queue
|
|
26
|
+
*/
|
|
27
|
+
workers: WorkerInfo[];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Index of the last selected worker for round-robin
|
|
31
|
+
*/
|
|
32
|
+
lastWorkerIndex: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* WorkerPool manages a pool of workers and distributes jobs among them
|
|
37
|
+
* using a round-robin algorithm.
|
|
38
|
+
*/
|
|
39
|
+
export class WorkerPool extends InteractsWithConsole {
|
|
40
|
+
/**
|
|
41
|
+
* Singleton instance of the worker pool
|
|
42
|
+
*/
|
|
43
|
+
private static _instance: WorkerPool;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* All workers in the pool, indexed by worker ID
|
|
47
|
+
*/
|
|
48
|
+
private workers: Map<string, WorkerInfo> = new Map();
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Queue-to-workers mapping for tracking which workers handle each queue
|
|
52
|
+
*/
|
|
53
|
+
private queueWorkers: Map<string, QueueWorkerState> = new Map();
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Last used worker per queue for true round-robin
|
|
57
|
+
*/
|
|
58
|
+
private lastUsedWorker: Map<string, string> = new Map();
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get the singleton instance of the worker pool
|
|
62
|
+
*/
|
|
63
|
+
public static get instance(): WorkerPool {
|
|
64
|
+
if (!WorkerPool._instance) {
|
|
65
|
+
WorkerPool._instance = new WorkerPool();
|
|
66
|
+
}
|
|
67
|
+
return WorkerPool._instance;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Private constructor to enforce singleton pattern
|
|
72
|
+
*/
|
|
73
|
+
private constructor() {
|
|
74
|
+
super();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Initialize the worker pool
|
|
79
|
+
*/
|
|
80
|
+
public async init(): Promise<void> {
|
|
81
|
+
this.info('WorkerPool initialized');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Add a worker to the pool and associate it with queues
|
|
86
|
+
*
|
|
87
|
+
* @param worker The worker information to add
|
|
88
|
+
* @param queues The queues this worker will handle
|
|
89
|
+
*/
|
|
90
|
+
public async addWorker(worker: WorkerInfo, queues: string[] = []): Promise<void> {
|
|
91
|
+
if (this.workers.has(worker.id)) {
|
|
92
|
+
this.warning(`Worker ${worker.id} is already registered in the pool`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Store the worker
|
|
97
|
+
this.workers.set(worker.id, worker);
|
|
98
|
+
|
|
99
|
+
// Associate worker with each queue
|
|
100
|
+
for (const queueName of queues) {
|
|
101
|
+
// Get or create queue state
|
|
102
|
+
const queueState = this.getOrCreateQueueState(queueName);
|
|
103
|
+
|
|
104
|
+
// Add worker to queue if not already present
|
|
105
|
+
if (!queueState.workers.some((w) => w.id === worker.id)) {
|
|
106
|
+
queueState.workers.push(worker);
|
|
107
|
+
this.debug(`Worker ${worker.id} assigned to queue '${queueName}'`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
this.info(`Worker ${worker.id} added to pool with ${queues.length} queues`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Remove a worker from the pool
|
|
116
|
+
*
|
|
117
|
+
* @param workerId The ID of the worker to remove
|
|
118
|
+
*/
|
|
119
|
+
public async removeWorker(workerId: string): Promise<void> {
|
|
120
|
+
const worker = this.workers.get(workerId);
|
|
121
|
+
if (!worker) {
|
|
122
|
+
this.warning(`Attempted to remove non-existent worker ${workerId}`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Remove worker from all queues
|
|
127
|
+
for (const [queueName, queueState] of this.queueWorkers.entries()) {
|
|
128
|
+
const workerIndex = queueState.workers.findIndex((w) => w.id === workerId);
|
|
129
|
+
if (workerIndex >= 0) {
|
|
130
|
+
queueState.workers.splice(workerIndex, 1);
|
|
131
|
+
|
|
132
|
+
// Adjust lastWorkerIndex if needed
|
|
133
|
+
if (queueState.lastWorkerIndex >= queueState.workers.length) {
|
|
134
|
+
queueState.lastWorkerIndex = queueState.workers.length > 0 ? 0 : -1;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Also adjust lastUsedWorker if it was this worker
|
|
138
|
+
if (this.lastUsedWorker.get(queueName) === workerId) {
|
|
139
|
+
this.lastUsedWorker.delete(queueName);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
this.debug(`Worker ${workerId} removed from queue '${queueName}'`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Remove worker from the pool
|
|
147
|
+
this.workers.delete(workerId);
|
|
148
|
+
this.info(`Worker ${workerId} removed from pool`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Terminate all workers in the pool
|
|
153
|
+
*/
|
|
154
|
+
public async terminate(): Promise<void> {
|
|
155
|
+
for (const workerId of this.workers.keys()) {
|
|
156
|
+
await this.removeWorker(workerId);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
this.info('WorkerPool terminated');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get the next available worker for a queue using round-robin
|
|
164
|
+
*
|
|
165
|
+
* @param queueName The name of the queue to get a worker for
|
|
166
|
+
* @returns The selected worker info or null if none available
|
|
167
|
+
*/
|
|
168
|
+
public getNextWorker(queueName: string): WorkerInfo | null {
|
|
169
|
+
const queueState = this.queueWorkers.get(queueName);
|
|
170
|
+
if (!queueState || queueState.workers.length === 0) {
|
|
171
|
+
this.warning(`No workers available for queue '${queueName}'`);
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// True round-robin selection that considers worker load
|
|
176
|
+
// First, sort workers by number of active jobs (lightest load first)
|
|
177
|
+
const sortedWorkers = [...queueState.workers].sort((a, b) => a.activeJobs - b.activeJobs);
|
|
178
|
+
|
|
179
|
+
// If there's a big difference in load (e.g., more than 5 jobs), prefer the least loaded worker
|
|
180
|
+
const leastLoaded = sortedWorkers[0];
|
|
181
|
+
const mostLoaded = sortedWorkers[sortedWorkers.length - 1];
|
|
182
|
+
|
|
183
|
+
if (mostLoaded.activeJobs - leastLoaded.activeJobs > 5) {
|
|
184
|
+
this.debug(
|
|
185
|
+
`Selected least loaded worker ${leastLoaded.id} for queue '${queueName}' (load: ${leastLoaded.activeJobs}/${mostLoaded.activeJobs})`
|
|
186
|
+
);
|
|
187
|
+
return leastLoaded;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Otherwise, use round-robin to ensure fair distribution
|
|
191
|
+
// Find the index of the last used worker, or -1 if none was used yet
|
|
192
|
+
const lastUsedWorkerId = this.lastUsedWorker.get(queueName);
|
|
193
|
+
let lastIndex = -1;
|
|
194
|
+
|
|
195
|
+
if (lastUsedWorkerId) {
|
|
196
|
+
lastIndex = queueState.workers.findIndex((w) => w.id === lastUsedWorkerId);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Move to the next worker
|
|
200
|
+
const nextIndex = (lastIndex + 1) % queueState.workers.length;
|
|
201
|
+
const selectedWorker = queueState.workers[nextIndex];
|
|
202
|
+
|
|
203
|
+
// Update the last used worker
|
|
204
|
+
this.lastUsedWorker.set(queueName, selectedWorker.id);
|
|
205
|
+
queueState.lastWorkerIndex = nextIndex;
|
|
206
|
+
|
|
207
|
+
this.debug(
|
|
208
|
+
`Selected worker ${selectedWorker.id} for queue '${queueName}' (worker ${nextIndex + 1}/${queueState.workers.length}, active jobs: ${selectedWorker.activeJobs})`
|
|
209
|
+
);
|
|
210
|
+
return selectedWorker;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Execute a job using a worker from the pool
|
|
215
|
+
*
|
|
216
|
+
* @param job The job to execute
|
|
217
|
+
* @param queueName The queue the job belongs to
|
|
218
|
+
* @param options Optional job dispatch options
|
|
219
|
+
* @returns A promise that resolves when the job is dispatched to a worker
|
|
220
|
+
*/
|
|
221
|
+
public async runJob(
|
|
222
|
+
job: Job,
|
|
223
|
+
queueName: string,
|
|
224
|
+
options: JobDispatchOptions = {}
|
|
225
|
+
): Promise<void> {
|
|
226
|
+
const worker = this.getNextWorker(queueName);
|
|
227
|
+
if (!worker) {
|
|
228
|
+
throw new Error(`No workers available for queue '${queueName}'`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Assign queue name to job
|
|
232
|
+
job.queueName = queueName;
|
|
233
|
+
|
|
234
|
+
// Apply job options
|
|
235
|
+
const jobOptions = {
|
|
236
|
+
scheduledFor: options.scheduledFor,
|
|
237
|
+
priority: options.priority || 10,
|
|
238
|
+
maxRetries: options.maxRetries,
|
|
239
|
+
retryDelay: options.retryDelay
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// Dispatch job to worker
|
|
243
|
+
try {
|
|
244
|
+
this.debug(`Dispatching job ${job.id} to worker ${worker.id} in queue '${queueName}'`);
|
|
245
|
+
// Pass job options to worker
|
|
246
|
+
if (typeof worker.addJob === 'function') {
|
|
247
|
+
const result = await worker.addJob(job, queueName);
|
|
248
|
+
if (!result) {
|
|
249
|
+
throw new Error(`Worker ${worker.id} failed to accept job ${job.id}`);
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
throw new Error(`Worker ${worker.id} does not implement addJob method`);
|
|
253
|
+
}
|
|
254
|
+
} catch (error) {
|
|
255
|
+
// If this worker fails, remove it and try another worker
|
|
256
|
+
this.error(`Failed to dispatch job ${job.id} to worker ${worker.id}: ${error}`);
|
|
257
|
+
await this.removeWorker(worker.id);
|
|
258
|
+
|
|
259
|
+
// Retry with another worker
|
|
260
|
+
await this.runJob(job, queueName, options);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Cancel a job across all workers
|
|
266
|
+
*
|
|
267
|
+
* @param jobId The ID of the job to cancel
|
|
268
|
+
* @param queueName Optional queue name to limit search
|
|
269
|
+
* @returns True if the job was canceled, false otherwise
|
|
270
|
+
*/
|
|
271
|
+
public async cancelJob(jobId: string, queueName?: string): Promise<boolean> {
|
|
272
|
+
let canceled = false;
|
|
273
|
+
|
|
274
|
+
if (queueName) {
|
|
275
|
+
// Try to cancel in specific queue
|
|
276
|
+
const queueState = this.queueWorkers.get(queueName);
|
|
277
|
+
if (queueState) {
|
|
278
|
+
// Try each worker in the queue
|
|
279
|
+
for (const worker of queueState.workers) {
|
|
280
|
+
try {
|
|
281
|
+
if (await worker.cancelJob(jobId, queueName)) {
|
|
282
|
+
canceled = true;
|
|
283
|
+
this.debug(`Job ${jobId} cancelled on worker ${worker.id}`);
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
} catch (error) {
|
|
287
|
+
this.warning(`Error cancelling job ${jobId} on worker ${worker.id}: ${error}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
// Try all workers
|
|
293
|
+
for (const worker of this.workers.values()) {
|
|
294
|
+
try {
|
|
295
|
+
if (await worker.cancelJob(jobId)) {
|
|
296
|
+
canceled = true;
|
|
297
|
+
this.debug(`Job ${jobId} cancelled on worker ${worker.id}`);
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
} catch (error) {
|
|
301
|
+
this.warning(`Error cancelling job ${jobId} on worker ${worker.id}: ${error}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return canceled;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Cancel all jobs across all workers
|
|
311
|
+
*
|
|
312
|
+
* @param queueName Optional queue name to limit cancellation
|
|
313
|
+
* @returns The number of jobs canceled
|
|
314
|
+
*/
|
|
315
|
+
public async cancelAllJobs(queueName?: string): Promise<number> {
|
|
316
|
+
let total = 0;
|
|
317
|
+
|
|
318
|
+
if (queueName) {
|
|
319
|
+
// Cancel in specific queue
|
|
320
|
+
const queueState = this.queueWorkers.get(queueName);
|
|
321
|
+
if (queueState) {
|
|
322
|
+
for (const worker of queueState.workers) {
|
|
323
|
+
try {
|
|
324
|
+
const count = await worker.cancelAllJobs(queueName);
|
|
325
|
+
total += count;
|
|
326
|
+
this.debug(`Cancelled ${count} jobs on worker ${worker.id} in queue ${queueName}`);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
this.warning(`Error cancelling jobs on worker ${worker.id}: ${error}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
// Cancel in all workers
|
|
334
|
+
for (const worker of this.workers.values()) {
|
|
335
|
+
try {
|
|
336
|
+
const count = await worker.cancelAllJobs();
|
|
337
|
+
total += count;
|
|
338
|
+
this.debug(`Cancelled ${count} jobs on worker ${worker.id}`);
|
|
339
|
+
} catch (error) {
|
|
340
|
+
this.warning(`Error cancelling jobs on worker ${worker.id}: ${error}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return total;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Get all queues being handled by this pool
|
|
350
|
+
*
|
|
351
|
+
* @returns An array of queue names
|
|
352
|
+
*/
|
|
353
|
+
public getQueues(): string[] {
|
|
354
|
+
return Array.from(this.queueWorkers.keys());
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Get all workers in the pool
|
|
359
|
+
*
|
|
360
|
+
* @returns An array of worker infos
|
|
361
|
+
*/
|
|
362
|
+
public getWorkers(): WorkerInfo[] {
|
|
363
|
+
return Array.from(this.workers.values());
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Get workers for a specific queue
|
|
368
|
+
*
|
|
369
|
+
* @param queueName The name of the queue
|
|
370
|
+
* @returns An array of worker infos for the queue
|
|
371
|
+
*/
|
|
372
|
+
public getWorkersForQueue(queueName: string): WorkerInfo[] {
|
|
373
|
+
const queueState = this.queueWorkers.get(queueName);
|
|
374
|
+
return queueState ? [...queueState.workers] : [];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Get or create queue state for tracking workers
|
|
379
|
+
*
|
|
380
|
+
* @param queueName The name of the queue
|
|
381
|
+
* @returns The queue worker state
|
|
382
|
+
*/
|
|
383
|
+
private getOrCreateQueueState(queueName: string): QueueWorkerState {
|
|
384
|
+
if (!this.queueWorkers.has(queueName)) {
|
|
385
|
+
this.queueWorkers.set(queueName, {
|
|
386
|
+
workers: [],
|
|
387
|
+
lastWorkerIndex: -1
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return this.queueWorkers.get(queueName)!;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Copyright (c) 2025-present Workbud Technologies Inc. All rights reserved.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
|
|
15
|
+
import 'reflect-metadata';
|
|
16
|
+
|
|
17
|
+
import type { Class } from 'type-fest';
|
|
18
|
+
import type { Job } from '../job';
|
|
19
|
+
import type { TransportEvent } from '../transport';
|
|
20
|
+
|
|
21
|
+
import { Service } from '@elysiumjs/core';
|
|
22
|
+
|
|
23
|
+
import { WorkerStatus } from '../worker';
|
|
24
|
+
import { ThreadWorker } from './thread.worker';
|
|
25
|
+
|
|
26
|
+
// Define self for TypeScript
|
|
27
|
+
declare const self: Worker;
|
|
28
|
+
|
|
29
|
+
// Ensure we're in a worker context
|
|
30
|
+
if (typeof self === 'undefined') {
|
|
31
|
+
throw new Error('This script must be run in a worker context');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Log worker startup
|
|
35
|
+
console.log(`Heracles thread worker starting...`);
|
|
36
|
+
|
|
37
|
+
// Set a global worker reference
|
|
38
|
+
let worker: ThreadWorker | null = null;
|
|
39
|
+
|
|
40
|
+
// For storing pre-imported job classes
|
|
41
|
+
const jobClasses = new Map<string, Class<Job>>();
|
|
42
|
+
|
|
43
|
+
// Handle all messages from the main thread
|
|
44
|
+
self.addEventListener('message', async (event) => {
|
|
45
|
+
try {
|
|
46
|
+
// Process the message based on its type
|
|
47
|
+
if (event.data && typeof event.data === 'object') {
|
|
48
|
+
const message = event.data as TransportEvent;
|
|
49
|
+
|
|
50
|
+
switch (message.type) {
|
|
51
|
+
case 'worker:init': {
|
|
52
|
+
// Initialize worker if not already initialized
|
|
53
|
+
if (!worker) {
|
|
54
|
+
const { queues = ['default'], options = {} } = message as any;
|
|
55
|
+
|
|
56
|
+
console.log(`Initializing worker with queues: ${queues.join(', ')}`);
|
|
57
|
+
|
|
58
|
+
// Initialize worker with provided queues
|
|
59
|
+
worker = new ThreadWorker(queues);
|
|
60
|
+
|
|
61
|
+
// Configure worker with options
|
|
62
|
+
if (options.concurrency) {
|
|
63
|
+
for (const queue of queues) {
|
|
64
|
+
await worker.setConcurrency(queue, options.concurrency);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Store worker in global scope for debug purposes
|
|
69
|
+
(self as any).__worker = worker;
|
|
70
|
+
|
|
71
|
+
// Start the worker
|
|
72
|
+
await worker.start();
|
|
73
|
+
|
|
74
|
+
// Send ready message back to main thread
|
|
75
|
+
self.postMessage({
|
|
76
|
+
type: 'worker:ready',
|
|
77
|
+
id: worker.id,
|
|
78
|
+
queues: Array.from(worker.getInfo().queues),
|
|
79
|
+
status: worker.status,
|
|
80
|
+
timestamp: Date.now()
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
case 'job:process': {
|
|
87
|
+
// Process a job
|
|
88
|
+
if (!worker) {
|
|
89
|
+
throw new Error('Worker not initialized');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Handle the job processing
|
|
93
|
+
const { job: jobName, args = [], queue = 'default', options } = message;
|
|
94
|
+
await handleMessage(message);
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
default:
|
|
99
|
+
// Other messages will be handled by the ThreadWorker itself
|
|
100
|
+
if (worker) {
|
|
101
|
+
await handleMessage(message);
|
|
102
|
+
} else {
|
|
103
|
+
console.warn(`Received message before worker initialization: ${message.type}`);
|
|
104
|
+
|
|
105
|
+
// Try to handle initialization on the fly
|
|
106
|
+
worker = new ThreadWorker(['default']);
|
|
107
|
+
await worker.start();
|
|
108
|
+
await handleMessage(message);
|
|
109
|
+
}
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} catch (error) {
|
|
114
|
+
console.error('Error handling message in worker thread:', error);
|
|
115
|
+
|
|
116
|
+
// Report error to main thread
|
|
117
|
+
self.postMessage({
|
|
118
|
+
type: 'worker:error',
|
|
119
|
+
error: error instanceof Error ? error.message : String(error),
|
|
120
|
+
timestamp: Date.now()
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Handle a transport message
|
|
127
|
+
*/
|
|
128
|
+
async function handleMessage(message: TransportEvent): Promise<void> {
|
|
129
|
+
if (!worker) {
|
|
130
|
+
throw new Error('Worker not initialized');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Forward the message to the worker instance for processing
|
|
134
|
+
await worker.handleMessage(message);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Send open message to main thread to indicate worker is ready for initialization
|
|
138
|
+
self.postMessage({ type: 'worker:open' });
|
|
139
|
+
|
|
140
|
+
// Handle uncaught errors
|
|
141
|
+
self.addEventListener('error', (event) => {
|
|
142
|
+
console.error('Uncaught error in worker thread:', event.error || event.message);
|
|
143
|
+
|
|
144
|
+
// Report error to main thread
|
|
145
|
+
self.postMessage({
|
|
146
|
+
type: 'worker:error',
|
|
147
|
+
error: event.error?.message || event.message || 'Unknown error',
|
|
148
|
+
timestamp: Date.now()
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Handle unhandled promise rejections
|
|
153
|
+
self.addEventListener('unhandledrejection', (event) => {
|
|
154
|
+
console.error('Unhandled rejection in worker thread:', event.reason);
|
|
155
|
+
|
|
156
|
+
// Report error to main thread
|
|
157
|
+
self.postMessage({
|
|
158
|
+
type: 'worker:error',
|
|
159
|
+
error: event.reason?.message || String(event.reason) || 'Unhandled promise rejection',
|
|
160
|
+
timestamp: Date.now()
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Initialize custom module loading if needed
|
|
165
|
+
if (typeof Bun !== 'undefined') {
|
|
166
|
+
// Bun-specific initialization
|
|
167
|
+
console.log('Running in Bun environment');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Export ThreadWorker and other components for testing/debugging
|
|
171
|
+
export { ThreadWorker, WorkerStatus };
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
// Copyright (c) 2025-present Workbud Technologies Inc. All rights reserved.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
|
|
15
|
+
import type { Class } from 'type-fest';
|
|
16
|
+
import type { Job } from '../job';
|
|
17
|
+
import type { JobDispatchOptions } from '../queue';
|
|
18
|
+
import type { TransportEvent } from '../transport';
|
|
19
|
+
|
|
20
|
+
import { Service } from '@elysiumjs/core';
|
|
21
|
+
|
|
22
|
+
import { JobStatus } from '../job';
|
|
23
|
+
import { TransportMode } from '../transport';
|
|
24
|
+
import { ThreadTransport } from '../transports/thread.transport';
|
|
25
|
+
import { BaseWorker } from './base.worker';
|
|
26
|
+
|
|
27
|
+
declare const self: Worker;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Worker class that receives jobs from parent thread.
|
|
31
|
+
*/
|
|
32
|
+
export class ThreadWorker extends BaseWorker {
|
|
33
|
+
/**
|
|
34
|
+
* Transport instance used for communication with main thread
|
|
35
|
+
*/
|
|
36
|
+
private readonly transport: ThreadTransport;
|
|
37
|
+
|
|
38
|
+
constructor(queues: string[] = ['default'], id?: string) {
|
|
39
|
+
super(id);
|
|
40
|
+
|
|
41
|
+
this.info(`ThreadWorker ${this.id} started with queues: ${queues.join(', ')}`);
|
|
42
|
+
|
|
43
|
+
// Create transport in consumer mode if in worker thread
|
|
44
|
+
this.transport = new ThreadTransport(TransportMode.CONSUMER);
|
|
45
|
+
this.debug('Created transport in consumer mode');
|
|
46
|
+
|
|
47
|
+
// Only set up message listeners if running in a worker thread
|
|
48
|
+
this.setupMessageListeners();
|
|
49
|
+
|
|
50
|
+
// Initialize the specified queues
|
|
51
|
+
for (const queueName of queues) {
|
|
52
|
+
if (!this.queues.has(queueName)) {
|
|
53
|
+
this.createQueue({ name: queueName });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Report that the worker is ready if in worker thread
|
|
58
|
+
self.postMessage({
|
|
59
|
+
type: 'worker:ready',
|
|
60
|
+
id: this.id,
|
|
61
|
+
queues: Array.from(this.queues.keys()),
|
|
62
|
+
status: this.status
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Set up message listeners for worker thread
|
|
68
|
+
*/
|
|
69
|
+
private setupMessageListeners(): void {
|
|
70
|
+
self.addEventListener('message', async (event: MessageEvent) => {
|
|
71
|
+
const message = event.data as TransportEvent;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
await this.handleMessage(message);
|
|
75
|
+
} catch (error: any) {
|
|
76
|
+
this.error(`Error handling message: ${error}`);
|
|
77
|
+
self.postMessage({
|
|
78
|
+
type: 'worker:error',
|
|
79
|
+
error: error.message || String(error),
|
|
80
|
+
workerId: this.id
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Handle errors
|
|
86
|
+
self.addEventListener('error', (e) => {
|
|
87
|
+
this.trace({ ...e.error, filename: e.filename }, `ThreadWorker ${this.id} error:`);
|
|
88
|
+
self.postMessage({
|
|
89
|
+
type: 'worker:error',
|
|
90
|
+
error: e.error?.message || e.message || 'Unknown error',
|
|
91
|
+
workerId: this.id
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Handle unhandled promise rejections
|
|
96
|
+
self.addEventListener('unhandledrejection', (e: any) => {
|
|
97
|
+
const error = e.reason || e;
|
|
98
|
+
this.trace(error, `ThreadWorker ${this.id} unhandled rejection:`);
|
|
99
|
+
self.postMessage({
|
|
100
|
+
type: 'worker:error',
|
|
101
|
+
error: error.message || String(error),
|
|
102
|
+
workerId: this.id
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Handle a message from the main thread
|
|
109
|
+
* @public This method is exposed to be called from thread.worker.entry.ts
|
|
110
|
+
*/
|
|
111
|
+
public async handleMessage(message: TransportEvent): Promise<void> {
|
|
112
|
+
switch (message.type) {
|
|
113
|
+
case 'job:process': {
|
|
114
|
+
// Process a new job
|
|
115
|
+
const { job: jobName, args = [], queue = 'default', options } = message;
|
|
116
|
+
this.debug(`ThreadWorker ${this.id} received job ${jobName} for queue ${queue}`);
|
|
117
|
+
await this.handleProcessJob(jobName, args, queue, {
|
|
118
|
+
...options,
|
|
119
|
+
scheduledFor: new Date(options?.scheduledFor ?? Date.now())
|
|
120
|
+
});
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
case 'job:cancel': {
|
|
125
|
+
// Cancel a job
|
|
126
|
+
const { jobId, queue = 'default' } = message;
|
|
127
|
+
const canceled = await this.cancelJob(jobId, queue);
|
|
128
|
+
this.sendStatusUpdate({
|
|
129
|
+
type: 'job:canceled',
|
|
130
|
+
jobId,
|
|
131
|
+
queue,
|
|
132
|
+
success: canceled,
|
|
133
|
+
count: canceled ? 1 : 0
|
|
134
|
+
});
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
case 'job:cancelAll': {
|
|
139
|
+
// Cancel all jobs in a queue
|
|
140
|
+
const { queue = 'default' } = message;
|
|
141
|
+
const canceledCount = await this.cancelAllJobs(queue);
|
|
142
|
+
this.sendStatusUpdate({
|
|
143
|
+
type: 'job:canceled',
|
|
144
|
+
queue,
|
|
145
|
+
count: canceledCount
|
|
146
|
+
});
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
case 'job:status': {
|
|
151
|
+
// Get job status
|
|
152
|
+
const { jobId, queue = 'default' } = message;
|
|
153
|
+
const job = await this.getJob(jobId, queue);
|
|
154
|
+
|
|
155
|
+
if (job) {
|
|
156
|
+
this.sendStatusUpdate({
|
|
157
|
+
type: 'job:status',
|
|
158
|
+
jobId,
|
|
159
|
+
queue,
|
|
160
|
+
status: job.status,
|
|
161
|
+
error: job.lastError?.message,
|
|
162
|
+
retries: job.retries,
|
|
163
|
+
startedAt: job.startedAt?.toISOString(),
|
|
164
|
+
completedAt: job.completedAt?.toISOString()
|
|
165
|
+
});
|
|
166
|
+
} else {
|
|
167
|
+
this.sendStatusUpdate({
|
|
168
|
+
type: 'job:status',
|
|
169
|
+
jobId,
|
|
170
|
+
queue,
|
|
171
|
+
status: 'unknown'
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
case 'worker:register': {
|
|
178
|
+
// Add new queues to this worker
|
|
179
|
+
if (message.queues?.length) {
|
|
180
|
+
for (const queueName of message.queues) {
|
|
181
|
+
if (!this.queues.has(queueName)) {
|
|
182
|
+
await this.createQueue({ name: queueName });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Send updated worker info
|
|
187
|
+
this.sendWorkerStatusUpdate();
|
|
188
|
+
}
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
case 'worker:unregister': {
|
|
193
|
+
// Stop the worker
|
|
194
|
+
await this.stop();
|
|
195
|
+
|
|
196
|
+
// Notify main thread that worker is stopping
|
|
197
|
+
this.sendStatusUpdate({
|
|
198
|
+
type: 'worker:status',
|
|
199
|
+
workerId: this.id,
|
|
200
|
+
status: 'stopped',
|
|
201
|
+
queues: Array.from(this.queues.keys()),
|
|
202
|
+
processing: 0,
|
|
203
|
+
waiting: 0
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Process the next job in a queue
|
|
213
|
+
*/
|
|
214
|
+
public async processNextJob(queueName: string): Promise<void> {
|
|
215
|
+
const queue = this.queues.get(queueName);
|
|
216
|
+
if (!queue || queue.paused || queue.processing >= queue.options.concurrency!) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const queuedJob = queue.jobs.shift();
|
|
221
|
+
if (!queuedJob) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (queuedJob.job.status === JobStatus.CANCELLED) {
|
|
226
|
+
this.info(`Skipping cancelled job ${queuedJob.job.id}`);
|
|
227
|
+
this.sendStatusUpdate({
|
|
228
|
+
type: 'job:status',
|
|
229
|
+
jobId: queuedJob.job.id,
|
|
230
|
+
queue: queueName,
|
|
231
|
+
status: JobStatus.CANCELLED
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
this.processQueueJobs(queueName);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Add to active jobs
|
|
239
|
+
queue.activeJobs.set(queuedJob.job.id, queuedJob);
|
|
240
|
+
queue.processing++;
|
|
241
|
+
|
|
242
|
+
// Send job started status update
|
|
243
|
+
try {
|
|
244
|
+
await this.transport.updateJobStatus(queuedJob.job.id, queuedJob.job.dispatchId, queueName, {
|
|
245
|
+
status: JobStatus.RUNNING,
|
|
246
|
+
startedAt: new Date().toISOString()
|
|
247
|
+
});
|
|
248
|
+
} catch (error) {
|
|
249
|
+
this.error(`Error updating job status to RUNNING: ${error}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
this.debug(`Processing job ${queuedJob.job.id} from queue '${queueName}'`);
|
|
254
|
+
await queuedJob.job.run();
|
|
255
|
+
|
|
256
|
+
// Handle job completion based on status
|
|
257
|
+
if (queuedJob.job.status === JobStatus.FAILED) {
|
|
258
|
+
// Determine max retries - use job-specific setting if available, otherwise queue setting
|
|
259
|
+
const maxRetries =
|
|
260
|
+
queuedJob.maxRetries !== undefined ? queuedJob.maxRetries : queue.options.maxRetries!;
|
|
261
|
+
|
|
262
|
+
if (queuedJob.retries < maxRetries) {
|
|
263
|
+
// Schedule for retry
|
|
264
|
+
queuedJob.retries++;
|
|
265
|
+
queuedJob.job.incrementRetries();
|
|
266
|
+
this.warning(
|
|
267
|
+
`Job ${queuedJob.job.id} failed, retrying (${queuedJob.retries}/${maxRetries})`
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
// Determine retry delay - use job-specific setting if available, otherwise queue setting
|
|
271
|
+
const retryDelay =
|
|
272
|
+
queuedJob.retryDelay !== undefined ? queuedJob.retryDelay : queue.options.retryDelay!;
|
|
273
|
+
|
|
274
|
+
// Set scheduled time for retry
|
|
275
|
+
const retryTime = new Date();
|
|
276
|
+
retryTime.setMilliseconds(retryTime.getMilliseconds() + retryDelay);
|
|
277
|
+
queuedJob.scheduledFor = retryTime;
|
|
278
|
+
|
|
279
|
+
// Send status update for scheduled retry
|
|
280
|
+
try {
|
|
281
|
+
await this.transport.updateJobStatus(queuedJob.job.id, queuedJob.job.id, queueName, {
|
|
282
|
+
status: JobStatus.SCHEDULED_FOR_RETRY,
|
|
283
|
+
error: queuedJob.job.lastError?.message,
|
|
284
|
+
retries: queuedJob.retries
|
|
285
|
+
});
|
|
286
|
+
} catch (error) {
|
|
287
|
+
this.error(`Error updating job status to SCHEDULED_FOR_RETRY: ${error}`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Schedule the retry
|
|
291
|
+
setTimeout(async () => {
|
|
292
|
+
if (queuedJob.job.status !== JobStatus.CANCELLED) {
|
|
293
|
+
queue.jobs.push(queuedJob);
|
|
294
|
+
this.processQueueJobs(queueName);
|
|
295
|
+
} else {
|
|
296
|
+
this.info(`Skipping cancelled job ${queuedJob.job.id} during retry delay`);
|
|
297
|
+
try {
|
|
298
|
+
await this.transport.updateJobStatus(
|
|
299
|
+
queuedJob.job.id,
|
|
300
|
+
queuedJob.job.id,
|
|
301
|
+
queueName,
|
|
302
|
+
{
|
|
303
|
+
status: JobStatus.CANCELLED
|
|
304
|
+
}
|
|
305
|
+
);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
this.error(`Error updating job status to CANCELLED: ${error}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}, retryDelay);
|
|
311
|
+
} else {
|
|
312
|
+
// Job failed permanently after all retries
|
|
313
|
+
const maxRetries =
|
|
314
|
+
queuedJob.maxRetries !== undefined ? queuedJob.maxRetries : queue.options.maxRetries!;
|
|
315
|
+
this.error(
|
|
316
|
+
`Job ${queuedJob.job.id} failed after ${queuedJob.retries} retries (max: ${maxRetries})`
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
// Send final failure status
|
|
320
|
+
const completedAt = queuedJob.job.completedAt?.toISOString() || new Date().toISOString();
|
|
321
|
+
try {
|
|
322
|
+
await this.transport.updateJobStatus(queuedJob.job.id, queuedJob.job.id, queueName, {
|
|
323
|
+
status: JobStatus.FAILED,
|
|
324
|
+
error: queuedJob.job.lastError?.message,
|
|
325
|
+
completedAt
|
|
326
|
+
});
|
|
327
|
+
} catch (error) {
|
|
328
|
+
this.error(`Error updating job status to FAILED: ${error}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (queue.options.pauseOnError) {
|
|
332
|
+
this.pause(queueName);
|
|
333
|
+
this.warning(`Queue '${queueName}' paused due to job failure`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
} else if (queuedJob.job.status === JobStatus.COMPLETED) {
|
|
337
|
+
// Job completed successfully
|
|
338
|
+
this.success(`Job ${queuedJob.job.id} completed successfully`);
|
|
339
|
+
|
|
340
|
+
// Send completion status
|
|
341
|
+
const completedAt = queuedJob.job.completedAt?.toISOString() || new Date().toISOString();
|
|
342
|
+
try {
|
|
343
|
+
await this.transport.updateJobStatus(queuedJob.job.id, queuedJob.job.id, queueName, {
|
|
344
|
+
status: JobStatus.COMPLETED,
|
|
345
|
+
completedAt
|
|
346
|
+
});
|
|
347
|
+
} catch (error) {
|
|
348
|
+
this.error(`Error updating job status to COMPLETED: ${error}`);
|
|
349
|
+
}
|
|
350
|
+
} else if (queuedJob.job.status === JobStatus.CANCELLED) {
|
|
351
|
+
// Job was cancelled while running
|
|
352
|
+
this.info(`Job ${queuedJob.job.id} cancelled while running`);
|
|
353
|
+
|
|
354
|
+
// Send cancellation status
|
|
355
|
+
const completedAt = queuedJob.job.completedAt?.toISOString() || new Date().toISOString();
|
|
356
|
+
try {
|
|
357
|
+
await this.transport.updateJobStatus(queuedJob.job.id, queuedJob.job.id, queueName, {
|
|
358
|
+
status: JobStatus.CANCELLED,
|
|
359
|
+
completedAt
|
|
360
|
+
});
|
|
361
|
+
} catch (error) {
|
|
362
|
+
this.error(`Error updating job status to CANCELLED: ${error}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
} catch (error: any) {
|
|
366
|
+
// Handle unexpected errors during job execution
|
|
367
|
+
this.error(`Unexpected error processing job ${queuedJob.job.id}: ${error}`);
|
|
368
|
+
|
|
369
|
+
// Send error status using transport if available
|
|
370
|
+
const completedAt = new Date().toISOString();
|
|
371
|
+
const errorMsg = error.message || String(error);
|
|
372
|
+
if (this.transport && typeof this.transport.updateJobStatus === 'function') {
|
|
373
|
+
await this.transport.updateJobStatus(queuedJob.job.id, queuedJob.job.id, queueName, {
|
|
374
|
+
status: JobStatus.FAILED,
|
|
375
|
+
error: errorMsg,
|
|
376
|
+
completedAt
|
|
377
|
+
});
|
|
378
|
+
} else {
|
|
379
|
+
this.sendStatusUpdate({
|
|
380
|
+
type: 'job:result',
|
|
381
|
+
jobId: queuedJob.job.id,
|
|
382
|
+
queue: queueName,
|
|
383
|
+
status: JobStatus.FAILED,
|
|
384
|
+
error: errorMsg,
|
|
385
|
+
completedAt
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (queue.options.pauseOnError) {
|
|
390
|
+
this.pause(queueName);
|
|
391
|
+
this.warning(`Queue '${queueName}' paused due to unexpected error`);
|
|
392
|
+
}
|
|
393
|
+
} finally {
|
|
394
|
+
// Remove job from active jobs and decrement processing count
|
|
395
|
+
queue.activeJobs.delete(queuedJob.job.id);
|
|
396
|
+
queue.processing--;
|
|
397
|
+
|
|
398
|
+
// Send worker status update
|
|
399
|
+
this.sendWorkerStatusUpdate();
|
|
400
|
+
|
|
401
|
+
// Process next job if available
|
|
402
|
+
this.processQueueJobs(queueName);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Create a job from a job class name and add it to a queue
|
|
408
|
+
*/
|
|
409
|
+
private async handleProcessJob(
|
|
410
|
+
jobName: string,
|
|
411
|
+
args: any[],
|
|
412
|
+
queue: string = 'default',
|
|
413
|
+
options?: Partial<JobDispatchOptions>
|
|
414
|
+
): Promise<void> {
|
|
415
|
+
this.debug(`Processing job ${jobName} in ThreadWorker for queue ${queue}`);
|
|
416
|
+
try {
|
|
417
|
+
// Get job class from service container
|
|
418
|
+
const JobClass = Service.get(jobName) as Class<Job>;
|
|
419
|
+
if (!JobClass) {
|
|
420
|
+
this.error(`Job class ${jobName} not found`);
|
|
421
|
+
throw new Error(`Job class ${jobName} not found`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Create job instance
|
|
425
|
+
const job = new JobClass(...(args || []));
|
|
426
|
+
job.queueName = queue;
|
|
427
|
+
|
|
428
|
+
// Prepare job options
|
|
429
|
+
const jobOptions: JobDispatchOptions = options || {};
|
|
430
|
+
|
|
431
|
+
// Convert ISO string date to Date object if needed
|
|
432
|
+
if (jobOptions.scheduledFor && typeof jobOptions.scheduledFor === 'string') {
|
|
433
|
+
jobOptions.scheduledFor = new Date(jobOptions.scheduledFor);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Set initial job status
|
|
437
|
+
const initialStatus =
|
|
438
|
+
jobOptions.scheduledFor && jobOptions.scheduledFor > new Date()
|
|
439
|
+
? JobStatus.SCHEDULED_FOR_RETRY
|
|
440
|
+
: JobStatus.PENDING;
|
|
441
|
+
|
|
442
|
+
try {
|
|
443
|
+
await this.transport.updateJobStatus(job.id, job.dispatchId, queue, {
|
|
444
|
+
status: initialStatus,
|
|
445
|
+
createdAt: new Date().toISOString()
|
|
446
|
+
});
|
|
447
|
+
} catch (error) {
|
|
448
|
+
this.error(`Error setting initial job status: ${error}`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Add job to queue with options
|
|
452
|
+
await this.addJob(job, queue, jobOptions);
|
|
453
|
+
|
|
454
|
+
// Send job received confirmation
|
|
455
|
+
this.sendStatusUpdate({
|
|
456
|
+
type: 'job:received',
|
|
457
|
+
jobId: job.id,
|
|
458
|
+
queue
|
|
459
|
+
});
|
|
460
|
+
} catch (error: any) {
|
|
461
|
+
// Log and report error
|
|
462
|
+
this.trace(
|
|
463
|
+
error,
|
|
464
|
+
`Error while adding job '${jobName}' to queue '${queue}' with worker ${this.id}`
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
this.sendStatusUpdate({
|
|
468
|
+
type: 'job:error',
|
|
469
|
+
jobId: `error_${Date.now()}`,
|
|
470
|
+
jobName,
|
|
471
|
+
queue,
|
|
472
|
+
error: error.message || String(error)
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Send a status update to the main thread (legacy method)
|
|
479
|
+
* @deprecated Use transport.updateJobStatus instead
|
|
480
|
+
*/
|
|
481
|
+
private sendStatusUpdate(message: any): void {
|
|
482
|
+
this.warning(`Using deprecated sendStatusUpdate. Use transport.updateJobStatus instead`);
|
|
483
|
+
self.postMessage(message);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Send worker status update to main thread
|
|
488
|
+
*/
|
|
489
|
+
private sendWorkerStatusUpdate(): void {
|
|
490
|
+
// Calculate waiting and processing counts
|
|
491
|
+
let waiting = 0;
|
|
492
|
+
let processing = 0;
|
|
493
|
+
|
|
494
|
+
for (const queue of this.queues.values()) {
|
|
495
|
+
waiting += queue.jobs.length;
|
|
496
|
+
processing += queue.processing;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Send status update
|
|
500
|
+
self.postMessage({
|
|
501
|
+
type: 'worker:status',
|
|
502
|
+
workerId: this.id,
|
|
503
|
+
queues: Array.from(this.queues.keys()),
|
|
504
|
+
status: this.status as string,
|
|
505
|
+
processing,
|
|
506
|
+
waiting
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Override start method to send status update
|
|
512
|
+
*/
|
|
513
|
+
public async start(): Promise<void> {
|
|
514
|
+
await super.start();
|
|
515
|
+
this.sendWorkerStatusUpdate();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Override stop method to send status update
|
|
520
|
+
*/
|
|
521
|
+
public async stop(force: boolean = false): Promise<void> {
|
|
522
|
+
await super.stop(force);
|
|
523
|
+
this.sendWorkerStatusUpdate();
|
|
524
|
+
}
|
|
525
|
+
}
|