forked from ByteVeda/flexiq
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathworker.ts
More file actions
475 lines (449 loc) · 17.7 KB
/
Copy pathworker.ts
File metadata and controls
475 lines (449 loc) · 17.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
import {
applyQueueOverrides,
applyTaskOverrides,
MiddlewareDisableStore,
middlewareKey,
OverridesStore,
} from "./dashboard/stores";
import { type Emitter, OUTCOME_KIND_EVENTS, type OutcomeEvent } from "./events";
import type { Middleware } from "./middleware";
import type {
JsOutcome,
NativeQueue,
NativeWorker,
WorkerOptions as NativeWorkerOptions,
QueueConfigInput,
TaskConfigInput,
} from "./native";
import type { ResourceRuntime } from "./resources";
import { deserializeCall, type PayloadCodec, type Serializer } from "./serializers";
import { createTaskCallback } from "./task-callback";
import type {
AnyHandler,
QueueLimits,
RegisteredTask,
TaskOptions,
WorkerRunOptions,
} from "./types";
import { createLogger } from "./utils";
import type { WorkflowTracker } from "./workflows";
const log = createLogger("worker");
/** How often the worker heartbeats (with resource health) to storage. */
const HEARTBEAT_INTERVAL_MS = 5000;
/** Outcome kind -> the middleware hook it triggers (events come from
* {@link OUTCOME_KIND_EVENTS}). */
const OUTCOME_HOOKS: Record<keyof typeof OUTCOME_KIND_EVENTS, keyof Middleware> = {
success: "onCompleted",
retry: "onRetry",
dead: "onDeadLetter",
cancelled: "onCancel",
};
/** Inputs assembled by {@link Queue.runWorker}. */
export interface WorkerStartParams {
tasks: ReadonlyMap<string, RegisteredTask>;
queueLimits: ReadonlyMap<string, QueueLimits>;
serializer: Serializer;
/** Named codec registry for per-task payload decode (see `TaskOptions.codecs`). */
codecs?: ReadonlyMap<string, PayloadCodec>;
middleware: readonly Middleware[];
emitter: Emitter;
resources: ResourceRuntime;
/** The queue's shared tracker (undefined on addons without workflows). */
workflowTracker?: WorkflowTracker;
/** Flushes the queue's pending topic subscriptions under this worker's id. */
declareSubscriptions?: (workerId: string) => Promise<void>;
/** Managed log-topic consumers to drive with a poll loop for the worker's life. */
logConsumers?: readonly PendingLogConsumer[];
/** Fired once on stop so the queue can drop this worker from its live set. @internal */
onStopped?: () => void;
run?: WorkerRunOptions;
}
/** A managed log-topic consumer recorded by `Queue.logConsumer`. */
export interface PendingLogConsumer {
topic: string;
name: string;
handler: AnyHandler;
pollIntervalMs: number;
batchSize: number;
onError: "retry" | "skip";
}
/** A running worker. Hold it for the worker's lifetime; call {@link Worker.stop}. */
export class Worker {
/** Memoized teardown, set by the first `stop()` — keeps it idempotent. */
private stopped?: Promise<void>;
private constructor(
private readonly native: NativeWorker,
private readonly queue: NativeQueue,
private readonly resources: ResourceRuntime,
private readonly heartbeat: ReturnType<typeof setInterval>,
private readonly consumerStops: readonly (() => void)[],
private readonly emitter: Emitter,
/** Shared with the heartbeat closure so a beat resolving after stop() stays silent. */
private readonly lifecycle: { stopped: boolean },
private readonly onStopped?: () => void,
) {}
/**
* Start a worker from a queue's task registry. Use {@link Queue.runWorker}
* rather than calling this directly.
*
* @internal
*/
static start(queue: NativeQueue, params: WorkerStartParams): Worker {
const { tasks, queueLimits, serializer, codecs, middleware, emitter, resources, run } = params;
// Dashboard-tunable state: per-task middleware disables are re-read on
// every invocation (live toggles); task/queue overrides apply here, at
// worker startup.
const disables = new MiddlewareDisableStore(queue);
// The job id is unused here: a worker has storage, so it reads the live
// toggle list by task name rather than taking one off the dispatch.
const middlewareFor = (taskName: string): readonly Middleware[] => {
const disabled = disables.getFor(taskName);
if (disabled.length === 0) {
return middleware;
}
return middleware.filter((mw, index) => !disabled.includes(middlewareKey(mw, index)));
};
// Advance workflow runs as node-jobs settle, unless disabled or unsupported.
const tracker = (run?.advanceWorkflows ?? true) ? (params.workflowTracker ?? null) : null;
const taskCallback = createTaskCallback({
tasks,
serializer,
codecs,
middlewareFor,
emitter,
resources,
queue,
});
const outcomeCallback = (outcome: JsOutcome): void => {
const kind = outcome.kind as keyof typeof OUTCOME_KIND_EVENTS;
const eventName = OUTCOME_KIND_EVENTS[kind];
if (!eventName) {
return;
}
const hookName = OUTCOME_HOOKS[kind];
const event: OutcomeEvent = {
jobId: outcome.jobId,
taskName: outcome.taskName,
queue: outcome.queue ?? undefined,
error: outcome.error ?? undefined,
retryCount: outcome.retryCount ?? undefined,
timedOut: outcome.timedOut ?? undefined,
durationMs: outcome.durationMs ?? undefined,
};
emitter.emit(eventName, event);
for (const mw of middlewareFor(outcome.taskName)) {
const hook = mw[hookName] as ((e: OutcomeEvent) => void) | undefined;
try {
// Promise.resolve captures async hooks' rejections too.
void Promise.resolve(hook?.(event)).catch((error) => {
log.debug(() => `${hookName} middleware hook rejected for ${outcome.jobId}`, error);
});
} catch (error) {
// outcome hook errors must not break the worker
log.debug(() => `${hookName} middleware hook threw for ${outcome.jobId}`, error);
}
}
tracker?.onOutcome(outcome);
};
const nativeOptions: NativeWorkerOptions = {
queues: run?.queues,
channelCapacity: run?.channelCapacity,
concurrency: run?.concurrency,
batchSize: run?.batchSize,
taskConfigs: applyTaskOverrides(
buildTaskConfigs(tasks),
tasks.keys(),
new OverridesStore(queue),
),
queueConfigs: applyQueueOverrides(buildQueueConfigs(queueLimits), new OverridesStore(queue)),
resources: resources.isEmpty ? undefined : resources.names,
mesh: run?.mesh,
retention: run?.retention,
pushDispatch: run?.pushDispatch,
};
const native = queue.runWorker(taskCallback, outcomeCallback, nativeOptions);
emitter.emit("worker.started", { workerId: native.id, queues: run?.queues });
// Lease the shared resource runtime only once the native worker actually
// started, so its worker-scoped values survive until the last worker on this
// queue stops (see ResourceRuntime). A failed start leaks no lease.
// The lease also starts the runtime's shared health checker (first lease
// only) — recreation of failing resources is per runtime, not per worker.
resources.acquireWorker();
// Register this worker's topic subscriptions (ephemeral ones under its id)
// now that the id exists. Registration is idempotent, so a failed flush is
// retried whole on every heartbeat tick until it succeeds — a silently
// missing subscription would drop deliveries for the worker's lifetime.
const flushSubscriptions = params.declareSubscriptions;
let subscriptionsDeclared = flushSubscriptions === undefined;
let declarationInFlight = false;
const declareSubscriptions = (): void => {
if (subscriptionsDeclared || declarationInFlight || !flushSubscriptions) {
return;
}
declarationInFlight = true;
void flushSubscriptions(native.id)
.then(() => {
subscriptionsDeclared = true;
})
.catch((error) => {
log.error(() => "subscription registration failed; retrying on next heartbeat", error);
})
.finally(() => {
declarationInFlight = false;
});
};
// Heartbeat with current resource health so inspection (and dead-worker
// reaping) see this worker as alive. Failures are logged, never thrown —
// the next beat retries. First beat goes out immediately.
let onlineReported = false;
const previousUnhealthy = new Set<string>();
const lifecycle = { stopped: false };
const sendHeartbeat = (): void => {
const snapshot = resources.healthSnapshot();
void queue
.workerHeartbeat(native.id, snapshot && JSON.stringify(snapshot))
.then((reapedWorkerIds) => {
// A beat that resolves after stop() must not emit lifecycle events
// out of order (clearInterval can't cancel an in-flight promise).
if (lifecycle.stopped) {
return;
}
// Online = the first heartbeat storage acknowledged, once.
if (!onlineReported) {
onlineReported = true;
emitter.emit("worker.online", { workerId: native.id });
}
// The heartbeat doubles as the dead-worker reaper: each reaped peer
// id is reported as that worker going offline.
for (const workerId of reapedWorkerIds) {
emitter.emit("worker.offline", { workerId });
}
})
.catch((error) => {
log.debug(() => "worker heartbeat failed", error);
});
// Report each resource's healthy → unhealthy transition exactly once.
const unhealthy = new Set(
Object.entries(snapshot ?? {})
.filter(([, state]) => state === "unhealthy")
.map(([name]) => name),
);
for (const resource of unhealthy) {
if (!previousUnhealthy.has(resource)) {
emitter.emit("worker.unhealthy", { workerId: native.id, resource });
}
}
previousUnhealthy.clear();
for (const resource of unhealthy) {
previousUnhealthy.add(resource);
}
// Same cadence, same reaper election: passing this worker's id gates the
// sweep so only the leader runs it. Per-tick failures are swallowed like
// the heartbeat's — the next beat retries.
void queue.reapEphemeralSubscriptions(native.id).catch((error) => {
log.debug(() => "ephemeral subscription reap failed", error);
});
declareSubscriptions();
};
sendHeartbeat();
const heartbeat = setInterval(sendHeartbeat, run?.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS);
heartbeat.unref();
// Managed log-topic consumers: one poll loop each, beside the heartbeat.
const consumerStops = startLogConsumers(queue, serializer, params.logConsumers ?? []);
return new Worker(
native,
queue,
resources,
heartbeat,
consumerStops,
emitter,
lifecycle,
params.onStopped,
);
}
/**
* Stop the worker; in-flight results drain before background tasks exit.
*
* Dispatch, the heartbeat and the log consumers halt synchronously, so
* ignoring the return value behaves exactly as a void `stop()` would. The
* returned promise resolves once worker-scoped resources have been disposed
* — await it when that matters (test teardown, graceful shutdown). It never
* rejects: teardown failures are logged, not thrown.
*
* Idempotent: later calls return the first teardown. Re-running it would
* release a second resource lease and tear down another worker's resources.
*/
stop(): Promise<void> {
if (!this.stopped) {
let settle!: () => void;
// Install the shared promise BEFORE teardown runs: `onStopped` and the
// `worker.stopped` listeners fire synchronously inside runStop(), and
// either may call stop() again — a reentrant call has to see this
// promise rather than start a second teardown.
this.stopped = new Promise<void>((resolve) => {
settle = resolve;
});
try {
void this.runStop().then(settle, settle);
} catch (error) {
log.debug(() => "worker stop failed", error);
settle();
}
}
return this.stopped;
}
private runStop(): Promise<void> {
this.lifecycle.stopped = true;
this.onStopped?.();
// One last sweep for orphaned ephemeral subscriptions before this worker's
// reap cadence goes away. Best effort — stopping must never throw.
void this.queue.reapEphemeralSubscriptions().catch((error) => {
log.debug(() => "final ephemeral subscription reap failed", error);
});
clearInterval(this.heartbeat);
for (const stop of this.consumerStops) {
stop();
}
this.native.stop();
this.emitter.emit("worker.stopped", { workerId: this.native.id });
// Dispose worker-scoped resources after the native worker quiesces (the
// teardown drains the runtime's health checker before touching caches).
// Best effort: lazy resources mean this is a no-op when none were built.
return this.resources.teardownWorker().catch((error) => {
log.debug(() => "worker-scope resource teardown failed", error);
});
}
}
/**
* Ask a task's `retryOn` predicate whether `error` is worth retrying. No
* predicate means retry, and so does one that throws — a broken classifier must
* not silently turn transient failures into dead letters.
*/
/** Start one poll loop per managed consumer; return their timers to clear on stop. */
function startLogConsumers(
queue: NativeQueue,
serializer: Serializer,
consumers: readonly PendingLogConsumer[],
): (() => void)[] {
const stops: (() => void)[] = [];
for (const consumer of consumers) {
let stopped = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const schedule = (delayMs: number): void => {
if (stopped) {
return;
}
timer = setTimeout(runOnce, delayMs);
timer.unref();
};
// Self-scheduling loop (not a fixed-cadence setInterval): after a batch that
// made progress, re-read immediately to drain a backlog; only wait the poll
// interval when caught up (empty) or backing off a retry poison.
const runOnce = (): void => {
void drainLogConsumerBatch(queue, serializer, consumer)
.then((outcome) => {
schedule(outcome === "drained" ? 0 : consumer.pollIntervalMs);
})
.catch((error) => {
log.error(() => `log consumer ${consumer.topic}/${consumer.name} poll failed`, error);
schedule(consumer.pollIntervalMs);
});
};
stops.push(() => {
stopped = true;
if (timer !== undefined) {
clearTimeout(timer);
}
});
runOnce(); // read immediately rather than waiting a full interval
}
return stops;
}
/** `empty` = nothing to read (wait the poll interval); `drained` = made progress,
* re-read immediately; `retry-backoff` = a retry-mode handler failure blocked the
* cursor, so wait one interval before re-reading rather than hot-looping. */
type DrainOutcome = "empty" | "drained" | "retry-backoff";
/** One poll: read a batch, invoke the handler per message, then advance the cursor.
* `retry` stops at the first failure and acks only the successes before it (and
* backs off); `skip` acks past a failure and keeps going. Payload decode runs
* inside the per-message guard so a bad payload obeys the same error policy. */
async function drainLogConsumerBatch(
queue: NativeQueue,
serializer: Serializer,
consumer: PendingLogConsumer,
): Promise<DrainOutcome> {
const messages = await queue.readTopicMessages(consumer.topic, consumer.name, consumer.batchSize);
if (messages.length === 0) {
return "empty";
}
let lastAcked: string | undefined;
let retryFailure = false;
for (const message of messages) {
try {
const args = deserializeCall(serializer, message.payload);
await consumer.handler(...args);
} catch (error) {
log.error(
() => `log consumer ${consumer.topic}/${consumer.name} handler failed on ${message.id}`,
error,
);
if (consumer.onError === "retry") {
retryFailure = true;
break;
}
}
lastAcked = message.id;
}
if (lastAcked !== undefined) {
await queue.ackTopicCursor(consumer.topic, consumer.name, lastAcked);
}
return retryFailure ? "retry-backoff" : "drained";
}
/** Collect per-task configs that actually set something. */
function buildTaskConfigs(tasks: ReadonlyMap<string, RegisteredTask>): TaskConfigInput[] {
const configs: TaskConfigInput[] = [];
for (const [name, task] of tasks) {
if (!task.options) {
continue;
}
const config = toTaskConfig(name, task.options);
if (setsSomething(config)) {
configs.push(config);
}
}
return configs;
}
function toTaskConfig(name: string, options: TaskOptions): TaskConfigInput {
return {
name,
maxRetries: options.maxRetries,
retryBaseDelayMs: options.retryBackoff?.baseMs,
retryMaxDelayMs: options.retryBackoff?.maxMs,
maxConcurrent: options.maxConcurrent,
maxInFlightPerTask: options.maxInFlightPerTask,
rateLimit: options.rateLimit,
retryBudget: options.retryBudget,
circuitBreaker: options.circuitBreaker,
};
}
/**
* Whether a task set any policy worth registering.
*
* Derived from the built config rather than a hand-listed set of option names:
* a list silently drops any option missing from it, so a task setting only the
* new option would never reach the scheduler — with no error, and invisible to
* type-checking. `name` is always present, so it can't stand in for a setting.
*/
function setsSomething({ name: _name, ...policy }: TaskConfigInput): boolean {
return Object.values(policy).some((value) => value !== undefined);
}
function buildQueueConfigs(limits: ReadonlyMap<string, QueueLimits>): QueueConfigInput[] {
return [...limits].map(([name, limit]) => ({
name,
maxConcurrent: limit.maxConcurrent,
rateLimit: limit.rateLimit,
codelTargetMs: limit.codel?.targetMs,
codelIntervalMs: limit.codel?.intervalMs,
dispatchOrder: limit.dispatchOrder,
}));
}