@blokjs/trigger-worker 0.6.18 → 0.6.20

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.
Files changed (37) hide show
  1. package/dist/WorkerTrigger.d.ts +27 -3
  2. package/dist/WorkerTrigger.js +168 -26
  3. package/dist/adapters/KafkaAdapter.d.ts +5 -0
  4. package/dist/adapters/KafkaAdapter.js +12 -1
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.js +2 -2
  7. package/package.json +5 -4
  8. package/CHANGELOG.md +0 -22
  9. package/__tests__/integration/nats-adapter.real-nats.test.ts +0 -116
  10. package/__tests__/integration/pgboss-adapter.real-pg.test.ts +0 -164
  11. package/__tests__/integration/rabbitmq-adapter.real-rabbitmq.test.ts +0 -179
  12. package/__tests__/integration/sqs-adapter.real-sqs.test.ts +0 -228
  13. package/src/WorkerTrigger.test.ts +0 -540
  14. package/src/WorkerTrigger.ts +0 -784
  15. package/src/adapters/BullMQAdapter.ts +0 -296
  16. package/src/adapters/InMemoryAdapter.ts +0 -280
  17. package/src/adapters/KafkaAdapter.ts +0 -277
  18. package/src/adapters/NATSAdapter.ts +0 -454
  19. package/src/adapters/PgBossAdapter.ts +0 -293
  20. package/src/adapters/RabbitMQAdapter.ts +0 -285
  21. package/src/adapters/RedisStreamsAdapter.ts +0 -286
  22. package/src/adapters/SQSAdapter.ts +0 -306
  23. package/src/adapters/factory.test.ts +0 -89
  24. package/src/adapters/factory.ts +0 -111
  25. package/src/adapters/new-adapters.test.ts +0 -130
  26. package/src/index.ts +0 -94
  27. package/template/.env.example +0 -13
  28. package/template/package.json +0 -45
  29. package/template/src/Nodes.ts +0 -10
  30. package/template/src/Workflows.ts +0 -8
  31. package/template/src/index.ts +0 -41
  32. package/template/src/runner/WorkerServer.ts +0 -34
  33. package/template/src/runner/types/Workflows.ts +0 -7
  34. package/template/src/workflows/jobs/process-job.ts +0 -47
  35. package/template/tsconfig.json +0 -31
  36. package/template/vitest.config.ts +0 -39
  37. package/tsconfig.json +0 -32
@@ -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;