@zintrust/workers 0.1.31 → 0.1.52
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/dist/ClusterLock.js +3 -2
- package/dist/DeadLetterQueue.js +3 -2
- package/dist/HealthMonitor.js +24 -13
- package/dist/Observability.js +8 -0
- package/dist/WorkerFactory.d.ts +4 -0
- package/dist/WorkerFactory.js +409 -42
- package/dist/WorkerInit.js +122 -43
- package/dist/WorkerMetrics.js +5 -1
- package/dist/WorkerRegistry.js +8 -0
- package/dist/WorkerShutdown.d.ts +0 -13
- package/dist/WorkerShutdown.js +1 -44
- package/dist/build-manifest.json +101 -85
- package/dist/config/workerConfig.d.ts +1 -0
- package/dist/config/workerConfig.js +7 -1
- package/dist/createQueueWorker.js +281 -42
- package/dist/dashboard/workers-api.js +8 -1
- package/dist/http/WorkerController.js +90 -35
- package/dist/http/WorkerMonitoringService.js +29 -2
- package/dist/http/middleware/FeaturesValidator.js +5 -4
- package/dist/index.d.ts +1 -2
- package/dist/index.js +0 -1
- package/dist/routes/workers.js +10 -7
- package/dist/storage/WorkerStore.d.ts +6 -3
- package/dist/storage/WorkerStore.js +16 -0
- package/dist/telemetry/api/TelemetryMonitoringService.js +29 -2
- package/dist/ui/router/ui.js +58 -29
- package/dist/ui/workers/index.html +202 -0
- package/dist/ui/workers/main.js +1952 -0
- package/dist/ui/workers/styles.css +1350 -0
- package/dist/ui/workers/zintrust.svg +30 -0
- package/package.json +5 -5
- package/src/ClusterLock.ts +13 -7
- package/src/ComplianceManager.ts +3 -2
- package/src/DeadLetterQueue.ts +6 -4
- package/src/HealthMonitor.ts +33 -17
- package/src/Observability.ts +11 -0
- package/src/WorkerFactory.ts +480 -43
- package/src/WorkerInit.ts +167 -48
- package/src/WorkerMetrics.ts +14 -8
- package/src/WorkerRegistry.ts +11 -0
- package/src/WorkerShutdown.ts +1 -69
- package/src/config/workerConfig.ts +9 -1
- package/src/createQueueWorker.ts +428 -43
- package/src/dashboard/workers-api.ts +8 -1
- package/src/http/WorkerController.ts +111 -36
- package/src/http/WorkerMonitoringService.ts +35 -2
- package/src/http/middleware/FeaturesValidator.ts +8 -19
- package/src/index.ts +2 -3
- package/src/routes/workers.ts +10 -8
- package/src/storage/WorkerStore.ts +21 -3
- package/src/telemetry/api/TelemetryMonitoringService.ts +35 -2
- package/src/types/queue-monitor.d.ts +2 -1
- package/src/ui/components/WorkerExpandPanel.js +0 -8
- package/src/ui/router/EmbeddedAssets.ts +3 -0
- package/src/ui/router/ui.ts +57 -39
- package/src/WorkerShutdownDurableObject.ts +0 -64
package/src/createQueueWorker.ts
CHANGED
|
@@ -1,5 +1,116 @@
|
|
|
1
1
|
import type { BullMQPayload, QueueMessage } from '@zintrust/core';
|
|
2
|
-
import
|
|
2
|
+
import * as Core from '@zintrust/core';
|
|
3
|
+
import { Env, Logger, Queue } from '@zintrust/core';
|
|
4
|
+
|
|
5
|
+
const RETRY_BASE_DELAY_MS = 1000;
|
|
6
|
+
const RETRY_MAX_DELAY_MS = 30000;
|
|
7
|
+
|
|
8
|
+
const getJobStateTracker = (): unknown => {
|
|
9
|
+
try {
|
|
10
|
+
return (Core as Record<string, unknown>)['JobStateTracker'];
|
|
11
|
+
} catch {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const getJobHeartbeatStore = (): unknown => {
|
|
17
|
+
try {
|
|
18
|
+
return (Core as Record<string, unknown>)['JobHeartbeatStore'];
|
|
19
|
+
} catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const getTimeoutManager = (): unknown => {
|
|
25
|
+
try {
|
|
26
|
+
return (Core as Record<string, unknown>)['TimeoutManager'];
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const getEnvInt = (key: string, fallback: number): number => {
|
|
33
|
+
const getter = (Env as { getInt?: (name: string, defaultValue: number) => number }).getInt;
|
|
34
|
+
if (typeof getter === 'function') {
|
|
35
|
+
return getter(key, fallback);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const raw = (Env as Record<string, unknown>)[key];
|
|
39
|
+
if (typeof raw === 'number' && Number.isFinite(raw)) return Math.floor(raw);
|
|
40
|
+
if (typeof raw === 'string' && raw.trim() !== '') {
|
|
41
|
+
const parsed = Number(raw);
|
|
42
|
+
if (Number.isFinite(parsed)) return Math.floor(parsed);
|
|
43
|
+
}
|
|
44
|
+
return fallback;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const resolveQueueJobTimeoutMs = (): number => {
|
|
48
|
+
const timeoutManager = getTimeoutManager();
|
|
49
|
+
const tm = (timeoutManager ?? {}) as { getQueueJobTimeoutMs?: () => number };
|
|
50
|
+
if (typeof tm.getQueueJobTimeoutMs === 'function') {
|
|
51
|
+
return tm.getQueueJobTimeoutMs();
|
|
52
|
+
}
|
|
53
|
+
return Math.max(1000, getEnvInt('QUEUE_JOB_TIMEOUT', 60) * 1000);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const runWithTimeout = async <T>(
|
|
57
|
+
operation: () => Promise<T>,
|
|
58
|
+
timeoutMs: number,
|
|
59
|
+
operationName: string
|
|
60
|
+
): Promise<T> => {
|
|
61
|
+
const timeoutManager = getTimeoutManager();
|
|
62
|
+
const tm = (timeoutManager ?? {}) as {
|
|
63
|
+
withTimeout?: <R>(
|
|
64
|
+
op: () => Promise<R>,
|
|
65
|
+
t: number,
|
|
66
|
+
name: string,
|
|
67
|
+
timeoutHandler?: () => Promise<R>
|
|
68
|
+
) => Promise<R>;
|
|
69
|
+
};
|
|
70
|
+
if (typeof tm.withTimeout === 'function') {
|
|
71
|
+
return tm.withTimeout(operation, timeoutMs, operationName);
|
|
72
|
+
}
|
|
73
|
+
return operation();
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const isTimeoutError = (error: unknown): boolean => {
|
|
77
|
+
const timeoutManager = getTimeoutManager();
|
|
78
|
+
const tm = (timeoutManager ?? {}) as { isTimeoutError?: (value: unknown) => boolean };
|
|
79
|
+
if (typeof tm.isTimeoutError === 'function') {
|
|
80
|
+
return tm.isTimeoutError(error);
|
|
81
|
+
}
|
|
82
|
+
if (error instanceof Error) {
|
|
83
|
+
return error.message.toLowerCase().includes('timed out');
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const normalizeAttempts = (value: unknown): number => {
|
|
89
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
|
|
90
|
+
return Math.floor(value);
|
|
91
|
+
}
|
|
92
|
+
return 0;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const getAttemptsFromMessage = <TPayload>(message: QueueMessage<TPayload>): number => {
|
|
96
|
+
const payloadAttempts =
|
|
97
|
+
typeof message.payload === 'object' && message.payload !== null
|
|
98
|
+
? normalizeAttempts((message.payload as Record<string, unknown>)['attempts'])
|
|
99
|
+
: 0;
|
|
100
|
+
const messageAttempts = normalizeAttempts(
|
|
101
|
+
(message as QueueMessage<TPayload> & { attempts?: number }).attempts
|
|
102
|
+
);
|
|
103
|
+
return Math.max(payloadAttempts, messageAttempts);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const getRetryDelayMs = (nextAttempts: number): number => {
|
|
107
|
+
const exponentialDelay = Math.min(
|
|
108
|
+
RETRY_BASE_DELAY_MS * 2 ** Math.max(0, nextAttempts - 1),
|
|
109
|
+
RETRY_MAX_DELAY_MS
|
|
110
|
+
);
|
|
111
|
+
const jitterMs = Math.floor(Math.random() * 250); // NOSONAR
|
|
112
|
+
return exponentialDelay + jitterMs;
|
|
113
|
+
};
|
|
3
114
|
|
|
4
115
|
type QueueWorker = {
|
|
5
116
|
processOne: (queueName?: string, driverName?: string) => Promise<boolean>;
|
|
@@ -36,58 +147,332 @@ const buildBaseLogFields = <TPayload>(
|
|
|
36
147
|
};
|
|
37
148
|
};
|
|
38
149
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
150
|
+
type TrackerApi = {
|
|
151
|
+
started?: (input: {
|
|
152
|
+
queueName: string;
|
|
153
|
+
jobId: string;
|
|
154
|
+
attempts: number;
|
|
155
|
+
timeoutMs?: number;
|
|
156
|
+
workerName?: string;
|
|
157
|
+
workerInstanceId?: string;
|
|
158
|
+
}) => Promise<void>;
|
|
159
|
+
heartbeat?: (input: {
|
|
160
|
+
queueName: string;
|
|
161
|
+
jobId: string;
|
|
162
|
+
workerInstanceId?: string;
|
|
163
|
+
}) => Promise<void>;
|
|
164
|
+
completed?: (input: {
|
|
165
|
+
queueName: string;
|
|
166
|
+
jobId: string;
|
|
167
|
+
processingTimeMs?: number;
|
|
168
|
+
}) => Promise<void>;
|
|
169
|
+
timedOut?: (input: {
|
|
170
|
+
queueName: string;
|
|
171
|
+
jobId: string;
|
|
172
|
+
reason?: string;
|
|
173
|
+
error?: unknown;
|
|
174
|
+
}) => Promise<void>;
|
|
175
|
+
failed?: (input: {
|
|
176
|
+
queueName: string;
|
|
177
|
+
jobId: string;
|
|
178
|
+
attempts?: number;
|
|
179
|
+
isFinal?: boolean;
|
|
180
|
+
retryAt?: string;
|
|
181
|
+
error?: unknown;
|
|
182
|
+
}) => Promise<void>;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
type HeartbeatStoreApi = {
|
|
186
|
+
heartbeat?: (input: {
|
|
187
|
+
queueName: string;
|
|
188
|
+
jobId: string;
|
|
189
|
+
workerInstanceId?: string;
|
|
190
|
+
intervalMs?: number;
|
|
191
|
+
}) => Promise<void>;
|
|
192
|
+
remove?: (queueName: string, jobId: string) => Promise<void>;
|
|
193
|
+
};
|
|
45
194
|
|
|
46
|
-
|
|
195
|
+
const getWorkerInstanceId = (): string | undefined => {
|
|
196
|
+
return typeof (Env as Record<string, unknown>)['WORKER_INSTANCE_ID'] === 'string'
|
|
197
|
+
? String((Env as Record<string, unknown>)['WORKER_INSTANCE_ID'])
|
|
198
|
+
: undefined;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const getTrackerApi = (): TrackerApi => {
|
|
202
|
+
return ((getJobStateTracker() ?? {}) as TrackerApi) ?? {};
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const getHeartbeatStoreApi = (): HeartbeatStoreApi => {
|
|
206
|
+
return ((getJobHeartbeatStore() ?? {}) as HeartbeatStoreApi) ?? {};
|
|
207
|
+
};
|
|
47
208
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
209
|
+
const removeHeartbeatIfSupported = async (queueName: string, jobId: string): Promise<void> => {
|
|
210
|
+
const heartbeatStore = getHeartbeatStoreApi();
|
|
211
|
+
if (typeof heartbeatStore.remove === 'function') {
|
|
212
|
+
await heartbeatStore.remove(queueName, jobId);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
52
215
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
216
|
+
const scheduleHeartbeatLoop = (
|
|
217
|
+
trackerApi: TrackerApi,
|
|
218
|
+
queueName: string,
|
|
219
|
+
jobId: string,
|
|
220
|
+
workerInstanceId: string | undefined,
|
|
221
|
+
heartbeatIntervalMs: number
|
|
222
|
+
): ReturnType<typeof setInterval> => {
|
|
223
|
+
return setInterval(() => {
|
|
224
|
+
if (typeof trackerApi.heartbeat === 'function') {
|
|
225
|
+
void trackerApi.heartbeat({
|
|
226
|
+
queueName,
|
|
227
|
+
jobId,
|
|
228
|
+
workerInstanceId,
|
|
57
229
|
});
|
|
58
|
-
// Re-queue original payload
|
|
59
|
-
await Queue.enqueue(queueName, message.payload as BullMQPayload, driverName);
|
|
60
|
-
await Queue.ack(queueName, message.id, driverName);
|
|
61
|
-
return false;
|
|
62
230
|
}
|
|
63
231
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const attempts = (message as QueueMessage<TPayload> & { attempts?: number }).attempts ?? 0;
|
|
72
|
-
|
|
73
|
-
Logger.error(`Failed to process ${options.kindLabel}`, {
|
|
74
|
-
...baseLogFields,
|
|
75
|
-
error,
|
|
76
|
-
attempts,
|
|
232
|
+
const heartbeatStore = getHeartbeatStoreApi();
|
|
233
|
+
if (typeof heartbeatStore.heartbeat === 'function') {
|
|
234
|
+
void heartbeatStore.heartbeat({
|
|
235
|
+
queueName,
|
|
236
|
+
jobId,
|
|
237
|
+
workerInstanceId,
|
|
238
|
+
intervalMs: heartbeatIntervalMs,
|
|
77
239
|
});
|
|
240
|
+
}
|
|
241
|
+
}, heartbeatIntervalMs);
|
|
242
|
+
};
|
|
78
243
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
244
|
+
const checkAndRequeueIfNotDue = async <TPayload>(
|
|
245
|
+
options: CreateQueueWorkerOptions<TPayload>,
|
|
246
|
+
queueName: string,
|
|
247
|
+
driverName: string | undefined,
|
|
248
|
+
message: QueueMessage<TPayload>,
|
|
249
|
+
baseLogFields: Record<string, unknown>
|
|
250
|
+
): Promise<boolean> => {
|
|
251
|
+
const payload = message.payload as Record<string, unknown> & { timestamp?: number };
|
|
252
|
+
const rawTimestamp = 'timestamp' in payload ? payload['timestamp'] : 0;
|
|
253
|
+
const timestamp = typeof rawTimestamp === 'number' ? rawTimestamp : 0;
|
|
86
254
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
255
|
+
if (timestamp <= Date.now()) return false;
|
|
256
|
+
|
|
257
|
+
Logger.info(`${options.kindLabel} not due yet, re-queueing`, {
|
|
258
|
+
...baseLogFields,
|
|
259
|
+
dueAt: new Date(timestamp).toISOString(),
|
|
260
|
+
});
|
|
261
|
+
await Queue.enqueue(queueName, message.payload as BullMQPayload, driverName);
|
|
262
|
+
await Queue.ack(queueName, message.id, driverName);
|
|
263
|
+
return true;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const onProcessSuccess = async <TPayload>(input: {
|
|
267
|
+
options: CreateQueueWorkerOptions<TPayload>;
|
|
268
|
+
trackerApi: TrackerApi;
|
|
269
|
+
queueName: string;
|
|
270
|
+
driverName?: string;
|
|
271
|
+
message: QueueMessage<TPayload>;
|
|
272
|
+
startedAtMs: number;
|
|
273
|
+
baseLogFields: Record<string, unknown>;
|
|
274
|
+
}): Promise<boolean> => {
|
|
275
|
+
await Queue.ack(input.queueName, input.message.id, input.driverName);
|
|
276
|
+
|
|
277
|
+
if (typeof input.trackerApi.completed === 'function') {
|
|
278
|
+
await input.trackerApi.completed({
|
|
279
|
+
queueName: input.queueName,
|
|
280
|
+
jobId: input.message.id,
|
|
281
|
+
processingTimeMs: Date.now() - input.startedAtMs,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
await removeHeartbeatIfSupported(input.queueName, input.message.id);
|
|
286
|
+
Logger.info(`${input.options.kindLabel} processed successfully`, input.baseLogFields);
|
|
287
|
+
return true;
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const onProcessFailure = async <TPayload>(input: {
|
|
291
|
+
options: CreateQueueWorkerOptions<TPayload>;
|
|
292
|
+
trackerApi: TrackerApi;
|
|
293
|
+
queueName: string;
|
|
294
|
+
driverName?: string;
|
|
295
|
+
message: QueueMessage<TPayload>;
|
|
296
|
+
baseLogFields: Record<string, unknown>;
|
|
297
|
+
error: unknown;
|
|
298
|
+
}): Promise<boolean> => {
|
|
299
|
+
const attempts = getAttemptsFromMessage(input.message);
|
|
300
|
+
const nextAttempts = attempts + 1;
|
|
301
|
+
const isFinal = nextAttempts >= input.options.maxAttempts;
|
|
302
|
+
let retryAt: string | undefined;
|
|
303
|
+
|
|
304
|
+
Logger.error(`Failed to process ${input.options.kindLabel}`, {
|
|
305
|
+
...input.baseLogFields,
|
|
306
|
+
error: input.error,
|
|
307
|
+
attempts: nextAttempts,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
if (isTimeoutError(input.error) && typeof input.trackerApi.timedOut === 'function') {
|
|
311
|
+
await input.trackerApi.timedOut({
|
|
312
|
+
queueName: input.queueName,
|
|
313
|
+
jobId: input.message.id,
|
|
314
|
+
reason: `Worker processing exceeded timeout for ${input.options.kindLabel}`,
|
|
315
|
+
error: input.error,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (nextAttempts < input.options.maxAttempts) {
|
|
320
|
+
const retryDelayMs = getRetryDelayMs(nextAttempts);
|
|
321
|
+
retryAt = new Date(Date.now() + retryDelayMs).toISOString();
|
|
322
|
+
const currentPayload =
|
|
323
|
+
typeof input.message.payload === 'object' && input.message.payload !== null
|
|
324
|
+
? (input.message.payload as Record<string, unknown>)
|
|
325
|
+
: ({ payload: input.message.payload } as Record<string, unknown>);
|
|
326
|
+
|
|
327
|
+
const payloadForRetry: BullMQPayload = {
|
|
328
|
+
...currentPayload,
|
|
329
|
+
attempts: nextAttempts,
|
|
330
|
+
timestamp: Date.now() + retryDelayMs,
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
await Queue.enqueue(input.queueName, payloadForRetry, input.driverName);
|
|
334
|
+
Logger.info(`${input.options.kindLabel} re-queued for retry`, {
|
|
335
|
+
...input.baseLogFields,
|
|
336
|
+
attempts: nextAttempts,
|
|
337
|
+
retryDelayMs,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
await Queue.ack(input.queueName, input.message.id, input.driverName);
|
|
342
|
+
await removeHeartbeatIfSupported(input.queueName, input.message.id);
|
|
343
|
+
|
|
344
|
+
if (typeof input.trackerApi.failed === 'function') {
|
|
345
|
+
await input.trackerApi.failed({
|
|
346
|
+
queueName: input.queueName,
|
|
347
|
+
jobId: input.message.id,
|
|
348
|
+
attempts: nextAttempts,
|
|
349
|
+
isFinal,
|
|
350
|
+
retryAt,
|
|
351
|
+
error: input.error,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return true;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const startTrackingAndHeartbeat = async <TPayload>(input: {
|
|
359
|
+
options: CreateQueueWorkerOptions<TPayload>;
|
|
360
|
+
trackerApi: TrackerApi;
|
|
361
|
+
queueName: string;
|
|
362
|
+
message: QueueMessage<TPayload>;
|
|
363
|
+
}): Promise<{ startedAtMs: number; heartbeatTimer?: ReturnType<typeof setInterval> }> => {
|
|
364
|
+
const startedAtMs = Date.now();
|
|
365
|
+
const timeoutMs = resolveQueueJobTimeoutMs();
|
|
366
|
+
const heartbeatIntervalMs = Math.max(1000, getEnvInt('JOB_HEARTBEAT_INTERVAL_MS', 10000));
|
|
367
|
+
const attempts = getAttemptsFromMessage(input.message);
|
|
368
|
+
const workerInstanceId = getWorkerInstanceId();
|
|
369
|
+
|
|
370
|
+
if (typeof input.trackerApi.started === 'function') {
|
|
371
|
+
await input.trackerApi.started({
|
|
372
|
+
queueName: input.queueName,
|
|
373
|
+
jobId: input.message.id,
|
|
374
|
+
attempts: attempts + 1,
|
|
375
|
+
timeoutMs,
|
|
376
|
+
workerName: input.options.kindLabel,
|
|
377
|
+
workerInstanceId,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const heartbeatStore = getHeartbeatStoreApi();
|
|
382
|
+
if (typeof heartbeatStore.heartbeat === 'function') {
|
|
383
|
+
await heartbeatStore.heartbeat({
|
|
384
|
+
queueName: input.queueName,
|
|
385
|
+
jobId: input.message.id,
|
|
386
|
+
workerInstanceId,
|
|
387
|
+
intervalMs: heartbeatIntervalMs,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const heartbeatTimer = scheduleHeartbeatLoop(
|
|
392
|
+
input.trackerApi,
|
|
393
|
+
input.queueName,
|
|
394
|
+
input.message.id,
|
|
395
|
+
workerInstanceId,
|
|
396
|
+
heartbeatIntervalMs
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
return { startedAtMs, heartbeatTimer };
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const processQueueMessage = async <TPayload>(
|
|
403
|
+
options: CreateQueueWorkerOptions<TPayload>,
|
|
404
|
+
queueName: string,
|
|
405
|
+
driverName?: string
|
|
406
|
+
): Promise<boolean> => {
|
|
407
|
+
const message = await Queue.dequeue<TPayload>(queueName, driverName);
|
|
408
|
+
if (!message) return false;
|
|
409
|
+
|
|
410
|
+
const baseLogFields = buildBaseLogFields(message, options.getLogFields);
|
|
411
|
+
|
|
412
|
+
const isRequeued = await checkAndRequeueIfNotDue(
|
|
413
|
+
options,
|
|
414
|
+
queueName,
|
|
415
|
+
driverName,
|
|
416
|
+
message,
|
|
417
|
+
baseLogFields
|
|
418
|
+
);
|
|
419
|
+
if (isRequeued) return false;
|
|
420
|
+
|
|
421
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
|
422
|
+
const trackerApi = getTrackerApi();
|
|
423
|
+
const timeoutMs = resolveQueueJobTimeoutMs();
|
|
424
|
+
let startedAtMs: number;
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
const tracking = await startTrackingAndHeartbeat({
|
|
428
|
+
options,
|
|
429
|
+
trackerApi,
|
|
430
|
+
queueName,
|
|
431
|
+
message,
|
|
432
|
+
});
|
|
433
|
+
startedAtMs = tracking.startedAtMs;
|
|
434
|
+
heartbeatTimer = tracking.heartbeatTimer;
|
|
435
|
+
|
|
436
|
+
Logger.info(`Processing queued ${options.kindLabel}`, baseLogFields);
|
|
437
|
+
await runWithTimeout(
|
|
438
|
+
async () => {
|
|
439
|
+
await options.handle(message.payload);
|
|
440
|
+
},
|
|
441
|
+
timeoutMs,
|
|
442
|
+
`${options.kindLabel}:${queueName}:${message.id}`
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
return onProcessSuccess({
|
|
446
|
+
options,
|
|
447
|
+
trackerApi,
|
|
448
|
+
queueName,
|
|
449
|
+
driverName,
|
|
450
|
+
message,
|
|
451
|
+
startedAtMs,
|
|
452
|
+
baseLogFields,
|
|
453
|
+
});
|
|
454
|
+
} catch (error) {
|
|
455
|
+
return onProcessFailure({
|
|
456
|
+
options,
|
|
457
|
+
trackerApi,
|
|
458
|
+
queueName,
|
|
459
|
+
driverName,
|
|
460
|
+
message,
|
|
461
|
+
baseLogFields,
|
|
462
|
+
error,
|
|
463
|
+
});
|
|
464
|
+
} finally {
|
|
465
|
+
if (heartbeatTimer !== undefined) {
|
|
466
|
+
clearInterval(heartbeatTimer);
|
|
90
467
|
}
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const createProcessOne = <TPayload>(
|
|
472
|
+
options: CreateQueueWorkerOptions<TPayload>
|
|
473
|
+
): ((queueName?: string, driverName?: string) => Promise<boolean>) => {
|
|
474
|
+
return async (queueName = options.defaultQueueName, driverName?: string): Promise<boolean> => {
|
|
475
|
+
return processQueueMessage(options, queueName, driverName);
|
|
91
476
|
};
|
|
92
477
|
};
|
|
93
478
|
|
|
@@ -552,7 +552,14 @@ async function getRedisQueueData(): Promise<QueueData> {
|
|
|
552
552
|
throw ErrorFactory.createConfigError('Redis driver not configured');
|
|
553
553
|
}
|
|
554
554
|
|
|
555
|
-
const monitor = QueueMonitor.create({
|
|
555
|
+
const monitor = QueueMonitor.create({
|
|
556
|
+
redis: {
|
|
557
|
+
host: redisConfig.host || 'localhost',
|
|
558
|
+
port: redisConfig.port || 6379,
|
|
559
|
+
db: redisConfig.database || 1,
|
|
560
|
+
password: redisConfig.password,
|
|
561
|
+
},
|
|
562
|
+
});
|
|
556
563
|
const snapshot = await monitor.getSnapshot();
|
|
557
564
|
|
|
558
565
|
let totalJobs = 0;
|
|
@@ -4,7 +4,14 @@
|
|
|
4
4
|
* HTTP handlers for worker management API
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
Cloudflare,
|
|
9
|
+
Env,
|
|
10
|
+
Logger,
|
|
11
|
+
getValidatedBody,
|
|
12
|
+
type IRequest,
|
|
13
|
+
type IResponse,
|
|
14
|
+
} from '@zintrust/core';
|
|
8
15
|
import type { Job } from 'bullmq';
|
|
9
16
|
import { CanaryController } from '../CanaryController';
|
|
10
17
|
import { HealthMonitor } from '../HealthMonitor';
|
|
@@ -34,42 +41,52 @@ const getBody = (req: IRequest): Record<string, unknown> => {
|
|
|
34
41
|
|
|
35
42
|
// ==================== Core Worker Operations ====================
|
|
36
43
|
|
|
37
|
-
|
|
38
|
-
* Create a new worker instance
|
|
39
|
-
* @param req.body.name - Worker name (required)
|
|
40
|
-
* @param req.body.queueName - Queue name (required)
|
|
41
|
-
* @param req.body.processor - Job processor function (required; internal only)
|
|
42
|
-
* @param req.body.version - Worker version (optional)
|
|
43
|
-
* @param req.body.options - BullMQ worker options (optional)
|
|
44
|
-
* @param req.body.infrastructure - Infrastructure config (optional)
|
|
45
|
-
* @param req.body.features - Feature flags (optional)
|
|
46
|
-
* @param req.body.datacenter - Datacenter placement config (optional)
|
|
47
|
-
* @returns Success response with worker name
|
|
48
|
-
*/
|
|
49
|
-
async function create(req: IRequest, res: IResponse): Promise<void> {
|
|
50
|
-
Logger.info('WorkerController.create called');
|
|
51
|
-
try {
|
|
52
|
-
const body = req.data() as unknown as WorkerFactoryConfig;
|
|
44
|
+
const isCloudflareEnv = (): boolean => Cloudflare.getWorkersEnv() !== null;
|
|
53
45
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
46
|
+
const validateCreatePayload = (body: WorkerFactoryConfig, res: IResponse): boolean => {
|
|
47
|
+
if (!body.name || !body.queueName || !body.processor || !body.version) {
|
|
48
|
+
res.setStatus(400).json({
|
|
49
|
+
error: 'Missing required fields',
|
|
50
|
+
message: 'name, queueName, processor, and version are required',
|
|
51
|
+
code: 'MISSING_REQUIRED_FIELDS',
|
|
52
|
+
});
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
return true;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const respondIfWorkerExists = async (
|
|
59
|
+
name: string,
|
|
60
|
+
persistenceOverride: ReturnType<typeof resolvePersistenceOverride>,
|
|
61
|
+
res: IResponse
|
|
62
|
+
): Promise<boolean> => {
|
|
63
|
+
const existing = await WorkerFactory.getPersisted(name, persistenceOverride);
|
|
64
|
+
if (!existing) return false;
|
|
65
|
+
|
|
66
|
+
res.status(409).json({
|
|
67
|
+
ok: false,
|
|
68
|
+
error: `Worker ${name} already exists`,
|
|
69
|
+
code: 'WORKER_EXISTS',
|
|
70
|
+
worker: existing,
|
|
71
|
+
});
|
|
72
|
+
return true;
|
|
73
|
+
};
|
|
62
74
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
75
|
+
const resolveCreateProcessor = async (
|
|
76
|
+
body: WorkerFactoryConfig,
|
|
77
|
+
res: IResponse
|
|
78
|
+
): Promise<{ processor: (job: Job) => Promise<unknown>; processorSpec?: string } | null> => {
|
|
79
|
+
const rawProcessor = body.processor;
|
|
80
|
+
let processor = rawProcessor as (job: Job) => Promise<unknown>;
|
|
81
|
+
let processorSpec: string | undefined;
|
|
66
82
|
|
|
83
|
+
if (!isCloudflareEnv()) {
|
|
67
84
|
if (typeof rawProcessor === 'string') {
|
|
68
85
|
processorSpec = rawProcessor;
|
|
69
86
|
const resolved = await WorkerFactory.resolveProcessorSpec(rawProcessor);
|
|
70
87
|
if (!resolved) {
|
|
71
88
|
res.setStatus(400).json({ error: 'Processor spec could not be resolved' });
|
|
72
|
-
return;
|
|
89
|
+
return null;
|
|
73
90
|
}
|
|
74
91
|
processor = resolved;
|
|
75
92
|
} else {
|
|
@@ -78,23 +95,81 @@ async function create(req: IRequest, res: IResponse): Promise<void> {
|
|
|
78
95
|
|
|
79
96
|
if (typeof processor !== 'function') {
|
|
80
97
|
res.setStatus(400).json({ error: 'Processor must be a function or resolvable path' });
|
|
81
|
-
return;
|
|
98
|
+
return null;
|
|
82
99
|
}
|
|
83
100
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
processor,
|
|
87
|
-
processorSpec,
|
|
88
|
-
};
|
|
101
|
+
return { processor, processorSpec };
|
|
102
|
+
}
|
|
89
103
|
|
|
90
|
-
|
|
104
|
+
// Cloudflare environment: treat string as spec, otherwise accept as-is
|
|
105
|
+
if (typeof rawProcessor === 'string') {
|
|
106
|
+
processorSpec = rawProcessor;
|
|
107
|
+
processor = async () => {};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { processor, processorSpec };
|
|
111
|
+
};
|
|
91
112
|
|
|
113
|
+
const finalizeWorkerCreate = async (config: WorkerFactoryConfig, res: IResponse): Promise<void> => {
|
|
114
|
+
const globalAutoStart = Env.getBool('WORKER_AUTO_START', false);
|
|
115
|
+
const workerAutoStart = config.autoStart ?? globalAutoStart;
|
|
116
|
+
const isCloudflare = isCloudflareEnv();
|
|
117
|
+
|
|
118
|
+
if (!isCloudflare && globalAutoStart && workerAutoStart) {
|
|
119
|
+
await WorkerFactory.create(config);
|
|
92
120
|
res.json({
|
|
93
121
|
ok: true,
|
|
94
122
|
workerName: config.name,
|
|
95
123
|
status: 'creating',
|
|
96
124
|
message: 'Worker creation started. Check status endpoint for progress.',
|
|
97
125
|
});
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await WorkerFactory.register(config);
|
|
130
|
+
res.json({
|
|
131
|
+
ok: true,
|
|
132
|
+
workerName: config.name,
|
|
133
|
+
status: 'registered',
|
|
134
|
+
message: isCloudflare
|
|
135
|
+
? 'Worker registered. Cloudflare environment detected; worker will be picked up by external processor.'
|
|
136
|
+
: 'Worker registered successfully.',
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Create a new worker instance
|
|
142
|
+
* @param req.body.name - Worker name (required)
|
|
143
|
+
* @param req.body.queueName - Queue name (required)
|
|
144
|
+
* @param req.body.processor - Job processor function (required; internal only)
|
|
145
|
+
* @param req.body.version - Worker version (optional)
|
|
146
|
+
* @param req.body.options - BullMQ worker options (optional)
|
|
147
|
+
* @param req.body.infrastructure - Infrastructure config (optional)
|
|
148
|
+
* @param req.body.features - Feature flags (optional)
|
|
149
|
+
* @param req.body.datacenter - Datacenter placement config (optional)
|
|
150
|
+
* @returns Success response with worker name
|
|
151
|
+
*/
|
|
152
|
+
async function create(req: IRequest, res: IResponse): Promise<void> {
|
|
153
|
+
Logger.info('WorkerController.create called');
|
|
154
|
+
try {
|
|
155
|
+
const body = req.data() as unknown as WorkerFactoryConfig;
|
|
156
|
+
if (!validateCreatePayload(body, res)) return;
|
|
157
|
+
|
|
158
|
+
const name = body.name;
|
|
159
|
+
const persistenceOverride = resolvePersistenceOverride(req);
|
|
160
|
+
const exists = await respondIfWorkerExists(name, persistenceOverride, res);
|
|
161
|
+
if (exists) return;
|
|
162
|
+
|
|
163
|
+
const resolvedProcessor = await resolveCreateProcessor(body, res);
|
|
164
|
+
if (!resolvedProcessor) return;
|
|
165
|
+
|
|
166
|
+
const config = {
|
|
167
|
+
...(body as WorkerFactoryConfig),
|
|
168
|
+
processor: resolvedProcessor.processor,
|
|
169
|
+
processorSpec: resolvedProcessor.processorSpec,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
await finalizeWorkerCreate(config, res);
|
|
98
173
|
} catch (error) {
|
|
99
174
|
Logger.error('WorkerController.create failed', error);
|
|
100
175
|
res.setStatus(500).json({ error: (error as Error).message });
|