@blokjs/trigger-worker 0.6.17 → 0.6.19
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/WorkerTrigger.d.ts +27 -3
- package/dist/WorkerTrigger.js +168 -26
- package/dist/adapters/KafkaAdapter.d.ts +5 -0
- package/dist/adapters/KafkaAdapter.js +12 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/package.json +5 -4
- package/CHANGELOG.md +0 -22
- package/__tests__/integration/nats-adapter.real-nats.test.ts +0 -116
- package/__tests__/integration/pgboss-adapter.real-pg.test.ts +0 -164
- package/__tests__/integration/rabbitmq-adapter.real-rabbitmq.test.ts +0 -179
- package/__tests__/integration/sqs-adapter.real-sqs.test.ts +0 -228
- package/src/WorkerTrigger.test.ts +0 -540
- package/src/WorkerTrigger.ts +0 -784
- package/src/adapters/BullMQAdapter.ts +0 -296
- package/src/adapters/InMemoryAdapter.ts +0 -280
- package/src/adapters/KafkaAdapter.ts +0 -277
- package/src/adapters/NATSAdapter.ts +0 -454
- package/src/adapters/PgBossAdapter.ts +0 -293
- package/src/adapters/RabbitMQAdapter.ts +0 -285
- package/src/adapters/RedisStreamsAdapter.ts +0 -286
- package/src/adapters/SQSAdapter.ts +0 -306
- package/src/adapters/factory.test.ts +0 -89
- package/src/adapters/factory.ts +0 -111
- package/src/adapters/new-adapters.test.ts +0 -130
- package/src/index.ts +0 -94
- package/template/.env.example +0 -13
- package/template/package.json +0 -45
- package/template/src/Nodes.ts +0 -10
- package/template/src/Workflows.ts +0 -8
- package/template/src/index.ts +0 -41
- package/template/src/runner/WorkerServer.ts +0 -34
- package/template/src/runner/types/Workflows.ts +0 -7
- package/template/src/workflows/jobs/process-job.ts +0 -47
- package/template/tsconfig.json +0 -31
- package/template/vitest.config.ts +0 -39
- package/tsconfig.json +0 -32
package/src/WorkerTrigger.ts
DELETED
|
@@ -1,784 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* WorkerTrigger - Background job processing for Blok workflows
|
|
3
|
-
*
|
|
4
|
-
* Extends TriggerBase to support long-running background jobs:
|
|
5
|
-
* - Concurrency controls (max N concurrent jobs)
|
|
6
|
-
* - Retry logic with exponential backoff
|
|
7
|
-
* - Job timeouts
|
|
8
|
-
* - Job priority and delay scheduling
|
|
9
|
-
* - Dead letter queue support
|
|
10
|
-
*
|
|
11
|
-
* Pattern:
|
|
12
|
-
* 1. loadNodes() - Load available nodes into NodeMap
|
|
13
|
-
* 2. loadWorkflows() - Load workflows with worker triggers
|
|
14
|
-
* 3. listen() - Connect to job backend and start processing
|
|
15
|
-
* 4. For each job:
|
|
16
|
-
* - Create context with this.createContext()
|
|
17
|
-
* - Populate ctx.request with job data
|
|
18
|
-
* - Execute workflow via this.run(ctx)
|
|
19
|
-
* - Ack on success, retry or DLQ on failure
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { type HelperResponse, type WorkerTriggerOpts, tryParseDuration } from "@blokjs/helper";
|
|
23
|
-
import {
|
|
24
|
-
type BlokService,
|
|
25
|
-
ConcurrencyLimitError,
|
|
26
|
-
ConcurrencyMetrics,
|
|
27
|
-
DebounceCoordinator,
|
|
28
|
-
DefaultLogger,
|
|
29
|
-
DeferredDispatchSignal,
|
|
30
|
-
type GlobalOptions,
|
|
31
|
-
Janitor,
|
|
32
|
-
NodeMap,
|
|
33
|
-
QueueExpiredError,
|
|
34
|
-
RunTracker,
|
|
35
|
-
TriggerBase,
|
|
36
|
-
type TriggerResponse,
|
|
37
|
-
createConcurrencyBackend,
|
|
38
|
-
createDebounceBackend,
|
|
39
|
-
} from "@blokjs/runner";
|
|
40
|
-
import type { Context, RequestContext } from "@blokjs/shared";
|
|
41
|
-
import { type Span, SpanStatusCode, metrics, trace } from "@opentelemetry/api";
|
|
42
|
-
import { v4 as uuid } from "uuid";
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Job received from worker queue
|
|
46
|
-
*/
|
|
47
|
-
export interface WorkerJob {
|
|
48
|
-
/** Unique job ID */
|
|
49
|
-
id: string;
|
|
50
|
-
/** Job data payload */
|
|
51
|
-
data: unknown;
|
|
52
|
-
/** Job metadata headers */
|
|
53
|
-
headers: Record<string, string>;
|
|
54
|
-
/** Queue name this job belongs to */
|
|
55
|
-
queue: string;
|
|
56
|
-
/** Job priority (higher = more important) */
|
|
57
|
-
priority: number;
|
|
58
|
-
/** Number of attempts made so far */
|
|
59
|
-
attempts: number;
|
|
60
|
-
/** Maximum retry attempts */
|
|
61
|
-
maxRetries: number;
|
|
62
|
-
/** Timestamp when job was created */
|
|
63
|
-
createdAt: Date;
|
|
64
|
-
/** Delay before processing (ms) */
|
|
65
|
-
delay?: number;
|
|
66
|
-
/** Job timeout (ms) */
|
|
67
|
-
timeout?: number;
|
|
68
|
-
/** Original raw job from provider */
|
|
69
|
-
raw: unknown;
|
|
70
|
-
/** Mark job as completed */
|
|
71
|
-
complete: () => Promise<void>;
|
|
72
|
-
/** Mark job as failed (optionally requeue) */
|
|
73
|
-
fail: (error: Error, requeue?: boolean) => Promise<void>;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Worker adapter interface - implemented by each job backend
|
|
78
|
-
*/
|
|
79
|
-
export interface WorkerAdapter {
|
|
80
|
-
/** Provider name (e.g., "bullmq", "in-memory") */
|
|
81
|
-
readonly provider: string;
|
|
82
|
-
|
|
83
|
-
/** Connect to the job backend */
|
|
84
|
-
connect(): Promise<void>;
|
|
85
|
-
|
|
86
|
-
/** Disconnect from the job backend */
|
|
87
|
-
disconnect(): Promise<void>;
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Start processing jobs from a queue
|
|
91
|
-
* @param config Worker trigger configuration
|
|
92
|
-
* @param handler Callback for each job
|
|
93
|
-
*/
|
|
94
|
-
process(config: WorkerTriggerOpts, handler: (job: WorkerJob) => Promise<void>): Promise<void>;
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Add a job to a queue (for programmatic dispatching)
|
|
98
|
-
* @param queue Queue name
|
|
99
|
-
* @param data Job payload
|
|
100
|
-
* @param opts Job options
|
|
101
|
-
*/
|
|
102
|
-
addJob(
|
|
103
|
-
queue: string,
|
|
104
|
-
data: unknown,
|
|
105
|
-
opts?: {
|
|
106
|
-
priority?: number;
|
|
107
|
-
delay?: number;
|
|
108
|
-
retries?: number;
|
|
109
|
-
timeout?: number;
|
|
110
|
-
jobId?: string;
|
|
111
|
-
},
|
|
112
|
-
): Promise<string>;
|
|
113
|
-
|
|
114
|
-
/** Stop processing a specific queue */
|
|
115
|
-
stopProcessing(queue: string): Promise<void>;
|
|
116
|
-
|
|
117
|
-
/** Check if connected */
|
|
118
|
-
isConnected(): boolean;
|
|
119
|
-
|
|
120
|
-
/** Health check */
|
|
121
|
-
healthCheck(): Promise<boolean>;
|
|
122
|
-
|
|
123
|
-
/** Get queue stats */
|
|
124
|
-
getQueueStats(queue: string): Promise<WorkerQueueStats>;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Queue statistics
|
|
129
|
-
*/
|
|
130
|
-
export interface WorkerQueueStats {
|
|
131
|
-
/** Number of jobs waiting to be processed */
|
|
132
|
-
waiting: number;
|
|
133
|
-
/** Number of jobs currently being processed */
|
|
134
|
-
active: number;
|
|
135
|
-
/** Number of completed jobs */
|
|
136
|
-
completed: number;
|
|
137
|
-
/** Number of failed jobs */
|
|
138
|
-
failed: number;
|
|
139
|
-
/** Number of delayed jobs */
|
|
140
|
-
delayed: number;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Workflow model with worker trigger configuration
|
|
145
|
-
*/
|
|
146
|
-
interface WorkerWorkflowModel {
|
|
147
|
-
path: string;
|
|
148
|
-
config: {
|
|
149
|
-
name: string;
|
|
150
|
-
version: string;
|
|
151
|
-
trigger?: {
|
|
152
|
-
worker?: WorkerTriggerOpts;
|
|
153
|
-
[key: string]: unknown;
|
|
154
|
-
};
|
|
155
|
-
[key: string]: unknown;
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* WorkerTrigger - Abstract base class for worker-based triggers
|
|
161
|
-
*
|
|
162
|
-
* Provides background job processing with:
|
|
163
|
-
* - Configurable concurrency per queue
|
|
164
|
-
* - Automatic retries with exponential backoff
|
|
165
|
-
* - Job timeouts with automatic failure
|
|
166
|
-
* - Priority-based job ordering
|
|
167
|
-
* - Delayed job scheduling
|
|
168
|
-
* - Queue statistics and monitoring
|
|
169
|
-
*/
|
|
170
|
-
export abstract class WorkerTrigger extends TriggerBase {
|
|
171
|
-
protected nodeMap: GlobalOptions = {} as GlobalOptions;
|
|
172
|
-
protected readonly tracer = trace.getTracer(
|
|
173
|
-
process.env.PROJECT_NAME || "trigger-worker-workflow",
|
|
174
|
-
process.env.PROJECT_VERSION || "0.0.1",
|
|
175
|
-
);
|
|
176
|
-
protected readonly logger = new DefaultLogger();
|
|
177
|
-
/**
|
|
178
|
-
* v0.7 PR 5 — the "default" adapter, used when a workflow's
|
|
179
|
-
* `trigger.worker.provider` field is omitted AND the
|
|
180
|
-
* `BLOK_WORKER_ADAPTER` env var is unset. Subclasses MAY set this
|
|
181
|
-
* for back-compat with the pre-v0.7 single-adapter pattern
|
|
182
|
-
* (`class WorkerServer extends WorkerTrigger { protected adapter = new NATSWorkerAdapter() }`).
|
|
183
|
-
*
|
|
184
|
-
* When unset AND no per-workflow provider is specified, the factory
|
|
185
|
-
* falls back to `in-memory`. The factory pool (`adapters/factory.ts`)
|
|
186
|
-
* tracks one connected adapter per provider so multiple workflows
|
|
187
|
-
* with the same provider share a single broker connection.
|
|
188
|
-
*/
|
|
189
|
-
protected adapter?: WorkerAdapter;
|
|
190
|
-
|
|
191
|
-
/** Active queues being processed */
|
|
192
|
-
protected activeQueues: Set<string> = new Set();
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* v0.7 PR 5 — adapter pool, keyed by provider name. Populated lazily
|
|
196
|
-
* inside `listen()` as workflows are matched to providers. Each
|
|
197
|
-
* adapter is connected once and reused across workflows that share
|
|
198
|
-
* its provider. Drained in `stop()`.
|
|
199
|
-
*/
|
|
200
|
-
protected adapterPool: Map<string, WorkerAdapter> = new Map();
|
|
201
|
-
|
|
202
|
-
// Subclasses provide these
|
|
203
|
-
protected abstract nodes: Record<string, BlokService<unknown>>;
|
|
204
|
-
protected abstract workflows: Record<string, HelperResponse>;
|
|
205
|
-
|
|
206
|
-
// Constructor removed in v0.6.3 — pre-fix it called `loadNodes()` +
|
|
207
|
-
// `loadWorkflows()`, but subclasses use class-field assignments for
|
|
208
|
-
// `nodes` / `workflows` (the canonical TypeScript pattern). Those
|
|
209
|
-
// fields run AFTER super(), so accessing `this.nodes` from the parent
|
|
210
|
-
// constructor read `undefined` and crashed with
|
|
211
|
-
// `TypeError: undefined is not an object (Object.keys(this.nodes))`.
|
|
212
|
-
// The registry init now happens at the start of `listen()`, after
|
|
213
|
-
// the subclass's fields are initialized.
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Load nodes into the node map
|
|
217
|
-
*/
|
|
218
|
-
loadNodes(): void {
|
|
219
|
-
this.nodeMap.nodes = new NodeMap();
|
|
220
|
-
const nodeKeys = Object.keys(this.nodes);
|
|
221
|
-
for (const key of nodeKeys) {
|
|
222
|
-
this.nodeMap.nodes.addNode(key, this.nodes[key]);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Load workflows into the workflow map
|
|
228
|
-
*/
|
|
229
|
-
loadWorkflows(): void {
|
|
230
|
-
this.nodeMap.workflows = this.workflows;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Start the worker processor - main entry point
|
|
235
|
-
*/
|
|
236
|
-
async listen(): Promise<number> {
|
|
237
|
-
const startTime = this.startCounter();
|
|
238
|
-
|
|
239
|
-
// Populate the trigger's node + workflow registries from the
|
|
240
|
-
// subclass's `nodes` / `workflows` fields. v0.6.3 fix — pre-fix
|
|
241
|
-
// these calls lived in the constructor and crashed because class
|
|
242
|
-
// fields haven't run yet at super-constructor time. See the comment
|
|
243
|
-
// where the old constructor used to live for the full reason.
|
|
244
|
-
this.loadNodes();
|
|
245
|
-
this.loadWorkflows();
|
|
246
|
-
|
|
247
|
-
try {
|
|
248
|
-
// Tier 2 #6 follow-up · install the cross-process concurrency
|
|
249
|
-
// backend (NATS KV) when the operator opted in via
|
|
250
|
-
// `BLOK_CONCURRENCY_BACKEND=nats-kv`. Default null preserves the
|
|
251
|
-
// existing in-process behavior.
|
|
252
|
-
//
|
|
253
|
-
// PR 3 D1 — record install attempts via OTel counter.
|
|
254
|
-
try {
|
|
255
|
-
const backend = createConcurrencyBackend();
|
|
256
|
-
if (backend) {
|
|
257
|
-
await backend.connect();
|
|
258
|
-
RunTracker.getInstance().setConcurrencyBackend(backend);
|
|
259
|
-
ConcurrencyMetrics.getInstance().recordBackendInstall({
|
|
260
|
-
backend: backend.name,
|
|
261
|
-
status: "success",
|
|
262
|
-
});
|
|
263
|
-
this.logger.log(`[concurrency] backend installed: ${backend.name}`);
|
|
264
|
-
}
|
|
265
|
-
} catch (err) {
|
|
266
|
-
ConcurrencyMetrics.getInstance().recordBackendInstall({
|
|
267
|
-
backend: "unknown",
|
|
268
|
-
status: "failure",
|
|
269
|
-
});
|
|
270
|
-
this.logger.error(
|
|
271
|
-
`[concurrency] backend install failed: ${err instanceof Error ? err.message : String(err)}; falling back to in-process behavior`,
|
|
272
|
-
);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// Tier C #1 · install the cross-process debounce backend
|
|
276
|
-
// (NATS KV / Redis) when the operator opted in via
|
|
277
|
-
// `BLOK_DEBOUNCE_BACKEND`. Default unset = the existing
|
|
278
|
-
// in-process behavior is preserved. On connect failure, log +
|
|
279
|
-
// fall back to in-memory coordination.
|
|
280
|
-
try {
|
|
281
|
-
const debounceBackend = createDebounceBackend();
|
|
282
|
-
if (debounceBackend) {
|
|
283
|
-
const leaseRaw = process.env.BLOK_DEBOUNCE_OWNER_LEASE_MS;
|
|
284
|
-
if (leaseRaw && /^\d+$/.test(leaseRaw)) {
|
|
285
|
-
DebounceCoordinator.getInstance().setOwnerLeaseMs(Number(leaseRaw));
|
|
286
|
-
}
|
|
287
|
-
await debounceBackend.connect();
|
|
288
|
-
DebounceCoordinator.getInstance().setBackend(debounceBackend);
|
|
289
|
-
this.logger.log(`[debounce] backend installed: ${debounceBackend.name}`);
|
|
290
|
-
}
|
|
291
|
-
} catch (err) {
|
|
292
|
-
this.logger.error(
|
|
293
|
-
`[debounce] backend install failed: ${err instanceof Error ? err.message : String(err)}; falling back to in-memory coordination`,
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// Tier 2 quick-wins follow-up · install crash handlers + recover
|
|
298
|
-
// orphaned runs from a previous (dead) process. Idempotent + opt-out
|
|
299
|
-
// via `BLOK_CRASH_AUTOFLIP_DISABLED=1`.
|
|
300
|
-
try {
|
|
301
|
-
WorkerTrigger.installCrashHandlers(this.logger);
|
|
302
|
-
const orphaned = WorkerTrigger.recoverOrphanedRuns(undefined, this.logger);
|
|
303
|
-
if (orphaned > 0) {
|
|
304
|
-
this.logger.log(`[crash-autoflip] flipped ${orphaned} orphaned run(s) to crashed on boot`);
|
|
305
|
-
}
|
|
306
|
-
} catch (err) {
|
|
307
|
-
this.logger.error(`[crash-autoflip] setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// Tier 2 follow-up · start the periodic storage janitor.
|
|
311
|
-
// Idempotent (singleton); opt-out via `BLOK_JANITOR_DISABLED=1`.
|
|
312
|
-
try {
|
|
313
|
-
Janitor.getInstance(RunTracker.getInstance().getStore(), this.logger).start();
|
|
314
|
-
} catch (err) {
|
|
315
|
-
this.logger.error(`[janitor] setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// Tier 2 follow-up · install graceful shutdown handlers
|
|
319
|
-
// (SIGTERM / SIGINT). Idempotent; opt-out via
|
|
320
|
-
// `BLOK_GRACEFUL_SHUTDOWN_DISABLED=1`.
|
|
321
|
-
try {
|
|
322
|
-
WorkerTrigger.installShutdownHandlers(this, this.logger);
|
|
323
|
-
} catch (err) {
|
|
324
|
-
this.logger.error(`[shutdown] setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// Find all workflows with worker triggers
|
|
328
|
-
const workerWorkflows = this.getWorkerWorkflows();
|
|
329
|
-
|
|
330
|
-
if (workerWorkflows.length === 0) {
|
|
331
|
-
this.logger.log("No workflows with worker triggers found");
|
|
332
|
-
return this.endCounter(startTime);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// Start processing each queue, dispatching to the right adapter
|
|
336
|
-
// based on the workflow's `provider` field (with back-compat
|
|
337
|
-
// fallback to `this.adapter` when subclasses still set it).
|
|
338
|
-
for (const workflow of workerWorkflows) {
|
|
339
|
-
const config = workflow.config.trigger?.worker as WorkerTriggerOpts;
|
|
340
|
-
const adapter = await this.resolveAdapterForWorkflow(config);
|
|
341
|
-
this.logger.log(
|
|
342
|
-
`Starting worker for queue: ${config.queue} via ${adapter.provider} (concurrency=${config.concurrency}, retries=${config.retries})`,
|
|
343
|
-
);
|
|
344
|
-
|
|
345
|
-
this.activeQueues.add(config.queue);
|
|
346
|
-
|
|
347
|
-
await adapter.process(config, async (job) => {
|
|
348
|
-
await this.handleJob(job, workflow, config);
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
this.logger.log(`Worker trigger started. Processing ${workerWorkflows.length} queue(s)`);
|
|
353
|
-
|
|
354
|
-
// Enable HMR in development mode
|
|
355
|
-
if (process.env.BLOK_HMR === "true" || process.env.NODE_ENV === "development") {
|
|
356
|
-
await this.enableHotReload();
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
return this.endCounter(startTime);
|
|
360
|
-
} catch (error) {
|
|
361
|
-
this.logger.error(`Failed to start worker trigger: ${(error as Error).message}`);
|
|
362
|
-
throw error;
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* Stop all workers and disconnect
|
|
368
|
-
*/
|
|
369
|
-
async stop(): Promise<void> {
|
|
370
|
-
// Stop each queue on its owning adapter — adapters are tracked
|
|
371
|
-
// in the pool so multi-provider workers all drain cleanly.
|
|
372
|
-
for (const queue of this.activeQueues) {
|
|
373
|
-
for (const adapter of this.adapterPool.values()) {
|
|
374
|
-
try {
|
|
375
|
-
await adapter.stopProcessing(queue);
|
|
376
|
-
} catch {
|
|
377
|
-
/* swallow — adapter may not own this queue */
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
this.logger.log(`Stopped processing queue: ${queue}`);
|
|
381
|
-
}
|
|
382
|
-
this.activeQueues.clear();
|
|
383
|
-
// Disconnect every adapter we ever connected.
|
|
384
|
-
for (const adapter of this.adapterPool.values()) {
|
|
385
|
-
try {
|
|
386
|
-
await adapter.disconnect();
|
|
387
|
-
} catch (err) {
|
|
388
|
-
this.logger.error(`[blok][worker] disconnect failed: ${(err as Error).message}`);
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
this.adapterPool.clear();
|
|
392
|
-
this.destroyMonitoring();
|
|
393
|
-
this.logger.log("Worker trigger stopped");
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
protected override async onHmrWorkflowChange(): Promise<void> {
|
|
397
|
-
this.logger.log("[HMR] Worker workflow changed, reloading...");
|
|
398
|
-
await this.waitForInFlightRequests();
|
|
399
|
-
await this.stop();
|
|
400
|
-
this.loadWorkflows();
|
|
401
|
-
await this.listen();
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/**
|
|
405
|
-
* Dispatch a job to a worker queue
|
|
406
|
-
*/
|
|
407
|
-
async dispatch(
|
|
408
|
-
queue: string,
|
|
409
|
-
data: unknown,
|
|
410
|
-
opts?: {
|
|
411
|
-
priority?: number;
|
|
412
|
-
delay?: number;
|
|
413
|
-
retries?: number;
|
|
414
|
-
timeout?: number;
|
|
415
|
-
jobId?: string;
|
|
416
|
-
},
|
|
417
|
-
): Promise<string> {
|
|
418
|
-
// Back-compat: when a subclass set `this.adapter`, use it.
|
|
419
|
-
// Otherwise dispatch via the first pool adapter — typically the
|
|
420
|
-
// only one when a process owns one trigger workflow.
|
|
421
|
-
const adapter =
|
|
422
|
-
this.adapter ??
|
|
423
|
-
(this.adapterPool.size > 0 ? (this.adapterPool.values().next().value as WorkerAdapter) : undefined);
|
|
424
|
-
if (!adapter) {
|
|
425
|
-
throw new Error(
|
|
426
|
-
"[blok][worker] dispatch() called before any adapter is connected. Call listen() first, or set this.adapter on the subclass.",
|
|
427
|
-
);
|
|
428
|
-
}
|
|
429
|
-
return adapter.addJob(queue, data, opts);
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/**
|
|
433
|
-
* Get statistics for a queue
|
|
434
|
-
*/
|
|
435
|
-
async getQueueStats(queue: string): Promise<WorkerQueueStats> {
|
|
436
|
-
const adapter =
|
|
437
|
-
this.adapter ??
|
|
438
|
-
(this.adapterPool.size > 0 ? (this.adapterPool.values().next().value as WorkerAdapter) : undefined);
|
|
439
|
-
if (!adapter) {
|
|
440
|
-
throw new Error(
|
|
441
|
-
"[blok][worker] getQueueStats() called before any adapter is connected. Call listen() first, or set this.adapter on the subclass.",
|
|
442
|
-
);
|
|
443
|
-
}
|
|
444
|
-
return adapter.getQueueStats(queue);
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
/**
|
|
448
|
-
* Get list of active queues
|
|
449
|
-
*/
|
|
450
|
-
getActiveQueues(): string[] {
|
|
451
|
-
return Array.from(this.activeQueues);
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/**
|
|
455
|
-
* v0.7 PR 5 — pick the adapter for a workflow's `provider` field.
|
|
456
|
-
*
|
|
457
|
-
* Resolution order:
|
|
458
|
-
* 1. Subclass-set `this.adapter` (back-compat: pre-v0.7 pattern
|
|
459
|
-
* where one process binds to one adapter at construction time).
|
|
460
|
-
* 2. Per-workflow `provider` field, looked up via the factory.
|
|
461
|
-
* 3. `BLOK_WORKER_ADAPTER` env var.
|
|
462
|
-
* 4. `in-memory` fallback.
|
|
463
|
-
*
|
|
464
|
-
* Adapters are connected on first use and pooled per provider so
|
|
465
|
-
* multiple workflows sharing a provider share one broker
|
|
466
|
-
* connection. Health-dependency registration also happens here so
|
|
467
|
-
* each provider is tracked individually in `/health`.
|
|
468
|
-
*/
|
|
469
|
-
protected async resolveAdapterForWorkflow(config: WorkerTriggerOpts): Promise<WorkerAdapter> {
|
|
470
|
-
// Subclass override wins for back-compat.
|
|
471
|
-
if (this.adapter) {
|
|
472
|
-
if (!this.adapter.isConnected()) {
|
|
473
|
-
await this.adapter.connect();
|
|
474
|
-
this.logger.log(`Connected to ${this.adapter.provider} worker system (subclass adapter)`);
|
|
475
|
-
this.registerAdapterHealth(this.adapter);
|
|
476
|
-
}
|
|
477
|
-
// Pool-track so stop() can drain it.
|
|
478
|
-
this.adapterPool.set(this.adapter.provider, this.adapter);
|
|
479
|
-
return this.adapter;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// Lazy-import the factory so the worker package doesn't pull in
|
|
483
|
-
// every adapter on import — only the ones actually exercised.
|
|
484
|
-
const { resolveProvider, createWorkerAdapter } = await import("./adapters/factory");
|
|
485
|
-
const provider = resolveProvider(config.provider);
|
|
486
|
-
let adapter = this.adapterPool.get(provider);
|
|
487
|
-
if (!adapter) {
|
|
488
|
-
adapter = createWorkerAdapter(provider);
|
|
489
|
-
await adapter.connect();
|
|
490
|
-
this.logger.log(`Connected to ${adapter.provider} worker system`);
|
|
491
|
-
this.registerAdapterHealth(adapter);
|
|
492
|
-
this.adapterPool.set(provider, adapter);
|
|
493
|
-
}
|
|
494
|
-
return adapter;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
private registerAdapterHealth(adapter: WorkerAdapter): void {
|
|
498
|
-
this.registerHealthDependency(`worker-${adapter.provider}`, async () => {
|
|
499
|
-
const healthy = await adapter.healthCheck();
|
|
500
|
-
return {
|
|
501
|
-
status: healthy ? ("healthy" as const) : ("unhealthy" as const),
|
|
502
|
-
lastChecked: Date.now(),
|
|
503
|
-
message: healthy ? "Connected" : "Connection lost",
|
|
504
|
-
};
|
|
505
|
-
});
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
/**
|
|
509
|
-
* Get all workflows that have worker triggers
|
|
510
|
-
*/
|
|
511
|
-
protected getWorkerWorkflows(): WorkerWorkflowModel[] {
|
|
512
|
-
const workflows: WorkerWorkflowModel[] = [];
|
|
513
|
-
|
|
514
|
-
for (const [path, workflow] of Object.entries(this.nodeMap.workflows || {})) {
|
|
515
|
-
const workflowConfig = (workflow as unknown as { _config: WorkerWorkflowModel["config"] })._config;
|
|
516
|
-
|
|
517
|
-
if (workflowConfig?.trigger) {
|
|
518
|
-
const triggerType = Object.keys(workflowConfig.trigger)[0];
|
|
519
|
-
|
|
520
|
-
if (triggerType === "worker" && workflowConfig.trigger.worker) {
|
|
521
|
-
workflows.push({
|
|
522
|
-
path,
|
|
523
|
-
config: workflowConfig,
|
|
524
|
-
});
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
return workflows;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
/**
|
|
533
|
-
* Handle an incoming job
|
|
534
|
-
*/
|
|
535
|
-
protected async handleJob(job: WorkerJob, workflow: WorkerWorkflowModel, config: WorkerTriggerOpts): Promise<void> {
|
|
536
|
-
const jobId = job.id || uuid();
|
|
537
|
-
const defaultMeter = metrics.getMeter("default");
|
|
538
|
-
const workerJobs = defaultMeter.createCounter("worker_jobs_processed", {
|
|
539
|
-
description: "Worker jobs processed",
|
|
540
|
-
});
|
|
541
|
-
const workerErrors = defaultMeter.createCounter("worker_jobs_failed", {
|
|
542
|
-
description: "Worker job failures",
|
|
543
|
-
});
|
|
544
|
-
const workerRetries = defaultMeter.createCounter("worker_jobs_retried", {
|
|
545
|
-
description: "Worker job retries",
|
|
546
|
-
});
|
|
547
|
-
|
|
548
|
-
await this.tracer.startActiveSpan(`worker:${config.queue}`, async (span: Span) => {
|
|
549
|
-
try {
|
|
550
|
-
const start = performance.now();
|
|
551
|
-
|
|
552
|
-
// Initialize configuration for this workflow
|
|
553
|
-
await this.configuration.init(workflow.path, this.nodeMap);
|
|
554
|
-
|
|
555
|
-
// Create context
|
|
556
|
-
const ctx: Context = this.createContext(undefined, workflow.path, jobId);
|
|
557
|
-
|
|
558
|
-
// Populate request with job data
|
|
559
|
-
ctx.request = {
|
|
560
|
-
body: job.data,
|
|
561
|
-
headers: job.headers,
|
|
562
|
-
query: {},
|
|
563
|
-
params: {
|
|
564
|
-
queue: job.queue,
|
|
565
|
-
jobId: job.id,
|
|
566
|
-
attempt: String(job.attempts),
|
|
567
|
-
priority: String(job.priority),
|
|
568
|
-
},
|
|
569
|
-
} as unknown as RequestContext;
|
|
570
|
-
|
|
571
|
-
// Store worker metadata in context
|
|
572
|
-
if (!ctx.vars) ctx.vars = {};
|
|
573
|
-
ctx.vars._worker_job = {
|
|
574
|
-
id: job.id,
|
|
575
|
-
queue: job.queue,
|
|
576
|
-
attempts: String(job.attempts),
|
|
577
|
-
maxRetries: String(job.maxRetries),
|
|
578
|
-
priority: String(job.priority),
|
|
579
|
-
createdAt: job.createdAt.toISOString(),
|
|
580
|
-
delay: String(job.delay ?? 0),
|
|
581
|
-
timeout: String(job.timeout ?? 0),
|
|
582
|
-
};
|
|
583
|
-
|
|
584
|
-
ctx.logger.log(
|
|
585
|
-
`Processing job ${jobId} from ${config.queue} (attempt ${job.attempts + 1}/${job.maxRetries + 1})`,
|
|
586
|
-
);
|
|
587
|
-
|
|
588
|
-
// v0.6 · apply the merged middleware chain (process-global →
|
|
589
|
-
// workflow-level → trigger-level) on the same ctx the main
|
|
590
|
-
// workflow will see. State mutations from middleware
|
|
591
|
-
// (e.g. ctx.state.identity) carry forward. Middleware that
|
|
592
|
-
// throws (via `@blokjs/throw`) propagates to the outer
|
|
593
|
-
// catch and is routed through the worker's retry / DLQ
|
|
594
|
-
// logic exactly like a main-workflow error.
|
|
595
|
-
await this.applyMiddlewareChain(ctx, this.nodeMap);
|
|
596
|
-
|
|
597
|
-
// Execute workflow with timeout if configured
|
|
598
|
-
let response: TriggerResponse;
|
|
599
|
-
if (config.timeout && config.timeout > 0) {
|
|
600
|
-
response = await this.executeWithTimeout(ctx, config.timeout);
|
|
601
|
-
} else {
|
|
602
|
-
response = await this.run(ctx);
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
const end = performance.now();
|
|
606
|
-
|
|
607
|
-
// Set span attributes
|
|
608
|
-
span.setAttribute("success", true);
|
|
609
|
-
span.setAttribute("job_id", jobId);
|
|
610
|
-
span.setAttribute("queue", config.queue);
|
|
611
|
-
span.setAttribute("attempts", job.attempts);
|
|
612
|
-
span.setAttribute("elapsed_ms", end - start);
|
|
613
|
-
span.setStatus({ code: SpanStatusCode.OK });
|
|
614
|
-
|
|
615
|
-
// Record metrics
|
|
616
|
-
workerJobs.add(1, {
|
|
617
|
-
env: process.env.NODE_ENV,
|
|
618
|
-
queue: config.queue,
|
|
619
|
-
workflow_name: this.configuration.name,
|
|
620
|
-
success: "true",
|
|
621
|
-
});
|
|
622
|
-
|
|
623
|
-
ctx.logger.log(`Job completed in ${(end - start).toFixed(2)}ms: ${jobId}`);
|
|
624
|
-
|
|
625
|
-
// Mark job as completed
|
|
626
|
-
await job.complete();
|
|
627
|
-
} catch (error) {
|
|
628
|
-
const errorMessage = (error as Error).message;
|
|
629
|
-
|
|
630
|
-
// Tier 2 #5 + #7 — deferred dispatch (delay/TTL/debounce).
|
|
631
|
-
// The run was deferred to a future timer; ACK without retry.
|
|
632
|
-
// The in-process scheduler owns the eventual dispatch, NOT
|
|
633
|
-
// the broker — re-queueing here would create a duplicate.
|
|
634
|
-
if (error instanceof DeferredDispatchSignal) {
|
|
635
|
-
span.setAttribute("success", false);
|
|
636
|
-
span.setAttribute("deferred", true);
|
|
637
|
-
span.setAttribute("deferred_status", error.info.status);
|
|
638
|
-
span.setStatus({ code: SpanStatusCode.OK, message: `deferred:${error.info.status}` });
|
|
639
|
-
|
|
640
|
-
this.logger.log(
|
|
641
|
-
`[scheduling] job ${jobId} runId=${error.info.runId} status=${error.info.status} ` +
|
|
642
|
-
`scheduledAt=${error.info.scheduledAt} pingCount=${error.info.pingCount} → ACK (no requeue)`,
|
|
643
|
-
);
|
|
644
|
-
|
|
645
|
-
await job.complete();
|
|
646
|
-
return;
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
// PR 1-5 polish — queue-mode TTL elapsed. The run is already
|
|
650
|
-
// flipped to `expired` (see TriggerBase queue branch); ACK
|
|
651
|
-
// without retry so the broker doesn't redeliver — the run
|
|
652
|
-
// will never succeed (timer won't re-fire). Distinct from
|
|
653
|
-
// the throttled NACK below.
|
|
654
|
-
if (error instanceof QueueExpiredError) {
|
|
655
|
-
span.setAttribute("success", false);
|
|
656
|
-
span.setAttribute("queue_expired", true);
|
|
657
|
-
span.setStatus({ code: SpanStatusCode.OK, message: "queue_expired" });
|
|
658
|
-
|
|
659
|
-
this.logger.log(
|
|
660
|
-
`[concurrency] job ${jobId} runId=${error.info.runId} key='${error.info.concurrencyKey}' ` +
|
|
661
|
-
`queueExpiredAt=${error.info.queueExpiredAt} → ACK (no requeue, run expired)`,
|
|
662
|
-
);
|
|
663
|
-
|
|
664
|
-
await job.complete();
|
|
665
|
-
return;
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
// Tier 2 #6 — concurrency gate denial. Distinct from a normal
|
|
669
|
-
// failure: NACK with redelivery so the broker re-queues the
|
|
670
|
-
// job with its existing back-off semantics. Doesn't count
|
|
671
|
-
// against the workflow's retry budget (different invariant).
|
|
672
|
-
// We always pass `willRetry: true` regardless of `job.attempts`
|
|
673
|
-
// because throttling is a transient resource state, not a
|
|
674
|
-
// permanent failure.
|
|
675
|
-
if (error instanceof ConcurrencyLimitError) {
|
|
676
|
-
span.setAttribute("success", false);
|
|
677
|
-
span.setAttribute("will_retry", true);
|
|
678
|
-
span.setAttribute("throttled", true);
|
|
679
|
-
span.setStatus({ code: SpanStatusCode.OK, message: "concurrency_limit_reached" });
|
|
680
|
-
|
|
681
|
-
this.logger.log(
|
|
682
|
-
`[concurrency] job ${jobId} key='${error.info.concurrencyKey}' ` +
|
|
683
|
-
`limit=${error.info.concurrencyLimit} inFlight=${error.info.currentInFlight} → NACK + redelivery`,
|
|
684
|
-
);
|
|
685
|
-
|
|
686
|
-
workerRetries.add(1, {
|
|
687
|
-
env: process.env.NODE_ENV,
|
|
688
|
-
queue: config.queue,
|
|
689
|
-
workflow_name: this.configuration?.name || "unknown",
|
|
690
|
-
reason: "throttled",
|
|
691
|
-
});
|
|
692
|
-
|
|
693
|
-
await job.fail(error as Error, true);
|
|
694
|
-
return;
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
const shouldRetry = job.attempts < job.maxRetries;
|
|
698
|
-
|
|
699
|
-
// Set span error
|
|
700
|
-
span.setAttribute("success", false);
|
|
701
|
-
span.setAttribute("will_retry", shouldRetry);
|
|
702
|
-
span.recordException(error as Error);
|
|
703
|
-
span.setStatus({ code: SpanStatusCode.ERROR, message: errorMessage });
|
|
704
|
-
|
|
705
|
-
if (shouldRetry) {
|
|
706
|
-
// Retry with exponential backoff. `config.delay` widened to
|
|
707
|
-
// `string | number | undefined` in Tier 2 #5 (duration strings).
|
|
708
|
-
// `calculateBackoff` only handles numbers; normalize via
|
|
709
|
-
// tryParseDuration. Fail-open to undefined → default backoff.
|
|
710
|
-
const delayMs =
|
|
711
|
-
typeof config.delay === "number"
|
|
712
|
-
? config.delay
|
|
713
|
-
: typeof config.delay === "string"
|
|
714
|
-
? (tryParseDuration(config.delay) ?? undefined)
|
|
715
|
-
: undefined;
|
|
716
|
-
const backoffMs = this.calculateBackoff(job.attempts, delayMs);
|
|
717
|
-
workerRetries.add(1, {
|
|
718
|
-
env: process.env.NODE_ENV,
|
|
719
|
-
queue: config.queue,
|
|
720
|
-
workflow_name: this.configuration?.name || "unknown",
|
|
721
|
-
attempt: String(job.attempts + 1),
|
|
722
|
-
});
|
|
723
|
-
|
|
724
|
-
this.logger.error(
|
|
725
|
-
`Job ${jobId} failed (attempt ${job.attempts + 1}/${job.maxRetries + 1}), retrying in ${backoffMs}ms: ${errorMessage}`,
|
|
726
|
-
);
|
|
727
|
-
|
|
728
|
-
await job.fail(error as Error, true);
|
|
729
|
-
} else {
|
|
730
|
-
// Max retries exhausted - send to DLQ
|
|
731
|
-
workerErrors.add(1, {
|
|
732
|
-
env: process.env.NODE_ENV,
|
|
733
|
-
queue: config.queue,
|
|
734
|
-
workflow_name: this.configuration?.name || "unknown",
|
|
735
|
-
});
|
|
736
|
-
|
|
737
|
-
this.logger.error(
|
|
738
|
-
`Job ${jobId} permanently failed after ${job.attempts + 1} attempts: ${errorMessage}`,
|
|
739
|
-
(error as Error).stack,
|
|
740
|
-
);
|
|
741
|
-
|
|
742
|
-
await job.fail(error as Error, false);
|
|
743
|
-
}
|
|
744
|
-
} finally {
|
|
745
|
-
span.end();
|
|
746
|
-
}
|
|
747
|
-
});
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
/**
|
|
751
|
-
* Execute workflow with a timeout
|
|
752
|
-
*/
|
|
753
|
-
protected async executeWithTimeout(ctx: Context, timeoutMs: number): Promise<TriggerResponse> {
|
|
754
|
-
return new Promise<TriggerResponse>((resolve, reject) => {
|
|
755
|
-
const timer = setTimeout(() => {
|
|
756
|
-
reject(new Error(`Job timed out after ${timeoutMs}ms`));
|
|
757
|
-
}, timeoutMs);
|
|
758
|
-
|
|
759
|
-
this.run(ctx)
|
|
760
|
-
.then((result) => {
|
|
761
|
-
clearTimeout(timer);
|
|
762
|
-
resolve(result);
|
|
763
|
-
})
|
|
764
|
-
.catch((error) => {
|
|
765
|
-
clearTimeout(timer);
|
|
766
|
-
reject(error);
|
|
767
|
-
});
|
|
768
|
-
});
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
/**
|
|
772
|
-
* Calculate exponential backoff delay
|
|
773
|
-
* Formula: min(baseDelay * 2^attempt, 30000) + jitter
|
|
774
|
-
*/
|
|
775
|
-
protected calculateBackoff(attempt: number, baseDelay?: number): number {
|
|
776
|
-
const base = baseDelay ?? 1000;
|
|
777
|
-
const maxDelay = 30000; // 30 seconds max
|
|
778
|
-
const exponential = Math.min(base * 2 ** attempt, maxDelay);
|
|
779
|
-
const jitter = Math.random() * exponential * 0.1; // 10% jitter
|
|
780
|
-
return Math.floor(exponential + jitter);
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
export default WorkerTrigger;
|